修复收货异常核对并发布灰度

明确逐托逐票SKU归属,覆盖短少、超收和清点更正。保留原申报、现场时间和完整审计。
This commit was merged in pull request #34.
This commit is contained in:
2026-11-06 20:13:50 +08:00
7 changed files with 559 additions and 94 deletions
@@ -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 | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 |
| 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 |
@@ -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<typeof import('@/shared/api/http')>()), 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 } })
@@ -82,17 +82,67 @@ export async function getInboundPlan(planNo: string, customerId: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(`/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 }>, 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<ReceivingReconciliationDto>, ApiEnvelope<ReceivingReconciliationDto>>(
`/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<ReceivingReconciliationResult>, ApiEnvelope<ReceivingReconciliationResult>>(
`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/receiving-reconciliation`, payload,
{ headers: { 'Idempotency-Key': idempotencyKey } },
))
}
@@ -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: '<div v-if="modelValue"><slot /><button data-confirm :disabled="confirmDisabled || confirmLoading" @click="$emit(\'confirm\')">确认并按实收入库</button></div>' }
vi.mock('../api/inboundPlans', () => ({ confirmReceivingReconciliation: vi.fn(), getReceivingReconciliation: vi.fn() }))
const dialog = {
name: 'AppDialog', props: ['modelValue', 'title', 'loading', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'],
template: '<div v-if="modelValue"><h2>{{ title }}</h2><slot /><button data-confirm :disabled="loading || confirmDisabled || confirmLoading" @click="$emit(\'confirm\')">确认收货异常</button></div>',
}
const numberInput = {
props: ['modelValue', 'min', 'max', 'disabled'], emits: ['update:modelValue'],
template: '<input type="number" :min="min" :max="max" :disabled="disabled" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value === \'\' ? undefined : Number($event.target.value))" />',
}
function detail(overrides: Partial<InboundPlanDetailDto> = {}): 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> = {}): 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<typeof render>) {
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<ReturnType<typeof confirmReceivingReconciliation>>) => 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<ReturnType<typeof confirmReceivingReconciliation>>
let finishOld!: (value: Result) => void
let failOld!: (error: Error) => void
let finishNew!: (value: Result) => void
const oldRequest = new Promise<Result>((resolve, reject) => { finishOld = resolve; failOld = reject })
const newRequest = new Promise<Result>(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()
})
})
@@ -1,68 +1,193 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
import {
confirmReceivingReconciliation, getReceivingReconciliation, type InboundPlanDetailDto,
type ReceivingReconciliationDto, type ReceivingReconciliationPallet, type ReceivingReconciliationReceipt,
} from '../api/inboundPlans'
import { overseasWarehousePermissions } from '../permissions'
import { inboundPlanStatusLabel } from '../lastMilePresentation'
type PalletDraft = ReceivingReconciliationPallet & { confirmedCount: number | undefined }
type ReceiptDraft = ReceivingReconciliationReceipt & { confirmedCount: number | undefined }
const props = defineProps<{ detail: InboundPlanDetailDto }>()
const emit = defineEmits<{ refresh: [] }>()
const access = useAccessPreviewStore()
const open = ref(false)
const saving = ref(false)
const loading = ref(false)
const reason = ref('')
const snapshot = ref<ReceivingReconciliationDto | null>(null)
const pallets = ref<PalletDraft[]>([])
const receipts = ref<ReceiptDraft[]>([])
const error = ref<ApiErrorDetails | null>(null)
const expected = computed(() => props.detail.expectedPackageCount)
const received = computed(() => props.detail.pickedPackageCount)
const shortage = computed(() => (expected.value ?? 0) - (received.value ?? 0))
const eligible = computed(() => access.can(overseasWarehousePermissions.inboundOrderEdit)
&& props.detail.status === 'PENDING_RECONCILIATION' && props.detail.version != null
&& props.detail.orderType !== 'ONE_CLICK_DROPSHIP' && props.detail.lines.length > 0
&& (props.detail.lines.every(line => !!line.fbaWarehouseCode) || props.detail.lines.every(line => !line.fbaWarehouseCode))
&& expected.value != null && received.value != null && received.value > 0 && shortage.value > 0)
const loadError = ref<ApiErrorDetails | null>(null)
let loadSequence = 0
const pending = computed(() => props.detail.status === 'PENDING_RECONCILIATION')
const eligible = computed(() => pending.value && access.can(overseasWarehousePermissions.inboundOrderEdit))
const confirmedTotal = computed(() => receipts.value.reduce((sum, row) => sum + (row.confirmedCount ?? 0), 0))
const confirmedCountedTotal = computed(() => pallets.value.reduce((sum, row) => sum + (row.confirmedCount ?? 0), 0))
const difference = computed(() => confirmedTotal.value - (snapshot.value?.expectedPackageCount ?? 0))
const validationMessage = computed(() => {
if (!snapshot.value) return ''
if (pallets.value.some(row => !validCount(row.confirmedCount, row.editable))
|| receipts.value.some(row => !validCount(row.confirmedCount, row.editable))) {
return '清点及归属须为非负整数,可编辑箱数范围为0至66'
}
const mismatch = pallets.value.find(pallet => receipts.value
.filter(row => row.palletId === pallet.palletId)
.reduce((sum, row) => sum + (row.confirmedCount ?? 0), 0) !== pallet.confirmedCount)
return mismatch ? `托盘${mismatch.palletNo || '—'}的逐SKU归属合计须等于确认清点箱数` : ''
})
const canSubmit = computed(() => eligible.value && !loading.value && !saving.value && !loadError.value
&& !!snapshot.value?.snapshotHash && snapshot.value.canConfirm && !validationMessage.value && !!reason.value.trim())
function validCount(value: number | undefined, editable: boolean): value is number {
return value != null && Number.isInteger(value) && value >= 0 && (!editable || value <= 66)
}
function receiptKey(row: ReceivingReconciliationReceipt) {
return JSON.stringify([row.customerId, row.planNo, row.lineId, row.palletId, row.skuCode])
}
// 保存失败后仅保留同一归属行的人工输入,已完成行始终采用最新只读事实。
async function loadSnapshot(preserveInputs = false) {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await getReceivingReconciliation(props.detail.planNo, props.detail.customerId)
if (sequence !== loadSequence) return
const oldPallets = new Map(pallets.value.map(row => [row.palletId, row]))
const oldReceipts = new Map(receipts.value.map(row => [receiptKey(row), row]))
snapshot.value = result
pallets.value = result.pallets.map(row => ({ ...row, confirmedCount: preserveInputs && row.editable && oldPallets.has(row.palletId)
? oldPallets.get(row.palletId)?.confirmedCount : row.countedPackageCount }))
receipts.value = result.receipts.map(row => ({ ...row, confirmedCount: preserveInputs && row.editable && oldReceipts.has(receiptKey(row))
? oldReceipts.get(receiptKey(row))?.confirmedCount : row.receivedPackageCount }))
} catch (cause) {
if (sequence === loadSequence) loadError.value = getApiErrorDetails(cause, '收货核对资料加载失败,请重新加载')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
function show() {
if (!eligible.value || saving.value) return
reason.value = ''; error.value = null; open.value = true
if (!eligible.value || saving.value || open.value) return
reason.value = ''
error.value = null
open.value = true
void loadSnapshot()
}
async function confirm() {
if (!open.value || saving.value || !eligible.value || !reason.value.trim()
|| props.detail.version == null || expected.value == null || received.value == null) return
saving.value = true; error.value = null
if (!canSubmit.value || !snapshot.value) return
const target = { planNo: props.detail.planNo, customerId: props.detail.customerId }
let sequence = loadSequence
// 切换单据或客户后,旧命令的回执不得影响当前详情或新命令状态。
const current = () => sequence === loadSequence
&& target.planNo === props.detail.planNo && target.customerId === props.detail.customerId
saving.value = true
error.value = null
try {
await confirmInboundShortage(props.detail.planNo, {
customerId: props.detail.customerId, version: props.detail.version,
expectedPackageCount: expected.value, receivedPackageCount: received.value, reason: reason.value.trim(),
const result = await confirmReceivingReconciliation(target.planNo, {
customerId: target.customerId, snapshotHash: snapshot.value.snapshotHash, reason: reason.value.trim(),
pallets: pallets.value.map(row => ({ palletId: row.palletId, countedPackageCount: row.confirmedCount! })),
receipts: receipts.value.map(row => ({ customerId: row.customerId, planNo: row.planNo, lineId: row.lineId,
palletId: row.palletId, skuCode: row.skuCode, receivedPackageCount: row.confirmedCount! })),
}, crypto.randomUUID())
ElMessage.success(`已确认短少,${received.value} 箱实收已入库`)
if (!current()) return
ElMessage.success(`已确认收货异常,实际归属${result.confirmedPackageCount}箱,涉及${result.reconciledPlanNos.length}张入库单`)
open.value = false
emit('refresh')
} catch (cause) {
error.value = getApiErrorDetails(cause, '短少确认失败,请核对最新收货记录后重试')
emit('refresh')
} finally { saving.value = false }
if (!current()) return
error.value = getApiErrorDetails(cause, '收货异常确认失败,请核对最新资料后重新确认')
// 失败不重放旧快照,重新加载后由用户再次确认保留的清点及归属输入。
saving.value = false
const reload = loadSnapshot(true)
sequence = loadSequence
await reload
if (current()) emit('refresh')
} finally {
if (current()) saving.value = false
}
}
watch([() => props.detail.customerId, () => props.detail.planNo, () => props.detail.status], () => {
loadSequence++
open.value = false
snapshot.value = null
pallets.value = []
receipts.value = []
reason.value = ''
error.value = null
loadError.value = null
loading.value = false
saving.value = false
if (pending.value) void loadSnapshot()
}, { immediate: true })
onBeforeUnmount(() => { loadSequence++ })
</script>
<template>
<el-button v-if="eligible" class="density-action" size="small" :disabled="saving" @click="show">确认短少入库</el-button>
<AppDialog v-model="open" class="admin-density-dialog" title="确认短少入库" width="560px"
confirm-text="确认并按实收入库" :confirm-loading="saving" :confirm-disabled="!eligible || !reason.trim()" @confirm="confirm">
<section v-if="pending" class="receiving-reconciliation" aria-label="本柜收货核对">
<div class="reconciliation-heading">
<strong>本柜收货数量</strong>
<el-button v-if="eligible" class="density-action" size="small" :disabled="saving" @click="show">确认收货异常</el-button>
</div>
<table v-loading="loading" class="density-table" aria-label="本柜原始收货数量"><tbody><tr>
<th scope="row">原应收箱数</th><td>{{ snapshot?.expectedPackageCount ?? '—' }}</td>
<th scope="row">现场清点箱数</th><td>{{ snapshot?.countedPackageCount ?? '—' }}</td>
<th scope="row">已归属箱数</th><td>{{ snapshot?.attributedPackageCount ?? '—' }}</td>
</tr></tbody></table>
<LoadErrorState v-if="loadError && !open" compact title="收货核对资料加载失败" :message="loadError.message" :loading="loading" @retry="loadSnapshot()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
</section>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="`确认收货异常 · ${detail.planNo}`"
width="min(1100px, calc(100vw - 48px))" fullscreenable :loading="loading"
confirm-text="确认收货异常" :confirm-loading="saving" :confirm-disabled="!canSubmit" @confirm="confirm">
<div class="density-dialog-sections">
<table class="density-table"><tbody>
<tr><th scope="row">入库单号</th><td>{{ detail.planNo }}</td></tr>
<tr><th scope="row">应收箱数</th><td>{{ expected }}</td></tr>
<tr><th scope="row">实收入库箱数</th><td>{{ received }}</td></tr>
<tr><th scope="row">确认短少箱数</th><td>{{ shortage }}</td></tr>
<LoadErrorState v-if="loadError" compact title="收货核对资料加载失败" :message="loadError.message" :loading="loading" @retry="loadSnapshot(true)"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-if="snapshot">
<table class="density-table" aria-label="本柜收货核对摘要"><tbody>
<tr><th scope="row">核对范围</th><td colspan="5">本柜全部涉及入库单 · {{ snapshot.containerNo || '—' }}</td></tr>
<tr><th scope="row">原应收箱数</th><td>{{ snapshot.expectedPackageCount }}</td><th scope="row">现场清点箱数</th><td>{{ snapshot.countedPackageCount }}</td><th scope="row">已归属箱数</th><td>{{ snapshot.attributedPackageCount }}</td></tr>
<tr><th scope="row">本次确认清点</th><td>{{ confirmedCountedTotal }}</td><th scope="row">本次实际归属</th><td>{{ confirmedTotal }}</td><th scope="row">实际与应收差异</th><td>{{ difference > 0 ? `超收${difference}` : difference < 0 ? `短少${-difference}` : '一致' }}</td></tr>
</tbody></table>
<el-form class="density-form" label-position="left" label-width="100px" :disabled="saving">
<el-form-item label="确认原因" required><el-input v-model="reason" aria-label="短少确认原因" type="textarea" :rows="2" maxlength="200" /></el-form-item>
<table class="density-table" aria-label="本柜涉及入库单"><thead><tr><th scope="col">客户</th><th scope="col">入库单号</th><th scope="col">当前状态</th><th scope="col">原应收箱数</th><th scope="col">已归属箱数</th></tr></thead><tbody>
<tr v-for="plan in snapshot.plans" :key="`${plan.customerId}-${plan.planNo}`"><td>{{ plan.customerName || '—' }}</td><td>{{ plan.planNo }}</td><td>{{ inboundPlanStatusLabel(plan.status) }}</td><td>{{ plan.expectedPackageCount }}</td><td>{{ plan.attributedPackageCount }}</td></tr>
</tbody></table>
<div class="reconciliation-table-wrap"><table class="density-table density-table--editable" aria-label="逐托现场清点"><thead><tr><th scope="col">托盘号</th><th scope="col">现场清点箱数</th><th scope="col">已归属箱数</th><th scope="col">确认清点箱数</th></tr></thead><tbody>
<tr v-for="pallet in pallets" :key="pallet.palletId"><td>{{ pallet.palletNo || '—' }}</td><td>{{ pallet.countedPackageCount }}</td><td>{{ pallet.attributedPackageCount }}</td><td>
<el-input-number v-if="pallet.editable" v-model="pallet.confirmedCount" :min="0" :max="66" :step="1" :precision="0" :disabled="saving || loading" :aria-label="`托盘${pallet.palletNo || '—'}确认清点箱数`" />
<span v-else>{{ pallet.confirmedCount }}(只读)</span>
</td></tr>
</tbody></table></div>
<div class="reconciliation-table-wrap"><table class="density-table density-table--editable" aria-label="逐票逐SKU实际归属"><thead><tr><th scope="col">入库单号/入仓码</th><th scope="col">托盘号</th><th scope="col">SKU/品名</th><th scope="col">本票SKU原应收</th><th scope="col">确认归属箱数</th></tr></thead><tbody>
<tr v-for="receipt in receipts" :key="receiptKey(receipt)"><td>{{ receipt.planNo }}<br>{{ receipt.inboundCode || '—' }}</td><td>{{ receipt.palletNo || '—' }}</td><td>{{ receipt.skuCode || '—' }}<br>{{ receipt.productName || '—' }}</td><td>{{ receipt.expectedPackageCount }}</td><td>
<el-input-number v-if="receipt.editable" v-model="receipt.confirmedCount" :min="0" :max="66" :step="1" :precision="0" :disabled="saving || loading" :aria-label="`${receipt.planNo}托盘${receipt.palletNo || '—'}SKU${receipt.skuCode}确认归属箱数`" />
<span v-else>{{ receipt.confirmedCount }}(只读)</span>
</td></tr>
</tbody></table></div>
<el-form class="density-form" label-position="left" label-width="100px" :disabled="saving || loading">
<el-form-item label="确认原因" required><el-input v-model="reason" aria-label="收货异常确认原因" type="textarea" :rows="2" maxlength="200" /></el-form-item>
</el-form>
<LoadErrorState v-if="error" compact title="短少确认未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="snapshot.blockedReason || !snapshot.canConfirm" compact title="当前收货核对未满足确认条件" :message="snapshot.blockedReason || '请先补齐收货核对资料'" :show-retry="false" />
<LoadErrorState v-if="validationMessage" compact title="请核对本次清点与归属" :message="validationMessage" :show-retry="false" />
</template>
<LoadErrorState v-if="error" compact title="收货异常确认未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</div>
</AppDialog>
</template>
<style scoped>
.receiving-reconciliation { margin-bottom: 12px; }
.reconciliation-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.reconciliation-table-wrap { overflow-x: auto; }
.reconciliation-table-wrap td { overflow-wrap: anywhere; }
</style>
@@ -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: '<button data-shortage @click="$emit(\'refresh\')">确认短少入库</button>' } }))
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '<button data-reconciliation @click="$emit(\'refresh\')">确认收货异常</button>' } }))
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()
@@ -129,7 +129,7 @@ onMounted(load)
<template><OverseasDetailShell workbench class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap">
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><span class="section-count">{{ palletRows.length }} 条</span></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><div class="detail-table-wrap">
<LoadErrorState v-if="printError" compact title="托盘标签打印失败" :message="printError.message" :show-retry="false"><template #details><ApiErrorMeta :details="printError" /></template></LoadErrorState>
<div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><RowActions :actions="palletActions(row as typeof palletRows[number])" text-actions @action="action => action === 'print' ? printPalletLabel(row as typeof palletRows[number]) : openMove(row as typeof palletRows[number])" /></template></DataTable></div></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>