diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index d5eeff94..8d59a5eb 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -317,7 +317,7 @@ | 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 | | 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 已接真实分页、显式客户、筛选、状态、加载/错误/空态;排车字段后端未提供,保持只读空值 | | 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue` | 已接计划详情和 `sourceOrderId`;接口未提供的预约/排车时间、备注和费用继续显示空值 | -| 入库单 | 确认短少入库 | 用户确认实际短少后的处理要求;Admin规范“简单确认、原因填写” | 详情托盘明细标题区操作,560px居中确认弹框;应收、实收和短少只读,原因必填 | `InboundShortageConfirmation.vue`;仅待差异核对的FBA短少和入库编辑权限可用 | 定向组件/API/页面测试与类型检查通过;1920×1080、100%待人工截图确认 | +| 入库单 | 确认收货异常 | 用户确认短少、超收及实际归属的处理要求;Admin规范“批量预览、对照与选择” | 详情托盘区展示本柜应收、现场清点及已归属摘要;1100px居中核对弹框,显示全柜涉及单号、逐托清点及逐票逐SKU归属,原因必填,已完成行只读 | `InboundShortageConfirmation.vue`;全部待差异核对计划具有编辑权限时可进入,提交受当前快照、服务端门禁及逐托合计校验约束 | 本轮4个定向文件34项测试及类型检查通过;1920×1080、100%待人工截图确认 | | 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 | | 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 | | 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 | diff --git a/src/modules/overseas-warehouse/api/inboundPlans.test.ts b/src/modules/overseas-warehouse/api/inboundPlans.test.ts index aafc6452..b160b9dc 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.test.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.test.ts @@ -1,15 +1,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { confirmInboundShortage, getInboundPlan, listInboundPlans } from './inboundPlans' +import { confirmReceivingReconciliation, getReceivingReconciliation, getInboundPlan, listInboundPlans } from './inboundPlans' vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn() } })) describe('inbound plan overseas API', () => { - beforeEach(() => vi.mocked(http.get).mockReset()) - it('confirms the current shortage with customer, version and idempotency key', async () => { - vi.mocked(http.post).mockResolvedValue({ code: '200', data: { status: 'PARTIAL_INBOUND' } }) - const payload = { customerId: '190000000000000001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '短少1箱' } - await confirmInboundShortage('IP/1', payload, 'request-key') - expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/confirm-shortage', payload, { headers: { 'Idempotency-Key': 'request-key' } }) + beforeEach(() => { vi.mocked(http.get).mockReset(); vi.mocked(http.post).mockReset() }) + it('reads the whole receiving scope with the explicit customer and encoded plan number', async () => { + const data = { snapshotHash: 'current-snapshot', expectedPackageCount: 21, countedPackageCount: 22, attributedPackageCount: 20 } + vi.mocked(http.get).mockResolvedValue({ code: '200', data }) + expect(await getReceivingReconciliation('IP/1', '190000000000000001')).toEqual(data) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/receiving-reconciliation', { params: { customerId: '190000000000000001' } }) + }) + it('confirms explicit pallet and SKU quantities with the current snapshot and idempotency key', async () => { + const data = { planNo: 'IP/1', status: 'COMPLETED', confirmedPackageCount: 22, reconciledPlanNos: ['IP/1'] } + vi.mocked(http.post).mockResolvedValue({ code: '200', data }) + const payload = { customerId: '190000000000000001', snapshotHash: 'current-snapshot', reason: '逐托核实超收1箱', + pallets: [{ palletId: '9007199254741001', countedPackageCount: 22 }], + receipts: [{ customerId: '190000000000000001', planNo: 'IP/1', lineId: '9007199254741002', palletId: '9007199254741001', skuCode: 'SKU-1', receivedPackageCount: 22 }] } + expect(await confirmReceivingReconciliation('IP/1', payload, 'request-key')).toEqual(data) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/receiving-reconciliation', payload, { headers: { 'Idempotency-Key': 'request-key' } }) }) it('pages plans with explicit customer scope', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) diff --git a/src/modules/overseas-warehouse/api/inboundPlans.ts b/src/modules/overseas-warehouse/api/inboundPlans.ts index aa9ab80b..5ff35ee0 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.ts @@ -82,17 +82,67 @@ export async function getInboundPlan(planNo: string, customerId: string) { return unwrapApiResponse(await http.get, ApiEnvelope>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } })) } -export interface ConfirmInboundShortagePayload { - customerId: string - version: number - expectedPackageCount: number - receivedPackageCount: number - reason: string +export interface ReceivingReconciliationPallet { + palletId: string + palletNo?: string | null + groupKey: string + countedPackageCount: number + attributedPackageCount: number + editable: boolean } -export async function confirmInboundShortage(planNo: string, payload: ConfirmInboundShortagePayload, idempotencyKey: string) { - return unwrapApiResponse(await http.post, ApiEnvelope<{ status: string }>>( - `/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/confirm-shortage`, payload, +export interface ReceivingReconciliationReceipt { + customerId: string + planNo: string + lineId: string + inboundCode?: string | null + palletId: string + palletNo?: string | null + skuCode: string + productName?: string | null + expectedPackageCount: number + receivedPackageCount: number + editable: boolean +} + +export interface ReceivingReconciliationDto { + planNo: string + containerNo?: string | null + snapshotHash: string + expectedPackageCount: number + countedPackageCount: number + attributedPackageCount: number + canConfirm: boolean + blockedReason?: string | null + plans: Array<{ customerId: string; planNo: string; customerName?: string | null; status: string; expectedPackageCount: number; attributedPackageCount: number }> + pallets: ReceivingReconciliationPallet[] + receipts: ReceivingReconciliationReceipt[] +} + +export interface ConfirmReceivingReconciliationPayload { + customerId: string + snapshotHash: string + reason: string + pallets: Array<{ palletId: string; countedPackageCount: number }> + receipts: Array<{ customerId: string; planNo: string; lineId: string; palletId: string; skuCode: string; receivedPackageCount: number }> +} + +export interface ReceivingReconciliationResult { + planNo: string + status: string + confirmedPackageCount: number + reconciledPlanNos: string[] +} + +export async function getReceivingReconciliation(planNo: string, customerId: string) { + return unwrapApiResponse(await http.get, ApiEnvelope>( + `/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/receiving-reconciliation`, { params: { customerId } }, + )) +} + +export async function confirmReceivingReconciliation(planNo: string, payload: ConfirmReceivingReconciliationPayload, idempotencyKey: string) { + return unwrapApiResponse(await http.post, ApiEnvelope>( + `/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/receiving-reconciliation`, payload, { headers: { 'Idempotency-Key': idempotencyKey } }, )) } diff --git a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts index bb0b95af..1b9084ad 100644 --- a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts +++ b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts @@ -1,68 +1,348 @@ import ElementPlus from 'element-plus' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans' +import { + confirmReceivingReconciliation, getReceivingReconciliation, + type InboundPlanDetailDto, type ReceivingReconciliationDto, +} from '../api/inboundPlans' import InboundShortageConfirmation from './InboundShortageConfirmation.vue' const access = vi.hoisted(() => ({ can: vi.fn(() => true) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => access })) -vi.mock('../api/inboundPlans', () => ({ confirmInboundShortage: vi.fn() })) -const dialog = { name: 'AppDialog', props: ['modelValue', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '
' } +vi.mock('../api/inboundPlans', () => ({ confirmReceivingReconciliation: vi.fn(), getReceivingReconciliation: vi.fn() })) +const dialog = { + name: 'AppDialog', props: ['modelValue', 'title', 'loading', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], + template: '

{{ title }}

', +} +const numberInput = { + props: ['modelValue', 'min', 'max', 'disabled'], emits: ['update:modelValue'], + template: '', +} function detail(overrides: Partial = {}): InboundPlanDetailDto { return { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4, orderType: 'FIRST_LEG', status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20, - lineCount: 1, palletCount: 2, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, - lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 21, plannedPalletCount: 2 }], + lineCount: 1, palletCount: 1, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, + lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 21, plannedPalletCount: 1 }], pallets: [], allocations: [], ...overrides } } -function render(value = detail()) { - return mount(InboundShortageConfirmation, { props: { detail: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialog } } }) +function snapshot(overrides: Partial = {}): ReceivingReconciliationDto { + return { planNo: 'IP-1', containerNo: 'CONT-1', snapshotHash: 'snapshot-1', expectedPackageCount: 21, + countedPackageCount: 20, attributedPackageCount: 20, canConfirm: true, blockedReason: null, + plans: [{ customerId: '9007199254741001', planNo: 'IP-1', customerName: '本轮客户', status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, attributedPackageCount: 20 }], + pallets: [{ palletId: '9007199254741002', palletNo: 'PLT-1', groupKey: 'G-1', countedPackageCount: 20, attributedPackageCount: 20, editable: true }], + receipts: [{ customerId: '9007199254741001', planNo: 'IP-1', lineId: '9007199254741003', inboundCode: 'IN-1', + palletId: '9007199254741002', palletNo: 'PLT-1', skuCode: 'SKU-1', productName: '货物', expectedPackageCount: 21, receivedPackageCount: 20, editable: true }], ...overrides } } -describe('入库短少确认', () => { - beforeEach(() => { access.can.mockReturnValue(true); vi.mocked(confirmInboundShortage).mockReset().mockResolvedValue({ status: 'PARTIAL_INBOUND' }) }) - it('requires a reason and submits actual counts without overwriting the expected count', async () => { +function render(value = detail()) { + return mount(InboundShortageConfirmation, { props: { detail: value }, global: { + plugins: [ElementPlus], stubs: { AppDialog: dialog, ElInputNumber: numberInput }, + } }) +} +async function open(wrapper: ReturnType) { + await flushPromises() + await wrapper.get('button').trigger('click') + await flushPromises() +} +const palletInput = 'input[aria-label="托盘PLT-1确认清点箱数"]' +const receiptInput = 'input[aria-label="IP-1托盘PLT-1SKUSKU-1确认归属箱数"]' + +describe('全柜收货异常确认', () => { + beforeEach(() => { + access.can.mockReturnValue(true) + vi.mocked(getReceivingReconciliation).mockReset().mockResolvedValue(snapshot()) + vi.mocked(confirmReceivingReconciliation).mockReset().mockResolvedValue({ planNo: 'IP-1', status: 'PARTIAL_INBOUND', confirmedPackageCount: 20, reconciledPlanNos: ['IP-1'] }) + }) + + it('shows separate whole-container expected, counted and attributed facts on the detail', async () => { + vi.mocked(getReceivingReconciliation).mockResolvedValue(snapshot({ countedPackageCount: 22, attributedPackageCount: 19 })) const wrapper = render() - await wrapper.find('button').trigger('click') - expect(wrapper.text()).toContain('21') - expect(wrapper.text()).toContain('20') + await flushPromises() + const summary = wrapper.get('[aria-label="本柜原始收货数量"]') + expect(summary.text()).toContain('原应收箱数21') + expect(summary.text()).toContain('现场清点箱数22') + expect(summary.text()).toContain('已归属箱数19') + expect(getReceivingReconciliation).toHaveBeenCalledWith('IP-1', '9007199254741001') + wrapper.unmount() + }) + + it('requires a reason and submits the exact current snapshot, pallet and SKU identities', async () => { + const wrapper = render() + await open(wrapper) + expect(wrapper.text()).toContain('确认收货异常 · IP-1') expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() - await wrapper.get('textarea').setValue('现场清点短少1箱') + await wrapper.get('textarea').setValue(' 逐托核实短少1箱 ') await wrapper.get('[data-confirm]').trigger('click') await flushPromises() - expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', { customerId: '9007199254741001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '现场清点短少1箱' }, expect.any(String)) + expect(confirmReceivingReconciliation).toHaveBeenCalledWith('IP-1', { + customerId: '9007199254741001', snapshotHash: 'snapshot-1', reason: '逐托核实短少1箱', + pallets: [{ palletId: '9007199254741002', countedPackageCount: 20 }], + receipts: [{ customerId: '9007199254741001', planNo: 'IP-1', lineId: '9007199254741003', palletId: '9007199254741002', skuCode: 'SKU-1', receivedPackageCount: 20 }], + }, expect.any(String)) expect(wrapper.emitted('refresh')).toHaveLength(1) expect(wrapper.find('textarea').exists()).toBe(false) wrapper.unmount() }) - it('preserves the reason on failure and uses fresh facts and a new key for retry', async () => { - vi.mocked(confirmInboundShortage).mockRejectedValueOnce(new Error('记录已变化')) + + it.each([0, 21, 22])('allows an explicit confirmed actual count of %s', async count => { + const wrapper = render(detail({ pickedPackageCount: count, version: undefined, orderType: 'ONE_CLICK_DROPSHIP' })) + await open(wrapper) + await wrapper.get(palletInput).setValue(count) + await wrapper.get(receiptInput).setValue(count) + await wrapper.get('textarea').setValue('已逐项核实现场数量及归属') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeUndefined() + if (count === 22) expect(wrapper.text()).toContain('超收1') + await wrapper.get('[data-confirm]').trigger('click') + await flushPromises() + expect(confirmReceivingReconciliation).toHaveBeenCalledWith('IP-1', expect.objectContaining({ + pallets: [{ palletId: '9007199254741002', countedPackageCount: count }], + receipts: [expect.objectContaining({ receivedPackageCount: count })], + }), expect.any(String)) + wrapper.unmount() + }) + + it('opens every pending reconciliation without inferring eligibility from the old detail quantities or line type', async () => { + const wrapper = render(detail({ version: undefined, expectedPackageCount: undefined, pickedPackageCount: undefined, + lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 1, plannedPalletCount: 1 }, + { id: '4', orderId: '2', fbaWarehouseCode: null, requiredPackageCount: 1, plannedPalletCount: 1 }] })) + await open(wrapper) + expect(wrapper.find('textarea').exists()).toBe(true) + wrapper.unmount() + }) + + it('keeps a blocked plan inspectable and shows the server reason', async () => { + vi.mocked(getReceivingReconciliation).mockResolvedValue(snapshot({ canConfirm: false, blockedReason: '该票冻结SKU缺失' })) const wrapper = render() - await wrapper.find('button').trigger('click'); await wrapper.get('textarea').setValue('短少1箱') - await wrapper.get('[data-confirm]').trigger('click'); await flushPromises() - expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('短少1箱') + await open(wrapper) + await wrapper.get('textarea').setValue('核对资料') + expect(wrapper.text()).toContain('该票冻结SKU缺失') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-confirm]').trigger('click') + expect(confirmReceivingReconciliation).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('never distributes an ambiguous multi-SKU physical count and validates each pallet separately', async () => { + const data = snapshot() + data.pallets[0]!.countedPackageCount = 22 + data.receipts[0]!.receivedPackageCount = 10 + data.receipts.push({ ...data.receipts[0]!, skuCode: 'SKU-2', receivedPackageCount: 10 }) + vi.mocked(getReceivingReconciliation).mockResolvedValue(data) + const wrapper = render() + await open(wrapper) + expect((wrapper.get(receiptInput).element as HTMLInputElement).value).toBe('10') + await wrapper.get('textarea').setValue('现场逐SKU核实') + expect(wrapper.text()).toContain('逐SKU归属合计须等于确认清点箱数') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() + await wrapper.get(receiptInput).setValue(12) + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) + + it('shows all affected plan numbers and preserves completed rows as read-only facts', async () => { + const data = snapshot() + data.plans.push({ customerId: '9007199254741004', planNo: 'IP-2', customerName: '同柜客户', status: 'COMPLETED', expectedPackageCount: 2, attributedPackageCount: 2 }) + data.pallets[0]!.countedPackageCount = 22 + data.receipts.push({ ...data.receipts[0]!, customerId: '9007199254741004', planNo: 'IP-2', lineId: '9007199254741005', receivedPackageCount: 2, editable: false }) + vi.mocked(getReceivingReconciliation).mockResolvedValue(data) + const wrapper = render() + await open(wrapper) + expect(wrapper.get('[aria-label="本柜涉及入库单"]').text()).toContain('IP-2') + expect(wrapper.get('[aria-label="逐票逐SKU实际归属"]').text()).toContain('2(只读)') + expect(wrapper.find('input[aria-label="IP-2托盘PLT-1SKUSKU-1确认归属箱数"]').exists()).toBe(false) + await wrapper.get('textarea').setValue('全柜逐票归属已核实') + await wrapper.get('[data-confirm]').trigger('click') + await flushPromises() + const payload = vi.mocked(confirmReceivingReconciliation).mock.calls[0]?.[1] + expect(payload?.receipts).toHaveLength(2) + expect(payload?.receipts[1]).toMatchObject({ planNo: 'IP-2', receivedPackageCount: 2 }) + wrapper.unmount() + }) + + it('rejects cross-pallet misattribution even when the whole-container totals match', async () => { + const data = snapshot() + data.pallets[0]!.countedPackageCount = 10 + data.pallets.push({ ...data.pallets[0]!, palletId: '9007199254741010', palletNo: 'PLT-2' }) + data.receipts[0]!.receivedPackageCount = 11 + data.receipts.push({ ...data.receipts[0]!, palletId: '9007199254741010', palletNo: 'PLT-2', receivedPackageCount: 9 }) + vi.mocked(getReceivingReconciliation).mockResolvedValue(data) + const wrapper = render() + await open(wrapper) + await wrapper.get('textarea').setValue('核对逐托归属') + expect(wrapper.text()).toContain('托盘PLT-1的逐SKU归属合计须等于确认清点箱数') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-confirm]').trigger('click') + expect(confirmReceivingReconciliation).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('submits completed read-only pallet and SKU quantities above 66 unchanged', async () => { + const data = snapshot() + data.plans.push({ customerId: '9007199254741004', planNo: 'IP-2', status: 'COMPLETED', expectedPackageCount: 80, attributedPackageCount: 80 }) + data.pallets.push({ ...data.pallets[0]!, palletId: '9007199254741010', palletNo: 'PLT-2', countedPackageCount: 80, attributedPackageCount: 80, editable: false }) + data.receipts.push({ ...data.receipts[0]!, planNo: 'IP-2', palletId: '9007199254741010', palletNo: 'PLT-2', receivedPackageCount: 80, editable: false }) + vi.mocked(getReceivingReconciliation).mockResolvedValue(data) + const wrapper = render() + await open(wrapper) + await wrapper.get('textarea').setValue('保留已完成票据归属,核对当前票据') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeUndefined() + expect(wrapper.text()).toContain('80(只读)') + expect(wrapper.get(palletInput).attributes('max')).toBe('66') + await wrapper.get('[data-confirm]').trigger('click') + await flushPromises() + const payload = vi.mocked(confirmReceivingReconciliation).mock.calls[0]?.[1] + expect(payload?.pallets[1]).toEqual({ palletId: '9007199254741010', countedPackageCount: 80 }) + expect(payload?.receipts[1]).toMatchObject({ planNo: 'IP-2', receivedPackageCount: 80 }) + wrapper.unmount() + }) + + it.each(['pallet', 'receipt'])('rejects a negative read-only %s quantity', async field => { + const data = snapshot() + if (field === 'pallet') { + data.pallets[0]!.editable = false + data.pallets[0]!.countedPackageCount = -1 + } else { + data.receipts[0]!.editable = false + data.receipts[0]!.receivedPackageCount = -1 + } + vi.mocked(getReceivingReconciliation).mockResolvedValue(data) + const wrapper = render() + await open(wrapper) + await wrapper.get('textarea').setValue('核对数量') + expect(wrapper.text()).toContain('清点及归属须为非负整数') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('keeps the entry available when the first reconciliation read fails', async () => { + vi.mocked(getReceivingReconciliation).mockRejectedValueOnce(new Error('暂时无法读取')) + const wrapper = render() + await flushPromises() + const action = wrapper.findAll('button').find(button => button.text() === '确认收货异常')! + await action.trigger('click') + await flushPromises() + expect(wrapper.find('textarea').exists()).toBe(true) + expect(getReceivingReconciliation).toHaveBeenCalledTimes(2) + wrapper.unmount() + }) + + it.each(['', '-1', '1.5', '67'])('blocks an invalid quantity %s', async value => { + const wrapper = render() + await open(wrapper) + await wrapper.get(palletInput).setValue(value) + await wrapper.get('textarea').setValue('核对数量') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() + expect(wrapper.text()).toContain('范围为0至66') + wrapper.unmount() + }) + + it('preserves human input after failure, reads a fresh hash and requires a new click and key', async () => { + vi.mocked(confirmReceivingReconciliation).mockRejectedValueOnce(new Error('现场记录已变化')) + const wrapper = render() + await open(wrapper) + await wrapper.get(palletInput).setValue(19) + await wrapper.get(receiptInput).setValue(19) + await wrapper.get('textarea').setValue('核对后短少2箱') + vi.mocked(getReceivingReconciliation).mockResolvedValue(snapshot({ snapshotHash: 'snapshot-2' })) + await wrapper.get('[data-confirm]').trigger('click') + await flushPromises() await wrapper.setProps({ detail: detail({ version: 5 }) }) - await wrapper.get('[data-confirm]').trigger('click'); await flushPromises() - const calls = vi.mocked(confirmInboundShortage).mock.calls - expect(calls[1]?.[1].version).toBe(5) + expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('核对后短少2箱') + expect((wrapper.get(palletInput).element as HTMLInputElement).value).toBe('19') + expect((wrapper.get(receiptInput).element as HTMLInputElement).value).toBe('19') + expect(confirmReceivingReconciliation).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeUndefined() + await wrapper.get('[data-confirm]').trigger('click') + await flushPromises() + const calls = vi.mocked(confirmReceivingReconciliation).mock.calls + expect(calls[1]?.[1]).toMatchObject({ snapshotHash: 'snapshot-2', pallets: [{ countedPackageCount: 19 }] }) expect(calls[1]?.[2]).not.toBe(calls[0]?.[2]) wrapper.unmount() }) - it('hides the action without edit permission', () => { - access.can.mockReturnValue(false) - const wrapper = render(); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount() - }) - it('allows ordinary overseas carton shortage confirmation with the same versioned payload', async () => { - const wrapper = render(detail({ lines: [{ id: '3', orderId: '2', fbaWarehouseCode: null, requiredPackageCount: 21, plannedPalletCount: 2 }] })) - await wrapper.get('button').trigger('click') - await wrapper.get('textarea').setValue('逐箱核对短少1箱') - await wrapper.get('[data-confirm]').trigger('click'); await flushPromises() - expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', expect.objectContaining({ - expectedPackageCount: 21, receivedPackageCount: 20, reason: '逐箱核对短少1箱', - }), expect.any(String)) - expect(wrapper.emitted('refresh')).toHaveLength(1) + + it('disables resubmission when the new snapshot cannot be loaded', async () => { + vi.mocked(confirmReceivingReconciliation).mockRejectedValueOnce(new Error('快照已失效')) + const wrapper = render() + await open(wrapper) + await wrapper.get('textarea').setValue('现场已核实') + vi.mocked(getReceivingReconciliation).mockRejectedValue(new Error('网络失败')) + await wrapper.get('[data-confirm]').trigger('click') + await flushPromises() + expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('现场已核实') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() + expect(confirmReceivingReconciliation).toHaveBeenCalledTimes(1) wrapper.unmount() }) - it.each([{ status: 'PARTIAL_INBOUND' }, { pickedPackageCount: 21 }, { pickedPackageCount: 22 }, { pickedPackageCount: 0 }, { version: undefined }, { orderType: 'ONE_CLICK_DROPSHIP' }])('rejects ineligible facts %o', (overrides) => { - const wrapper = render(detail(overrides)); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount() + + it('does not duplicate an in-flight confirmation', async () => { + let finish!: (value: Awaited>) => void + vi.mocked(confirmReceivingReconciliation).mockReturnValue(new Promise(resolve => { finish = resolve })) + const wrapper = render() + await open(wrapper) + await wrapper.get('textarea').setValue('已确认') + await wrapper.get('[data-confirm]').trigger('click') + await wrapper.get('[data-confirm]').trigger('click') + expect(confirmReceivingReconciliation).toHaveBeenCalledTimes(1) + finish({ planNo: 'IP-1', status: 'PARTIAL_INBOUND', confirmedPackageCount: 20, reconciledPlanNos: ['IP-1'] }) + await flushPromises() + wrapper.unmount() + }) + + it.each([ + { changed: 'plan', outcome: 'success' }, { changed: 'plan', outcome: 'error' }, + { changed: 'customer', outcome: 'success' }, { changed: 'customer', outcome: 'error' }, + ])('ignores a late $outcome after the $changed changes and preserves the new submission', async ({ changed, outcome }) => { + type Result = Awaited> + let finishOld!: (value: Result) => void + let failOld!: (error: Error) => void + let finishNew!: (value: Result) => void + const oldRequest = new Promise((resolve, reject) => { finishOld = resolve; failOld = reject }) + const newRequest = new Promise(resolve => { finishNew = resolve }) + vi.mocked(confirmReceivingReconciliation).mockReturnValueOnce(oldRequest).mockReturnValueOnce(newRequest) + const wrapper = render() + await open(wrapper) + await wrapper.get('textarea').setValue('旧单原因') + await wrapper.get('[data-confirm]').trigger('click') + + const nextDetail = detail(changed === 'plan' ? { planNo: 'IP-NEW' } : { customerId: '9007199254741099' }) + const nextSnapshot = snapshot({ planNo: nextDetail.planNo, snapshotHash: 'new-target-snapshot' }) + nextSnapshot.plans = nextSnapshot.plans.map(row => ({ ...row, planNo: nextDetail.planNo, customerId: nextDetail.customerId })) + nextSnapshot.receipts = nextSnapshot.receipts.map(row => ({ ...row, planNo: nextDetail.planNo, customerId: nextDetail.customerId })) + vi.mocked(getReceivingReconciliation).mockResolvedValue(nextSnapshot) + await wrapper.setProps({ detail: nextDetail }) + await open(wrapper) + expect(getReceivingReconciliation).toHaveBeenLastCalledWith(nextDetail.planNo, nextDetail.customerId) + await wrapper.get('textarea').setValue('新单原因') + await wrapper.get('[data-confirm]').trigger('click') + expect(confirmReceivingReconciliation).toHaveBeenCalledTimes(2) + expect(confirmReceivingReconciliation).toHaveBeenLastCalledWith(nextDetail.planNo, + expect.objectContaining({ customerId: nextDetail.customerId, snapshotHash: 'new-target-snapshot' }), expect.any(String)) + const readsBeforeOldResponse = vi.mocked(getReceivingReconciliation).mock.calls.length + + if (outcome === 'success') finishOld({ planNo: 'IP-1', status: 'COMPLETED', confirmedPackageCount: 20, reconciledPlanNos: ['IP-1'] }) + else failOld(new Error('旧单返回失败')) + await flushPromises() + expect(wrapper.emitted('refresh')).toBeUndefined() + expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('新单原因') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() + expect(wrapper.text()).not.toContain('旧单返回失败') + expect(getReceivingReconciliation).toHaveBeenCalledTimes(readsBeforeOldResponse) + + finishNew({ planNo: nextDetail.planNo, status: 'COMPLETED', confirmedPackageCount: 20, reconciledPlanNos: [nextDetail.planNo] }) + await flushPromises() + expect(wrapper.emitted('refresh')).toHaveLength(1) + expect(wrapper.find('textarea').exists()).toBe(false) + wrapper.unmount() + }) + + it('hides the action without edit permission and for completed plans', async () => { + access.can.mockReturnValue(false) + const wrapper = render() + await flushPromises() + expect(wrapper.find('button').exists()).toBe(false) + expect(wrapper.text()).toContain('现场清点箱数') + access.can.mockReturnValue(true) + await wrapper.setProps({ detail: detail({ status: 'COMPLETED' }) }) + expect(wrapper.find('button').exists()).toBe(false) + wrapper.unmount() }) }) diff --git a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue index 201b9cc1..3ae72a4a 100644 --- a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue +++ b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue @@ -1,68 +1,193 @@ + + diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts index d9be3861..004cc338 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts @@ -8,9 +8,9 @@ vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, q vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() })) vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null })) -vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '' } })) +vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '' } })) -it('provides the scoped plan facts and reloads after shortage confirmation', async () => { +it('provides the scoped plan facts and reloads after receiving reconciliation', async () => { const detail = { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4, status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20, lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } @@ -19,7 +19,8 @@ it('provides the scoped plan facts and reloads after shortage confirmation', asy await flushPromises() expect(wrapper.findComponent({ name: 'InboundShortageConfirmation' }).props('detail')).toEqual(detail) expect(wrapper.text()).toContain('待差异核对') - await wrapper.get('[data-shortage]').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('确认收货异常') + await wrapper.get('[data-reconciliation]').trigger('click'); await flushPromises() expect(getInboundPlan).toHaveBeenCalledTimes(2) expect(getInboundPlan).toHaveBeenLastCalledWith('IP-1', '9007199254741001') wrapper.unmount() diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue index 92a393e1..8caca8f6 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue @@ -129,7 +129,7 @@ onMounted(load)