diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts index 53603f31..1c16303c 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts @@ -70,8 +70,25 @@ function measurement(overrides: Partial = {}): Inbound actualVolumeCbm: 0.6, volumeWeightKg: 100.2, actualGoodsName: '蓝牙音箱', + latestDeclaration: { + referenceId: 'DOC-001', + totalPackageCount: 10, + grossWeightKg: 118.5, + volumeCbm: 0.8, + goodsName: '蓝牙音箱', + items: [{ + inboundDetailId: '8101', + itemNo: 1, + cargoName: '蓝牙音箱', + packageCount: 10, + lengthCm: 50, + widthCm: 40, + heightCm: 30, + }], + }, items: [{ id: '9101', + inboundDetailId: '8101', boxCode: 'RK202608040001#10-1', itemNo: '001', boxSpecification: '50×40×30cm', @@ -162,6 +179,33 @@ describe('InboundMeasurementPanel', () => { expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true) }) + it('warns when declared dimensions or gross weight differ from measurements by at least ten percent', async () => { + vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ + actualGrossWeightKg: 132, + items: [{ + ...measurement().items[0], + lengthCm: 55, + widthCm: 36, + }], + })) + const wrapper = await mountPanel() + const alert = wrapper.find('.measurement-deviation-alert') + + expect(alert.exists()).toBe(true) + expect(alert.text()).toContain('下单箱规与测量数据差异达到 10%') + expect(alert.text()).toContain('长偏差 10%') + expect(alert.text()).toContain('下单 50 cm → 实测 55 cm') + expect(alert.text()).toContain('宽偏差 10%') + expect(alert.text()).toContain('重量偏差 11.4%') + expect(alert.text()).not.toContain('高偏差') + }) + + it('does not warn when every declared and measured value differs by less than ten percent', async () => { + const wrapper = await mountPanel() + + expect(wrapper.find('.measurement-deviation-alert').exists()).toBe(false) + }) + it('opens the manual carton entry form in an editable measurement state', async () => { const wrapper = await mountPanel() const button = wrapper.findAll('button').find((candidate) => candidate.text().includes('手动录入箱规')) diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue index 8b975b29..0a499ddc 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue @@ -113,6 +113,64 @@ const registrationOpen = computed(() => hasContext.value && canView.value && Boo const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null) const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未测量'))) +interface MeasurementDeviationWarning { + key: string + text: string +} + +const measurementDeviationWarnings = computed(() => { + const resource = detail.value + if (!resource) return [] + + const warnings: MeasurementDeviationWarning[] = [] + const declaredItems = resource.latestDeclaration?.items || [] + const dimensions = [ + { key: 'lengthCm', label: '长' }, + { key: 'widthCm', label: '宽' }, + { key: 'heightCm', label: '高' }, + ] as const + + for (const dimension of dimensions) { + const differences = resource.items.flatMap((measuredItem) => { + const declaredItem = declaredItems.find((candidate) => ( + measuredItem.inboundDetailId != null + && candidate.inboundDetailId != null + && String(candidate.inboundDetailId) === String(measuredItem.inboundDetailId) + )) || declaredItems.find((candidate) => candidate.itemNo != null && Number(candidate.itemNo) === Number(measuredItem.itemNo)) + if (!declaredItem) return [] + const declaredValue = declaredItem[dimension.key] + const measuredValue = measuredItem[dimension.key] + const ratio = deviationRatio(measuredValue, declaredValue) + if (ratio == null || ratio < 0.1 - 1e-9) return [] + return [{ + measuredItem, + declaredValue, + measuredValue, + ratio, + }] + }).sort((left, right) => right.ratio - left.ratio) + + const largest = differences[0] + if (!largest) continue + const boxLabel = largest.measuredItem.boxCode || `第 ${largest.measuredItem.itemNo} 条箱规` + const countSuffix = differences.length > 1 ? `,共 ${differences.length} 条箱规` : '' + warnings.push({ + key: dimension.key, + text: `${dimension.label}偏差 ${formatPercentage(largest.ratio)}(${boxLabel}:下单 ${formatNumber(largest.declaredValue)} cm → 实测 ${formatNumber(largest.measuredValue)} cm${countSuffix})`, + }) + } + + const declaredGrossWeight = resource.latestDeclaration?.grossWeightKg ?? resource.originalGrossWeightKg + const weightRatio = deviationRatio(resource.actualGrossWeightKg, declaredGrossWeight) + if (weightRatio != null && weightRatio >= 0.1 - 1e-9) { + warnings.push({ + key: 'grossWeightKg', + text: `重量偏差 ${formatPercentage(weightRatio)}(下单 ${formatNumber(declaredGrossWeight)} kg → 实测 ${formatNumber(resource.actualGrossWeightKg)} kg)`, + }) + } + return warnings +}) + const { captureBaseline } = useUnsavedWorkRegistration({ key: 'p2-inbound-measurement-editor', label: computed(() => `入仓码 ${detail.value?.inboundCode || '待补充'} 的测量草稿`), @@ -419,6 +477,15 @@ function formatNumber(value: number | null | undefined, maximumFractionDigits = return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value) } +function deviationRatio(actual: number | null | undefined, declared: number | null | undefined) { + if (actual == null || declared == null || !Number.isFinite(actual) || !Number.isFinite(declared) || declared <= 0) return null + return Math.abs(actual - declared) / Math.abs(declared) +} + +function formatPercentage(ratio: number) { + return `${formatNumber(ratio * 100, 1)}%` +} + function formatDifference(actual: number | null | undefined, declared: number | null | undefined, maximumFractionDigits = 3, unit = '') { if (actual == null || declared == null || !Number.isFinite(actual) || !Number.isFinite(declared)) return null const difference = actual - declared @@ -539,6 +606,17 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may + +

{{ warning.text }}

+
+

平台实测

@@ -702,6 +780,8 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may .measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } .measurement-summary-context { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px 20px; } .measurement-summary-header span { color: var(--cc-muted); font-size: 11px; } +.measurement-deviation-alert { margin: 0; width: auto; } +.measurement-deviation-alert p { margin: 3px 0 0; line-height: 1.55; } .measurement-summary-context p { display: inline-flex; min-width: 0; align-items: baseline; gap: 6px; margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 600; } .measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; } .measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 2px 0; } diff --git a/src/modules/inbounds/measurement/types.ts b/src/modules/inbounds/measurement/types.ts index 4484c21d..095ca8de 100644 --- a/src/modules/inbounds/measurement/types.ts +++ b/src/modules/inbounds/measurement/types.ts @@ -13,6 +13,7 @@ export type InboundMeasurementProcessStatus = export interface InboundMeasurementItem { id: MeasurementIdentifier + inboundDetailId?: MeasurementIdentifier | null boxCode: string | null itemNo: string boxSpecification: string @@ -36,6 +37,25 @@ export interface InboundMeasurementItem { remark: string | null } +export interface InboundMeasurementDeclarationItem { + inboundDetailId: MeasurementIdentifier | null + itemNo: number | null + cargoName: string | null + packageCount: number | null + lengthCm: number | null + widthCm: number | null + heightCm: number | null +} + +export interface InboundMeasurementDeclarationSnapshot { + referenceId: string | null + totalPackageCount: number | null + grossWeightKg: number | null + volumeCbm: number | null + goodsName: string | null + items: InboundMeasurementDeclarationItem[] +} + export interface InboundMeasurementWeightResult { id: MeasurementIdentifier fileId: MeasurementIdentifier @@ -88,6 +108,7 @@ export interface InboundMeasurementDetail { actualVolumeCbm: number | null volumeWeightKg: number | null actualGoodsName: string | null + latestDeclaration?: InboundMeasurementDeclarationSnapshot | null items: InboundMeasurementItem[] weightResults: InboundMeasurementWeightResult[] } diff --git a/src/modules/unified-orders/components/OrderDeclarationEditorDialog.test.ts b/src/modules/unified-orders/components/OrderDeclarationEditorDialog.test.ts index e3bca781..17bad94c 100644 --- a/src/modules/unified-orders/components/OrderDeclarationEditorDialog.test.ts +++ b/src/modules/unified-orders/components/OrderDeclarationEditorDialog.test.ts @@ -1,21 +1,23 @@ -import ElementPlus, { ElMessageBox } from 'element-plus' +import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { updatePlatformUnifiedOrderDeclaration } from '@/modules/unified-orders/api/unifiedOrders' +import { listWarehouseLogisticsChannels } from '@/modules/ocean-bookings' import OrderDeclarationEditorDialog from './OrderDeclarationEditorDialog.vue' vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ updatePlatformUnifiedOrderDeclaration: vi.fn() })) +vi.mock('@/modules/ocean-bookings', () => ({ listWarehouseLogisticsChannels: vi.fn() })) const detail = { summary: { orderId: '90001', tenantId: '208', inboundCode: 'RK-001', mainStatus: 'WAIT_MEASURE', - receivingWarehouseName: '深圳仓', destinationWarehouseName: '洛杉矶中转仓', + receivingWarehouseId: '700000000000000001', receivingWarehouseName: '深圳仓', destinationWarehouseName: '洛杉矶中转仓', channelCode: 'OLD', channelName: '原渠道', channelChanged: false, expectedArrivalAt: '2026-09-06T10:00:00', customerRemark: '原备注', }, order: { - order: { version: 7, channelCode: 'OLD', channelName: '原渠道', customerRemark: '原备注' }, - cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048 }], + order: { version: 7, receivingWarehouseId: '700000000000000001', channelCode: 'OLD', channelName: '原渠道', customerRemark: '原备注' }, + cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048, currency: 'USD', material: '铝', usage: '照明' }], parties: [], internalNotes: [], statusHistory: [], }, timeline: [], @@ -28,8 +30,8 @@ function createWrapper() { plugins: [ElementPlus], stubs: { AppDialog: { - props: ['modelValue', 'confirmLoading'], emits: ['confirm', 'update:modelValue'], - template: '
', + props: ['modelValue', 'confirmLoading', 'confirmDisabled'], emits: ['confirm', 'update:modelValue'], + template: '
', }, LoadErrorState: { props: ['title', 'message'], template: '
{{ title }} {{ message }}
' }, ApiErrorMeta: true, @@ -41,35 +43,72 @@ function createWrapper() { describe('OrderDeclarationEditorDialog', () => { beforeEach(() => { vi.mocked(updatePlatformUnifiedOrderDeclaration).mockReset() + vi.mocked(listWarehouseLogisticsChannels).mockReset() + vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([ + { channelCode: 'OLD', channelName: '原渠道' }, + { channelCode: 'NEW', channelName: '新渠道' }, + ]) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never) + vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() }) as never) }) - it('requires a second confirmation when the channel changes and keeps inbound/warehouse facts read-only', async () => { + it('loads formal warehouse channels and submits the code/name pair from one selected option', async () => { vi.mocked(updatePlatformUnifiedOrderDeclaration).mockResolvedValue({ version: 8 } as never) const wrapper = createWrapper() + await flushPromises() + expect(listWarehouseLogisticsChannels).toHaveBeenCalledWith('700000000000000001') expect(wrapper.text()).toContain('国内收货仓和中转仓不可修改,如需变更请重新下单') expect(wrapper.text()).toContain('RK-001') - const inputs = wrapper.findAll('input') - await inputs[0]!.setValue('NEW') + const channelSelect = wrapper.findAllComponents({ name: 'ElSelect' })[0]! + channelSelect.vm.$emit('update:modelValue', 'NEW') + channelSelect.vm.$emit('change', 'NEW') + await wrapper.vm.$nextTick() await wrapper.get('[data-testid="save"]').trigger('click') await flushPromises() - expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('现有入仓码和箱唛不会改变'), '确认变更渠道', expect.any(Object)) - expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({ expectedVersion: 7, channelCode: 'NEW' })) + expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('原箱码继续有效,无需更换已打印箱唛'), '确认变更渠道', expect.any(Object)) + expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('纸面上的旧渠道文字不会自动变化'), '确认变更渠道', expect.any(Object)) + expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({ expectedVersion: 7, channelCode: 'NEW', channelName: '新渠道' })) + expect(ElMessage.success).toHaveBeenCalledWith(expect.stringContaining('订单已标记为“曾变更渠道”')) expect(wrapper.emitted('updated')).toHaveLength(1) expect(wrapper.emitted('update:modelValue')).toContainEqual([false]) }) - it('preserves the editor and entered values when the versioned update fails', async () => { + it('does not expose free-text channel inputs and presents cargoes as a compact table with row editing', async () => { + const wrapper = createWrapper() + await flushPromises() + + expect(wrapper.find('input[aria-label="渠道编码"]').attributes('readonly')).toBeDefined() + expect(wrapper.findAllComponents({ name: 'ElSelect' })).toHaveLength(1) + expect(wrapper.find('.cargo-table').exists()).toBe(true) + expect(wrapper.text()).toContain('箱数 / 数量') + expect(wrapper.text()).toContain('材质 / 用途') + expect(wrapper.text()).toContain('2 箱 / 20') + + const editButton = wrapper.findAll('button').find((button) => button.text() === '编辑')! + await editButton.trigger('click') + expect(wrapper.find('input[aria-label="币种"]').attributes('readonly')).toBeDefined() + }) + + it('preserves the editor when the versioned update fails', async () => { vi.mocked(updatePlatformUnifiedOrderDeclaration).mockRejectedValue(new Error('订单版本已变化,请刷新详情后重试')) const wrapper = createWrapper() - const inputs = wrapper.findAll('input') - await inputs[1]!.setValue('保留填写的渠道名') + await flushPromises() await wrapper.get('[data-testid="save"]').trigger('click') await flushPromises() expect(wrapper.find('.load-error').exists()).toBe(true) - expect((inputs[1]!.element as HTMLInputElement).value).toBe('保留填写的渠道名') + expect(wrapper.text()).toContain('原渠道') expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) + + it('fails closed and keeps API error evidence when formal channel candidates cannot load', async () => { + vi.mocked(listWarehouseLogisticsChannels).mockRejectedValue(Object.assign(new Error('无权限'), { requestId: 'req-1', traceId: 'trace-1' })) + const wrapper = createWrapper() + await flushPromises() + + expect(wrapper.text()).toContain('渠道候选加载失败') + expect(wrapper.get('[data-testid="save"]').attributes('disabled')).toBeDefined() + expect(updatePlatformUnifiedOrderDeclaration).not.toHaveBeenCalled() + }) }) diff --git a/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue b/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue index dc9294dd..ff819888 100644 --- a/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue +++ b/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue @@ -1,10 +1,11 @@