This commit is contained in:
2026-09-03 10:46:07 +08:00
parent 913a8df7f3
commit 176f815519
17 changed files with 1128 additions and 44 deletions
@@ -5,10 +5,12 @@ import { AlertTriangle, Image, Plus, Save, Send, Upload } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing'
import type {
InboundMeasurementDetail,
InboundMeasurementItem,
@@ -19,6 +21,7 @@ const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
openWarehouseSuggestion?: boolean
}>()
const emit = defineEmits<{
@@ -28,10 +31,12 @@ const emit = defineEmits<{
submitted: []
}>()
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coefficientMode'> & {
itemId: string
clientKey: string
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
chargeRuleId: string | null
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
}
interface MeasurementFormModel {
@@ -57,10 +62,15 @@ const loading = ref(false)
const saving = ref(false)
const submitting = ref(false)
const manualEntryOpen = ref(false)
const warehouseSuggestionOpen = ref(false)
const warehouseSuggestionAutoOpened = ref(false)
const warehouseSuggestion = reactive({ itemId: '', packageCount: 1, remark: '', coefficientMode: 'ADD' as 'ADD' | 'MULTIPLY' })
const manualEntrySubmitting = ref(false)
const manualEntryImage = ref<File | null>(null)
const manualEntryErrors = ref<string[]>([])
const detail = ref<InboundMeasurementDetail | null>(null)
const chargeRules = ref<CustomerChargeRule[]>([])
const chargeRulesLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const operationError = ref<ApiErrorDetails | null>(null)
const conflictDetected = ref(false)
@@ -95,6 +105,7 @@ const canView = computed(() => access.can('platform:firstleg:inbound:measure:vie
const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
const canWarehouseSuggestion = computed(() => access.can('platform:firstleg:inbound:warehouse-suggestion:edit'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
const countEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
@@ -112,6 +123,9 @@ const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== basel
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未测量')))
const suggestionItem = computed(() => form.items.find((item) => item.itemId === warehouseSuggestion.itemId) || null)
const suggestionTotalVolume = computed(() => Number(suggestionItem.value?.serverValues.unitVolumeCbm || 0) * warehouseSuggestion.packageCount)
const suggestionGirth = computed(() => suggestionItem.value ? suggestionItem.value.lengthCm + 2 * (suggestionItem.value.widthCm + suggestionItem.value.heightCm) : 0)
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'p2-inbound-measurement-editor',
@@ -203,6 +217,7 @@ async function submitManualEntry() {
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
return {
itemId: String(item.id),
inboundDetailId: String(item.inboundDetailId || item.id),
clientKey: nextItemKey(),
boxCode: item.boxCode,
itemNo: item.itemNo,
@@ -227,9 +242,41 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
totalVolumeCbm: item.totalVolumeCbm,
volumeWeightKg: item.volumeWeightKg,
},
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
coefficientMode: item.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : item.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL',
}
}
function openWarehouseSuggestion() {
if (!canWarehouseSuggestion.value && !canEdit.value) return
const first = form.items[0]
warehouseSuggestion.itemId = first?.itemId || ''
warehouseSuggestion.packageCount = first?.packageCount || 1
warehouseSuggestion.remark = first?.remark || ''
warehouseSuggestion.coefficientMode = first?.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : 'ADD'
warehouseSuggestionOpen.value = true
}
function applyWarehouseSuggestion() {
const item = suggestionItem.value
if (!item) { ElMessage.warning('请选择当前实测 SKU'); return }
if (!Number.isInteger(warehouseSuggestion.packageCount) || warehouseSuggestion.packageCount < 1) { ElMessage.warning('箱数必须是大于 0 的整数'); return }
item.packageCount = warehouseSuggestion.packageCount
item.remark = warehouseSuggestion.remark.trim() || null
item.coefficientMode = warehouseSuggestion.coefficientMode
warehouseSuggestionOpen.value = false
ElMessage.success('仓库建议数据已加入,请保存测量数据')
}
async function loadChargeRules() {
if (!customerId.value || !access.can('firstleg:fba-pricing:rule:view')) return
chargeRulesLoading.value = true
try {
chargeRules.value = await listCustomerChargeRules(customerId.value)
} catch { chargeRules.value = [] }
finally { chargeRulesLoading.value = false }
}
function clearForm() {
form.measurementId = null
form.version = 0
@@ -261,6 +308,11 @@ async function loadMeasurement() {
const resource = await getInboundMeasurement(customerId.value, props.inboundId)
if (sequence !== requestSequence) return
applyDetail(resource)
void loadChargeRules()
if (props.openWarehouseSuggestion && !warehouseSuggestionAutoOpened.value && form.items.length) {
warehouseSuggestionAutoOpened.value = true
openWarehouseSuggestion()
}
} catch (error) {
if (sequence !== requestSequence) return
detail.value = null
@@ -304,6 +356,14 @@ function validateDraft() {
else if (value > 999_999_999.999) itemErrors.push(`${label}超出系统允许上限 999,999,999.999 cm`)
else if (Math.abs(value * 1000 - Math.round(value * 1000)) > 1e-6) itemErrors.push(`${label}最多保留 3 位小数`)
}
const outerDimensions = [item.outerLengthCm, item.outerWidthCm, item.outerHeightCm]
if (outerDimensions.some((value) => value != null)) {
if (outerDimensions.some((value) => value == null)) itemErrors.push('仓库修改外箱长、宽、高必须同时填写')
outerDimensions.forEach((value) => {
if (value != null && (!Number.isFinite(value) || value <= 0)) itemErrors.push('仓库修改外箱尺寸必须大于 0')
else if (value != null && Math.abs(value * 1000 - Math.round(value * 1000)) > 1e-6) itemErrors.push('仓库修改外箱尺寸最多保留 3 位小数')
})
}
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
})
if (general.length) errors.general = general
@@ -348,7 +408,16 @@ async function saveDraft() {
conflictDetected.value = false
try {
const originalCounts = new Map((detail.value?.items || []).map((item) => [String(item.id), item.packageCount]))
const draftChanged = Boolean(detail.value && (detail.value.actualGoodsName || '') !== form.actualGoodsName)
const originalDraftValues = new Map((detail.value?.items || []).map((item) => [String(item.id), JSON.stringify({
lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.chargeRuleId || null, coefficientMode: item.coefficientMode || 'ACTUAL', remark: item.remark || null,
})]))
const draftChanged = Boolean(detail.value && ((detail.value.actualGoodsName || '') !== form.actualGoodsName || form.items.some((item) => originalDraftValues.get(item.itemId) !== JSON.stringify({
lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.chargeRuleId || null, coefficientMode: item.coefficientMode, remark: item.remark || null,
}))))
const changedItems = form.items.filter((item) => originalCounts.get(item.itemId) !== item.packageCount)
let version = form.version
if (draftChanged) {
@@ -356,7 +425,14 @@ async function saveDraft() {
measurementId: form.measurementId,
version,
actualGoodsName: form.actualGoodsName.trim() || null,
items: form.items.map(({ itemId: _itemId, clientKey: _clientKey, serverValues: _serverValues, ...item }) => item),
items: form.items.map((item) => ({
inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
packageCount: item.packageCount, lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.coefficientMode === 'MULTIPLY' ? item.chargeRuleId : null,
coefficientMode: item.coefficientMode === 'ACTUAL' ? null : item.coefficientMode, remark: item.remark,
})),
})
version = saved.version
}
@@ -461,6 +537,17 @@ function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
}
function chargeRuleName(ruleId: string | null | undefined) {
if (!ruleId) return '默认规则'
const rule = chargeRules.value.find((item) => item.id === ruleId)
return rule ? chargeRuleLabel(rule) : ruleId
}
function chargeRuleLabel(rule: CustomerChargeRule) {
const remark = rule.remark?.trim()
return `系数 ${rule.lengthFactor}${remark ? ` · ${remark}` : ''}${rule.defaultRule ? '(默认)' : ''}`
}
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
@@ -470,6 +557,7 @@ watch(
operationError.value = null
conflictDetected.value = false
validationErrors.value = {}
warehouseSuggestionAutoOpened.value = false
clearForm()
if (hasContext.value && canView.value) void loadMeasurement()
},
@@ -567,7 +655,10 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
<div><h3>箱规明细</h3></div>
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
<div class="measurement-section-actions">
<el-button v-if="canWarehouseSuggestion" type="primary" plain :disabled="!mayMaintain || !form.items.length" @click="openWarehouseSuggestion"><Plus :size="15" />添加仓库建议数据</el-button>
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
</div>
</div>
<el-table
@@ -591,13 +682,22 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</dl>
</template>
</el-table-column>
<el-table-column type="index" label="#" width="48" align="center" />
<el-table-column prop="itemNo" label="#" width="70" align="center" />
<el-table-column label="箱号" min-width="156" show-overflow-tooltip>
<template #default="{ row }"><span class="measurement-box-code">{{ row.boxCode || '—' }}</span></template>
</el-table-column>
<el-table-column label="尺寸 长×宽×高(cm)" width="166" align="center">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
<el-table-column label="仓库修改外箱(长)" width="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
</el-table-column>
<el-table-column label="仓库修改外箱(宽)" width="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerWidthCm" aria-label="仓库修改外箱宽" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
</el-table-column>
<el-table-column label="仓库修改外箱(高)" width="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerHeightCm" aria-label="仓库修改外箱高" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
</el-table-column>
<el-table-column label="确认箱数" width="132" align="center">
<template #default="{ row }">
<el-input-number
@@ -612,6 +712,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
/>
</template>
</el-table-column>
<el-table-column label="计算方式" min-width="150">
<template #default="{ row }">
<el-select v-model="row.coefficientMode" placeholder="选择计算方式">
<el-option label="实测原尺寸(不调整)" value="ACTUAL" />
<el-option label="按箱规尺寸策略" value="ADD" />
<el-option label="按箱规尺寸系数" value="MULTIPLY" :disabled="!chargeRules.some((rule) => rule.defaultRule && rule.status === 'ACTIVE')" />
</el-select>
</template>
</el-table-column>
<el-table-column label="总体积(m³)" width="108" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
</el-table-column>
@@ -626,6 +735,18 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</el-table-column>
</el-table>
<el-empty v-else description="暂无扫描箱测量数据" />
<div v-if="form.items.length" class="warehouse-suggestion-table">
<h3>仓库建议数据</h3>
<el-table :data="form.items" border stripe empty-text="暂无仓库建议数据">
<el-table-column prop="itemNo" label="当前实测 SKU" min-width="150" />
<el-table-column label="外箱规 长×宽×高(cm)" min-width="190"><template #default="{ row }">{{ formatNumber(row.outerLengthCm) }} × {{ formatNumber(row.outerWidthCm) }} × {{ formatNumber(row.outerHeightCm) }}</template></el-table-column>
<el-table-column prop="packageCount" label="箱数" width="90" />
<el-table-column label="周长(cm)" width="110"><template #default="{ row }">{{ formatNumber(row.serverValues.girthCm) }}</template></el-table-column>
<el-table-column label="总体积(m³)" width="120"><template #default="{ row }">{{ formatNumber(row.serverValues.unitVolumeCbm == null ? null : row.serverValues.unitVolumeCbm * row.packageCount, 6) }}</template></el-table-column>
<el-table-column label="计算策略" min-width="190"><template #default="{ row }">{{ row.coefficientMode === 'MULTIPLY' ? '按箱规尺寸系数' : '按箱规尺寸策略' }}<span v-if="row.coefficientMode === 'MULTIPLY'">({{ chargeRuleName(row.chargeRuleId) }})</span></template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
</el-table>
</div>
</section>
</fieldset>
</form>
@@ -639,6 +760,21 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</div>
</footer>
<AppDialog v-model="warehouseSuggestionOpen" title="添加仓库建议数据" width="min(680px, calc(100vw - 28px))" confirm-text="加入建议" @confirm="applyWarehouseSuggestion">
<el-form label-position="top" class="warehouse-suggestion-form">
<el-form-item label="当前实测 SKU" required><el-select v-model="warehouseSuggestion.itemId" filterable><el-option v-for="item in form.items" :key="item.itemId" :label="`${item.itemNo} · ${item.boxSpecification || item.boxCode || '实测SKU'}`" :value="item.itemId" /></el-select></el-form-item>
<el-form-item label="箱数" required><el-input-number v-model="warehouseSuggestion.packageCount" :min="1" :max="1000000" step-strictly /></el-form-item>
<el-form-item label="计算方式"><el-select v-model="warehouseSuggestion.coefficientMode"><el-option label="按箱规尺寸策略" value="ADD" /><el-option label="按箱规尺寸系数" value="MULTIPLY" /></el-select></el-form-item>
<el-form-item label="备注"><el-input v-model="warehouseSuggestion.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<dl v-if="suggestionItem" class="suggestion-preview">
<div><dt>长 × 宽 × 高</dt><dd>{{ formatNumber(suggestionItem.lengthCm) }} × {{ formatNumber(suggestionItem.widthCm) }} × {{ formatNumber(suggestionItem.heightCm) }} cm</dd></div>
<div><dt>箱规周长</dt><dd>{{ formatNumber(suggestionGirth) }} cm</dd></div>
<div><dt>单箱体积</dt><dd>{{ formatNumber(suggestionItem.serverValues.unitVolumeCbm, 6) }} m³</dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(suggestionTotalVolume, 6) }} m³</dd></div>
</dl>
</AppDialog>
<el-dialog v-model="imagePreviewOpen" title="当前有效测量图片" width="min(720px, 92vw)" destroy-on-close @closed="releaseImagePreview">
<div class="measurement-image-preview">
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
@@ -717,6 +853,8 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
.measurement-declaration h4 { margin: 0 0 10px; color: #344054; font-size: 12px; font-weight: 700; }
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.measurement-section__heading > div { min-width: 0; }
.measurement-section-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }.measurement-section-actions :deep(.el-button) { margin-left: 0; }
.warehouse-suggestion-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }.warehouse-suggestion-form :deep(.el-select),.warehouse-suggestion-form :deep(.el-input-number) { width: 100%; }.warehouse-suggestion-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.suggestion-preview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 4px 0 0; border: 1px solid var(--cc-border); border-radius: 6px; }.suggestion-preview div { padding: 12px; border-right: 1px solid var(--cc-border); }.suggestion-preview div:last-child { border-right: 0; }.suggestion-preview dt { color: var(--cc-muted); font-size: 11px; }.suggestion-preview dd { margin: 5px 0 0; color: var(--cc-text); font-size: 13px; font-weight: 650; }
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
@@ -738,6 +876,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
.measurement-table :deep(.el-table__cell) { padding: 7px 0; }
.measurement-table :deep(.cell) { line-height: 1.35; }
.measurement-table :deep(.measurement-table__row--error > td.el-table__cell) { background: var(--el-color-danger-light-9); }
.warehouse-suggestion-table { display: grid; gap: 10px; margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--cc-text); font-size: 14px; }
.measurement-box-code { font-weight: 650; }
.measurement-count-editor { width: 104px !important; }
.measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 20px; margin: 0; padding: 4px 18px 8px 62px; }