fix: unify receiving discrepancy confirmation
This commit is contained in:
@@ -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>
|
||||
</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>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="error" compact title="短少确认未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<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>
|
||||
<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="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>
|
||||
|
||||
Reference in New Issue
Block a user