diff --git a/src/modules/inbounds/components/InboundTaskList.vue b/src/modules/inbounds/components/InboundTaskList.vue index 22a6f360..b104d66a 100644 --- a/src/modules/inbounds/components/InboundTaskList.vue +++ b/src/modules/inbounds/components/InboundTaskList.vue @@ -33,6 +33,7 @@ type FilterModel = QueryModel & { } const props = defineProps<{ mode: InboundTaskMode; title?: string }>() +const emit = defineEmits<{ changed: [] }>() const router = useRouter() const access = useAccessPreviewStore() const page = ref(1) @@ -216,6 +217,7 @@ function openDetail(row: InboundOrderSummary) { } async function handleTaskChanged() { + emit('changed') const target = selectedInbound.value if (!isReview.value) { reviewDialogOpen.value = false @@ -295,6 +297,7 @@ async function batchApproveMeasurements() { } } await loadTasks() + if (successCount) emit('changed') const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';') const pricingReason = [...new Set(pricingWarnings.filter(Boolean))].slice(0, 3).join(';') if (!failureCount && !pricingWarnings.length) ElMessage.success(`已批量审核通过 ${successCount} 条测量数据,费用初算已生成`) diff --git a/src/modules/overseas-business/CONTRACT_GAPS.md b/src/modules/overseas-business/CONTRACT_GAPS.md index 2c36e5c6..83a9a360 100644 --- a/src/modules/overseas-business/CONTRACT_GAPS.md +++ b/src/modules/overseas-business/CONTRACT_GAPS.md @@ -6,14 +6,16 @@ - 海外采购:客户候选、采购仓候选、列表、详情操作流水及 `PATCH` 三态编辑;缺失字段不修改,显式 `null` 清空。 - 采购审核 `review`:采购详情页只对待审核订单和独立审核权限开放,提交订单版本、通过/拒绝决定、稳定幂等键和可选备注。 -- 本土卡车:平台列表、详情、创建和仅待审核状态允许的普通编辑。 +- 本土卡车:平台列表、独立详情页、创建和仅待审核状态允许的普通编辑;详情页使用 `truck:status` 权限接入订单审核、累计实收、超托审核、托盘分配、待派车、已派车、发运、完成及取消。全部命令提交当前版本和稳定幂等键,结果不明时保留原请求供重试;失败保留表单与错误关联信息。 +- 本土卡车拼车来源订单保持承运安排只读,状态随既有来货、排车和签收事实推进;不展示独立单审核或状态命令。 - 本土散板:独立稳定子域的列表、详情数据承载、创建和仅待接单状态允许的编辑;详情只用于既有编辑页预填,不新增详情路由。 - canonical 权限统一为 `platform:overseas-business:*`;长整型 ID 与 decimal 字段在适配层归一化。 明确不接入: - 采购出库不提供独立 `/outbounds` 端点;采购编辑通过出库明细维护出库量并派生待出库、部分出库、全部出库状态,不直接操作海外仓 PDA 可用库存。 -- 卡车状态推进、收托盘和散板状态推进:后端存在,但当前原型没有对应操作承载方式。 +- 散板独立单状态推进:后端存在,但当前原型没有对应操作承载方式。 +- 独立卡车履约记录使用现有卡车接口,不自动生成 WMS 库存、海外仓排车单、出库单或派送单;派车、发运和完成按实际发生情况由平台确认。 - 海外出库、海外派送和 Merchant API。 剩余验收项仅为真实 Gateway/Token 环境 HTTP 验收及 `1920×1080`、浏览器缩放 `100%` 的人工页面操作和截图确认。 diff --git a/src/modules/overseas-business/PROTOTYPE_MATRIX.md b/src/modules/overseas-business/PROTOTYPE_MATRIX.md index 0a4238f7..5170e260 100644 --- a/src/modules/overseas-business/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-business/PROTOTYPE_MATRIX.md @@ -29,7 +29,7 @@ | 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 | | 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 | | 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_REVIEW` 可真实提交 | -| 本土卡车 | 详情 | 列表渲染函数 13563 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 | +| 本土卡车 | 详情、审核与后续履约 | 原型未定义;2026-10-20 用户明确要求补齐流程并加审核 | 独立详情页 + 居中操作确认弹框 | 列表待审核单显示审核入口,其他状态可查看详情;独立单使用既有具名命令,按状态与 truck:status 权限展示审核、实收、超托审核、分配、待派车、派车、发运、完成与取消。展示资料、托盘和审核操作历史;拼车来源沿承运安排只读。待人工 1920×1080、100% 截图确认 | | 本土散板 | 列表 | `page-overseas-looseboard` 222–244 行;`renderLooseboardList()` 13589–13605 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 | | 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;使用独立稳定子域真实提交并显式选择客户 | | 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_ACCEPTANCE` 可真实提交 | diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts index 19317c13..e28a4baa 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts @@ -7,10 +7,11 @@ import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getOverseasPurchaseEditor, getUsTruckOrder, listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases, listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, - updateUsTruckOrder, + updateUsTruckOrder, executeUsTruckCommand, } from './platformOverseasBusiness' import type { UpdateLocalTransportOrderRequest } from '../types' import { readPendingProcurement } from '../procurementPending' +import type { TruckOperation } from '../truckWorkflow' const envelope = (data: T) => ({ code: '200', message: 'success', data }) const purchaseSummary = { @@ -92,7 +93,7 @@ describe('platform overseas business API adapter', () => { expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { version: 4, remark: '内部' }, { params: { customerId: '10001' } }) }) - it('supports platform truck list/detail/create/update without status endpoints', async () => { + it('supports platform truck list/detail/create/update', async () => { const detail = { summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] } overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' })).mockResolvedValueOnce(envelope(detail)) expect((await listUsTruckOrders({ routeKeyword: ' LA ' })).records[0]?.totalWeightKg).toBe('123.45') @@ -106,6 +107,32 @@ describe('platform overseas business API adapter', () => { expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', update, { params: { customerId: '9007199254741999' } }) }) + it.each([ + { command: 'review', payload: { version: 2, idempotencyKey: 'review-key', decision: 'APPROVE' } }, + { command: 'review', payload: { version: 2, idempotencyKey: 'reject-key', decision: 'REJECT', remark: '资料不全' } }, + { command: 'receive-pallets', payload: { version: 2, idempotencyKey: 'receive-key', actualPalletCount: 3, packageCount: 10 } }, + { command: 'review-pallet-overage', payload: { version: 2, idempotencyKey: 'overage-key', decision: 'APPROVE' } }, + { command: 'allocate-pallets', payload: { version: 2, idempotencyKey: 'allocate-key', palletIds: ['9007199254741001'] } }, + ...(['prepare-dispatch', 'dispatch', 'start-transit', 'complete'] as const).map(command => ({ command, payload: { version: 2, idempotencyKey: command } })), + { command: 'cancel', payload: { version: 2, idempotencyKey: 'cancel-key', status: 'CANCELLED', remark: '客户取消' } }, + ])('sends the scoped named truck command $command with version and unchanged replay key', async operation => { + const response = envelope({ summary: { ...truckSummary, version: '3', palletCount: '2', packageCount: '10', receivedPalletCount: '1', allocatedPalletCount: '0' }, pallets: [{ id: '9007199254741001', warehousePalletId: null }], histories: [] }) + overseas.post.mockResolvedValue(response); overseas.patch.mockResolvedValue(response) + const result = await executeUsTruckCommand('TR/1', '9007199254741999', operation) + const mutation = operation.command === 'cancel' ? overseas.patch : overseas.post + expect(mutation).toHaveBeenCalledWith(`/platform/v1/last-leg/us-truck-orders/TR%2F1/${operation.command === 'cancel' ? 'status' : operation.command}`, operation.payload, { params: { customerId: '9007199254741999' } }) + expect(result).toMatchObject({ version: 3, palletCount: 2, receivedPalletCount: 1, allocatedPalletCount: 0 }) + expect(result.pallets[0].id).toBe('9007199254741001') + expect(operation.command === 'cancel' ? overseas.post : overseas.patch).not.toHaveBeenCalled() + }) + + it('propagates a truck command version conflict without fallback status writes', async () => { + overseas.post.mockResolvedValue({ code: 'A0409', message: '版本冲突', data: null, requestId: 'TRUCK-409' }) + await expect(executeUsTruckCommand('TR-1', '10002', { command: 'complete', payload: { version: 2, idempotencyKey: 'original' } })).rejects.toMatchObject({ code: 'A0409', requestId: 'TRUCK-409' }) + expect(overseas.post).toHaveBeenCalledTimes(1) + expect(overseas.patch).not.toHaveBeenCalled() + }) + it('supports looseboard list/detail/create and PUT update', async () => { const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] } overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail)) diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.ts b/src/modules/overseas-business/api/platformOverseasBusiness.ts index 194f4eee..807c5bde 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.ts @@ -1,4 +1,5 @@ import type { PlatformProcurement } from '../procurementTypes' +import type { TruckOperation } from '../truckWorkflow' import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { @@ -32,15 +33,15 @@ interface LooseboardDetailPayload { function normalizePage(payload: PagePayload, normalize: (value: TPayload) => TResult): OverseasBusinessPage { return { records: (payload.records || []).map(normalize), page: Number(payload.page), size: Number(payload.size), total: Number(payload.total), pages: Number(payload.pages) } } -function procurementInteger(value: unknown): number { - if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('采购数据中的数量或版本无效,请刷新后重试') +function procurementInteger(value: unknown, business = '采购'): number { + if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error(`${business}数据中的数量或版本无效,请刷新后重试`) return Number(value) } function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseSummary { return { ...value, version: procurementInteger(value.version), purchaseQuantity: procurementInteger(value.purchaseQuantity), id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) } } function truckSummary(value: TruckSummaryPayload): LocalTransportOrder { - return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) } + return { ...value, id: String(value.id), customerId: String(value.customerId), version: procurementInteger(value.version, '卡车'), palletCount: procurementInteger(value.palletCount, '卡车'), packageCount: procurementInteger(value.packageCount, '卡车'), receivedPalletCount: procurementInteger(value.receivedPalletCount, '卡车'), allocatedPalletCount: procurementInteger(value.allocatedPalletCount, '卡车'), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) } } function truckDetail(value: TruckDetailPayload): LocalTransportDetail { return { ...truckSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, pallets: (value.pallets || []).map((item) => ({ ...item, id: String(item.id), warehousePalletId: item.warehousePalletId == null ? null : String(item.warehousePalletId) })), histories: value.histories || [] } @@ -130,6 +131,14 @@ export async function updateUsTruckOrder(orderNo: string, customerId: string, pa const response = await http.patch, ApiEnvelope>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) return truckDetail(unwrapApiResponse(response)) } +/** 正向履约使用具名命令,通用状态接口仅用于取消。 */ +export async function executeUsTruckCommand(orderNo: string, customerId: string, operation: TruckOperation) { + const path = `${truckBasePath}/${encodeURIComponent(orderNo)}/${operation.command === 'cancel' ? 'status' : operation.command}` + const response = operation.command === 'cancel' + ? await http.patch, ApiEnvelope>(path, operation.payload, { params: { customerId } }) + : await http.post, ApiEnvelope>(path, operation.payload, { params: { customerId } }) + return truckDetail(unwrapApiResponse(response)) +} export async function listLocalLooseboardOrders(query: LocalLooseboardQuery = {}) { const response = await http.get>, ApiEnvelope>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined } }) return normalizePage(unwrapApiResponse(response), looseboardSummary) diff --git a/src/modules/overseas-business/components/TruckOrderActions.vue b/src/modules/overseas-business/components/TruckOrderActions.vue new file mode 100644 index 00000000..66ebcfb4 --- /dev/null +++ b/src/modules/overseas-business/components/TruckOrderActions.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/src/modules/overseas-business/pages/TransportOrdersView.vue b/src/modules/overseas-business/pages/TransportOrdersView.vue index 6a5235e8..2883049d 100644 --- a/src/modules/overseas-business/pages/TransportOrdersView.vue +++ b/src/modules/overseas-business/pages/TransportOrdersView.vue @@ -1,7 +1,7 @@ @@ -111,7 +126,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route - + diff --git a/src/modules/overseas-business/pages/TruckOrderDetailView.test.ts b/src/modules/overseas-business/pages/TruckOrderDetailView.test.ts new file mode 100644 index 00000000..a7b24d61 --- /dev/null +++ b/src/modules/overseas-business/pages/TruckOrderDetailView.test.ts @@ -0,0 +1,194 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAuthStore } from '@/core/auth/auth' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { ApiResponseError } from '@/shared/api/http' +import AppDialog from '@/shared/components/AppDialog.vue' +import TruckOrderActions from '../components/TruckOrderActions.vue' +import { executeUsTruckCommand, getUsTruckOrder } from '../api/platformOverseasBusiness' +import { overseasBusinessPermissions as permissions } from '../permissions' +import type { LocalTransportDetail } from '../types' +import TruckOrderDetailView from './TruckOrderDetailView.vue' + +const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'TR-TEST' }, query: { customerId: '9007199254741999' } as Record } })) +vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) })) +vi.mock('../api/platformOverseasBusiness', () => ({ getUsTruckOrder: vi.fn(), executeUsTruckCommand: vi.fn() })) +vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) })) + +function order(overrides: Partial = {}): LocalTransportDetail { + return { + id: '9007199254741001', orderNo: 'TR-TEST', customerId: '9007199254741999', fleetArrangement: null, + vehicleType: 'PALLET_15', origin: 'LA', originAddress: '起运仓地址', destination: 'ONT', destinationAddress: '目的仓地址', + originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002', + pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', + palletCount: 0, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, packageCount: 20, + totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD', + status: 'PENDING_REVIEW', version: 1, remark: '运输备注', createdBy: '7', createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', + pallets: [], histories: [], ...overrides, + } +} +function mountDetail(manage = true) { + const pinia = createPinia(); setActivePinia(pinia) + const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] } + auth.permissions = [permissions.truckList, ...(manage ? [permissions.truckStatus] : [])] + useAccessPreviewStore().initializeAfterLogin() + return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true } } }) +} +async function click(wrapper: VueWrapper, label: string) { + const button = wrapper.findAll('button').find(item => item.text() === label) + expect(button, `button ${label}`).toBeDefined() + await button!.trigger('click'); await flushPromises() +} +async function confirm(wrapper: VueWrapper) { + wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() +} +const formModel = (wrapper: VueWrapper) => wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' }).props('model') + +enableAutoUnmount(afterEach) +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + routing.route.params = { orderId: 'TR-TEST' }; routing.route.query = { customerId: '9007199254741999' } + vi.mocked(getUsTruckOrder).mockResolvedValue(order()) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('truck review and fulfillment', () => { + it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => { + let current = order() + vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => { + const next = { ...current, version: current.version + 1 } + switch (operation.command) { + case 'review': next.status = 'WAITING_INBOUND'; break + case 'receive-pallets': + next.status = 'WAITING_CONSOLIDATION'; next.palletCount = 2; next.receivedPalletCount = 2 + next.pallets = [1, 2].map(sequenceNo => ({ id: `900719925474100${sequenceNo}`, sequenceNo, palletNo: `P-${sequenceNo}`, source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: '2026-10-20T10:00:00' })) + break + case 'allocate-pallets': next.status = 'ALLOCATED'; next.allocatedPalletCount = 2; next.pallets = next.pallets.map(row => ({ ...row, status: 'ALLOCATED' })); break + case 'prepare-dispatch': next.status = 'PENDING_DISPATCH'; break + case 'dispatch': next.status = 'DISPATCHED'; break + case 'start-transit': next.status = 'IN_TRANSIT'; break + case 'complete': next.status = 'COMPLETED'; break + } + next.histories = [...current.histories, { operationType: operation.command === 'review' ? 'REVIEW' : operation.command.toUpperCase().replaceAll('-', '_'), fromStatus: current.status, toStatus: next.status, operatorId: '操作员7', remark: operation.payload.remark || null, createdAt: '2026-10-20T10:00:00' }] + current = next; return next + }) + const wrapper = mountDetail(); await flushPromises() + expect(getUsTruckOrder).toHaveBeenCalledWith('TR-TEST', '9007199254741999') + expect(wrapper.text()).toContain('起运仓地址'); expect(wrapper.text()).toContain('审核与操作记录') + await click(wrapper, '审核'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper) + expect(wrapper.text()).toContain('登记实收'); expect(wrapper.text()).toContain('资料核对通过') + await click(wrapper, '登记实收'); Object.assign(formModel(wrapper), { actualPalletCount: 2, packageCount: 20 }); await confirm(wrapper) + await click(wrapper, '分配托盘'); formModel(wrapper).palletIds = current.pallets.map(row => row.id); await confirm(wrapper) + for (const label of ['确认待派车', '确认已派车', '确认发运', '确认完成']) { await click(wrapper, label); await confirm(wrapper) } + expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0) + expect(current.status).toBe('COMPLETED') + expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1, 2, 3, 4, 5, 6, 7]) + expect(vi.mocked(executeUsTruckCommand).mock.calls[2][2]).toMatchObject({ command: 'allocate-pallets', payload: { palletIds: ['9007199254741001', '9007199254741002'] } }) + }) + + it('requires a rejection reason and displays the resulting rejected state', async () => { + const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核') + formModel(wrapper).decision = 'REJECT'; await confirm(wrapper) + expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写拒绝或取消原因') + formModel(wrapper).remark = '取送时间需重新确认' + vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 })) + await confirm(wrapper) + expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) })) + expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0) + }) + + it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => { + vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 })) + const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核') + formModel(wrapper).remark = '核对完成'; await confirm(wrapper) + expect(wrapper.text()).toContain('订单版本冲突') + expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true) + const original = structuredClone(vi.mocked(executeUsTruckCommand).mock.calls[0][2]) + wrapper.findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises() + await click(wrapper, '审核'); await confirm(wrapper) + expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original) + expect(wrapper.text()).toContain('登记实收') + }) + + it('locks duplicate submission until the original response arrives', async () => { + let finish!: (value: LocalTransportDetail) => void + vi.mocked(executeUsTruckCommand).mockImplementation(() => new Promise(resolve => { finish = resolve })) + const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper) + expect(executeUsTruckCommand).toHaveBeenCalledTimes(1) + expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true) + finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises() + expect(wrapper.text()).toContain('登记实收') + }) + + it('validates cumulative receipt totals without submitting invalid input', async () => { + vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'RECEIVING', palletCount: 3, receivedPalletCount: 2 })) + const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '登记实收') + formModel(wrapper).actualPalletCount = 1; await confirm(wrapper) + expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('不能少于已实收托数') + Object.assign(formModel(wrapper), { actualPalletCount: 3, packageCount: -1 }); await confirm(wrapper) + expect(executeUsTruckCommand).not.toHaveBeenCalled() + }) + + it('requires received pallets and permits partial allocation using their string IDs', async () => { + const pallet = { id: '9007199254741007', sequenceNo: 1, palletNo: 'P-1', source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: null } + vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', palletCount: 2, receivedPalletCount: 2, pallets: [pallet] })) + const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '分配托盘'); await confirm(wrapper) + expect(executeUsTruckCommand).not.toHaveBeenCalled() + formModel(wrapper).palletIds = [pallet.id] + vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'PARTIALLY_ALLOCATED', version: 2, allocatedPalletCount: 1, pallets: [{ ...pallet, status: 'ALLOCATED' }] })) + await confirm(wrapper) + expect(wrapper.text()).toContain('部分分配'); expect(wrapper.text()).not.toContain('确认待派车') + expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'allocate-pallets', payload: expect.objectContaining({ palletIds: [pallet.id] }) })) + }) + + it.each(['APPROVE', 'REJECT'] as const)('reviews pallet overage with %s', async decision => { + vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PALLET_OVERAGE_REVIEW', palletCount: 2, receivedPalletCount: 3 })) + vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', version: 2 })) + const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '超托审核') + Object.assign(formModel(wrapper), { decision, remark: '已核对超收货物' }); await confirm(wrapper) + expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review-pallet-overage', payload: expect.objectContaining({ decision }) })) + }) + + it('requires cancellation reason and submits only the cancellation status', async () => { + const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '取消订单'); await confirm(wrapper) + expect(executeUsTruckCommand).not.toHaveBeenCalled() + formModel(wrapper).remark = '客户取消需求'; vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'CANCELLED', version: 2 })); await confirm(wrapper) + expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'cancel', payload: expect.objectContaining({ status: 'CANCELLED', remark: '客户取消需求' }) })) + }) + + it('keeps list-only users read-only and guards direct attempts to emit submit', async () => { + const wrapper = mountDetail(false); await flushPromises() + expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0) + wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() + expect(executeUsTruckCommand).not.toHaveBeenCalled() + }) + + it('keeps pool sourced orders on the existing fleet fulfillment path', async () => { + vi.mocked(getUsTruckOrder).mockResolvedValue(order({ fleetArrangement: { + sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-TEST', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', + plannedDepartureAt: null, originWarehouseName: 'LA', destinationLabel: 'ONT', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, + fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, + deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '按拼车承运实际履约推进', + } })) + const wrapper = mountDetail(); await flushPromises() + expect(wrapper.text()).toContain('按拼车承运实际履约推进') + expect(wrapper.findComponent(TruckOrderActions).exists()).toBe(false) + expect(executeUsTruckCommand).not.toHaveBeenCalled() + }) + + it('shows a scoped error without requesting detail when customer is missing', async () => { + routing.route.query = {}; const wrapper = mountDetail(); await flushPromises() + expect(getUsTruckOrder).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少有效客户或订单信息') + }) + + it('retains error metadata and retries a failed detail read', async () => { + vi.mocked(getUsTruckOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, 'DETAIL-409')).mockResolvedValueOnce(order()) + const wrapper = mountDetail(); await flushPromises() + expect(wrapper.text()).toContain('读取失败'); expect(wrapper.text()).toContain('DETAIL-409') + await wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises() + expect(wrapper.text()).toContain('TR-TEST'); expect(wrapper.text()).toContain('审核与操作记录') + }) +}) diff --git a/src/modules/overseas-business/pages/TruckOrderDetailView.vue b/src/modules/overseas-business/pages/TruckOrderDetailView.vue new file mode 100644 index 00000000..bc588082 --- /dev/null +++ b/src/modules/overseas-business/pages/TruckOrderDetailView.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/src/modules/overseas-business/routes.test.ts b/src/modules/overseas-business/routes.test.ts index 5e68c088..8f2a2378 100644 --- a/src/modules/overseas-business/routes.test.ts +++ b/src/modules/overseas-business/routes.test.ts @@ -10,6 +10,7 @@ describe('overseas business routes', () => { ['overseas-business/purchases/:purchaseId/edit', permission.purchaseEdit], ['overseas-business/trucks', permission.truckList], ['overseas-business/trucks/new', permission.truckCreate], + ['overseas-business/trucks/:orderId', permission.truckList], ['overseas-business/trucks/:orderId/edit', permission.truckEdit], ['overseas-business/looseboards', permission.looseboardList], ['overseas-business/looseboards/new', permission.looseboardCreate], @@ -17,10 +18,10 @@ describe('overseas business routes', () => { ]) }) - it('does not invent excluded create or detail routes', () => { + it('adds the requested truck detail without opening unrelated routes', () => { const paths = overseasBusinessRoutes.map((route) => route.path) expect(paths).not.toContain('overseas-business/purchases/new') - expect(paths).not.toContain('overseas-business/trucks/:orderId') + expect(paths).toContain('overseas-business/trucks/:orderId') expect(paths).not.toContain('overseas-business/looseboards/:orderId') expect(JSON.stringify(overseasBusinessRoutes)).not.toContain('permissionAnyOf') }) diff --git a/src/modules/overseas-business/routes.ts b/src/modules/overseas-business/routes.ts index 85a30fcd..f6a62a7b 100644 --- a/src/modules/overseas-business/routes.ts +++ b/src/modules/overseas-business/routes.ts @@ -34,6 +34,12 @@ export const overseasBusinessRoutes = [ props: { domain: 'truck' }, meta: { title: '新增本土卡车订单', description: '创建海外本土卡车订单', permission: permission.truckCreate, showPageHeading: false }, }, + { + path: 'overseas-business/trucks/:orderId', + name: 'overseas-business-truck-detail', + component: () => import('./pages/TruckOrderDetailView.vue'), + meta: { title: '本土卡车订单详情', description: '审核及处理本土卡车订单', permission: permission.truckList, showPageHeading: false }, + }, { path: 'overseas-business/trucks/:orderId/edit', name: 'overseas-business-truck-edit', diff --git a/src/modules/overseas-business/truckWorkflow.ts b/src/modules/overseas-business/truckWorkflow.ts new file mode 100644 index 00000000..bae5632b --- /dev/null +++ b/src/modules/overseas-business/truckWorkflow.ts @@ -0,0 +1,46 @@ +import type { LocalTransportDetail, LocalTransportStatus } from './types' + +export const truckStatusLabels: Record = { + PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', + PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', + PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消', +} +export const truckCommandLabels = { + review: '审核', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘', + 'prepare-dispatch': '确认待派车', dispatch: '确认已派车', 'start-transit': '确认发运', complete: '确认完成', cancel: '取消订单', +} as const +export type TruckCommand = keyof typeof truckCommandLabels +export interface TruckCommandBase { version: number; idempotencyKey: string; remark?: string } +export type TruckOperation = + | { command: 'review' | 'review-pallet-overage'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT' } } + | { command: 'receive-pallets'; payload: TruckCommandBase & { actualPalletCount: number; packageCount?: number; palletPackageCounts?: number[] } } + | { command: 'allocate-pallets'; payload: TruckCommandBase & { palletIds: string[] } } + | { command: 'prepare-dispatch' | 'dispatch' | 'start-transit' | 'complete'; payload: TruckCommandBase } + | { command: 'cancel'; payload: TruckCommandBase & { status: 'CANCELLED' } } + +const nextCommands: Partial> = { + PENDING_REVIEW: 'review', WAITING_INBOUND: 'receive-pallets', RECEIVING: 'receive-pallets', + PALLET_OVERAGE_REVIEW: 'review-pallet-overage', WAITING_CONSOLIDATION: 'allocate-pallets', + PARTIALLY_ALLOCATED: 'allocate-pallets', ALLOCATED: 'prepare-dispatch', PENDING_DISPATCH: 'dispatch', + DISPATCHED: 'start-transit', IN_TRANSIT: 'complete', +} +const cancellable = new Set(['PENDING_REVIEW', 'WAITING_INBOUND', 'RECEIVING', 'PALLET_OVERAGE_REVIEW', 'WAITING_CONSOLIDATION', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH']) + +/** 拼车来源只读,独立单据只能执行当前状态对应的具名命令。 */ +export function availableTruckCommands(order: Pick): TruckCommand[] { + if (order.fleetArrangement) return [] + const next = nextCommands[order.status] + return [...(next ? [next] : []), ...(cancellable.has(order.status) ? ['cancel' as const] : [])] +} + +export const truckCommandHints: Record = { + review: '请核对车型、路线、两端联系信息、取送时间和货物资料。通过后进入待入仓,拒绝后结束本次申请。', + 'receive-pallets': '按实际收货填写截至本次的累计实收托数。首次实收确定本单托数,后续超出已确认托数需要超托审核。', + 'review-pallet-overage': '通过后将超收托盘纳入本单;拒绝后将超收托盘标记为拒收,按原已确认托数继续。', + 'allocate-pallets': '选择本单已实收且未分配的托盘,可分批分配;全部已接收托盘分配完成后才能进入待派车。', + 'prepare-dispatch': '确认全部已接收托盘已分配完成,将本单交由调度安排车辆。', + dispatch: '请在车辆安排已实际完成后确认,本单将进入已派车。', + 'start-transit': '请在车辆已实际发运后确认,本单将进入运输中。', + complete: '请在运输及交付已实际完成后确认。确认完成后不能撤回。', + cancel: '取消后本单不能继续履约,请填写取消原因并核对已有收货和托盘记录。', +} diff --git a/src/modules/overseas-warehouse/api/cargoDiscards.ts b/src/modules/overseas-warehouse/api/cargoDiscards.ts index b404cfd7..509aa253 100644 --- a/src/modules/overseas-warehouse/api/cargoDiscards.ts +++ b/src/modules/overseas-warehouse/api/cargoDiscards.ts @@ -3,7 +3,12 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string } export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null } -export interface CargoDiscardItem { cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null } +export interface CargoDiscardItem { quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null } +export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null } +export async function getCargoDiscardStatistics(query: CargoDiscardQuery) { + const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/cargo-discards/statistics', { params: query }) + return unwrapApiResponse(response) +} export interface CargoDiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string } export interface CargoDiscardDetail { photos: CargoDiscardPhoto[]; summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] } export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] } diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index 59a14a2c..dedcfab9 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -52,6 +52,7 @@ export interface RelabelWarehouseOptionDto { } export interface RelabelSummaryDto { + operationTypes?: Array<'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' | 'RETURN_BULK_SKU_LABEL'> id: string relabelNo: string lastlegOrderId?: string | null @@ -93,7 +94,7 @@ export interface RelabelSummaryDto { export interface RelabelItemDto { id: string - operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' + operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' | 'RETURN_BULK_SKU_LABEL' skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }> skuCode?: string | null productNameZh?: string | null @@ -129,6 +130,30 @@ export interface RelabelDetailDto { completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string | null; reviewerName?: string | null; reviewRemark?: string | null; completedAt?: string | null } | null } +export interface RelabelQuantity { + labelPieceCount: number | null + cartonCount: number | null + skuCount: number + duplicateCarton: boolean + quantityUnit: string + sourcePalletCount: number | null + targetPalletCount: number | null +} +export interface RelabelQuantityTotals { + orderCount: number + skuCount: number + knownLabelPieceCount: number + unknownPieceOrders: number + knownCartonCount: number + unknownCartonOrders: number + orders: Record +} +/** 汇总同一筛选下全部单据,不以当前页合计代替。 */ +export async function getRelabelQuantities(query: RelabelOrderQuery) { + const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/relabel-applications/quantity-summary', { params: query }) + return unwrapApiResponse(response) +} + export interface ReturnPackingPlanBoxItem { skuCode: string quantity: number @@ -233,8 +258,14 @@ export async function changeRelabelOrderStatus(relabelNo: string, status: string return unwrapApiResponse(response) } +export interface RelabelReviewTask { + version: number + taskStatus: string + items?: Array<{ sourcePalletCode?: string | null; sourcePalletBasis?: string }> + history?: Array<{ action: string; detail?: string }> +} export async function getRelabelReviewTask(relabelNo: string) { - const response = await http.get, ApiEnvelope<{ version: number; taskStatus: string }>>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`) + const response = await http.get, ApiEnvelope>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`) return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/api/returnInventory.ts b/src/modules/overseas-warehouse/api/returnInventory.ts index e780a454..062c545c 100644 --- a/src/modules/overseas-warehouse/api/returnInventory.ts +++ b/src/modules/overseas-warehouse/api/returnInventory.ts @@ -5,10 +5,11 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; relabelLockedQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string } export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string } export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string } -export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] } +export interface ReturnCartonDiscardHistory { discardNo: string; skuCode: string; productNameZh?: string; beforeQuantity: number; quantity: number; afterQuantity: number; operatorId?: string; occurredAt: string } +export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[]; discardHistory?: ReturnCartonDiscardHistory[] } export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string } export interface ReturnPackingSource { cartonMark: string; sourceReturnNo?: string; sourceUnitType?: string; items: ReturnCartonDetail['items'] } -export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string } +export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND' | 'DISCARD'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string } export interface ReturnInventoryQuery { page: number; size: number; customerId?: string; overseasWarehouseId?: string; skuCode?: string; cartonMark?: string; returnNo?: string; status?: string } export async function listReturnStock(params: ReturnInventoryQuery) { const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.test.ts b/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.test.ts index 2fa5cb48..a7e6c4ca 100644 --- a/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.test.ts +++ b/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.test.ts @@ -30,6 +30,19 @@ function fixture(): WarehouseTransferDetailDto { beforeEach(() => { vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset() }) afterEach(() => vi.restoreAllMocks()) describe('转运预报与实际签收明细', () => { + it('keeps historical receipt totals after discard or shipment and leaves unknown declaration differences blank', async () => { + const value = fixture() + value.items[0]!.quantity = null + value.cartons[0]!.status = 'DISCARD_PENDING' + value.cartons[1]!.status = 'SHIPPED' + value.cartons[2]!.status = 'DISCARDED' + vi.mocked(getWarehouseTransfer).mockResolvedValue(value) + const wrapper = render(); await flushPromises() + expect(wrapper.emitted('summary')!.at(-1)![0]).toMatchObject({ boxes: 3, quantity: 27, skus: 2, forecastQuantity: null }) + const rows = wrapper.findAllComponents(DataTableStub)[0]!.props('data') + expect(rows[0]).toMatchObject({ receivedQuantity: 20, receivedBoxes: 2, quantityDifference: null }) + expect(wrapper.findAll('.test-table')[0]!.text()).toContain('未记录') + }) it('shows every SKU in a mixed box and counts its physical measurements once', async () => { const value = fixture() value.cartons = [{ ...value.cartons[0]!, quantity: 10, items: [{ transferItemId: '10', skuCode: 'SKU-A', quantity: 3 }, { transferItemId: '20', skuCode: 'SKU-B', quantity: 7 }] }] diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue b/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue index 74fa0e02..e455f5a1 100644 --- a/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue +++ b/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue @@ -46,7 +46,8 @@ const cartonsOpen = ref(false) let generation = 0 // 同一完成原因保存于箱记录,去重后在尾程详情直接展示。 const discrepancyReasons = computed(() => [...new Set((detail.value?.cartons || []).map(box => box.discrepancyReason?.trim()).filter((reason): reason is string => Boolean(reason)))]) -const cartons = computed(() => (detail.value?.cartons || []).filter(box => box.status === 'COUNTED')) +// 已签收是历史事实,后续废弃占用、废弃或出库不能从实收汇总中移除。 +const cartons = computed(() => (detail.value?.cartons || []).filter(box => ['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'].includes(box.status))) function itemFor(box: WarehouseTransferCartonDto) { return detail.value?.items.find(item => box.transferItemId != null && String(item.id) === String(box.transferItemId)) } function boxLines(box: WarehouseTransferCartonDto) { if (box.items?.length) return box.items @@ -64,9 +65,10 @@ watch([detail, totals], () => emit('summary', detail.value ? { ...totals.value, const comparisonRows = computed(() => (detail.value?.items || []).map(item => { const boxes = cartons.value.filter(box => boxLines(box).some(line => String(line.transferItemId) === String(item.id))) const receivedQuantity = boxes.reduce((sum, box) => sum + boxLines(box).filter(line => String(line.transferItemId) === String(item.id)).reduce((total, line) => total + Number(line.quantity), 0), 0) - return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: receivedQuantity - Number(item.quantity || 0), boxDifference: boxes.length - Number(item.packageCount || 0) } + return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: item.quantity == null ? null : receivedQuantity - Number(item.quantity), boxDifference: boxes.length - Number(item.packageCount || 0) } })) function differenceText(value: unknown) { + if (value == null) return '未记录' if (!cartons.value.length) return '待签收' const difference = Number(value) return difference > 0 ? `+${difference}` : String(difference) diff --git a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue index ace13fbf..3c1d86c8 100644 --- a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue +++ b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue @@ -9,13 +9,16 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone import StatusBadge from '@/shared/components/StatusBadge.vue' import AppDialog from '@/shared/components/AppDialog.vue' import { getApiErrorDetails } from '@/shared/api/http' -import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards' +import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, getCargoDiscardStatistics, type CargoDiscardStatistics, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { overseasWarehousePermissions as permission } from '../permissions' import { useAccessPreviewStore } from '@/core/access/accessPreview' const access = useAccessPreviewStore(); const rows = ref([]); const customers = ref([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref(null); const filters = ref({ customerId: '', discardNo: '', inboundCode: '', status: '' }) +const statistics = ref(null) +/** 数量事实缺失时明确提示,不用零代替未记录。 */ +function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` } const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }] -const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }] +const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }] const statusLabels: Record = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } const photoUrls = reactive>({}); const photoLoading = reactive>({}); const photoErrors = reactive>({}) const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean)) @@ -32,14 +35,16 @@ async function loadPhoto(fileId: string) { watch(detailOpen, value => { if (!value) clearPhotos() }) onBeforeUnmount(clearPhotos) function text(key: string) { return String(filters.value[key] || '').trim() || undefined } -async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; try { const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { loading.value = false } } +/** 同一筛选读取分页与全量统计,失败时清除旧数据,避免陈旧合计。 */ +let loadGeneration = 0 +async function load(model: QueryModel = filters.value) { const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; try { const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }; const [result, summary] = await Promise.all([listCargoDiscards(query), getCargoDiscardStatistics(query)]); if (generation !== loadGeneration) return; rows.value = result.records; total.value = result.total; statistics.value = summary } catch (e) { if (generation !== loadGeneration) return; rows.value = []; total.value = 0; statistics.value = null; ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { if (generation === loadGeneration) loading.value = false } } async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true; clearPhotos(); void Promise.all(detail.value.photos.map(photo => loadPhoto(photo.fileId))) } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } } async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } } onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts index 0b9c8225..5f1c1b17 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts @@ -15,6 +15,16 @@ vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn() })) enableAutoUnmount(afterEach) describe('LastMileOrderDetailView domestic detail alignment', () => { + it('counts mixed parcel and box receipts by their physical unit instead of calling both parcels', async () => { + vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1' }, cargo: [], fees: [], statusHistory: [], operationHistory: [], + returnReceipt: { order: { returnNo: 'RT-1', expectedQuantity: 8, receivedQuantity: 8 }, receipts: [{ receiptNo: 'P1', unitType: 'PARCEL' }, { receiptNo: 'B1', unitType: 'BOX', length: 30, width: 20, height: 10, weight: 2 }], + cartons: [{ receiptNo: 'P1', cartonMark: 'PK1', items: [] }, { receiptNo: 'B1', cartonMark: 'RC1', items: [] }] } }) + const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises() + await wrapper.get('#tab-return-receipt').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('签收实物明细(1 包裹 · 1 箱)') + expect(wrapper.text()).not.toContain('2 包裹') + expect(wrapper.text()).toContain('长 30 cm · 宽 20 cm · 高 10 cm · 体积 0.006 m³ · 重量 2 kg') + }) it.each(['PENDING_REVIEW', 'PARTIALLY_RECEIVED', 'COMPLETED'])('shows only customer SKU forecast regardless of receipt status (%s)', async status => { vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1', currentStatus: status, packageCount: 30 }, cargo: [], fees: [], statusHistory: [], operationHistory: [], diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue index d572d6ee..5d41d0a6 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue @@ -75,10 +75,25 @@ function isSingleLineBusinessField(field: DetailField) { return field.label === const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充')) const isReturnOrder = computed(() => detail.value?.order.orderType === 'RETURN_RELABEL') -const returnReceiptUnitLabel = computed(() => detail.value?.returnReceipt?.receipts.some(receipt => receipt.unitType === 'PARCEL') ? '包裹' : '箱') +const returnReceiptUnits = computed(() => { + const receiptTypes = new Map((detail.value?.returnReceipt?.receipts || []).map(receipt => [receipt.receiptNo, receipt.unitType])) + const counts = { parcels: 0, boxes: 0, unknown: 0 } + for (const carton of detail.value?.returnReceipt?.cartons || []) { + const type = receiptTypes.get(carton.receiptNo) + if (type === 'PARCEL') counts.parcels++ + else if (type === 'BOX' || type === 'PALLET') counts.boxes++ + else counts.unknown++ + } + return counts +}) +const returnReceiptUnitLabel = computed(() => returnReceiptUnits.value.parcels && !returnReceiptUnits.value.boxes && !returnReceiptUnits.value.unknown ? '包裹' : returnReceiptUnits.value.parcels || returnReceiptUnits.value.unknown ? '实物' : '箱') +const returnReceiptUnitSummary = computed(() => { + const { parcels, boxes, unknown } = returnReceiptUnits.value + return [parcels ? `${parcels} 包裹` : '', boxes ? `${boxes} 箱` : '', unknown ? `${unknown} 个未识别载体` : ''].filter(Boolean).join(' · ') || '0 箱' +}) const signedReturnCartons = computed(() => (detail.value?.returnReceipt?.cartons || []).map(box => { - const parcel = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && receipt.unitType === 'PARCEL') - return { ...box, length: box.length ?? parcel?.length, width: box.width ?? parcel?.width, height: box.height ?? parcel?.height, weight: box.weight ?? parcel?.weight } + const measuredUnit = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && (receipt.unitType === 'PARCEL' || receipt.unitType === 'BOX')) + return { ...box, length: box.length ?? measuredUnit?.length, width: box.width ?? measuredUnit?.width, height: box.height ?? measuredUnit?.height, weight: box.weight ?? measuredUnit?.weight } })) function returnCartonVolume(box: { length?: number | null; width?: number | null; height?: number | null }) { if (box.length == null || box.width == null || box.height == null) return '—' @@ -302,7 +317,7 @@ onMounted(load) -

签收{{ returnReceiptUnitLabel }}明细({{ detail.returnReceipt.cartons?.length || 0 }} {{ returnReceiptUnitLabel }})

+

签收{{ returnReceiptUnitLabel }}明细({{ returnReceiptUnitSummary }})

@@ -319,7 +334,7 @@ onMounted(load)

合箱记录

- + diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue index b013cbb0..4e39d620 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue @@ -5,7 +5,7 @@ import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue' import { ElMessage, ElMessageBox } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' -import { changeRelabelOrderStatus, confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders' +import { changeRelabelOrderStatus, confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework, getRelabelQuantities, type RelabelQuantity } from '../api/relabelOrders' import { useRoute, useRouter } from 'vue-router' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' @@ -21,6 +21,11 @@ const route = useRoute() const router = useRouter() const orderId = computed(() => String(route.params.orderId || '')) const detail = ref(null) +const quantity = ref(null) +const currentPallets = ref([]) +const targetPallets = ref([]) +const sourceMissing = ref(false) +const quantityMessage = ref('') const loading = ref(false) const access = useAccessPreviewStore() const returning = ref(false) @@ -65,7 +70,7 @@ const previewTitle = ref('') const previewOpen = ref(false) const groups = [ { title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] }, - { title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] }, + { title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '贴标件数', 'SKU种数', '当前关联托盘', '本轮目标托盘', '原托盘快照', '预算总价'] }, { title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] }, { title: '费用明细', fields: ['换标费总计', '资金异常说明'] }, ] @@ -96,7 +101,12 @@ const fieldValues = computed>(() => { 更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—', 商品SKU: [...new Set(items.flatMap(relabelSkuCodes))].join('、') || '—', 中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—', - 箱数: Number(value?.application.totalPackageCount || 0), + 箱数: quantity.value?.cartonCount ?? '未提供', + 贴标件数: quantity.value?.labelPieceCount ?? '未提供/不适用', + SKU种数: quantity.value?.skuCount ?? '未提供', + 当前关联托盘: currentPallets.value.length ? `${currentPallets.value.length} 托:${currentPallets.value.join('、')}${sourceMissing.value ? '(部分来源未提供)' : ''}` : '未提供', + 本轮目标托盘: targetPallets.value.length ? `${targetPallets.value.length} 托:${targetPallets.value.join('、')}` : '未提供/不适用', + 原托盘快照: '未提供(当前关联托盘不代表换标前快照)', 预算总价: '—', 贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—', 上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—', @@ -108,7 +118,24 @@ const fieldValues = computed>(() => { async function load() { if (!orderId.value) { loadError.value = { message: '详情缺少换标单号,请从换标单列表重新进入' }; return } loading.value = true; loadError.value = null - try { detail.value = await getRelabelOrder(orderId.value) } + try { + detail.value = await getRelabelOrder(orderId.value) + quantity.value = null; quantityMessage.value = ''; currentPallets.value = []; targetPallets.value = []; sourceMissing.value = false + try { + const result = await getRelabelQuantities({ page: 1, size: 1, relabelNo: orderId.value }) + quantity.value = result.orders[orderId.value] || null + if (quantity.value?.duplicateCarton) quantityMessage.value = '该单存在重复箱唛关联,箱数已按箱唛去重,贴标件数待核查。' + const task = await getRelabelReviewTask(orderId.value) + currentPallets.value = [...new Set((task.items || []).map(item => item.sourcePalletCode).filter((value): value is string => Boolean(value)))] + sourceMissing.value = (task.items || []).some(item => !item.sourcePalletCode) + const history = task.history || [] + const lastRework = history.map(item => item.action).lastIndexOf('REWORK_ARCHIVE') + const targets = history.slice(lastRework + 1).filter(item => item.action === 'PALLET_BIND').flatMap(item => { + try { const parsed: { targetPallets?: string[] } = JSON.parse(item.detail || '{}'); return parsed.targetPallets || [] } catch { return [] } + }) + targetPallets.value = [...new Set(targets)] + } catch { quantityMessage.value = '部分数量或托盘资料加载失败,未将缺失值显示为0。' } + } catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') } finally { loading.value = false } } @@ -145,6 +172,7 @@ async function previewCompletionPhoto(fileId: string) {
确认换标完成退回重新贴标

PDA 换标完工证据

{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}
共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片照片 {{ index + 1 }}
暂未上传完工照片

总换标 PDF

{{ detail.application.totalFileName }}直接预览
暂未上传总换标PDF
+

{{ quantityMessage }}

箱唛换标信息

共 {{ detail?.items.length || 0 }} 条
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts index 72c13f8e..ad348753 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts @@ -8,6 +8,7 @@ import { createRelabelOrder, getRelabelOrder, listRelabelOrders, + getRelabelQuantities, listRelabelWarehouseOptions, } from '../api/relabelOrders' import RelabelOrdersView from './RelabelOrdersView.vue' @@ -21,6 +22,7 @@ vi.mock('../api/relabelOrders', () => ({ createRelabelOrder: vi.fn(), getRelabelOrder: vi.fn(), listRelabelOrders: vi.fn(), + getRelabelQuantities: vi.fn(), listRelabelWarehouseOptions: vi.fn(), })) @@ -62,6 +64,7 @@ describe('RelabelOrdersView', () => { expect(wrapper.text()).toContain('贴标要求111') }) beforeEach(() => { + vi.mocked(getRelabelQuantities).mockReset().mockResolvedValue({ orderCount: 0, skuCount: 0, knownLabelPieceCount: 0, unknownPieceOrders: 0, knownCartonCount: 0, unknownCartonOrders: 0, orders: {} }) vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue index 7e98069a..530f9721 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue @@ -15,14 +15,15 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import AppDialog from '@/shared/components/AppDialog.vue' -import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders' +import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, getRelabelQuantities, type RelabelQuantityTotals, type RelabelWarehouseOptionDto } from '../api/relabelOrders' import { overseasWarehousePermissions as permission } from '../permissions' import { formatOverseasDateTime, relabelBusinessStatusLabel } from '../lastMilePresentation' import type { RelabelOrderListItem } from '../types' const access = useAccessPreviewStore() const router = useRouter() -const rows = ref([]) +type RelabelRow = Omit & { cartons: string; labelPieces: string; skuCount: string } +const rows = ref([]) const loading = ref(false) const voiding = ref('') const loadError = ref(null) @@ -33,11 +34,15 @@ const warehousesLoading = ref(false) const page = ref(1) const pageSize = ref(10) const total = ref(0) +const quantities = ref(null) +const quantityError = ref('') +function dimension(value: number | null | undefined) { return value == null ? '未提供' : String(value) } +function knownDimension(value: number, unknown: number, unit: string) { return Number(value) === 0 && Number(unknown) > 0 ? `${unit === '箱' ? '箱数' : '贴标件数'}未提供(${unknown}票)` : `已知 ${value} ${unit}${Number(unknown) > 0 ? `(${unknown}票未提供)` : ''}` } const filters = ref({ relabelNo: '', sku: '', status: '' }) const fields = computed(() => [ { key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 }, { key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 }, - { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已作废', value: 'CANCELLED' }] }, + { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '待客户确认', value: 'CUSTOMER_CONFIRM_PENDING' }, { label: '已驳回', value: 'REJECTED' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已作废', value: 'CANCELLED' }] }, ]) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, @@ -46,6 +51,8 @@ const columns: DataTableColumn[] = [ { key: 'sku', label: '商品SKU', minWidth: 150 }, { key: 'productName', label: '中文品名', minWidth: 180 }, { key: 'cartons', label: '箱数', width: 90, align: 'right' }, + { key: 'labelPieces', label: '贴标件数', width: 110, align: 'right' }, + { key: 'skuCount', label: 'SKU种数', width: 100, align: 'right' }, { key: 'budgetTotal', label: '预算总价', width: 120, align: 'right' }, { key: 'relabelCost', label: '换标费用', width: 120, align: 'right' }, { key: 'walletStatus', label: '资金状态', width: 120 }, @@ -61,7 +68,7 @@ const detailLoading = ref(false) const editSourceStatus = ref('') const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' }) const createIdempotencyKey = ref('') -const selectedRows = ref([]) +const selectedRows = ref([]) const allowedStatusOptions = computed(() => { if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED'] if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED'] @@ -77,7 +84,7 @@ function openPackingPlan() { void router.push({ name: 'overseas-return-inventory', query: { packing: '1', ...(customerIds[0] ? { customerId: customerIds[0] } : {}) } }) } async function openForm(mode: 'create' | 'edit', source?: object) { - const row = source as RelabelOrderListItem | undefined + const row = source as RelabelRow | undefined dialogMode.value = mode Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' }) editSourceStatus.value = '' @@ -173,21 +180,23 @@ async function submitForm() { } function openDetail(source: object) { - const row = source as RelabelOrderListItem + const row = source as RelabelRow void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) } }) } function relabelStatus(value: unknown) { + if (value === 'REJECTED') return '已驳回' + if (value === 'PENDING_REVIEW') return '待审核' if (value === 'CANCELLED') return '已作废' if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认' return relabelBusinessStatusLabel(value) } function canVoid(source: object) { - const row = source as RelabelOrderListItem + const row = source as RelabelRow return !['READY_FOR_OUTBOUND', 'COMPLETED', 'CANCELLED'].includes(row.rawStatus || relabelStatusCode(row.status)) } async function voidOrder(source: object) { - const row = source as RelabelOrderListItem + const row = source as RelabelRow if (voiding.value || !canVoid(row) || !access.can(permission.relabelOrderStatus)) return voiding.value = String(row.orderNo) try { @@ -213,7 +222,7 @@ function relabelStatusCode(value: unknown) { } function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record)[String(value || '')] || String(value || '—') } function canAudit(source: object) { - const row = source as RelabelOrderListItem + const row = source as RelabelRow return row.reviewChannel !== 'TAIL_ORDER' && (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW' } @@ -221,10 +230,14 @@ async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) loading.value = true; loadError.value = null try { - const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }) + const query = { page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined } + quantities.value = null; quantityError.value = '' + const result = await listRelabelOrders(query) + try { quantities.value = await getRelabelQuantities(query) } + catch { quantityError.value = '数量汇总加载失败,请重试;未将当前页作为全部合计' } rows.value = result.records.map((item) => ({ customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'), - productName: item.productNameZh?.trim() || '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '', + productName: item.productNameZh?.trim() || '—', cartons: dimension(quantities.value?.orders[item.relabelNo]?.cartonCount), labelPieces: dimension(quantities.value?.orders[item.relabelNo]?.labelPieceCount), skuCount: dimension(quantities.value?.orders[item.relabelNo]?.skuCount), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '', reviewChannel: item.reviewChannel || undefined, createdAt: formatOverseasDateTime(item.createdAt), })) @@ -243,6 +256,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' }) +
{{ quantityError }}
@@ -280,6 +294,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })