Merge origin/release/test and reconcile overseas business tests

This commit is contained in:
2026-10-30 18:03:35 +08:00
23 changed files with 791 additions and 75 deletions
+2 -2
View File
@@ -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`。
@@ -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, [])
@@ -686,9 +686,9 @@ onMounted(async () => {
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving"><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving" :aria-label="`选择${documentTitle}文件`"><FileUp :size="16" />选择文件</el-button><template v-if="selectedDocument" #tip><div class="el-upload__tip">上传新文件将替换当前资料并保留历史。</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving" aria-label="选择目的港清关资料文件"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
@@ -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: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>',
},
}))
@@ -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 () => {
@@ -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
<tbody>
<tr><th scope="row">箱数</th><td>{{ formatNumber(detail.originalPackageCount, 0) }}</td><td>{{ formatNumber(detail.actualPackageCount, 0) }}</td><td>{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</td></tr>
<tr><th scope="row">毛重(kg)</th><td>{{ formatNumber(detail.originalGrossWeightKg) }}</td><td>{{ formatNumber(detail.actualGrossWeightKg) }}</td><td>{{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</td></tr>
<tr><th scope="row">体积(m³)</th><td>{{ formatNumber(detail.originalVolumeCbm, 3) }}</td><td>{{ formatNumber(detail.actualVolumeCbm, 3) }}</td><td>{{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 3, ' m³') }}</td></tr>
<tr><th scope="row">体积(m³)</th><td>{{ formatNumber(detail.originalVolumeCbm, 3) }}</td><td>{{ formatNumber(measuredTotalVolumeCbm, 3) }}</td><td>{{ formatDifference(measuredTotalVolumeCbm, detail.originalVolumeCbm, 3, ' m³') }}</td></tr>
</tbody>
</table></div>
<div class="review-extra-facts"><span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span><span>体积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span></div>
<div class="review-extra-facts"><span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span><span>计费体积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span></div>
</section>
<section class="review-items density-dialog-section" data-tone="source" aria-labelledby="review-items-title">
@@ -249,13 +295,10 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template>
</el-table-column>
<el-table-column label="单箱体积(m³)" width="106" align="right">
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 3) }}</template>
<template #default="{ row }">{{ formatNumber(measuredUnitVolumeCbm(row as MeasurementReviewItem), 3) }}</template>
</el-table-column>
<el-table-column label="总体积(m³)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 3) }}</template>
</el-table-column>
<el-table-column label="体积重(kg)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
<template #default="{ row }">{{ formatNumber(measuredVolumeCbm(row as MeasurementReviewItem), 3) }}</template>
</el-table-column>
<el-table-column label="测量时间" width="220" class-name="review-time-cell">
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
@@ -265,6 +308,42 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<el-empty v-else description="暂无箱规测量明细" :image-size="48" />
</section>
<section v-if="warehouseOuterItems.length" class="review-items review-outer-items density-dialog-section" aria-labelledby="review-outer-title">
<header class="review-section-heading density-dialog-section__title">
<h3 id="review-outer-title" class="review-detail-heading"><Ruler :size="18" aria-hidden="true" />仓库修改外箱</h3>
<span>{{ warehouseOuterItems.length }} 条</span>
</header>
<div class="review-table-scroll">
<el-table :data="warehouseOuterItems" border stripe table-layout="fixed" class="review-table" show-summary :summary-method="warehouseOuterSummary">
<el-table-column type="index" label="#" width="54" align="center" />
<el-table-column label="箱号" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
</el-table-column>
<el-table-column label="仓库修改外箱(长)cm" min-width="150" align="right">
<template #default="{ row }">{{ formatNumber(row.outerLengthCm) }}</template>
</el-table-column>
<el-table-column label="仓库修改外箱(宽)cm" min-width="150" align="right">
<template #default="{ row }">{{ formatNumber(row.outerWidthCm) }}</template>
</el-table-column>
<el-table-column label="仓库修改外箱(高)cm" min-width="150" align="right">
<template #default="{ row }">{{ formatNumber(row.outerHeightCm) }}</template>
</el-table-column>
<el-table-column label="箱数" width="66" align="right">
<template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template>
</el-table-column>
<el-table-column label="计算后单箱体积(m³)" min-width="138" align="right">
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 3) }}</template>
</el-table-column>
<el-table-column label="计算后总体积(m³)" min-width="138" align="right">
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 3) }}</template>
</el-table-column>
<el-table-column label="计费体积重(kg)" min-width="120" align="right">
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
</el-table-column>
</el-table>
</div>
</section>
<section class="review-items review-weight-items density-dialog-section" data-tone="source" aria-labelledby="review-weight-title">
<header class="review-section-heading density-dialog-section__title">
<h3 id="review-weight-title" class="review-detail-heading"><Scale :size="18" aria-hidden="true" />称重明细</h3>
@@ -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)
@@ -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<HTMLElement>()
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) {
</script>
<template>
<section v-loading="loading" class="loading-detail-content admin-workbench" :class="{ 'admin-density loading-density': mode === 'preload' || dense, 'workbench-panel is-compact is-page-surface': !embedded }" :aria-label="title" :aria-busy="loading">
<section ref="printRoot" v-loading="loading" class="loading-detail-content admin-workbench" :class="{ 'admin-density loading-density': mode === 'preload' || dense, 'workbench-panel is-compact is-page-surface': !embedded }" :aria-label="title" :aria-busy="loading">
<section class="detail-overview" aria-label="清单概览">
<header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }">
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button>
@@ -177,7 +187,7 @@ function formatNumber(value?: number | null, digits = 3) {
<h1>{{ title }}</h1>
</div>
<div v-if="detail && summary" class="detail-page-header__actions">
<el-button v-if="mode === 'confirmed'" :disabled="loading" @click="printLoadingList"><Printer :size="16" />打印装柜清单</el-button>
<el-button v-if="mode === 'confirmed'" :disabled="loading || Boolean(error)" @click="printDetail"><Printer :size="16" />打印装柜清单</el-button>
<PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
<template v-if="canEditItemRemark">
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
@@ -185,7 +195,7 @@ function formatNumber(value?: number | null, digits = 3) {
</template>
</div>
</header>
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息">
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息" data-loading-list-print-summary>
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待补录' }}</strong></div>
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
@@ -213,7 +223,7 @@ function formatNumber(value?: number | null, digits = 3) {
</div>
<section class="detail-section">
<div class="workbench-table"><DataTable
<div class="workbench-table" data-loading-list-print-items><DataTable
:data="detail.items"
:columns="itemColumns"
stripe
@@ -3,7 +3,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import ConfirmedLoadingListDetailView from './ConfirmedLoadingListDetailView.vue'
const mocks = vi.hoisted(() => ({ get: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record<string, string> } }))
const mocks = vi.hoisted(() => ({ get: vi.fn(), print: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => mocks }))
vi.mock('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get, getPreloadPlan: vi.fn() }))
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
@@ -11,7 +11,7 @@ vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ m
function render() {
return shallowMount(ConfirmedLoadingListDetailView, { global: { stubs: {
AdminPage: { template: '<main><slot /></main>' },
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<section />' },
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], methods: { print: () => mocks.print() }, template: '<section />' },
} } })
}
const completed = (planId = '1') => ({ summary: { status: 'COMPLETED', planId, loadingListNo: `LIST-${planId}` }, items: [], histories: [] })
@@ -21,7 +21,7 @@ describe('confirmed loading list detail page', () => {
vi.clearAllMocks()
mocks.route = reactive({ params: { planId: '1' }, query: { warehouseId: '11' } })
mocks.get.mockResolvedValue(completed())
vi.spyOn(window, 'print').mockImplementation(() => {})
mocks.print.mockResolvedValue(true)
})
afterEach(() => vi.restoreAllMocks())
it('loads read-only detail on the dedicated page and returns to the list', async () => {
@@ -29,7 +29,7 @@ describe('confirmed loading list detail page', () => {
expect(mocks.get).toHaveBeenCalledWith('1', '11')
const detail = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
expect(detail.props('detail')).toEqual(completed())
expect(window.print).not.toHaveBeenCalled()
expect(mocks.print).not.toHaveBeenCalled()
vi.spyOn(window.history, 'length', 'get').mockReturnValue(1)
detail.vm.$emit('back')
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-lists' })
@@ -38,7 +38,7 @@ describe('confirmed loading list detail page', () => {
it('prints only after detail has loaded and removes the print request', async () => {
mocks.route.query.print = '1'
const wrapper = render(); await flushPromises()
expect(window.print).toHaveBeenCalledTimes(1)
expect(mocks.print).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false)
expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
wrapper.unmount()
@@ -48,7 +48,7 @@ describe('confirmed loading list detail page', () => {
mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error').message).toBe('清单不可用')
expect(window.print).not.toHaveBeenCalled()
expect(mocks.print).not.toHaveBeenCalled()
wrapper.unmount()
})
it('ignores a stale response after switching manifests', async () => {
@@ -1,23 +1,24 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { useLoadingListDetail } from '../useLoadingListDetail'
import { printLoadingList } from '../printLoadingList'
const route = useRoute()
const router = useRouter()
const { detail, loading, error, load } = useLoadingListDetail()
const detailView = ref<InstanceType<typeof LoadingListDetailDrawer>>()
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
async function loadDetail() {
const result = await load('confirmed', planId.value, warehouseId.value)
if (!result || detail.value !== result || route.query.print !== '1') return
await printLoadingList()
await nextTick()
if (!await detailView.value?.print() || detail.value !== result) return
const { print: _print, ...query } = route.query
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
}
@@ -34,7 +35,7 @@ watch([planId, warehouseId], () => void loadDetail())
<template>
<AdminPage class="admin-workbench admin-density">
<div class="confirmed-loading-detail-page">
<LoadingListDetailDrawer dense
<LoadingListDetailDrawer ref="detailView" dense
mode="confirmed"
:loading="loading"
:detail="detail"
@@ -0,0 +1,101 @@
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { printLoadingList } from './printLoadingList'
afterEach(() => { vi.restoreAllMocks(); document.body.replaceChildren() })
function manifest() {
document.body.innerHTML = `<aside>平台导航</aside><nav>后台页签</nav><section id="manifest" aria-label="装柜清单 · LIST-TEST">
<header><button>返回货柜概览</button><button>打印装柜清单</button></header>
<section class="detail-summary" data-loading-list-print-summary><div><span>柜次号</span><strong>CB-TEST</strong></div><div><span>集装箱号</span><strong>CONTAINER-TEST</strong></div></section>
<div data-loading-list-print-items><button>字段设置</button><div style="width:3000px;overflow:hidden"><table>
<thead><tr><th>入仓码</th><th>SKU</th><th>备注</th></tr></thead>
<tbody><tr><td style="width:200px;white-space:nowrap">IN-FIRST</td><td>SKU &amp; &lt;盒&gt;</td><td>第一行备注</td></tr>
<tr><td>IN-LAST</td><td>SKU-LAST</td><td>最后一列完整备注</td></tr></tbody>
</table></div></div><section>操作历史:创建计划、调整货物</section></section>`
return document.getElementById('manifest')!
}
function interceptPrint(onPrint: (frame: HTMLIFrameElement) => void) {
let frame: HTMLIFrameElement | undefined
const append = document.body.append.bind(document.body)
vi.spyOn(document.body, 'append').mockImplementation((...nodes) => {
append(...nodes)
for (const node of nodes) {
if (!(node instanceof HTMLIFrameElement)) continue
frame = node
vi.spyOn(node.contentWindow!, 'focus').mockImplementation(() => {})
vi.spyOn(node.contentWindow!, 'print').mockImplementation(() => onPrint(node))
}
})
return async () => {
await nextTick()
if (!frame) throw new Error('Print frame was not created')
frame.dispatchEvent(new Event('load'))
return frame
}
}
describe('loading list printing', () => {
it('prints the manifest summary and every cargo row and column in an isolated document', async () => {
const root = manifest(), original = root.outerHTML
const hostPrint = vi.spyOn(window, 'print').mockImplementation(() => {})
let snapshot: Document | undefined
const load = interceptPrint(frame => {
snapshot = frame.contentDocument!
expect(snapshot.title).toBe('装柜清单 · LIST-TEST')
expect(snapshot.querySelector('h1')?.textContent).toBe('装柜清单 · LIST-TEST')
expect(snapshot.body.textContent).toContain('CB-TEST')
expect(snapshot.body.textContent).toContain('CONTAINER-TEST')
expect(snapshot.querySelectorAll('tbody tr')).toHaveLength(2)
expect(snapshot.querySelectorAll('thead th')).toHaveLength(3)
expect(snapshot.body.textContent).toContain('最后一列完整备注')
expect(snapshot.body.textContent).toContain('SKU & <盒>')
expect(snapshot.querySelector('td')?.getAttribute('style')).toBeNull()
expect(snapshot.body.textContent).not.toMatch(/平台导航|后台页签|返回货柜概览|字段设置|操作历史/)
expect(snapshot.querySelectorAll('button')).toHaveLength(0)
frame.contentWindow!.dispatchEvent(new Event('afterprint'))
})
const pending = printLoadingList(root)
const frame = await load()
await expect(pending).resolves.toBe(true)
expect(snapshot).toBeDefined()
expect(hostPrint).not.toHaveBeenCalled()
expect(frame.isConnected).toBe(false)
expect(root.outerHTML).toBe(original)
})
it('keeps the print document until the dialog closes and prevents duplicate print jobs', async () => {
const root = manifest(), print = vi.fn()
const load = interceptPrint(print)
const pending = printLoadingList(root)
const frame = await load()
expect(frame.isConnected).toBe(true)
expect(print).toHaveBeenCalledOnce()
await expect(printLoadingList(root)).resolves.toBe(false)
expect(document.querySelectorAll('iframe')).toHaveLength(1)
frame.contentWindow!.dispatchEvent(new Event('afterprint'))
await expect(pending).resolves.toBe(true)
expect(frame.isConnected).toBe(false)
expect(document.querySelector('aside')?.textContent).toBe('平台导航')
})
it('never falls back to printing the entire page when the manifest is missing or incomplete', async () => {
const hostPrint = vi.spyOn(window, 'print').mockImplementation(() => {})
await expect(printLoadingList(null)).rejects.toThrow('清单尚未加载完成')
await expect(printLoadingList(document.createElement('section'))).rejects.toThrow('清单尚未加载完成')
expect(hostPrint).not.toHaveBeenCalled()
expect(document.querySelector('iframe')).toBeNull()
})
it('removes the isolated document after a print failure', async () => {
const root = manifest(), original = root.outerHTML
const load = interceptPrint(() => { throw new Error('printer unavailable') })
const pending = printLoadingList(root)
const rejected = expect(pending).rejects.toThrow('printer unavailable')
const frame = await load()
await rejected
expect(frame.isConnected).toBe(false)
expect(root.outerHTML).toBe(original)
})
})
+79 -3
View File
@@ -1,6 +1,82 @@
import { nextTick } from 'vue'
export async function printLoadingList() {
await nextTick()
window.print()
const printStyles = `
@page { size: A4 landscape; margin: 10mm; }
body { margin: 0; color: #111; font: 10pt/1.4 Arial, "Microsoft YaHei", sans-serif; }
h1 { margin: 0 0 12px; font-size: 16pt; }
.detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 16px; border-top: 1px solid #aaa; border-left: 1px solid #aaa; }
.detail-summary > div { display: flex; gap: 8px; padding: 6px; border-right: 1px solid #aaa; border-bottom: 1px solid #aaa; }
.detail-summary > div > span:first-child { flex-shrink: 0; color: #555; }
.detail-summary strong { min-width: 0; overflow-wrap: anywhere; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 8pt; }
caption { margin-bottom: 6px; font-size: 11pt; font-weight: bold; text-align: left; }
th, td { padding: 4px; border: 1px solid #aaa; vertical-align: top; text-align: left; overflow-wrap: anywhere; }
th { background: #eee; }
.cell { white-space: normal; overflow-wrap: anywhere; }
small { display: block; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
`
let printing = false
function printDocumentHtml(root: HTMLElement) {
const summary = root.querySelector('[data-loading-list-print-summary]')
const items = root.querySelector('[data-loading-list-print-items]')
const head = items?.querySelector('thead')
const body = items?.querySelector('tbody')
if (!summary || !head || !body) throw new Error('清单尚未加载完成,请稍后重试')
const title = root.getAttribute('aria-label') || '装柜清单'
const page = document.implementation.createHTMLDocument(title)
page.documentElement.lang = 'zh-CN'
const style = page.createElement('style')
style.textContent = printStyles
page.head.append(style)
const heading = page.createElement('h1')
heading.textContent = title
const table = page.createElement('table')
const caption = page.createElement('caption')
caption.textContent = '装柜货物明细'
table.append(caption, head.cloneNode(true), body.cloneNode(true))
page.body.append(heading, summary.cloneNode(true), table)
page.body.querySelectorAll('button, input, svg, [hidden], [aria-hidden="true"]').forEach(node => node.remove())
page.body.querySelectorAll<HTMLElement>('[style]').forEach(node => {
if (node.style.display === 'none') node.remove()
else node.removeAttribute('style')
})
return `<!doctype html>${page.documentElement.outerHTML}`
}
export async function printLoadingList(root: HTMLElement | null | undefined): Promise<boolean> {
if (printing) return false
if (!root) throw new Error('清单尚未加载完成,请稍后重试')
printing = true
let frame: HTMLIFrameElement | undefined
try {
await nextTick()
const html = printDocumentHtml(root)
frame = document.createElement('iframe')
frame.title = '装柜清单打印'
frame.setAttribute('aria-hidden', 'true')
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:297mm;height:210mm;border:0'
document.body.append(frame)
const printDocument = frame.contentDocument
if (!printDocument) throw new Error('无法创建打印页面,请重试')
const printFrame = frame
return await new Promise<boolean>((resolve, reject) => {
printFrame.addEventListener('load', () => {
const target = printFrame.contentWindow
if (!target) { reject(new Error('无法创建打印页面,请重试')); return }
target.addEventListener('afterprint', () => resolve(true), { once: true })
try { target.focus(); target.print() } catch (cause) { reject(cause) }
}, { once: true })
printDocument.open()
printDocument.write(html)
printDocument.close()
})
} finally {
frame?.remove()
printing = false
}
}
+7 -6
View File
@@ -3,28 +3,29 @@
## 路径基准与读取顺序
- 本文件适用于 `src/modules/overseas-business/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,采购与本土业务细则见 `../../../docs/business-index.md`。
- 三条业务线共用同一模块:`purchases`(海外采购)、`trucks`(本土卡车)、`looseboards`(本土散板);`trucks` 与 `looseboards` 复用同一组页面,按路由区分模式。
- 后端只查 `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/`、`financefee/`;纯页面与文案不读后端。
- 本模块承载海外采购(`purchases`)、本土卡车(`trucks`)、本土散板(`looseboards`)和海外费用(`fees`);卡车/散板复用页面,仓租库存位于费用页页签,无独立路由。
- 后端只查 `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/`、`financefee/`、`storagerent/`;纯页面与文案不读后端。
## 页面与入口
| 目标 | 页面/路由 | API 与后端 |
|---|---|---|
| 海外采购订单列表与库存变化 | `pages/PurchaseOrdersView.vue`;`overseas-business/purchases` | `api/platformOverseasBusiness.ts`;`lastleg-service/procurement/` |
| 采购订单详情(商品、库存、订单、快递、进销存) | `pages/PurchaseOrderDetailView.vue`;`overseas-business/purchases/:purchaseId` | `api/platformOverseasBusiness.ts` |
| 采购详情、分批收货/出库与签收 | `pages/PurchaseOrderDetailView.vue`、`components/ProcurementWorkflowPanel.vue`;`overseas-business/purchases/:purchaseId` | `api/platformOverseasBusiness.ts`、`api/procurementWorkflow.ts`、`api/procurementCommands.ts` |
| 采购订单编辑(采购商品、库存、快递) | `pages/PurchaseOrderEditView.vue`;`overseas-business/purchases/:purchaseId/edit` | `api/platformOverseasBusiness.ts`(`getOverseasPurchaseEditor`、`updateOverseasPurchase`) |
| 采购订单分区字段:管控、财务、节点、实物、运输 | `components/ProcurementControlFields.vue`、`ProcurementFinanceFields.vue`、`ProcurementNodeActions.vue`、`ProcurementPhysicalFields.vue`、`ProcurementTransportFields.vue` | `api/procurementCommands.ts`;节点动作与费用入池归 `lastleg-service/financefee/` |
| 采购订单分区字段:费用(纯展示,无请求) | `components/ProcurementChargeFields.vue` | 只接收 props,数据由详情页 `PurchaseOrderDetailView.vue` 提供 |
| 本土卡车订单列表 | `pages/TransportOrdersView.vue`;`overseas-business/trucks` | `api/platformOverseasBusiness.ts`;`lastleg-service/trucking/` |
| 本土卡车新增/编辑 | `pages/TransportOrderFormView.vue`;`overseas-business/trucks/new`、`overseas-business/trucks/:orderId/edit` | `api/platformOverseasBusiness.ts`;`components/TransportDemandForm.vue` |
| 本土散板订单列表与新增/编辑 | `pages/TransportOrdersView.vue`、`pages/TransportOrderFormView.vue`;`overseas-business/looseboards`、`.../new`、`.../:orderId/edit` | `api/platformOverseasBusiness.ts`;`lastleg-service/looseboard/` |
| 车次安排摘要 | `components/FleetArrangementSummary.vue` | 无直接请求;数据由 `fleetArrangement.ts` 与父页面传入 |
| 海外费用明细、审核、调价与资金/投递重试 | `pages/OverseasFeesView.vue`;`overseas-business/fees` | `api/overseasFees.ts`、`components/OverseasFeeRetryHistory.vue`;Backend `financefee/` |
| 业务单费用窗口与新增费用 | `components/BusinessDocumentFees.vue` → `BusinessOrderFees.vue`、`OverseasFeeEditor.vue` | `api/overseasFees.ts`;采购及其他业务单共用,不重建费用链路 |
| 仓租库存、计费明细与审核调价 | `components/StorageRentPanel.vue`、`StorageRentFeeDetails.vue`、`StorageRentFeeActions.vue` | `api/storageRent.ts`;Backend `storagerent/`,审核沿海外费用接口 |
## 关键边界
- 采购按 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md` 的七状态流程执行:审核、分批收货、客户确认、分批出库及签收扣采购库存;费用复用海外费用窗口,默认余额支付,不恢复旧两段样品节点。
- 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。
- 采购费用入海外费用池后由 `finance/` 记账,本模块不判定收付状态,不重复写入费用行。
- 费用、仓租及钱包/账单边界按 Lastleg 详细索引“海外业务费用来源”“自动仓租及出库资金门禁”核对;页面只消费返回状态和操作资格,不推定收付状态或重复记账。
- 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。
- 同一路由组件的两种模式(卡车/散板)变更时,两侧都要回归,避免只改一侧。
@@ -1,5 +1,5 @@
import AppDialog from '@/shared/components/AppDialog.vue'
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessage } from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
@@ -66,6 +66,32 @@ const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', busi
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail })
return { promise, resolve, reject }
}
async function mountPurchaseReviewRequests() {
const first = deferred<Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>>()
const second = deferred<Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>>()
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [
{ ...purchase, id: '101', orderNo: 'OP-A', status: 'PENDING_REVIEW', procurementDirection: 'OVERSEAS_TO_DOMESTIC' },
{ ...purchase, id: '102', orderNo: 'OP-B', status: 'PENDING_REVIEW', procurementDirection: 'DOMESTIC_TO_OVERSEAS' },
], page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(direction => direction === 'OVERSEAS_TO_DOMESTIC' ? first.promise : second.promise)
const wrapper = mountPage(PurchaseOrdersView)
await flushPromises()
const buttons = wrapper.findAll('button').filter(button => button.text() === '审核')
await buttons[0]!.trigger('click')
const dialog = wrapper.findComponent(AppDialog)
const openSecond = async () => {
dialog.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
await buttons[1]!.trigger('click')
}
return { wrapper, dialog, first, second, openSecond }
}
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
describe('overseas business mounted pages', () => {
@@ -222,6 +248,66 @@ describe('overseas business mounted pages', () => {
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ version: 3, decision: 'REJECT', remark: '商品型号与要求不符' }))
expect(submitProcurementCommand).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expect.objectContaining({ reason: expect.anything() }))
})
it('keeps the current procurement warehouses when an earlier order resolves after the new order', async () => {
const { dialog, first, second, openSecond } = await mountPurchaseReviewRequests()
await openSecond()
second.resolve([{ warehouseId: 'W-B', warehouseCode: 'B', warehouseName: 'B 仓', countryCode: 'CN' }])
await flushPromises()
first.resolve([{ warehouseId: 'W-A', warehouseCode: 'A', warehouseName: 'A 仓', countryCode: 'US' }])
await flushPromises()
expect(dialog.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['W-B'])
const form = dialog.findComponent({ name: 'ElForm' }).props('model') as { purchaseWarehouseId: string }
form.purchaseWarehouseId = 'W-B'
dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-B', purchase.customerId, 'review', expect.objectContaining({ purchaseWarehouseId: 'W-B', version: purchase.version }))
})
it.each(['success', 'failure'])('ignores an old procurement warehouse %s while the current request is still loading', async outcome => {
const notification = vi.spyOn(ElMessage, 'error')
try {
const { dialog, first, second, openSecond } = await mountPurchaseReviewRequests()
await openSecond()
if (outcome === 'success') first.resolve([{ warehouseId: 'W-A', warehouseCode: 'A', warehouseName: 'A 仓', countryCode: 'US' }])
else first.reject(new Error('A 仓库查询失败'))
await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.findComponent({ name: 'ElSelect' }).props('loading')).toBe(true)
expect(dialog.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
expect(notification).not.toHaveBeenCalled()
second.resolve([{ warehouseId: 'W-B', warehouseCode: 'B', warehouseName: 'B 仓', countryCode: 'CN' }])
await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(dialog.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['W-B'])
} finally { notification.mockRestore() }
})
it.each(['close', 'unmount', 'account'])('drops late procurement warehouse failures after %s', async action => {
const notification = vi.spyOn(ElMessage, 'error')
try {
const { wrapper, dialog, first } = await mountPurchaseReviewRequests()
if (action === 'close') dialog.vm.$emit('update:modelValue', false)
else if (action === 'unmount') wrapper.unmount()
else { const auth = useAuthStore(); auth.user = { ...auth.user!, id: 'other-operator' } }
await flushPromises()
first.reject(new Error('已离开的订单查询失败'))
await flushPromises()
expect(notification).not.toHaveBeenCalled()
if (action !== 'unmount') {
expect(dialog.props('modelValue')).toBe(false)
expect(dialog.props('confirmDisabled')).toBe(false)
}
} finally { notification.mockRestore() }
})
it('still reports a current procurement warehouse failure and releases its loading state', async () => {
const notification = vi.spyOn(ElMessage, 'error')
try {
const { dialog, first } = await mountPurchaseReviewRequests()
first.reject(new Error('当前仓库查询失败'))
await flushPromises()
expect(notification).toHaveBeenCalledOnce()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.props('confirmDisabled')).toBe(false)
expect(dialog.findComponent({ name: 'ElSelect' }).props('loading')).toBe(false)
} finally { notification.mockRestore() }
})
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
@@ -300,7 +386,7 @@ describe('overseas business mounted pages', () => {
? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址']
: ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址']
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels])
Object.assign(model, { vehicleType: 'PALLET_15', estimatedPalletCount: 1, origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 1 : 0, currency: 'CNY' })
Object.assign(model, { vehicleType: 'PALLET_15', estimatedPalletCount: domain === 'truck' ? 2 : 0, origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY' })
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
await submit.trigger('click'); await flushPromises()
expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
@@ -310,7 +396,8 @@ describe('overseas business mounted pages', () => {
expect(command).toHaveBeenCalledOnce()
const [customer, payload] = command.mock.calls[0]!
expect(customer).toBe('10001')
expect(payload).toEqual(expect.objectContaining({ origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', totalWeightKg: 0, totalVolumeCbm: 0, budgetAmount: domain === 'truck' ? 1 : 0, currency: 'CNY', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' }))
expect(payload).toEqual(expect.objectContaining({ origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', totalWeightKg: 0, totalVolumeCbm: 0, budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' }))
if (domain === 'truck') { expect(payload).toMatchObject({ palletCount: 2 }); expect(payload).not.toHaveProperty('packageCount') }
for (const field of ['customerId', 'tenantId', 'orderNo', 'status', 'transportMode', 'containerType']) expect(payload).not.toHaveProperty(field)
})
@@ -327,7 +414,7 @@ describe('overseas business mounted pages', () => {
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const model = wrapper.findComponent({ name: 'ElForm' }).props('model'); Object.assign(model, { estimatedPalletCount: 1, budgetAmount: 1 }); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const model = wrapper.findComponent({ name: 'ElForm' }).props('model'); Object.assign(model, { estimatedPalletCount: 2, budgetAmount: 100 }); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
it('requires split contacts and both local times, rejecting reversed times', async () => {
@@ -30,6 +30,8 @@ const reviewOpen = ref(false), reviewSaving = ref(false), reviewLoading = ref(fa
const reviewRow = ref<OverseasPurchaseSummary | null>(null), reviewWarehouses = ref<Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>>([])
const reviewRequestKey = ref('')
const reviewForm = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', purchaseWarehouseId: '', remark: '' })
let reviewGeneration = 0
watch(reviewOpen, value => { if (!value) invalidateReviewRequest() }, { flush: 'sync' })
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
@@ -53,7 +55,7 @@ const columns: DataTableColumn[] = [
]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = ''
onBeforeUnmount(() => { generation++ })
onBeforeUnmount(() => { generation++; invalidateReviewRequest() })
function load() {
const query: OverseasPurchaseQuery = { page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || ''), status: String(applied.value.status || '') as OverseasPurchaseStatus | '' }
const key = JSON.stringify([auth.user?.id, query])
@@ -69,19 +71,29 @@ function load() {
})
inFlight = promise; return promise
}
watch(() => auth.user?.id, () => { rows.value = []; total.value = 0; void load() })
watch(() => auth.user?.id, () => { reviewOpen.value = false; rows.value = []; total.value = 0; void load() })
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function open(row: OverseasPurchaseSummary) { void router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
function invalidateReviewRequest() {
reviewGeneration++
reviewLoading.value = false; reviewWarehouses.value = []; reviewRow.value = null
}
async function beginReview(row: OverseasPurchaseSummary) {
if (reviewSaving.value) return
const current = ++reviewGeneration; const owner = auth.user?.id
reviewRow.value = row; reviewWarehouses.value = []; reviewRequestKey.value = crypto.randomUUID(); Object.assign(reviewForm, { decision: 'APPROVE', purchaseWarehouseId: '', remark: '' }); reviewOpen.value = true
reviewLoading.value = true
try { reviewWarehouses.value = await listOverseasPurchaseWarehouseOptions(row.procurementDirection) }
catch (error) { ElMessage.error(getApiErrorDetails(error, '仓库加载失败').message) }
finally { reviewLoading.value = false }
// 关闭、切换订单或操作员后,旧请求的结果、提示和结束回调均不得影响当前审核。
const isCurrent = () => current === reviewGeneration && reviewOpen.value && owner === auth.user?.id
try {
const warehouses = await listOverseasPurchaseWarehouseOptions(row.procurementDirection)
if (isCurrent()) reviewWarehouses.value = warehouses
} catch (error) { if (isCurrent()) ElMessage.error(getApiErrorDetails(error, '仓库加载失败').message) }
finally { if (isCurrent()) reviewLoading.value = false }
}
async function submitReview() {
const row = reviewRow.value
if (!row || reviewSaving.value) return
if (!row || reviewSaving.value || reviewLoading.value || !reviewOpen.value) return
if (reviewForm.decision === 'APPROVE' && !reviewForm.purchaseWarehouseId) { ElMessage.warning('请选择采购仓库'); return }
if (reviewForm.decision === 'REJECT' && !reviewForm.remark.trim()) { ElMessage.warning('请填写驳回原因'); return }
reviewSaving.value = true
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Driver, Vehicle } from '../dispatchContracts'
import DispatchTimeZonePreview from './DispatchTimeZonePreview.vue'
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
const driver = defineModel<Driver>('driver', { required: true })
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
@@ -66,7 +67,7 @@ function selectDriver(id: string) {
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
<tr v-if="driver.mode === 'MASTER'"><td colspan="2"><el-form-item label="联系电话"><el-input :model-value="masterDriverPhone" readonly /></el-form-item></td></tr>
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
<tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item></td></tr>
<tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /><DispatchTimeZonePreview :value="driver.evidence.expectedTaskEndAt" label="预计任务结束" /></el-form-item></td></tr>
</tbody></table>
</template>
@@ -0,0 +1,34 @@
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import DispatchTimeZonePreview from './DispatchTimeZonePreview.vue'
it.each([
['2026-07-01T10:00:00Z', '2026-07-01 06:00:00', '2026-07-01 03:00:00'],
['2026-12-01T10:00:00Z', '2026-12-01 05:00:00', '2026-12-01 02:00:00'],
['2026-07-01T08:00:00+08:00', '2026-06-30 20:00:00', '2026-06-30 17:00:00'],
['2026-11-01T05:30:00Z', '2026-11-01 01:30:00', '2026-10-31 22:30:00'],
['2026-11-01T06:30:00Z', '2026-11-01 01:30:00', '2026-10-31 23:30:00'],
])('converts the same instant with regional daylight saving rules: %s', (value, eastern, pacific) => {
const wrapper = mount(DispatchTimeZonePreview, { props: { value, label: '计划发车时间' } })
expect(wrapper.attributes('aria-label')).toBe('计划发车时间时区换算')
expect(wrapper.findAll('dt').map(item => item.text())).toEqual(['美东', '美西'])
expect(wrapper.findAll('dd').map(item => item.text())).toEqual([eastern, pacific])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each([null, '', 'invalid', '2026-07-01T10:00:00', '2026-07-01'])('does not invent an instant for an empty or unzoned value: %s', value => {
const wrapper = mount(DispatchTimeZonePreview, { props: { value, label: '发车时间' } })
expect(wrapper.findAll('dd').map(item => item.text())).toEqual(['—', '—'])
wrapper.unmount()
})
it('updates and clears both previews without emitting a changed input', async () => {
const wrapper = mount(DispatchTimeZonePreview, { props: { value: '2026-07-01T10:00:00Z', label: '预计到达时间' } })
await wrapper.setProps({ value: '2026-12-01T10:00:00Z' })
expect(wrapper.findAll('dd').map(item => item.text())).toEqual(['2026-12-01 05:00:00', '2026-12-01 02:00:00'])
await wrapper.setProps({ value: '' })
expect(wrapper.findAll('dd').map(item => item.text())).toEqual(['—', '—'])
expect(wrapper.emitted()).toEqual({})
wrapper.unmount()
})
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ value?: string | null; label: string }>()
const zones = [
{ label: '美东', timeZone: 'America/New_York' },
{ label: '美西', timeZone: 'America/Los_Angeles' },
]
// 只换算带时差的同一时间点,地区时区负责夏令时;不回写原始录入值。
const times = computed(() => {
const instant = props.value && /(?:Z|[+-]\d{2}:\d{2})$/i.test(props.value) ? Date.parse(props.value) : NaN
return zones.map(zone => {
if (!Number.isFinite(instant)) return { label: zone.label, value: '—' }
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
timeZone: zone.timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).formatToParts(instant).map(part => [part.type, part.value]))
return { label: zone.label, value: `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}` }
})
})
</script>
<template>
<dl class="dispatch-time-zone-preview" :aria-label="`${label}时区换算`">
<div v-for="time in times" :key="time.label"><dt>{{ time.label }}</dt><dd>{{ time.value }}</dd></div>
</dl>
</template>
<style scoped>
.dispatch-time-zone-preview { display: flex; flex: 0 0 100%; flex-wrap: wrap; gap: 2px 12px; width: 100%; margin: 0; color: var(--cc-muted); font-size: var(--admin-font-body, 13px); line-height: 1.6; }
.dispatch-time-zone-preview > div { display: flex; flex-wrap: wrap; gap: 0 6px; }
.dispatch-time-zone-preview dt, .dispatch-time-zone-preview dd { margin: 0; white-space: nowrap; }
</style>
@@ -4,6 +4,7 @@ import type { DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
import type { DispatchDestinationAppointment, DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
import { formatOverseasDateTime } from '../lastMilePresentation'
import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchTimeZonePreview from './DispatchTimeZonePreview.vue'
const props = defineProps<{ detail: DispatchDetail; disabled: boolean }>()
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
@@ -18,9 +19,9 @@ const loadTypes = ['托盘', '地板', '混装']
const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
const appointmentFacts = computed(() => new Map(props.detail.dispatchInfo.fbaAppointments.map(row => [row.destinationKey, row])))
/** 修改编号后不继续显示原编号的确认时间,保存后由服务端重新匹配正式确认。 */
function confirmedTime(appointment: DispatchDestinationAppointment) {
function confirmedAt(appointment: DispatchDestinationAppointment) {
const fact = appointmentFacts.value.get(appointment.destinationKey)
return formatOverseasDateTime(fact?.appointmentNo === appointment.appointmentNo?.trim() ? fact?.confirmedAt : null)
return fact?.appointmentNo === appointment.appointmentNo?.trim() ? fact?.confirmedAt : null
}
</script>
@@ -30,12 +31,12 @@ function confirmedTime(appointment: DispatchDestinationAppointment) {
<div class="density-dialog-sections">
<section class="density-dialog-section" aria-label="本车资源登记">
<h3 class="density-dialog-section__title">车辆与司机</h3>
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /></el-form-item></td></tr></tbody></table>
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /><DispatchTimeZonePreview :value="detail.dispatchInfo.departedAt" label="发车时间" /></el-form-item></td></tr></tbody></table>
<DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
</section>
<section class="density-dialog-section" aria-label="本车时间登记">
<h3 class="density-dialog-section__title">排车时间</h3>
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="plannedDepartureAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="estimatedArrivalAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table>
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="plannedDepartureAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /><DispatchTimeZonePreview :value="plannedDepartureAt" label="计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="estimatedArrivalAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /><DispatchTimeZonePreview :value="estimatedArrivalAt" label="预计到达时间" /></el-form-item></td></tr></tbody></table>
</section>
<section class="density-dialog-section" aria-label="派送补充资料">
<h3 class="density-dialog-section__title">派送信息</h3>
@@ -49,7 +50,7 @@ function confirmedTime(appointment: DispatchDestinationAppointment) {
<el-form-item label="预约账号"><el-input v-model="maintenance.appointmentAccount" :disabled="disabled" maxlength="128" placeholder="请输入预约账号" /></el-form-item>
</div>
<table v-if="maintenance.destinationAppointments.length" class="density-table density-table--editable" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody>
<tr v-for="appointment in maintenance.destinationAppointments" :key="appointment.destinationKey"><td>{{ appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '—' }}</td><td><el-form-item :label="`${appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '目的仓'} 预约号`"><el-input v-model="appointment.appointmentNo" :disabled="disabled" maxlength="128" placeholder="填写已约好的编号,提交审核前必填" /></el-form-item></td><td>{{ confirmedTime(appointment) }}</td><td>{{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }}</td></tr>
<tr v-for="appointment in maintenance.destinationAppointments" :key="appointment.destinationKey"><td>{{ appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '—' }}</td><td><el-form-item :label="`${appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '目的仓'} 预约号`"><el-input v-model="appointment.appointmentNo" :disabled="disabled" maxlength="128" placeholder="填写已约好的编号,提交审核前必填" /></el-form-item></td><td>{{ formatOverseasDateTime(confirmedAt(appointment)) }}<DispatchTimeZonePreview v-if="confirmedAt(appointment)" :value="confirmedAt(appointment)" label="确认送达时间" /></td><td>{{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" aria-label="本车费用信息"><h3 class="density-dialog-section__title">费用信息</h3><slot name="fees"><p class="density-dialog-note">{{ detail.documents.deliveries.length ? '费用以关联派送单的正式费用记录为准。' : '尚未生成派送单费用。' }}</p></slot></section>
@@ -88,6 +88,8 @@ describe('dispatch workflow native layout', () => {
expect(table.findAll('input')).toHaveLength(1)
expect((table.get('input').element as HTMLInputElement).value).toBe('ISA-ONE')
expect(table.findAll('tbody td')[2]!.text()).not.toBe('—')
expect(table.get('[aria-label="确认送达时间时区换算"]').text()).toContain('美东2026-10-25 13:00:00')
expect(table.get('[aria-label="确认送达时间时区换算"]').text()).toContain('美西2026-10-25 10:00:00')
for (const shipment of shipmentNumbers) expect(table.text()).toContain(shipment)
await table.get('input').setValue('ISA-CHANGED')
expect(table.findAll('tbody td')[2]!.text()).toBe('—')
@@ -144,6 +146,26 @@ describe('dispatch workflow native layout', () => {
await dialog.findAll('button').find(button => button.text() === '驳回')!.trigger('click'); await flushPromises()
expect(action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ decision: 'REJECTED', reason: '请客服补齐预约时间' }))
})
it('shows eastern and pacific previews for maintenance times and saves the original offset values', async () => {
const source = detail()
const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...source.dispatchInfo,
departedAt: '2026-07-01T15:00:00Z', plannedDepartureAt: '2026-07-01T16:00:00Z', estimatedArrivalAt: '2026-07-01T17:00:00Z',
} })
expect(field(dialog, '发车时间').get('[aria-label="发车时间时区换算"]').text()).toBe('美东2026-07-01 11:00:00美西2026-07-01 08:00:00')
expect(field(dialog, '计划发车时间').get('[aria-label="计划发车时间时区换算"]').text()).toBe('美东2026-07-01 12:00:00美西2026-07-01 09:00:00')
expect(field(dialog, '预计到达时间').get('[aria-label="预计到达时间时区换算"]').text()).toBe('美东2026-07-01 13:00:00美西2026-07-01 10:00:00')
expect(field(dialog, '预计任务结束').get('[aria-label="预计任务结束时区换算"]').text()).toBe('美东2026-11-01 05:00:00美西2026-11-01 02:00:00')
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-07-02T08:00:00+08:00')
await flushPromises()
expect(field(dialog, '计划发车时间').get('[aria-label="计划发车时间时区换算"]').text()).toBe('美东2026-07-01 20:00:00美西2026-07-01 17:00:00')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({
plannedDepartureAt: '2026-07-02T08:00:00+08:00', estimatedArrivalAt: '2026-07-01T17:00:00Z',
driver: expect.objectContaining({ evidence: expect.objectContaining({ expectedTaskEndAt: '2026-11-01T10:00:00Z' }) }),
}))
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
})
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
const { dialog } = await render('EDIT')
expect(dialog.props('title')).toBe('维护车辆 · PC/1')
@@ -96,6 +96,38 @@ describe('CarrierScheduleQueryPanel loading states', () => {
wrapper.unmount()
})
it.each([false, true])('renders PDA warehouse events as business text without technical references (dense=%s)', async (dense) => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const businessRemark = '托盘 TP-TEST-018 已核对箱数,按实际拣货结果提交。'.repeat(6)
const wrapper = mount(CarrierScheduleQueryPanel, {
props: { dense, oceanBookingId: '2', warehouseTimeline: [
{ eventType: 'PDA_PICK_PALLET', detail: 'item-pick_pallet-10001-20001-batch-a', operatorName: '仓库员甲', occurredAt: '2026-10-29T11:58:00' },
{ eventType: 'PDA_PICK_PALLET', detail: 'item-pick_pallet-10001-20002-batch-b', operatorName: '仓库员乙', occurredAt: '2026-10-29T11:58:00' },
{ eventType: 'PDA_PICK_PALLET', summary: 'item-pick_pallet-10001-20003-batch-c', reason: businessRemark, operatorName: '仓库员丙', occurredAt: '2026-10-29T11:58:30' },
{ eventType: 'PDA_COMPLETE_LOADING', detail: 'complete-loading-test-key', operatorName: '复核员', occurredAt: '2026-10-29T11:59:00' },
{ eventType: 'PDA_BATCH', detail: 'batch-reference', idempotencyKey: 'batch-reference', operatorName: '仓库员', occurredAt: '2026-10-29T11:57:00' },
] },
global: { plugins: [ElementPlus, pinia] },
})
await flushPromises()
const rows = dense ? wrapper.findAll('.fulfillment-table tbody tr') : wrapper.findAll('.fulfillment-timeline li')
expect(rows).toHaveLength(5)
expect(rows[0]!.text()).toContain('完成装柜')
expect(rows[0]!.text()).toContain('已通过 PDA 完成装柜作业')
expect(rows[1]!.text()).toContain(businessRemark)
for (const row of rows.slice(2, 4)) {
expect(row.text()).toContain('托盘拣货')
expect(row.text()).toContain('已通过 PDA 完成托盘拣货')
expect(row.text()).toContain('仓内操作')
}
expect(rows[4]!.text()).toContain('已通过 PDA 提交拣货结果')
expect(wrapper.text()).toContain('仓库员甲')
expect(wrapper.text()).toContain('仓库员乙')
expect(wrapper.text()).not.toMatch(/PDA_PICK_PALLET|item-pick_pallet-|complete-loading-test-key|batch-reference|确认离仓|已出库/)
expect(wrapper.get('.history-count').text()).toBe('仓内 5 条 · 共 5 条')
})
it.each([false, true])('isolates nested manual entry from its parent and reopens in ordinary mode (dense=%s)', async (dense) => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const host = mount(AppDialog, {
@@ -111,7 +111,18 @@ function providerErrorText(value?: string | null) {
return message
}
function historyText(action: string) { return ({ AUTO_CREATE: '国内出仓自动建任务', CREATE: '人工建立查询任务', NODE_OBSERVED: '记录标准节点', PROVIDER_QUERY: '船司查询', MANUAL_NODE: '人工补录标准节点', STOP: '停止查询' } as Record<string, string>)[action] || action }
function warehouseEventText(value: string) { return ({ STATUS_UPDATE: '状态更新', PUSH_PRELOAD: '推送预装清单', CONFIRM_PRELOAD: '确认预装清单', TRANSPORT_UPDATE: '更新运输信息', PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜' } as Record<string, string>)[value] || value || '状态更新' }
const pdaWarehouseEvents: Record<string, { title: string; summary: string }> = {
PDA_CLAIM: { title: '领取 PDA 任务', summary: '已领取 PDA 作业任务' },
PDA_BATCH: { title: '提交拣货结果', summary: '已通过 PDA 提交拣货结果' },
PDA_SUBMIT_PICK: { title: '提交拣货结果', summary: '已通过 PDA 提交拣货结果' },
PDA_PICK_PALLET: { title: '托盘拣货', summary: '已通过 PDA 完成托盘拣货' },
PDA_COMPLETE_LOADING: { title: '完成装柜', summary: '已通过 PDA 完成装柜作业' },
}
function warehouseEventText(value: string) {
return pdaWarehouseEvents[value]?.title
|| ({ STATUS_UPDATE: '状态更新', PUSH_PRELOAD: '推送预装清单', CONFIRM_PRELOAD: '确认预装清单', TRANSPORT_UPDATE: '更新运输信息' } as Record<string, string>)[value]
|| (value.startsWith('PDA_') ? 'PDA 仓内操作' : value || '状态更新')
}
function nonBlank(value: unknown) { return typeof value === 'string' ? value.trim() : '' }
function hasStructuredChanges(value: unknown) {
if (Array.isArray(value)) return value.length > 0
@@ -170,12 +181,15 @@ const filteredWarehouseTimeline = computed(() => {
return result
})
function warehouseEventSummary(entry: WarehouseTimelineEntry) {
const value = nonBlank(entry.summary) || nonBlank(entry.changeSummary) || nonBlank(entry.reason) || nonBlank(entry.detail)
if (value && value.length <= 120 && !/(?:operation|idempot|claim-|pda-submit|complete-)/i.test(value)) return value
const technicalReferences = [entry.id, entry.eventId, entry.idempotencyKey].map(nonBlank).filter(Boolean)
const value = [entry.summary, entry.changeSummary, entry.reason, entry.detail].map(nonBlank).find((text) =>
text && !technicalReferences.includes(text) && !/(?:operation|idempot|^item[-_:]|claim-|pda-submit|complete-)/i.test(text))
if (value) return value
const from = nonBlank(entry.fromStatus); const to = nonBlank(entry.toStatus)
if (from && to && from !== to) return `${statusLabel(from)} → ${statusLabel(to)}`
if (hasStructuredChanges(entry.changedFields) || hasStructuredChanges(entry.fieldChanges) || hasStructuredChanges(entry.changes)) return '业务字段已更新'
return '操作已记录'
const action = warehouseAction(entry)
return pdaWarehouseEvents[action]?.summary || (action.startsWith('PDA_') ? 'PDA 仓内操作已记录' : '操作已记录')
}
const fulfillmentEvents = computed<FulfillmentEvent[]>(() => {
const values: FulfillmentEvent[] = []
@@ -4,6 +4,8 @@ import { ApiResponseError } from '@/shared/api/http'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
import { downloadPlatformProductImage, getPlatformUnifiedOrder, listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
import { adjustPublishedInboundFee, calculateInboundFee, confirmInboundFee, downloadPlatformInboundHoldEvidence, getInboundMeasurement, getLatestInboundFee, getPlatformInboundHold, holdPlatformInbound, previewInboundFee, releasePlatformInbound, sendInboundFee, updateMeasurementOuterDimensions, uploadPlatformInboundHoldEvidence, type InboundFeePreview } from '@/modules/inbounds'
import { listCustomerChargeRules } from '@/modules/fba-pricing'
import { getCustomerDimensionConfig } from '@/modules/dimension-config'
import UnifiedOrdersView from './UnifiedOrdersView.vue'
const { routeQuery, routerPush } = vi.hoisted(() => ({ routeQuery: { tenantId: '6001', from: '' }, routerPush: vi.fn() }))
@@ -16,6 +18,8 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
vi.mock('@/modules/iam', async importOriginal => ({
...await importOriginal<typeof import('@/modules/iam')>(), listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('@/modules/fba-pricing', () => ({ listCustomerChargeRules: vi.fn() }))
vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn() }))
vi.mock('@/modules/inbounds', () => ({
downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(),
updateMeasurementOuterDimensions: vi.fn(),
@@ -117,6 +121,8 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
afterEach(() => vi.useRealTimers())
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getCustomerDimensionConfig).mockReset().mockResolvedValue({ tenantId: '6001', defaultCalculationMode: 'ACTUAL', rules: [] })
vi.mocked(listCustomerChargeRules).mockReset().mockResolvedValue([])
vi.mocked(previewInboundFee).mockReset().mockResolvedValue(feePreview())
routeQuery.from = ''
canPermission.mockImplementation(() => true)
@@ -160,6 +166,55 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
wrapper.unmount()
})
it.each([
{ defaultMode: 'ADD' as const, outerDimensions: [66, 56, 56], outerUnitVolume: 0.207, outerVolume: 4.554 },
{ defaultMode: 'MULTIPLY' as const, outerDimensions: [77.5, 65, 65], outerUnitVolume: 0.3275, outerVolume: 7.205 },
])('keeps declared dimensions and volume original when the customer default is $defaultMode', async ({ defaultMode, outerDimensions, outerUnitVolume, outerVolume }) => {
const cargo = { itemNo: 1, lengthCm: 60, widthCm: 50, heightCm: 50, packageCount: 22, grossWeightKg: 200, volumeCbm: 3.3 }
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue({ ...detail, order: { ...detail.order, cargoes: [cargo] } } as never)
vi.mocked(getCustomerDimensionConfig).mockResolvedValue({
tenantId: '6001', defaultCalculationMode: defaultMode,
rules: [{ id: 'dimension-1', boxType: 'EXTRA_LARGE', boxTypeName: '特大箱',
innerLengthCm: 60, innerWidthCm: 50, innerHeightCm: 50, edgeAdditionCm: 2,
outerLengthCm: 64, outerWidthCm: 54, outerHeightCm: 54, volumeCbm: 0.187,
remark: null, version: 1, createdAt: null, updatedAt: null }],
})
vi.mocked(listCustomerChargeRules).mockResolvedValue([{
id: 'rule-1', tenantId: '6001', ruleCode: 'DEFAULT', ruleVersion: 1, customerId: '6001', customerName: '测试客户',
businessType: 'FBA', chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167,
lengthFactor: 1.25, widthFactor: 1.25, heightFactor: 1.25, defaultRule: true,
roundingScale: 4, roundingMode: 'CEILING', currency: 'CNY', effectiveFrom: '2026-01-01',
effectiveTo: null, status: 'ACTIVE', remark: null,
}])
const measurement = savedOuterMeasurement()
vi.mocked(getInboundMeasurement).mockResolvedValue({ ...measurement, items: [{ ...measurement.items[0],
outerLengthCm: null, outerWidthCm: null, outerHeightCm: null,
calculatedOuterLengthCm: outerDimensions[0], calculatedOuterWidthCm: outerDimensions[1], calculatedOuterHeightCm: outerDimensions[2],
coefficientMode: defaultMode, calculationRuleVersion: 'CUSTOMER_DIMENSION_V1',
unitVolumeCbm: outerUnitVolume, totalVolumeCbm: outerVolume, volumeWeightKg: outerVolume * 167,
}] } as never)
const wrapper = mountDetail()
await flushPromises()
const vm = wrapper.vm as unknown as {
customerMeasurementRows: Record<string, unknown>[]
outerMeasurementRows: Record<string, unknown>[]
actualMeasurementRows: Record<string, unknown>[]
measurementSectionModes: { customer: string; outer: string; actual: string }
feeConfirmRows: { source: string; count: number; weight: number; volume: number }[]
loadMeasurementDetails(): Promise<void>
}
await vm.loadMeasurementDetails()
expect(vm.customerMeasurementRows[0]).toMatchObject({ lengthCm: 60, widthCm: 50, heightCm: 50, unitVolumeCbm: 0.15, totalVolumeCbm: 3.3 })
const customerReference = vm.feeConfirmRows.find(row => row.source === '客户下单数据')!
expect(customerReference).toMatchObject({ count: 22, volume: 3.3 })
expect(customerReference.weight).toBeCloseTo(200)
expect(vm.measurementSectionModes).toEqual({ customer: 'ACTUAL', outer: defaultMode, actual: 'ACTUAL' })
expect(vm.outerMeasurementRows[0]).toMatchObject({ lengthCm: outerDimensions[0], widthCm: outerDimensions[1], heightCm: outerDimensions[2], totalVolumeCbm: outerVolume })
expect(vm.actualMeasurementRows[0]).toMatchObject({ lengthCm: 62, widthCm: 52, heightCm: 52, totalVolumeCbm: 3.6894 })
expect(cargo).toEqual({ itemNo: 1, lengthCm: 60, widthCm: 50, heightCm: 50, packageCount: 22, grossWeightKg: 200, volumeCbm: 3.3 })
wrapper.unmount()
})
it('shows actual and outer unit volumes and totals consistently with four decimal places', async () => {
const wrapper = mountDetail()
await flushPromises()
@@ -246,6 +301,41 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
wrapper.unmount()
})
it('keeps confirm-and-send available after sending until the customer confirms, without reloading the page', async () => {
const fee = { unitPrice: 13.6, chargeableQuantity: 650.298, chargeUnit: 'KG', roundingScale: 4, rateCode: 'RATE', feeStatus: 'CALCULATED' }
vi.mocked(calculateInboundFee).mockResolvedValue(fee as never)
vi.mocked(confirmInboundFee).mockResolvedValue({ ...fee, feeStatus: 'CONFIRMED' } as never)
vi.mocked(sendInboundFee).mockResolvedValue({ ...fee, feeStatus: 'SENT' } as never)
const wrapper = mountDetail()
await flushPromises()
const vm = wrapper.vm as unknown as FeeConfirmVm & {
activeDetailTab: string
automaticFee: { feeStatus: string } | null
}
vm.activeDetailTab = 'measurement'
vm.measurementDetail = { ...savedOuterMeasurement(), customerFeeConfirmed: false }
await flushPromises()
expect(wrapper.find('[data-testid="confirm-send-fee-button"]').exists()).toBe(true)
await vm.openFeeConfirmDialog()
await vm.executeConfirmAndSendFee()
await flushPromises()
expect(vm.automaticFee?.feeStatus).toBe('SENT')
expect(wrapper.find('[data-testid="confirm-send-fee-button"]').exists()).toBe(true)
await wrapper.get('[data-testid="confirm-send-fee-button"]').trigger('click')
await flushPromises()
await vm.executeConfirmAndSendFee()
await flushPromises()
expect(sendInboundFee).toHaveBeenCalledTimes(2)
expect(wrapper.find('[data-testid="confirm-send-fee-button"]').exists()).toBe(true)
vm.measurementDetail = { ...vm.measurementDetail, customerFeeConfirmed: true }
await flushPromises()
expect(wrapper.find('[data-testid="confirm-send-fee-button"]').exists()).toBe(false)
wrapper.unmount()
})
it('refreshes the amount from current outer values instead of an older fee snapshot', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ unitPrice: 13.6, chargeableQuantity: 616.1298, totalAmount: 8379.37 } as never)
const wrapper = mountDetail()
@@ -257,6 +347,8 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
expect(vm.feeConfirmVolume).toBe(3.894)
expect(vm.feeConfirmReference?.chargeableQuantity).toBe(650.298)
expect(vm.feeConfirmEstimatedTotal).toBe(8844.05)
const referenceRow = wrapper.findAll('tr').find(row => row.text().startsWith('参考计费量'))!
expect(referenceRow.findAll('td')[0]!.text()).toBe('650.3 KG')
expect(getLatestInboundFee).not.toHaveBeenCalled()
expect(calculateInboundFee).not.toHaveBeenCalled()
wrapper.unmount()
@@ -1321,8 +1321,8 @@ async function loadMeasurementDetails() {
? savedModes[0]!
: configuredMode === 'MULTIPLY' && activeMeasurementChargeRule.value
? 'MULTIPLY' : configuredMode === 'ADD' ? 'ADD' : 'ACTUAL'
measurementSectionModes.customer = configuredMode === 'MULTIPLY' && activeMeasurementChargeRule.value
? 'MULTIPLY' : configuredMode === 'ADD' ? 'ADD' : 'ACTUAL'
// 客户下单数据默认展示原始申报尺寸,不能跟随外箱计费默认规则调整。
measurementSectionModes.customer = 'ACTUAL'
measurementSectionModes.actual = 'ACTUAL'
measurementLoadedKey.value = key
} catch (error) {
@@ -2494,7 +2494,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-unit-price" v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading || confirmFeeSending" placeholder="请输入对客单价" /><span>{{ feeConfirmPriceUnit }}</span></div></td>
</tr>
<tr v-if="feeConfirmReference">
<th scope="row">参考计费量</th><td>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</td>
<th scope="row">参考计费量</th><td>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.chargeUnit === 'KG' ? 2 : feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</td>
<th scope="row">按当前单价预计合计</th><td><strong>{{ feeConfirmEstimatedTotal == null ? '—' : feeConfirmMoney(feeConfirmEstimatedTotal, feeConfirmReference.currency) }}</strong></td>
</tr>
<tr v-if="feeConfirmPriceChanged">
@@ -2707,7 +2707,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
<p>{{ detail.summary.measurement?.completedAt ? `测量完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p>
<div class="measurement-title-actions">
<el-button
v-if="can('firstleg:inbound:confirm-send-fee') && automaticFee?.feeStatus !== 'SENT' && !['LOADED', 'DELIVERED'].includes(detail.summary.mainStatus)"
v-if="can('firstleg:inbound:confirm-send-fee') && !measurementDetail?.customerFeeConfirmed && !['LOADED', 'DELIVERED'].includes(detail.summary.mainStatus)"
type="primary"
:loading="confirmFeeSending"
title="确认并发送费用"
@@ -2803,13 +2803,13 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
<el-table-column label="托盘类型" width="160">
<template #default="{ row: weightResult }">{{ weightResult.palletType || '未记录类型' }}</template>
</el-table-column>
<el-table-column label="服务端确认毛重(kg)" min-width="180" align="right">
<el-table-column label="服务端确认毛重(kg)" min-width="180" align="left">
<template #default="{ row: weightResult }">{{ formatNumber(weightResult.grossWeightKg) }}</template>
</el-table-column>
<el-table-column label="托盘皮重(kg)" min-width="140" align="right">
<el-table-column label="托盘皮重(kg)" min-width="140" align="left">
<template #default="{ row: weightResult }">{{ formatNumber(weightResult.palletTareWeightKg) }}</template>
</el-table-column>
<el-table-column label="货物重量(kg)" min-width="140" align="right">
<el-table-column label="货物重量(kg)" min-width="140" align="left">
<template #default="{ row: weightResult }">{{ formatNumber(weightResult.netWeightKg) }}</template>
</el-table-column>
</el-table>