diff --git a/src/modules/inbounds/weight/api/weightOcr.test.ts b/src/modules/inbounds/weight/api/weightOcr.test.ts index 62cbe281..c6329d3b 100644 --- a/src/modules/inbounds/weight/api/weightOcr.test.ts +++ b/src/modules/inbounds/weight/api/weightOcr.test.ts @@ -112,6 +112,19 @@ describe('inbound weight OCR API', () => { { version: 1 }, { params: { tenantId: '10001' } }, ) + + const confirmWithAdjustment = { + version: 0, + adjustedWeightValue: 286.125, + adjustedWeightUnit: 'KG' as const, + adjustmentReason: 'OCR 原值 285.500 KG,人工确认 286.125 KG', + } + await confirmWeightResult('10001', '20001', '30002', confirmWithAdjustment) + expect(http.post).toHaveBeenLastCalledWith( + '/platform/v1/first-leg/inbounds/20001/weight-results/30002/confirm', + confirmWithAdjustment, + { params: { tenantId: '10001' } }, + ) }) it('downloads an authenticated image as a blob', async () => { diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts new file mode 100644 index 00000000..1c25ae17 --- /dev/null +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts @@ -0,0 +1,115 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useAuthStore } from '@/core/auth/auth' +import { + confirmWeightResult, + correctWeightResult, + getWeightWorkspace, +} from '../api/weightOcr' +import type { WeightResultResource, WeightWorkspaceResource } from '../types' +import WeightOcrPanel from './WeightOcrPanel.vue' + +vi.mock('../api/weightOcr', async () => { + const actual = await vi.importActual('../api/weightOcr') + return { + ...actual, + confirmWeightResult: vi.fn(), + correctWeightResult: vi.fn(), + getWeightWorkspace: vi.fn(), + } +}) + +const pendingWeight: WeightResultResource = { + id: '30001', + fileId: '40001', + ocrProvider: 'FILE_WEIGHT_OCR', + ocrRawText: '285.5', + ocrWeightValue: 285.5, + ocrWeightUnit: 'KG', + confidence: 0.50972, + adjustedWeightValue: null, + adjustedWeightUnit: null, + finalWeightKg: null, + recognitionStatus: 'PENDING_CONFIRM', + adjustmentReason: null, + adjustedBy: null, + adjustedAt: null, + confirmedBy: null, + confirmedAt: null, + version: 0, +} + +const workspace: WeightWorkspaceResource = { + inboundId: '20001', + inboundCode: 'RK-20001', + processStatus: 'MEASURING', + actualGrossWeightKg: null, + weightResults: [pendingWeight], +} + +enableAutoUnmount(afterEach) + +function mountPanel() { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = [ + 'platform:firstleg:inbound:measure:view', + 'platform:firstleg:inbound:weight:edit', + 'platform:firstleg:inbound:weight:confirm', + ] + useAccessPreviewStore().initializeAfterLogin() + return mount(WeightOcrPanel, { + attachTo: document.body, + props: { customerId: '10001', inboundId: '20001' }, + global: { plugins: [pinia, ElementPlus] }, + }) +} + +describe('WeightOcrPanel confirmation behavior', () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + vi.mocked(getWeightWorkspace).mockResolvedValue(workspace) + vi.mocked(confirmWeightResult).mockResolvedValue({ + ...pendingWeight, + recognitionStatus: 'CONFIRMED', + finalWeightKg: 285.5, + version: 1, + }) + }) + + it('confirms an unchanged OCR value with one request', async () => { + const wrapper = mountPanel() + await flushPromises() + + await wrapper.get('.weight-row__editor .el-button--success').trigger('click') + await flushPromises() + + expect(confirmWeightResult).toHaveBeenCalledTimes(1) + expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 }) + expect(correctWeightResult).not.toHaveBeenCalled() + }) + + it('sends the adjusted value and a specific audit reason in the same confirmation request', async () => { + const wrapper = mountPanel() + await flushPromises() + + await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('286.125') + await wrapper.get('.weight-row__editor .el-button--success').trigger('click') + await flushPromises() + + expect(confirmWeightResult).toHaveBeenCalledTimes(1) + expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { + version: 0, + adjustedWeightValue: 286.125, + adjustedWeightUnit: 'KG', + adjustmentReason: 'OCR 原值 285.500 KG,人工确认 286.125 KG', + }) + expect(correctWeightResult).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts index f1f63590..583f8166 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts @@ -25,8 +25,9 @@ describe('WeightOcrPanel contract', () => { expect(source).toContain('workspace.value?.actualGrossWeightKg') expect(source).toContain('v-model="weightDrafts[row.id].value"') expect(source).toContain('识别值已自动填入,可修改后直接确认保存。') - expect(source).toContain("adjustmentReason: '人工核对后修改识别重量'") - expect(source).toContain('version = corrected.version') + expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)') + expect(source).toContain('payload.adjustmentReason = buildAdjustmentReason(row)') + expect(source).not.toContain('version = corrected.version') expect(source).not.toContain('title="人工修正重量"') }) diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.vue b/src/modules/inbounds/weight/components/WeightOcrPanel.vue index 816a9f90..ba7c8899 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.vue +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.vue @@ -106,6 +106,12 @@ function hasWeightDraftChanged(row: WeightResultResource) { return Number(draft.value) !== Number(savedValue) || draft.unit !== savedUnit } +function buildAdjustmentReason(row: WeightResultResource) { + const draft = weightDrafts.value[row.id] + const ocrUnit = normalizeUnit(row.ocrWeightUnit) + return `OCR 原值 ${formatWeight(row.ocrWeightValue, ocrUnit)},人工确认 ${formatWeight(Number(draft.value), draft.unit)}` +} + async function loadWorkspace() { if (!customerId.value || !props.inboundId || !canView.value) { workspace.value = null @@ -188,21 +194,17 @@ async function confirmResult(row: WeightResultResource) { activeAction.value = key actionError.value = null try { - let version = row.version + const payload = { version: row.version } as Parameters[3] if (hasWeightDraftChanged(row)) { if (!canEdit.value) { ElMessage.warning('当前账号缺少重量修改权限') return } - const corrected = await correctWeightResult(customerId.value, props.inboundId, row.id, { - version, - adjustedWeightValue: Number(draft.value), - adjustedWeightUnit: draft.unit, - adjustmentReason: '人工核对后修改识别重量', - }) - version = corrected.version + payload.adjustedWeightValue = Number(draft.value) + payload.adjustedWeightUnit = draft.unit + payload.adjustmentReason = buildAdjustmentReason(row) } - await confirmWeightResult(customerId.value, props.inboundId, row.id, { version }) + await confirmWeightResult(customerId.value, props.inboundId, row.id, payload) ElMessage.success('重量已确认并计入实际总重量') await loadWorkspace() emit('changed') @@ -399,6 +401,7 @@ defineExpose({ reload: loadWorkspace }) 单位 diff --git a/src/modules/inbounds/weight/types.ts b/src/modules/inbounds/weight/types.ts index c0c61630..0855a4ea 100644 --- a/src/modules/inbounds/weight/types.ts +++ b/src/modules/inbounds/weight/types.ts @@ -60,4 +60,7 @@ export interface CorrectWeightResultRequest { export interface ConfirmWeightResultRequest { version: number + adjustedWeightValue?: number + adjustedWeightUnit?: 'KG' | 'LB' + adjustmentReason?: string } diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 15bb68d6..b0cfdf65 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -43,7 +43,17 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain("{ value: 'UNRECORDED', label: '未录入' }") expect(source).not.toContain("{ value: 'DRAFT', label: '草稿' }") expect(source).not.toContain("{ value: 'CANCELLED', label: '已取消' }") - expect(source).toContain("placeholder: '请输入订单号'") + expect(source).not.toContain("key: 'orderNo'") + expect(source.match(/key: 'inboundCode', type: 'input'/g)).toHaveLength(1) + expect(source).not.toContain("key: 'keyword'") + const prototypeFieldOrder = [ + 'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'internalOrderStatus', 'expectedArrivalDate', + 'feeReviewStatus', 'tenantId', 'containerNo', 'cabinetNo', 'channelName', 'sailingDate', 'loaded', + 'fullContainer', 'measurementReviewStatus', 'fbaCode', 'createdRange', + ] + const fieldPositions = prototypeFieldOrder.map((key) => source.indexOf(`key: '${key}'`)) + expect(fieldPositions).toEqual([...fieldPositions].sort((left, right) => left - right)) + expect(source).toContain('preference-key="unified-orders-query-v2"') }) it('covers real list states and keeps operation column first and left-fixed', () => { @@ -61,6 +71,7 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain("NOT_SUBMITTED: '未提交'") expect(source).toContain("{ key: 'customerRemark', prop: 'customerRemark', label: '备注'") expect(source).toContain("{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '客服内部备注'") + expect(source.match(/ { diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index 1608f3e4..55994f65 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -23,6 +23,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import LineClampText from '@/shared/components/LineClampText.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import { @@ -33,8 +34,6 @@ import { type FilterModel = QueryModel & { tenantId: string - keyword: string - orderNo: string mainStatus: UnifiedOrderMainStatus | '' receivingWarehouseId: string destinationWarehouseId: string @@ -127,24 +126,6 @@ const receivingWarehouseOptions = computed(() => warehouses.value .map((warehouse) => ({ value: String(warehouse.id), label: `${warehouse.warehouseName}(${warehouse.warehouseCode})` }))) const primaryQueryFields = computed(() => [ - { - key: 'tenantId', - type: 'select', - width: 220, - label: '公司', - placeholder: customerLoadError.value ? '客户列表加载失败' : customerLoading.value ? '正在加载客户' : '全部客户', - options: customers.value.map((customer) => ({ - value: String(customer.id), - label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`, - })), - props: { 'aria-label': '客户', filterable: true, loading: customerLoading.value, disabled: Boolean(customerLoadError.value) }, - }, - { key: 'keyword', type: 'input', width: 260, label: '综合搜索', placeholder: '请输入订单号', props: { 'aria-label': '订单号关键词' } }, - { key: 'orderNo', type: 'input', width: 220, label: '订单号', placeholder: '请输入订单号', props: { 'aria-label': '订单号' } }, - { key: 'createdRange', type: 'daterange', width: 300, label: '创建日期', placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } }, -]) - -const advancedQueryFields = computed(() => [ { key: 'receivingWarehouseId', type: 'select', width: 220, label: '定位仓库', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择定位仓库', options: receivingWarehouseOptions.value, props: { 'aria-label': '定位仓库', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } }, { key: 'destinationWarehouseId', type: 'select', width: 220, label: '交货仓', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } }, { key: 'inboundCode', type: 'input', width: 220, label: '入仓码', placeholder: '请输入入仓码', props: { 'aria-label': '入仓码' } }, @@ -152,11 +133,26 @@ const advancedQueryFields = computed(() => [ { value: 'SUBMITTED', label: '待审核' }, { value: 'APPROVED', label: '已审核' }, { value: 'ACCEPTED', label: '已受理' }, { value: 'PROCESSING', label: '处理中' }, { value: 'COMPLETED', label: '已完成' }, ], props: { 'aria-label': '订单状态' } }, +]) + +const advancedQueryFields = computed(() => [ { key: 'expectedArrivalDate', type: 'date', width: 190, label: '预计到达时间', placeholder: '请选择预计到达日期', props: { 'aria-label': '预计到达时间' } }, { key: 'feeReviewStatus', type: 'select', width: 190, label: '费用审核状态', placeholder: '请选择费用审核状态', options: [ { value: 'UNRECORDED', label: '未录入' }, { value: 'PENDING', label: '待审核' }, { value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' }, ], props: { 'aria-label': '费用审核状态' } }, + { + key: 'tenantId', + type: 'select', + width: 220, + label: '公司', + placeholder: customerLoadError.value ? '客户列表加载失败' : customerLoading.value ? '正在加载客户' : '请选择公司', + options: customers.value.map((customer) => ({ + value: String(customer.id), + label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`, + })), + props: { 'aria-label': '公司', filterable: true, loading: customerLoading.value, disabled: Boolean(customerLoadError.value) }, + }, { key: 'containerNo', type: 'input', width: 210, label: '集装箱号', placeholder: '请输入集装箱号', props: { 'aria-label': '集装箱号' } }, { key: 'cabinetNo', type: 'input', width: 190, label: '柜号', placeholder: '请输入柜号', props: { 'aria-label': '柜号' } }, { key: 'channelName', type: 'input', width: 210, label: '渠道', placeholder: '请输入渠道名称', props: { 'aria-label': '渠道' } }, @@ -168,6 +164,7 @@ const advancedQueryFields = computed(() => [ { value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' }, ], props: { 'aria-label': '测量数据审核状态' } }, { key: 'fbaCode', type: 'input', width: 220, label: '商品 FBA 编码', placeholder: '请输入商品 FBA 编码', props: { 'aria-label': '商品 FBA 编码' } }, + { key: 'createdRange', type: 'daterange', width: 300, label: '创建日期', placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } }, ]) const allQueryFields = computed(() => [...primaryQueryFields.value, ...advancedQueryFields.value]) @@ -234,7 +231,7 @@ const columns: DataTableColumn[] = [ function emptyFilters(): FilterModel { return { - tenantId: '', keyword: '', orderNo: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '', + tenantId: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '', inboundCode: '', internalOrderStatus: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '', containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '', fbaCode: '', createdRange: [], @@ -622,8 +619,6 @@ async function loadOrders() { const range = filters.value.createdRange const result = await listPlatformUnifiedOrders({ tenantId: filters.value.tenantId || undefined, - keyword: trimmed(filters.value.keyword), - orderNo: trimmed(filters.value.orderNo), mainStatus: filters.value.mainStatus || undefined, receivingWarehouseId: filters.value.receivingWarehouseId || undefined, destinationWarehouseId: filters.value.destinationWarehouseId || undefined, @@ -918,21 +913,21 @@ onMounted(() => {
-
+
合计箱数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}总材积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}
-
+
合计箱数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record[]).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).gross) }}总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).volume, 6) }}总材积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record[]).volumetric) }}
-
+
合计箱数:{{ measurementDetail?.actualPackageCount ?? '—' }}总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}总材积:{{ formatNumber(measurementDetail?.volumeWeightKg) }}

