diff --git a/AGENTS.md b/AGENTS.md index de7adabb..ffb91f64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ | 费用项目、计费科目 | `modules/fee-items/AGENTS.md` | | 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` | | 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` | -| 海外采购、卡车、散板 | `modules/overseas-business/AGENTS.md` | +| 海外采购、卡车、散板、海外费用明细与仓租 | `modules/overseas-business/AGENTS.md` | | 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` | | 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` | | 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | @@ -91,7 +91,7 @@ - 新建客户时,公司联系电话同时初始化首个管理员登录手机号;创建后公司联系电话只属于企业资料,与首个管理员、成员及平台用户登录手机号独立。编辑客户资料不得提交账号手机号变更,账号手机号改动统一走用户管理并由 IAM/Auth 撤销对应会话。 - 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。 - 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。 -- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。 +- 国内离仓状态按真实货柜展示:`ContainerDetailView.vue` 将权威状态传给 `ContainerCustomsPanel.vue`,页面不提供独立确认离仓按钮;报关完成不等于已离仓。不逐入仓提交离仓、不把拖车状态当阻断门禁或重复追加实际船节点。 - `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。 - `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。 - 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index 5dde558d..efa2db57 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -485,6 +485,11 @@ describe('destination document automatic upload', () => { await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())! expect(dialog.props('showConfirm')).toBe(false) + const documentName = type === 'DELIVERY_ORDER' ? 'DO 资料' : '目的港清关资料' + expect(dialog.props('title')).toContain(documentName) + expect(dialog.find('.el-form-item__label').exists()).toBe(false) + expect(dialog.text()).not.toContain(`请选择${documentName}。`) + expect(dialog.find(`[aria-label="选择${documentName}文件"]`).exists()).toBe(true) const file = new File(['data'], '资料.pdf', { type: 'application/pdf' }) const select = dialog.findComponent(ElUpload).props('onChange')! select({ raw: file } as never, []) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index c7c67bae..15f1253f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -686,9 +686,9 @@ onMounted(async () => { -
选择文件
+
选择文件
- + diff --git a/src/modules/inbounds/review/MeasurementReviewPanel.test.ts b/src/modules/inbounds/review/MeasurementReviewPanel.test.ts index 3d516a11..33035947 100644 --- a/src/modules/inbounds/review/MeasurementReviewPanel.test.ts +++ b/src/modules/inbounds/review/MeasurementReviewPanel.test.ts @@ -19,6 +19,7 @@ vi.mock('../weight/components/WeightOcrPanel.vue', () => ({ name: 'WeightOcrPanel', props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean, tableMode: Boolean }, emits: ['changed'], + methods: { reload: async () => {} }, template: '', }, })) @@ -42,6 +43,14 @@ const detail: MeasurementReviewDetail = { weightResults: [], } +const measuredItem: MeasurementReviewDetail['items'][number] = { + id: 'item-raw', boxCode: 'BOX-RAW-001', itemNo: '1', boxSpecification: null, packageCount: 22, + lengthCm: 62, widthCm: 52, heightCm: 52, girthCm: 270, unitWeightKg: 25.9318, + unitVolumeCbm: 0.3275, totalWeightKg: 570.5, totalVolumeCbm: 7.205, volumeWeightKg: 1203.235, + intelligentImageFileId: null, machineNo: null, deviceMeasuredAt: null, overLimitWarning: null, + outerLengthCm: 75, outerWidthCm: 66, outerHeightCm: 66, remark: null, +} + enableAutoUnmount(afterEach) describe('MeasurementReviewPanel', () => { @@ -98,18 +107,23 @@ describe('MeasurementReviewPanel', () => { expect(source).not.toContain('listMeasurementReviewHistory') }) - it.each([true, false])('describes dropship palletizing and uses the server result when pricing succeeds=%s', async (initialPricingSucceeded) => { - vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_PALLETIZE', feeStatus: initialPricingSucceeded ? 'CALCULATED' : 'UNPRICED', initialPricingSucceeded, initialPricingMessage: null, initialFee: null }) + it.each([ + { fulfillmentType: 'ONE_CLICK_DROPSHIP', processStatus: 'WAIT_PALLETIZE', targetLabel: '待一件代发组托', initialPricingSucceeded: true }, + { fulfillmentType: 'ONE_CLICK_DROPSHIP', processStatus: 'WAIT_PALLETIZE', targetLabel: '待一件代发组托', initialPricingSucceeded: false }, + { fulfillmentType: 'FBA', processStatus: 'WAIT_SPLIT', targetLabel: '待分柜', initialPricingSucceeded: true }, + { fulfillmentType: 'FBA', processStatus: 'WAIT_SPLIT', targetLabel: '待分柜', initialPricingSucceeded: false }, + ])('describes $fulfillmentType approval target and uses the server result when pricing succeeds=$initialPricingSucceeded', async ({ fulfillmentType, processStatus, targetLabel, initialPricingSucceeded }) => { + vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus, feeStatus: initialPricingSucceeded ? 'CALCULATED' : 'UNPRICED', initialPricingSucceeded, initialPricingMessage: null, initialFee: null }) const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) const success = vi.spyOn(ElMessage, 'success') const warning = vi.spyOn(ElMessage, 'warning') - const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001', fulfillmentType: 'ONE_CLICK_DROPSHIP' }, global: { plugins: [ElementPlus] } }) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001', fulfillmentType }, global: { plugins: [ElementPlus] } }) await flushPromises() expect(wrapper.text()).not.toContain('通过并进入') await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click') await flushPromises() - expect(confirmation).toHaveBeenCalledWith('审核通过后,入库单将进入待分柜', '确认测量', expect.objectContaining({ confirmButtonText: '通过' })) - expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining('入库单已进入待一件代发组托')) + expect(confirmation).toHaveBeenCalledWith(`审核通过后,入库单将进入${targetLabel}`, '确认测量', expect.objectContaining({ confirmButtonText: '通过' })) + expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining(`入库单已进入${targetLabel}`)) expect(approveMeasurement).toHaveBeenCalledTimes(1) expect(wrapper.emitted('changed')).toHaveLength(1) }) @@ -125,7 +139,7 @@ describe('MeasurementReviewPanel', () => { expect(wrapper.get('.review-toolbar__context').text()).toBe('客户:欧阳的公司 · 渠道:海运') }) - it('shows declared facts, authoritative measurement totals, and box-detail evidence for manual review', async () => { + it('shows declared facts, raw measured volume, weight totals and box-detail evidence for manual review', async () => { vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, originalPackageCount: 9, @@ -153,19 +167,19 @@ describe('MeasurementReviewPanel', () => { expect(comparisonRows.map(row => row.findAll('th, td').map(cell => cell.text()))).toEqual([ ['箱数', '9', '10', '差异 +1'], ['毛重(kg)', '118', '120', '差异 +2 kg'], - ['体积(m³)', '2', '2.2', '差异 +0.2 m³'], + ['体积(m³)', '2', '0.72', '差异 -1.28 m³'], ]) expect(wrapper.get('.review-comparison').text()).not.toContain('实际品名') expect(wrapper.get('.review-comparison').text()).not.toContain('原单据号码') const extraFacts = wrapper.get('.review-extra-facts').text().replace(/\s/g, '') expect(extraFacts).toContain('货物净重110kg') - expect(extraFacts).toContain('体积重367.4kg') + expect(extraFacts).toContain('计费体积重367.4kg') const items = wrapper.get('.review-items').text() expect(items).toContain('箱规测量明细') expect(items).toContain('BOX-001') expect(items).toContain('60 × 40 × 30') - expect(items).toContain('120.2') + expect(items).not.toContain('120.2') expect(items).toContain('2026-08-18 10:30:00') expect(items).not.toContain('每箱毛重(kg)') expect(items).not.toContain('总重量(kg)') @@ -182,6 +196,103 @@ describe('MeasurementReviewPanel', () => { expect(source).toContain('.review-table :deep(.review-time-cell .cell) { white-space: nowrap; }') }) + it('uses original measured dimensions for unit volume, row volume and summary without replacing billing snapshots', async () => { + const value = { ...detail, originalVolumeCbm: 3.3, actualVolumeCbm: 7.205, items: [{ ...measuredItem }] } + vi.mocked(getMeasurementReviewDetail).mockResolvedValue(value) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + const volumeRow = wrapper.get('.review-comparison tbody tr:last-child') + expect(volumeRow.findAll('td').map(cell => cell.text())).toEqual(['3.3', '3.688', '差异 +0.388 m³']) + const cells = wrapper.get('.review-items .el-table__body tbody tr').findAll('td') + expect(cells.slice(5, 7).map(cell => cell.text())).toEqual(['0.168', '3.688']) + expect(wrapper.get('.review-items').text()).not.toContain('1,203.235') + expect(value.actualVolumeCbm).toBe(7.205) + expect(value.items[0]).toMatchObject({ unitVolumeCbm: 0.3275, totalVolumeCbm: 7.205, outerLengthCm: 75 }) + }) + + it('sums all unrounded measured row volumes before formatting the review comparison', async () => { + vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, originalVolumeCbm: 3.3, actualVolumeCbm: 9.999, + items: [{ ...measuredItem }, { ...measuredItem, id: 'item-2', boxCode: 'BOX-RAW-002', lengthCm: 50, widthCm: 40, heightCm: 30, packageCount: 2 }] }) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(wrapper.get('.review-comparison tbody tr:last-child').findAll('td').map(cell => cell.text())).toEqual(['3.3', '3.808', '差异 +0.508 m³']) + }) + + it('separates warehouse overrides and their saved volume from original measured data', async () => { + const corrected = { ...measuredItem, outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53, + unitVolumeCbm: 0.177, totalVolumeCbm: 3.894, volumeWeightKg: 650.298 } + const unmodified = { ...measuredItem, id: 'item-unmodified', boxCode: 'BOX-RAW-002', + lengthCm: 10, widthCm: 20, heightCm: 30, packageCount: 2, + outerLengthCm: null, outerWidthCm: null, outerHeightCm: null, + unitVolumeCbm: 0.006, totalVolumeCbm: 0.012, volumeWeightKg: 2.004 } + const value = { ...detail, originalVolumeCbm: 3.3, actualPackageCount: 24, + actualVolumeCbm: 3.906, items: [corrected, unmodified] } + vi.mocked(getMeasurementReviewDetail).mockResolvedValue(value) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + + expect(wrapper.get('.review-comparison tbody tr:last-child').findAll('td').map(cell => cell.text())).toEqual(['3.3', '3.7', '差异 +0.4 m³']) + const measuredCells = wrapper.get('.review-items .el-table__body tbody tr').findAll('td') + expect(measuredCells[3]!.text()).toBe('62 × 52 × 52') + expect(measuredCells.slice(5, 7).map(cell => cell.text())).toEqual(['0.168', '3.688']) + const outer = wrapper.get('.review-outer-items') + expect(outer.get('h3').text()).toBe('仓库修改外箱') + expect(outer.findAll('.el-table__body tbody tr')).toHaveLength(1) + expect(outer.get('.el-table__body tbody tr').findAll('td').map(cell => cell.text())).toEqual([ + '1', 'BOX-RAW-001', '63', '53', '53', '22', '0.177', '3.894', '650.298', + ]) + expect(outer.get('.el-table__footer').findAll('td').map(cell => cell.text())).toEqual([ + '合计', '', '', '', '', '22', '—', '3.894', '650.298', + ]) + expect(value.items[0]).toBe(corrected) + expect(corrected).toMatchObject({ lengthCm: 62, unitVolumeCbm: 0.177, totalVolumeCbm: 3.894 }) + }) + + it('hides the warehouse outer section when no manual dimension was entered', async () => { + vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, items: [{ ...measuredItem, + outerLengthCm: null, outerWidthCm: null, outerHeightCm: null }] }) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(wrapper.find('.review-outer-items').exists()).toBe(false) + expect(wrapper.get('.review-items').text()).toContain('3.688') + }) + + it('keeps partial outer edits and missing saved volumes explicit, then removes the section after clearing', async () => { + const item = { ...measuredItem, outerLengthCm: 63, outerWidthCm: null, outerHeightCm: null, + unitVolumeCbm: null, totalVolumeCbm: null, volumeWeightKg: null } + vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, items: [item] }) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + const outer = wrapper.get('.review-outer-items') + expect(outer.get('.el-table__body tbody tr').findAll('td').slice(2).map(cell => cell.text())).toEqual([ + '63', '—', '—', '22', '—', '—', '—', + ]) + expect(outer.get('.el-table__footer').findAll('td').slice(7).map(cell => cell.text())).toEqual(['—', '—']) + vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, version: 5, items: [{ ...item, outerLengthCm: null }] }) + await wrapper.get('.review-refresh').trigger('click') + await flushPromises() + expect(wrapper.find('.review-outer-items').exists()).toBe(false) + expect(wrapper.get('.review-items').text()).toContain('3.688') + }) + + it('totals only the saved correction rows before formatting', async () => { + vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, items: [ + { ...measuredItem, totalVolumeCbm: 1.2344 }, + { ...measuredItem, id: 'item-2', boxCode: 'BOX-RAW-002', totalVolumeCbm: 1.2344 }, + ] }) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(wrapper.get('.review-outer-items .el-table__footer').findAll('td')[7]!.text()).toBe('2.469') + }) + + it.each([{ lengthCm: null }, { widthCm: 0 }, { heightCm: Number.NaN }, { packageCount: null }, { packageCount: 1.5 }])('keeps incomplete or invalid measured volume unknown instead of using billing totals: %o', async (invalid) => { + vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, items: [{ ...measuredItem }, { ...measuredItem, ...invalid, id: 'item-invalid' }] }) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(wrapper.get('.review-comparison tbody tr:last-child').findAll('td').map(cell => cell.text())).toEqual(['2', '—', '—']) + expect(wrapper.findAll('.review-items .el-table__body tbody tr')[1]!.findAll('td')[6]!.text()).toBe('—') + }) + it('keeps difference labels absent when declared values are unavailable', async () => { vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, originalGrossWeightKg: null, originalVolumeCbm: null }) const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) @@ -190,7 +301,7 @@ describe('MeasurementReviewPanel', () => { const rows = wrapper.findAll('.review-comparison tbody tr') expect(rows[0].findAll('td').at(-1)?.text()).toBe('一致') expect(rows[1].findAll('td').map(cell => cell.text())).toEqual(['—', '120', '—']) - expect(rows[2].findAll('td').map(cell => cell.text())).toEqual(['—', '2.2', '—']) + expect(rows[2].findAll('td').map(cell => cell.text())).toEqual(['—', '—', '—']) }) it('reuses WeightOcrPanel and enables only its review correction mode', async () => { diff --git a/src/modules/inbounds/review/MeasurementReviewPanel.vue b/src/modules/inbounds/review/MeasurementReviewPanel.vue index 39105005..59b79327 100644 --- a/src/modules/inbounds/review/MeasurementReviewPanel.vue +++ b/src/modules/inbounds/review/MeasurementReviewPanel.vue @@ -15,7 +15,7 @@ import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLab import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue' import type { WeightResultResource } from '../weight/types' import { approveMeasurement, getMeasurementReviewDetail } from './api' -import type { MeasurementReviewDetail } from './types' +import type { MeasurementReviewDetail, MeasurementReviewItem } from './types' const props = withDefaults(defineProps<{ customerId?: string @@ -60,6 +60,52 @@ const displayChannelName = computed(() => detail.value?.channelName?.trim() || d const measurementCount = computed(() => detail.value?.items.length || 0) const weightCount = computed(() => detail.value?.weightResults.length || 0) +// 实测展示只采用原始尺寸与确认箱数,规则计算后的体积仍留在服务端计费快照中。 +function measuredUnitVolumeCbm(item: MeasurementReviewItem) { + const { lengthCm, widthCm, heightCm } = item + if (lengthCm == null || widthCm == null || heightCm == null + || ![lengthCm, widthCm, heightCm].every(value => Number.isFinite(value) && value > 0)) return null + const volume = lengthCm * widthCm * heightCm / 1_000_000 + return Number.isFinite(volume) ? volume : null +} + +function measuredVolumeCbm(item: MeasurementReviewItem) { + const unitVolume = measuredUnitVolumeCbm(item) + if (unitVolume == null || item.packageCount == null || !Number.isInteger(item.packageCount) || item.packageCount < 1) return null + const volume = unitVolume * item.packageCount + return Number.isFinite(volume) ? volume : null +} + +const measuredTotalVolumeCbm = computed(() => { + if (!detail.value?.items.length) return null + let total = 0 + for (const item of detail.value.items) { + const volume = measuredVolumeCbm(item) + if (volume == null) return null + total += volume + } + return Number.isFinite(total) ? total : null +}) + +const warehouseOuterItems = computed(() => (detail.value?.items || []).filter(item => + item.outerLengthCm != null || item.outerWidthCm != null || item.outerHeightCm != null)) + +function warehouseOuterTotal(key: 'packageCount' | 'totalVolumeCbm' | 'volumeWeightKg') { + if (!warehouseOuterItems.value.length) return null + let total = 0 + for (const item of warehouseOuterItems.value) { + const value = item[key] + if (value == null || !Number.isFinite(value) || value < 0) return null + total += value + } + return Number.isFinite(total) ? total : null +} + +function warehouseOuterSummary() { + return ['合计', '', '', '', '', formatNumber(warehouseOuterTotal('packageCount'), 0), '—', + formatNumber(warehouseOuterTotal('totalVolumeCbm')), formatNumber(warehouseOuterTotal('volumeWeightKg'))] +} + function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) { if (value === null || value === undefined || !Number.isFinite(value)) return '—' return new Intl.NumberFormat('zh-CN', { maximumFractionDigits, minimumFractionDigits: 0 }).format(value) @@ -142,7 +188,7 @@ async function confirmApprove() { if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return try { await ElMessageBox.confirm( - '审核通过后,入库单将进入待分柜', + `审核通过后,入库单将进入${approvalTargetLabel.value}`, '确认测量', { customClass: 'admin-density-confirm', confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' }, ) @@ -213,10 +259,10 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action 箱数{{ formatNumber(detail.originalPackageCount, 0) }}{{ formatNumber(detail.actualPackageCount, 0) }}{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }} 毛重(kg){{ formatNumber(detail.originalGrossWeightKg) }}{{ formatNumber(detail.actualGrossWeightKg) }}{{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }} - 体积(m³){{ formatNumber(detail.originalVolumeCbm, 3) }}{{ formatNumber(detail.actualVolumeCbm, 3) }}{{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 3, ' m³') }} + 体积(m³){{ formatNumber(detail.originalVolumeCbm, 3) }}{{ formatNumber(measuredTotalVolumeCbm, 3) }}{{ formatDifference(measuredTotalVolumeCbm, detail.originalVolumeCbm, 3, ' m³') }} -
货物净重 {{ formatNumber(detail.actualNetWeightKg) }} kg体积重 {{ formatNumber(detail.volumeWeightKg) }} kg
+
货物净重 {{ formatNumber(detail.actualNetWeightKg) }} kg计费体积重 {{ formatNumber(detail.volumeWeightKg) }} kg
@@ -249,13 +295,10 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action - + - - - - + @@ -265,6 +308,42 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
+
+
+

