修复采购审核竞态并整理管理页面展示
This commit is contained in:
@@ -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 & <盒></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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: 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: 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 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>
|
||||
|
||||
Reference in New Issue
Block a user