diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index f2fc6cef..a442fa83 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -317,6 +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%待人工截图确认 | | 入库单 | 编辑 | `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 407687ae..aafc6452 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.test.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.test.ts @@ -1,10 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { getInboundPlan, listInboundPlans } from './inboundPlans' +import { confirmInboundShortage, getInboundPlan, listInboundPlans } from './inboundPlans' -vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn() } })) +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' } }) + }) 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 } }) await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' }) diff --git a/src/modules/overseas-warehouse/api/inboundPlans.ts b/src/modules/overseas-warehouse/api/inboundPlans.ts index e31767d2..aa9ab80b 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.ts @@ -45,6 +45,7 @@ export interface InboundPlanSummaryDto { } export interface InboundPlanDetailDto { + version?: number id: string planNo: string customerId: string @@ -81,6 +82,21 @@ 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 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, + { headers: { 'Idempotency-Key': idempotencyKey } }, + )) +} + export interface PlatformInventoryMoveRequest { overseasWarehouseId: string sourcePalletCode: string diff --git a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts new file mode 100644 index 00000000..b4af8b14 --- /dev/null +++ b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts @@ -0,0 +1,57 @@ +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 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: '
' } +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 }], + pallets: [], allocations: [], ...overrides } +} +function render(value = detail()) { + return mount(InboundShortageConfirmation, { props: { detail: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialog } } }) +} +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 () => { + const wrapper = render() + await wrapper.find('button').trigger('click') + expect(wrapper.text()).toContain('21') + expect(wrapper.text()).toContain('20') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() + 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(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('记录已变化')) + 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 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(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.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() + }) +}) diff --git a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue new file mode 100644 index 00000000..3bf237c9 --- /dev/null +++ b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/modules/overseas-warehouse/lastMilePresentation.test.ts b/src/modules/overseas-warehouse/lastMilePresentation.test.ts index 97f8d09f..b913af9d 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.test.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.test.ts @@ -129,6 +129,7 @@ describe('last-mile order presentation adapter', () => { ['SIGNED', '已签收'], ['PICKING', '拣货中'], ['PARTIAL_INBOUND', '部分入库'], + ['PENDING_RECONCILIATION', '待差异核对'], ['COMPLETED', '全部入库'], ])('maps inbound plan status %s to %s', (status, expected) => { expect(inboundPlanStatusLabel(status)).toBe(expected) diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 55af9b4c..0191d68c 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -46,6 +46,7 @@ const inboundPlanStatusLabels: Record = { PDA_PENDING: '待入库', SIGNED: '已签收', PICKING: '拣货中', + PENDING_RECONCILIATION: '待差异核对', PARTIAL_INBOUND: '部分入库', COMPLETED: '全部入库', } diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts new file mode 100644 index 00000000..2e141647 --- /dev/null +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts @@ -0,0 +1,25 @@ +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { expect, it, vi } from 'vitest' +import { getInboundPlan } from '../api/inboundPlans' +import InboundOrderDetailView from './InboundOrderDetailView.vue' + +vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: '9007199254741001' } }), useRouter: () => ({ push: vi.fn() }) })) +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: '' } })) + +it('provides the scoped plan facts and reloads after shortage confirmation', 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: [] } + vi.mocked(getInboundPlan).mockResolvedValue(detail) + const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } }) + 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(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 1e82212c..8dc3e62c 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue @@ -14,6 +14,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans' import { listOverseasLocations, type OverseasLocationDto } from '../api/locations' import OverseasDetailShell from '../components/OverseasDetailShell.vue' +import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue' import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation' const route = useRoute(); const router = useRouter() @@ -98,7 +99,7 @@ onMounted(load)