+ {{ warehouseOuterItems.length }} 条 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.behavior.test.ts b/src/modules/loading-lists/components/LoadingListDetailDrawer.behavior.test.ts index 7a879625..f25f4cac 100644 --- a/src/modules/loading-lists/components/LoadingListDetailDrawer.behavior.test.ts +++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.behavior.test.ts @@ -2,8 +2,11 @@ import { flushPromises, mount, shallowMount } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import ElementPlus from 'element-plus' import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue' +import { printLoadingList } from '../printLoadingList' import type { LoadingDetail } from '../api/platformLoadingLists' +vi.mock('../printLoadingList', () => ({ printLoadingList: vi.fn(async () => true) })) + afterEach(() => vi.restoreAllMocks()) describe('shared manifest presentation', () => { @@ -33,7 +36,7 @@ describe('shared manifest presentation', () => { }) it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => { - const print = vi.spyOn(window, 'print').mockImplementation(() => {}) + const print = vi.mocked(printLoadingList) const wrapper = shallowMount(LoadingListDetailDrawer, { props: { mode: 'confirmed', dense: true, backLabel, canEdit: false, loading: false, error: null, detail: { summary: { planId: '1', warehouseId: '11', loadingListNo: 'LP-TEST', status: 'COMPLETED' }, items: [], histories: [] } as unknown as LoadingDetail }, @@ -44,7 +47,7 @@ describe('shared manifest presentation', () => { const button = wrapper.get('.detail-page-header__actions button') expect(button.text()).toBe('打印装柜清单') await button.trigger('click'); await flushPromises() - expect(print).toHaveBeenCalledTimes(1) + expect(print).toHaveBeenCalledExactlyOnceWith(wrapper.element) expect(wrapper.get('.detail-page-header__back').text()).toBe(backLabel || '返回列表') await wrapper.get('.detail-page-header__back').trigger('click') expect(wrapper.emitted('back')).toHaveLength(1) diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue index 0e1c4102..8ce0538f 100644 --- a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue +++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue @@ -2,6 +2,7 @@ import '@/styles/adminDensity.css' import '@/styles/adminWorkbench.css' import { computed, ref, watch } from 'vue' +import { ElMessage } from 'element-plus' import { ArrowLeft, Check, ChevronRight, Pencil, Printer } from '@lucide/vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' @@ -30,6 +31,15 @@ const props = defineProps<{ }>() const emit = defineEmits<{ back: []; retry: []; edit: []; editRemarks: [item: LoadingItem]; confirmPreload: [] }>() +const printRoot = ref() + +async function printDetail() { + if (props.mode !== 'confirmed' || props.loading || props.error || !props.detail) return false + try { return await printLoadingList(printRoot.value) } + catch (cause) { ElMessage.error(cause instanceof Error ? cause.message : '装柜清单打印失败,请重试'); return false } +} + +defineExpose({ print: printDetail }) const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false } const baseItemColumns: DataTableColumn[] = [ @@ -169,7 +179,7 @@ function formatNumber(value?: number | null, digits = 3) {