测量图片

测量视频

@@ -966,7 +961,7 @@ onMounted(() => { >{{ tab.label }} - + - + diff --git a/src/shared/components/LineClampText.test.ts b/src/shared/components/LineClampText.test.ts new file mode 100644 index 00000000..9da384e0 --- /dev/null +++ b/src/shared/components/LineClampText.test.ts @@ -0,0 +1,43 @@ +import { defineComponent } from 'vue' +import { shallowMount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import LineClampText from './LineClampText.vue' + +const TooltipStub = defineComponent({ + props: { content: String, disabled: Boolean, visible: Boolean }, + template: '
', +}) + +describe('LineClampText', () => { + it('defaults to one line and exposes the full text to hover and focus', async () => { + const wrapper = shallowMount(LineClampText, { + props: { content: '第一行\n第二行' }, + global: { stubs: { ElTooltip: TooltipStub } }, + }) + const text = wrapper.get('.line-clamp-text') + expect(text.attributes('style')).toContain('--cc-line-clamp: 1') + expect(text.attributes('aria-label')).toBe('第一行\n第二行') + expect(wrapper.get('.tooltip-stub').attributes('data-content')).toBe('第一行\n第二行') + expect(wrapper.get('.tooltip-stub').attributes('data-visible')).toBe('false') + + await text.trigger('mouseenter') + expect(wrapper.get('.tooltip-stub').attributes('data-visible')).toBe('true') + await text.trigger('mouseleave') + expect(wrapper.get('.tooltip-stub').attributes('data-visible')).toBe('false') + await text.trigger('focus') + expect(wrapper.get('.tooltip-stub').attributes('data-visible')).toBe('true') + await text.trigger('blur') + expect(wrapper.get('.tooltip-stub').attributes('data-visible')).toBe('false') + }) + + it('accepts two and three line limits', () => { + expect(shallowMount(LineClampText, { + props: { content: 'text', lines: 2 }, + global: { stubs: { ElTooltip: TooltipStub } }, + }).get('.line-clamp-text').attributes('style')).toContain('--cc-line-clamp: 2') + expect(shallowMount(LineClampText, { + props: { content: 'text', lines: 3 }, + global: { stubs: { ElTooltip: TooltipStub } }, + }).get('.line-clamp-text').attributes('style')).toContain('--cc-line-clamp: 3') + }) +}) diff --git a/src/shared/components/LineClampText.vue b/src/shared/components/LineClampText.vue new file mode 100644 index 00000000..88b992c1 --- /dev/null +++ b/src/shared/components/LineClampText.vue @@ -0,0 +1,48 @@ + + + + +