feat(transfer): expose stock outbound and durable receipt recovery UI

This commit is contained in:
2026-10-05 06:16:21 +08:00
parent 578df0f927
commit 93f193c5d6
7 changed files with 432 additions and 2 deletions
@@ -1,11 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { allocateWarehouseTransfer, getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer } from './warehouseTransfers'
import { allocateWarehouseTransfer, getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer, outboundWarehouseTransfer, receiveWarehouseTransfer, listTransferReceivingWarehouses } from './warehouseTransfers'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
describe('warehouseTransfers API', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the resource-role receiving options contract, never warehouse master list', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ id: 'NY', customerId: 0, warehouseCode: 'NY', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], total: 1, page: 1, size: 200, pages: 1 } })
expect((await listTransferReceivingWarehouses()).records[0]?.customerId).toBe('0')
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/warehouse-admin/overseas-receiving-options', { params: { page: 1, size: 200 } })
})
it('keeps outbound and receipt original bodies and customer scope without inventing receipt rows', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT/1', packageCount: '2', status: 'PARTIALLY_RECEIVED', allocations: [] } })
const outbound = { idempotencyKey: 'out-original' }
await outboundWarehouseTransfer('customer', 'WT/1', outbound)
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/warehouse-transfers/WT%2F1/outbound', outbound, { params: { customerId: 'customer' } })
const receipt = { sourceInventoryPositionId: 'position', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约仓', destinationWarehouseCode: 'ABE4', destinationPalletId: 'pallet', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'location', warehouseLocationCode: 'LOC-NY', batchNo: 'WT/1', idempotencyKey: 'receipt-original' }
await receiveWarehouseTransfer('customer', 'WT/1', receipt)
await receiveWarehouseTransfer('customer', 'WT/1', receipt)
expect(vi.mocked(http.post).mock.calls[1]).toEqual(vi.mocked(http.post).mock.calls[2])
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/warehouse-transfers/WT%2F1/receipts', receipt, { params: { customerId: 'customer' } })
})
it('normalizes detail, review and allocation box counts at the API boundary', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', packageCount: '1' } })
@@ -2,6 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
import { normalizeWarehouseTransferQuantity } from './warehouseTransferQuantities'
import type { ReceivingOverseasWarehouseDto } from './pallets'
export interface WarehouseTransferQuery {
page: number
@@ -85,8 +86,20 @@ export interface WarehouseTransferDetailDto extends WarehouseTransferSummaryDto
relabelBindings: WarehouseTransferRelabelBindingDto[]
customerConfirmedAt?: string | null
actualShipAt?: string | null
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
sourceOrders?: Array<{ lastlegOrderId: string; lastlegOrderNo?: string | null; inboundOrderId: string; inboundCode?: string | null }>
allocations?: Array<{ inventoryPositionId: string; inboundOrderId: string; inboundCode?: string | null; palletId: string; palletNo?: string | null; packageCount: number; status: string; destinationInventoryPositionId?: string | null; skuCode?: string | null }>
receiptCommands?: WarehouseTransferReceiptCommandDto[]
}
export interface WarehouseTransferReceiptCommandDto {
sourceInventoryPositionId: string
status: 'PREPARED' | 'RESERVED' | 'COMPLETED'
requestHash: string
request: ReceiveWarehouseTransferPayload
target: { warehouseId: string; warehouseCode: string; warehouseName: string; palletId: string; palletNo: string; locationId: string; locationCode: string; fbaId?: string | null; fbaCode: string; fbaName?: string | null }
}
export interface AllocateWarehouseTransferPayload {
@@ -94,6 +107,57 @@ export interface AllocateWarehouseTransferPayload {
allocations: Array<{ inventoryPositionId: string; palletId: string; packageCount: number }>
}
export interface OutboundWarehouseTransferPayload { idempotencyKey: string; remark?: string }
export interface ReceiveWarehouseTransferPayload {
sourceInventoryPositionId: string
destinationOverseasWarehouseId: string
destinationOverseasWarehouseCode: string
destinationOverseasWarehouseName: string
destinationWarehouseId?: string
destinationWarehouseCode: string
destinationWarehouseName?: string
destinationPalletId: string
destinationPalletNo: string
warehouseLocationId: string
warehouseLocationCode: string
batchNo: string
idempotencyKey: string
remark?: string
}
export async function outboundWarehouseTransfer(customerId: string, transferNo: string, payload: OutboundWarehouseTransferPayload) {
const response = await http.post<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/outbound`, payload, { params: { customerId } },
)
return normalizeTransferDetail(unwrapApiResponse(response))
}
export async function receiveWarehouseTransfer(customerId: string, transferNo: string, payload: ReceiveWarehouseTransferPayload) {
const response = await http.post<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/receipts`, payload, { params: { customerId } },
)
return normalizeTransferDetail(unwrapApiResponse(response))
}
export async function listTransferReceivingWarehouses() {
const records: ReceivingOverseasWarehouseDto[] = []
let total: number | undefined
for (let page = 1; page <= 100; page++) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<ReceivingOverseasWarehouseDto>>, ApiEnvelope<OverseasPagePayload<ReceivingOverseasWarehouseDto>>>(
'/platform/v1/warehouse-admin/overseas-receiving-options', { params: { page, size: 200 } },
)
const value = normalizeOverseasPage(unwrapApiResponse(response))
if (total !== undefined && total !== value.total) throw new Error('收货仓列表已变化,请重新查询')
total = value.total
records.push(...value.records.map(w => ({ ...w, id: String(w.id), customerId: String(w.customerId) })))
if (records.length === total) {
if (new Set(records.map(w => w.id)).size !== records.length || records.some(w => w.customerId !== '0' || w.warehouseType !== 'OVERSEAS' || w.status !== 'ACTIVE')) throw new Error('收货仓返回范围不符合要求')
return { records }
}
if (!value.records.length || records.length > total) throw new Error('收货仓分页不完整,请重新查询')
}
throw new Error('收货仓分页超出查询限制')
}
type WarehouseTransferDetailWire = Omit<WarehouseTransferDetailDto, 'packageCount'> & { packageCount: number | string }
function normalizeTransferDetail(value: WarehouseTransferDetailWire): WarehouseTransferDetailDto {
return { ...value, packageCount: normalizeWarehouseTransferQuantity(value.packageCount, '本票转运箱数') }
@@ -12,6 +12,7 @@ import { allocateWarehouseTransfer, getWarehouseTransfer, type AllocateWarehouse
import { listWarehouseTransferSources, type WarehouseTransferSourceCandidate } from '../api/warehouseTransferSources'
import { lastMileStatusLabel, relabelApplicationStatusLabel } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import WarehouseTransferMovementPanel from './WarehouseTransferMovementPanel.vue'
const props = defineProps<{ customerId: string; transferNo: string }>()
const router = useRouter(), access = useAccessPreviewStore()
@@ -117,12 +118,13 @@ const gateText = computed(() => {
if (!['READY_FOR_OUTBOUND', 'COMPLETED'].includes(detail.value.relabelStatus || '')) return '本票换标尚未完成复核和客户确认,不得提前出库。请沿关联换标单查看 PDA 作业及客户确认进度。'
return '换标已完成。后续出库仍须按转运单与服务端库存门禁执行。'
})
const allocationRows = computed(() => (detail.value?.allocations || []).map(row => ({ ...row, skuCode: row.skuCode && row.skuCode !== 'MIXED' ? row.skuCode : '—' })))
const allocationRows = computed(() => (detail.value?.allocations || []).map(row => ({ ...row, skuCode: row.skuCode && row.skuCode !== 'MIXED' ? row.skuCode : '—', movementStatus: ({ RESERVED: '已分配', SHIPPED: '已发运', RECEIVED: '目标已实收' } as Record<string, string>)[row.status] || '—' })))
const columns: DataTableColumn[] = [
{ key: 'inboundCode', prop: 'inboundCode', label: '来源入仓码', minWidth: 160 },
{ key: 'palletNo', prop: 'palletNo', label: '来源托盘码', minWidth: 180 },
{ key: 'skuCode', prop: 'skuCode', label: '实际 SKU', minWidth: 150 },
{ key: 'packageCount', prop: 'packageCount', label: '分配箱数', width: 100 },
{ key: 'movementStatus', prop: 'movementStatus', label: '履约状态', width: 120 },
]
async function load() {
const current = ++request
@@ -159,6 +161,7 @@ onBeforeUnmount(() => { request++; sourceRequest++ })
<p v-if="gateText" role="status">{{ gateText }}</p>
<el-button v-if="detail.relabelNo && access.can(permission.relabelOrderList)" @click="openRelabel">查看本票换标单</el-button>
<DataTable :data="allocationRows" :columns="columns" row-key="inventoryPositionId" :show-pagination="false" empty-text="尚未确认本票库存来源分配" />
<WarehouseTransferMovementPanel :customer-id="customerId" :detail="detail" @updated="detail = $event" />
</template>
</section>
<AppDialog v-model="allocationOpen" class="admin-density-dialog" title="选择本票库存来源" width="min(1320px, calc(100vw - 40px))" :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
@@ -0,0 +1,123 @@
import { flushPromises, mount, enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import WarehouseTransferMovementPanel from './WarehouseTransferMovementPanel.vue'
import { getWarehouseTransfer, outboundWarehouseTransfer, receiveWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers'
import { getPallet, listPallets } from '../api/pallets'
import { getOverseasLocation } from '../api/locations'
const { can } = vi.hoisted(() => ({ can: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn(), outboundWarehouseTransfer: vi.fn(), receiveWarehouseTransfer: vi.fn(), listTransferReceivingWarehouses: vi.fn() }))
vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() }))
vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() }))
enableAutoUnmount(afterEach)
const detail = (status = 'ALLOCATED'): WarehouseTransferDetailDto => ({ id: 'hidden', transferNo: 'WT-1', packageCount: 1, status, needRelabel: false, fbaWarehouseCode: 'ABE4', destinationWarehouseCode: 'ABE4', items: [], cartons: [], pallets: [], relabelBindings: [], receiptCommands: [], allocations: [{ inventoryPositionId: 'hidden-source', inboundOrderId: 'hidden-inbound', inboundCode: 'IN-1', palletId: 'hidden-la', palletNo: 'LA-CODE', skuCode: 'NEW', packageCount: 1, status: status === 'ALLOCATED' ? 'RESERVED' : 'SHIPPED' }] })
const render = (value = detail()) => mount(WarehouseTransferMovementPanel, { props: { customerId: 'customer', detail: value }, global: { plugins: [ElementPlus] } })
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(x => x.text() === label)!
describe('transfer movement UI', () => {
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); can.mockReturnValue(true) })
it('uses the overseas resource warehouse permission and does not require warehouse master access', async () => {
can.mockImplementation(code => code !== 'platform:warehouse:list')
const permitted = render(detail('OUTBOUNDED')); expect(permitted.text()).toContain('登记目标仓实收')
can.mockImplementation(code => code !== 'platform:overseas-warehouse:warehouse:list')
const denied = render(detail('OUTBOUNDED')); expect(denied.text()).not.toContain('登记目标仓实收'); expect(listTransferReceivingWarehouses).not.toHaveBeenCalled()
})
it.each([
{ cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: false, canReselect: true },
{ cause: new Error('response lost'), recorded: false, canReselect: false },
{ cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: true, canReselect: false },
])('allows target correction only after definite rejection plus authoritative no-command readback: $canReselect', async ({ cause, recorded, canReselect }) => {
const body = { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'original-key' }
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: { type: 'receipt', body } }))
const fresh = detail('OUTBOUNDED')
if (recorded) fresh.receiptCommands = [{ sourceInventoryPositionId: 'hidden-source', status: 'PREPARED', requestHash: 'hash', request: body, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }]
vi.mocked(receiveWarehouseTransfer).mockRejectedValue(cause); vi.mocked(getWarehouseTransfer).mockResolvedValue(fresh)
const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('重新选择目标')
await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises()
expect(wrapper.text().includes('重新选择目标')).toBe(canReselect)
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending.body).toEqual(body)
if (canReselect) {
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [] })
await button(wrapper, '重新选择目标').trigger('click'); await flushPromises()
expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending).toBeNull()
expect(wrapper.text()).toContain('上次目标选择被拒绝:目标不可用')
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
}
})
it('recovers a reserved original server command in a fresh browser and never queries arbitrary occupied resources', async () => {
const command: WarehouseTransferReceiptCommandDto = { sourceInventoryPositionId: 'hidden-source', status: 'RESERVED', requestHash: 'server-hash', request: { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'server-original' }, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }
const d = detail('OUTBOUNDED'); d.receiptCommands = [command]
const completed = detail('RECEIVED'); completed.allocations![0]!.status = 'RECEIVED'; completed.receiptCommands = [{ ...command, status: 'COMPLETED' }]
vi.mocked(receiveWarehouseTransfer).mockResolvedValue(completed)
const wrapper = render(d); await flushPromises()
expect(wrapper.text()).toContain('从本票服务端记录恢复'); expect(wrapper.text()).not.toContain('登记目标仓实收')
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
expect(receiveWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer', 'WT-1', command.request)
expect(wrapper.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'RECEIVED' })
expect(listPallets).not.toHaveBeenCalled(); expect(getPallet).not.toHaveBeenCalled()
})
it('does not use a browser remembered occupied target when server receipt history is missing', async () => {
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: null, previous: { warehouse: { id: 'OTHER' }, pallet: { id: 'OTHER', status: 'OCCUPIED' } } }))
const d = detail('PARTIALLY_RECEIVED'); delete d.receiptCommands
const wrapper = render(d); expect(wrapper.text()).toContain('尚未返回服务端实收操作记录'); expect(wrapper.text()).not.toContain('登记目标仓实收'); expect(getPallet).not.toHaveBeenCalled()
})
it('permits same occupied target from completed server history in a fresh browser', async () => {
const d = detail('PARTIALLY_RECEIVED')
const completed: WarehouseTransferReceiptCommandDto = { sourceInventoryPositionId: 'already-received', status: 'COMPLETED', requestHash: 'hash', request: { sourceInventoryPositionId: 'already-received', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'first-key' }, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }
d.receiptCommands = [completed]
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [{ id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] })
vi.mocked(getPallet).mockResolvedValue({ id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'OCCUPIED', version: 1 })
vi.mocked(getOverseasLocation).mockResolvedValue({ id: 'L-NY', warehouseId: 'NY', locationCode: 'LOC-NY', locationType: 'NORMAL', sharedOverflow: false, status: 'OCCUPIED', version: 1 })
const wrapper = render(d); await button(wrapper, '登记目标仓实收').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('目标:NY-CODE / PALLET-NY / LOC-NY')
expect(getPallet).toHaveBeenCalledExactlyOnceWith('NY', 'P-NY')
expect(listPallets).not.toHaveBeenCalled()
await wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'hidden-source'); await flushPromises()
expect(button(wrapper, '确认本来源实收').attributes('disabled')).toBeUndefined()
expect(receiveWarehouseTransfer).not.toHaveBeenCalled()
})
it('requires permission and explicit actual-outbound confirmation; unknown outcome survives remount with same body', async () => {
vi.mocked(outboundWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(detail('OUTBOUNDED'))
const first = render(); await button(first, '确认本票出库').trigger('click'); expect(outboundWarehouseTransfer).not.toHaveBeenCalled()
await button(first, '确认实际出库').trigger('click'); await flushPromises()
const original = JSON.parse(JSON.stringify(vi.mocked(outboundWarehouseTransfer).mock.calls[0]))
expect(first.text()).toContain('原操作内容已保留'); first.unmount()
const second = render(detail('OUTBOUNDED')); await flushPromises()
expect(second.text()).not.toContain('hidden-source'); expect(second.text()).toContain('重试原操作')
await button(second, '重试原操作').trigger('click'); await flushPromises()
expect(vi.mocked(outboundWarehouseTransfer).mock.calls[1]).toEqual(original)
expect(second.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'OUTBOUNDED' })
can.mockReturnValue(false); const denied = render(); expect(denied.text()).not.toContain('确认本票出库')
})
it('queries only selected warehouse page, reads exact bound location and keeps receipt request after response loss', async () => {
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [{ id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] })
const p = { id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'IDLE', version: 0 }
vi.mocked(listPallets).mockResolvedValue({ records: [p], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPallet).mockResolvedValue(p)
vi.mocked(getOverseasLocation).mockResolvedValue({ id: 'L-NY', warehouseId: 'NY', locationCode: 'LOC-NY', locationType: 'NORMAL', sharedOverflow: false, status: 'IDLE', version: 0 })
vi.mocked(receiveWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(detail('RECEIVED'))
const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '登记目标仓实收').trigger('click'); await flushPromises()
expect(listPallets).not.toHaveBeenCalled()
await wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'hidden-source'); await wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'NY')
await wrapper.get('input[aria-label="目标托盘码查询"]').setValue('PALLET-NY'); await button(wrapper, '查询目标托盘').trigger('click'); await flushPromises()
expect(listPallets).toHaveBeenCalledExactlyOnceWith('NY', { page: 1, size: 20, keyword: 'PALLET-NY', status: 'IDLE' })
wrapper.findAllComponents({ name: 'ElSelect' })[2]!.vm.$emit('update:modelValue', 'P-NY'); await flushPromises()
expect(getOverseasLocation).toHaveBeenCalledExactlyOnceWith('NY', 'L-NY')
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
const original = JSON.parse(JSON.stringify(vi.mocked(receiveWarehouseTransfer).mock.calls[0]))
expect(original[2]).toMatchObject({ sourceInventoryPositionId: 'hidden-source', destinationPalletNo: 'PALLET-NY', warehouseLocationCode: 'LOC-NY' })
await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises()
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
expect(vi.mocked(receiveWarehouseTransfer).mock.calls[1]).toEqual(original)
expect(wrapper.text()).not.toContain('hidden-source')
})
it('does not request resources when permission is absent or source already received', async () => {
can.mockImplementation(code => code !== 'platform:overseas-warehouse:location:list')
const denied = render(detail('OUTBOUNDED')); expect(denied.text()).not.toContain('登记目标仓实收'); expect(listTransferReceivingWarehouses).not.toHaveBeenCalled()
can.mockReturnValue(true); const done = detail('RECEIVED'); done.allocations![0]!.status = 'RECEIVED'; const wrapper = render(done); expect(wrapper.text()).not.toContain('登记目标仓实收')
})
})
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import axios from 'axios'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { outboundWarehouseTransfer, receiveWarehouseTransfer, getWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type OutboundWarehouseTransferPayload, type ReceiveWarehouseTransferPayload, type WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers'
import { getPallet, listPallets, type PalletDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { getOverseasLocation } from '../api/locations'
import { canOutboundTransfer, createReceiptCommand, receiptTargetValid, type ReceiptTarget } from './warehouseTransferReceiptSelection'
const props = defineProps<{ customerId: string; detail: WarehouseTransferDetailDto }>()
const emit = defineEmits<{ updated: [detail: WarehouseTransferDetailDto] }>()
const access = useAccessPreviewStore()
type Pending = { type: 'outbound'; body: OutboundWarehouseTransferPayload } | { type: 'receipt'; body: ReceiveWarehouseTransferPayload }
const pending = ref<Pending | null>(null)
const definiteRejection = ref(false), reselectAllowed = ref(false), rejectionEvidence = ref<ApiErrorDetails | null>(null)
function definiteBusinessRejection(cause: unknown) {
const codes = ['A0400', 'A0404', 'A0409', 'A0422']
const statusKnown = (status?: number) => status === undefined || [200, 400, 404, 409, 422].includes(status)
if (cause instanceof ApiResponseError) return codes.includes(cause.code) && statusKnown(cause.status)
return axios.isAxiosError<{ code?: string }>(cause) && !!cause.response && statusKnown(cause.response.status) && codes.includes(cause.response.data?.code || '')
}
const completedTargets = computed(() => props.detail.receiptCommands?.filter(c => c.status === 'COMPLETED').map(c => c.target).filter((t, i, all) => all.findIndex(v => v.warehouseId === t.warehouseId && v.palletId === t.palletId && v.locationId === t.locationId) === i) ?? [])
const previous = computed(() => completedTargets.value[0] ?? null)
function acknowledgedTarget(value: ReceiptTarget) { return completedTargets.value.find(t => t.warehouseId === value.warehouse.id && t.palletId === value.pallet.id && t.locationId === value.location.id) ?? null }
const unfinished = computed(() => props.detail.receiptCommands?.filter(c => c.status !== 'COMPLETED') ?? [])
const busy = ref(false), error = ref<ApiErrorDetails | null>(null), notice = ref(''), open = ref(false)
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]), warehouseId = ref(''), keyword = ref(''), page = ref(1), total = ref(0)
const pallets = ref<PalletDto[]>([]), target = ref<ReceiptTarget | null>(null), sourceId = ref('')
let generation = 0
const canManage = computed(() => access.can('lastleg:warehouse-transfer:manage'))
const canResources = computed(() => canManage.value && access.can('platform:overseas-warehouse:warehouse:list') && access.can('platform:overseas-warehouse:pallet:list') && access.can('platform:overseas-warehouse:location:list'))
const shipped = computed(() => (props.detail.allocations || []).filter(a => a.status === 'SHIPPED'))
const outboundReady = computed(() => canManage.value && canOutboundTransfer(props.detail))
const receiptReady = computed(() => canResources.value && Array.isArray(props.detail.receiptCommands) && ['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(props.detail.status) && shipped.value.length > 0 && !unfinished.value.length)
const selectedValid = computed(() => target.value && shipped.value.some(a => a.inventoryPositionId === sourceId.value) && receiptTargetValid(props.detail, target.value, acknowledgedTarget(target.value)))
const storageKey = () => `warehouse-transfer-movement:${props.customerId}:${props.detail.transferNo}`
function persist() { sessionStorage.setItem(storageKey(), JSON.stringify({ pending: pending.value, definiteRejection: definiteRejection.value })) }
function restore() {
generation++; busy.value = false; pending.value = null; error.value = null; notice.value = ''; open.value = false
definiteRejection.value = false; reselectAllowed.value = false; rejectionEvidence.value = null
warehouses.value = []; pallets.value = []; warehouseId.value = ''; target.value = null; sourceId.value = ''; keyword.value = ''; page.value = 1; total.value = 0
try {
const saved = sessionStorage.getItem(storageKey())
if (!saved) { recoverServerCommand(); return }
const value: { pending: Pending | null; definiteRejection?: boolean } = JSON.parse(saved)
if (value.pending && (!['outbound', 'receipt'].includes(value.pending.type) || !value.pending.body?.idempotencyKey)) throw new Error('invalid saved command')
pending.value = value.pending; definiteRejection.value = value.definiteRejection === true; recoverServerCommand()
} catch { error.value = { message: '本次操作记录无法读取,请联系管理员核对原操作,暂不能提交新操作。' }; busy.value = true }
}
function recoverServerCommand(command?: WarehouseTransferReceiptCommandDto) {
if (pending.value?.type === 'receipt' && props.detail.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === pending.value?.body.idempotencyKey)) { pending.value = null; persist() }
const server = command ?? (unfinished.value.length === 1 ? unfinished.value[0] : undefined)
if (server && canManage.value) {
definiteRejection.value = false; reselectAllowed.value = false
pending.value = { type: 'receipt', body: JSON.parse(JSON.stringify(server.request)) as ReceiveWarehouseTransferPayload }
notice.value = '已从本票服务端记录恢复原实收操作。'
}
}
watch(() => [props.customerId, props.detail.transferNo], restore, { immediate: true })
watch(() => props.detail.receiptCommands, () => { if (!busy.value) recoverServerCommand() })
async function checkCurrent() {
if (busy.value) return
const current = generation, customerId = props.customerId, no = props.detail.transferNo
busy.value = true; error.value = null
try {
const value = await getWarehouseTransfer(customerId, no)
if (current !== generation) return
if (value.transferNo !== no) throw new Error('返回的转运单不一致')
const command = pending.value
reselectAllowed.value = !!(command?.type === 'receipt' && definiteRejection.value && Array.isArray(value.receiptCommands)
&& !value.receiptCommands.some(c => c.request.idempotencyKey === command.body.idempotencyKey)
&& !value.receiptCommands.some(c => c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)
&& value.allocations?.some(a => a.inventoryPositionId === command.body.sourceInventoryPositionId && a.status === 'SHIPPED'))
if ((command?.type === 'outbound' && ['OUTBOUNDED', 'PARTIALLY_RECEIVED', 'RECEIVED'].includes(value.status))
|| (command?.type === 'receipt' && value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey))) { pending.value = null; persist() }
emit('updated', value)
notice.value = reselectAllowed.value ? '服务端确认原请求未建立实收命令,来源仍待实收,可重新选择目标。' : pending.value ? '已核对当前状态;原操作仍保留,可使用原内容确认结果。' : '当前转运状态已刷新。'
}
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '当前转运状态核对失败') }
finally { if (current === generation) busy.value = false }
}
async function reselectTarget() {
if (!reselectAllowed.value || busy.value || pending.value?.type !== 'receipt' || !definiteRejection.value) return
const original = pending.value
if (error.value) rejectionEvidence.value = error.value
try { pending.value = null; definiteRejection.value = false; persist() }
catch (cause) { pending.value = original; definiteRejection.value = true; error.value = getApiErrorDetails(cause, '原操作记录无法更新,暂不能重新选择'); return }
reselectAllowed.value = false; target.value = null; sourceId.value = ''; keyword.value = ''; pallets.value = []; error.value = null
await openReceipt()
}
async function openReceipt() {
if (!receiptReady.value || busy.value || pending.value) return
open.value = true; busy.value = true; error.value = null; const current = generation
try { const result = await listTransferReceivingWarehouses(); if (current !== generation) return; warehouses.value = result.records; if (previous.value) { warehouseId.value = previous.value.warehouseId; await selectPallet(previous.value.palletId) } }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '收货仓加载失败') }
finally { if (current === generation) busy.value = false }
}
async function searchPallets(nextPage = 1) {
if (!warehouseId.value || pending.value || !canResources.value || busy.value) return
const current = generation, warehouse = warehouseId.value
busy.value = true; error.value = null; target.value = null
try { const result = await listPallets(warehouse, { page: nextPage, size: 20, keyword: keyword.value.trim() || undefined, status: 'IDLE' }); if (current !== generation || warehouse !== warehouseId.value) return; pallets.value = result.records; total.value = result.total; page.value = nextPage }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '目标托盘查询失败') }
finally { if (current === generation) busy.value = false }
}
async function selectPallet(id: string) {
if (pending.value || !canResources.value) return
const current = generation, warehouse = warehouseId.value
target.value = null; error.value = null; busy.value = true
try {
const w = warehouses.value.find(x => x.id === warehouse); if (!w) throw new Error('请选择有效收货仓')
const p = await getPallet(warehouse, id); if (!p.locationId) throw new Error('目标托盘尚未绑定库位,请在托盘管理中核对')
const l = await getOverseasLocation(warehouse, p.locationId)
if (current !== generation || warehouse !== warehouseId.value) return
const value = { warehouse: w, pallet: p, location: l }
if (!receiptTargetValid(props.detail, value, acknowledgedTarget(value))) throw new Error('目标托盘、库位状态或 FBA 目的仓不符合本票要求')
target.value = value
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '收货资源核对失败') }
finally { if (current === generation) busy.value = false }
}
function changeWarehouse() { target.value = null; pallets.value = []; page.value = 1; total.value = 0 }
async function submit(type: 'outbound' | 'receipt') {
if (busy.value || !canManage.value || (type === 'receipt' && !canResources.value)) return
if (!pending.value) {
definiteRejection.value = false; reselectAllowed.value = false
try {
if (type === 'outbound') { if (!outboundReady.value) return; pending.value = { type, body: { idempotencyKey: crypto.randomUUID() } } }
else { if (!receiptReady.value || !target.value || !selectedValid.value) return; pending.value = { type, body: createReceiptCommand(props.detail, sourceId.value, target.value, crypto.randomUUID(), acknowledgedTarget(target.value)) } }
persist() // Preserve the exact command before transport, including across close/navigation/reload.
} catch (cause) { error.value = getApiErrorDetails(cause, '原操作记录保存失败,未提交请求'); pending.value = null; return }
}
const command = pending.value
if (command.type !== type) return
const current = generation, customer = props.customerId, no = props.detail.transferNo
busy.value = true; error.value = null
try {
const value = command.type === 'outbound' ? await outboundWarehouseTransfer(customer, no, command.body) : await receiveWarehouseTransfer(customer, no, command.body)
if (current !== generation) return
if (value.transferNo !== no) throw new ApiResponseError('操作响应与当前转运单不一致,请核对原操作', 'TRANSFER_IDENTITY_MISMATCH')
if (command.type === 'receipt' && !value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey && c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)) throw new ApiResponseError('实收响应尚未返回本次完成记录,请保留原操作并只读核对', 'TRANSFER_RECEIPT_RECORD_MISSING')
pending.value = null; persist(); emit('updated', value); open.value = false; target.value = null; sourceId.value = ''
notice.value = command.type === 'outbound' ? '本票库存已确认出库。' : '本来源目标实收已确认。'
} catch (cause) { if (current === generation) { pending.value = command; definiteRejection.value = definiteBusinessRejection(cause); reselectAllowed.value = false; error.value = getApiErrorDetails(cause, '操作结果尚未确认,已保留原内容,请核对后重试原操作'); if (definiteRejection.value) rejectionEvidence.value = error.value; try { persist() } catch { /* Existing pre-transport record remains authoritative for retry. */ } } }
finally { if (current === generation) busy.value = false }
}
</script>
<template>
<section aria-label="转运出库与目标实收" class="movement-panel">
<template v-if="canManage">
<el-button v-if="outboundReady && !pending" :disabled="busy" @click="open = !open">确认本票出库</el-button>
<el-button v-if="receiptReady && !pending" :disabled="busy" @click="openReceipt">登记目标仓实收</el-button>
<div v-if="open && outboundReady && !pending" aria-label="出库确认"><p>{{ detail.transferNo }} · {{ detail.packageCount }} 箱。确认实际移出后,全部已分配来源将发运。</p><el-button :loading="busy" @click="submit('outbound')">确认实际出库</el-button></div>
<div v-if="open && receiptReady && !pending" aria-label="目标仓实收选择">
<el-form label-position="top" class="movement-fields">
<el-form-item label="已发运来源"><el-select v-model="sourceId" aria-label="已发运来源" :disabled="busy" placeholder="请选择来源"><el-option v-for="a in shipped" :key="a.inventoryPositionId" :value="a.inventoryPositionId" :label="`${a.inboundCode || '—'} / ${a.palletNo || '—'} / ${a.skuCode || '—'} · ${a.packageCount} 箱`" /></el-select></el-form-item>
<el-form-item label="真实收货仓"><el-select v-model="warehouseId" aria-label="真实收货仓" :disabled="busy" placeholder="请选择收货仓" @change="changeWarehouse"><el-option v-for="w in warehouses" :key="w.id" :value="w.id" :label="`${w.warehouseCode} · ${w.warehouseName}`" /></el-select></el-form-item>
<el-form-item label="目标托盘码查询"><el-input v-model="keyword" aria-label="目标托盘码查询" :disabled="busy" placeholder="输入托盘码查询" @keyup.enter="searchPallets()" /><el-button :disabled="busy || !warehouseId" @click="searchPallets()">查询目标托盘</el-button></el-form-item>
<el-form-item label="目标托盘"><el-select :model-value="target?.pallet.id || ''" aria-label="目标托盘" :disabled="busy" placeholder="请选择查询结果" @update:model-value="selectPallet(String($event))"><el-option v-for="p in pallets" :key="p.id" :value="p.id" :label="`${p.palletCode || '—'} · ${p.fbaWarehouseCode || '—'}`" /></el-select></el-form-item>
</el-form>
<el-button v-if="page > 1" :disabled="busy" @click="searchPallets(page - 1)">上一页</el-button><el-button v-if="page * 20 < total" :disabled="busy" @click="searchPallets(page + 1)">下一页</el-button>
<template v-for="t in completedTargets" :key="t.palletId"><el-button v-if="warehouseId === t.warehouseId" :disabled="busy" @click="selectPallet(t.palletId)">核对本票已收目标 {{ t.palletNo }}</el-button></template>
<p v-if="target">目标:{{ target.warehouse.warehouseCode }} / {{ target.pallet.palletCode }} / {{ target.location.locationCode }} · FBA {{ target.pallet.fbaWarehouseCode }}</p>
<p>按当前来源全部箱数实收,库位使用目标托盘已绑定的真实库位。</p><el-button :disabled="busy || !selectedValid" :loading="busy" @click="submit('receipt')">确认本来源实收</el-button>
</div>
<p v-if="pending">原操作内容已保留,请先核对当前状态,再使用原内容确认结果。{{ pending.type === 'receipt' ? `${pending.body.destinationPalletNo} / ${pending.body.warehouseLocationCode}` : detail.transferNo }}</p>
<template v-if="unfinished.length > 1 && !pending"><p>本票存在多条未完成实收,请选择需要恢复的原操作。</p><el-button v-for="c in unfinished" :key="c.request.idempotencyKey" :disabled="busy" @click="recoverServerCommand(c)">恢复 {{ c.target.palletNo }} / {{ c.target.locationCode }}</el-button></template>
<el-button v-if="pending" :disabled="busy || (pending.type === 'receipt' && !canResources)" :loading="busy" @click="submit(pending.type)">重试原操作</el-button>
<el-button v-if="pending" :disabled="busy" @click="checkCurrent">只读核对当前状态</el-button>
<el-button v-if="reselectAllowed && pending?.type === 'receipt'" :disabled="busy" @click="reselectTarget">重新选择目标</el-button>
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !canResources">登记目标实收需要收货仓、托盘、库位查询及转运管理权限。</p>
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !Array.isArray(detail.receiptCommands)">本票尚未返回服务端实收操作记录,请联系管理员核对接口后再登记实收。</p>
</template>
<p v-if="notice" role="status">{{ notice }}</p><div v-if="error" role="alert"><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><div v-if="rejectionEvidence"><p>上次目标选择被拒绝:{{ rejectionEvidence.message }}</p><ApiErrorMeta :details="rejectionEvidence" /></div>
</section>
</template>
<style scoped>
.movement-panel { margin-block: var(--admin-space-3); }
.movement-fields { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: var(--admin-space-3); }
.movement-fields :deep(.el-form-item) { margin-bottom: var(--admin-space-2); }
</style>
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { canOutboundTransfer, createReceiptCommand, receiptTargetValid, type ReceiptTarget } from './warehouseTransferReceiptSelection'
import type { WarehouseTransferDetailDto } from '../api/warehouseTransfers'
const detail = (): WarehouseTransferDetailDto => ({ id: 'hidden-id', transferNo: 'WT-1', needRelabel: true, relabelNo: 'RL-1', relabelStatus: 'READY_FOR_OUTBOUND', fbaWarehouseCode: 'ABE4', destinationWarehouseCode: 'ABE4', packageCount: 2, status: 'ALLOCATED', items: [], cartons: [], pallets: [], relabelBindings: [], allocations: [1, 2].map(i => ({ inventoryPositionId: 'POS-' + i, inboundOrderId: 'INTERNAL', palletId: 'LA-' + i, packageCount: 1, status: 'RESERVED', skuCode: 'NEW' })) })
const target = (): ReceiptTarget => ({ warehouse: { id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, pallet: { id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'IDLE', version: 0 }, location: { id: 'L-NY', warehouseId: 'NY', locationCode: 'LOC-NY', locationType: 'NORMAL', sharedOverflow: false, status: 'IDLE', version: 0 } })
describe('transfer movement eligibility', () => {
it('blocks unconfirmed relabel and incomplete allocation before outbound', () => { const d = detail(); expect(canOutboundTransfer(d)).toBe(true); d.relabelStatus = 'CUSTOMER_CONFIRM_PENDING'; expect(canOutboundTransfer(d)).toBe(false); d.relabelStatus = 'READY_FOR_OUTBOUND'; d.allocations!.pop(); expect(canOutboundTransfer(d)).toBe(false) })
it('uses only shipped unreceived source with exact physical warehouse, FBA and binding', () => { const d = detail(), t = target(); d.status = 'PARTIALLY_RECEIVED'; d.allocations![0]!.status = 'RECEIVED'; d.allocations![1]!.status = 'SHIPPED'; expect(() => createReceiptCommand(d, 'POS-1', t, 'key')).toThrow(); expect(createReceiptCommand(d, 'POS-2', t, 'key')).toMatchObject({ sourceInventoryPositionId: 'POS-2', destinationOverseasWarehouseId: 'NY', destinationPalletNo: 'PALLET-NY', warehouseLocationCode: 'LOC-NY', destinationWarehouseCode: 'ABE4', idempotencyKey: 'key' }); t.pallet.locationId = 'wrong'; expect(receiptTargetValid(d, t)).toBe(false) })
it('rejects another occupied target and permits only the server acknowledged shared target', () => { const d = detail(), t = target(), previous = { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' }; t.pallet.status = 'OCCUPIED'; t.location.status = 'OCCUPIED'; expect(receiptTargetValid(d, t)).toBeFalsy(); expect(receiptTargetValid(d, t, previous)).toBe(true); t.pallet.id = 'OTHER'; expect(receiptTargetValid(d, t, previous)).toBeFalsy() })
it('rejects ACTIVE physical location and wrong FBA; supports ACTIVE virtual location', () => { const d = detail(), t = target(); t.location.status = 'ACTIVE'; expect(receiptTargetValid(d, t)).toBeFalsy(); t.location.locationType = 'VIRTUAL'; expect(receiptTargetValid(d, t)).toBeTruthy(); t.pallet.fbaWarehouseCode = 'OTHER'; expect(receiptTargetValid(d, t)).toBe(false) })
})
@@ -0,0 +1,30 @@
import type { WarehouseTransferDetailDto, ReceiveWarehouseTransferPayload, WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers'
import type { PalletDto, ReceivingOverseasWarehouseDto } from '../api/pallets'
import type { OverseasLocationDto } from '../api/locations'
export type ReceiptTarget = { warehouse: ReceivingOverseasWarehouseDto; pallet: PalletDto; location: OverseasLocationDto }
export function canOutboundTransfer(detail: WarehouseTransferDetailDto) {
return detail.status === 'ALLOCATED' && !!detail.fbaWarehouseCode
&& (!detail.needRelabel || (!!detail.relabelNo && ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(detail.relabelStatus || '')))
&& !!detail.allocations?.length && detail.allocations.every(a => a.status === 'RESERVED')
&& detail.allocations.reduce((sum, a) => sum + a.packageCount, 0) === detail.packageCount
}
export function receiptTargetValid(detail: WarehouseTransferDetailDto, target: ReceiptTarget, previous?: WarehouseTransferReceiptCommandDto['target'] | null) {
const { warehouse: w, pallet: p, location: l } = target
const same = previous && previous.warehouseId === w.id && previous.palletId === p.id && previous.locationId === l.id
return w.warehouseType === 'OVERSEAS' && w.status === 'ACTIVE' && w.customerId === '0'
&& p.warehouseId === w.id && l.warehouseId === w.id && p.locationId === l.id && p.customerId === '0'
&& !!p.palletCode && !!l.locationCode && p.fbaWarehouseCode === detail.destinationWarehouseCode
&& (p.status === 'IDLE' || (same && p.status === 'OCCUPIED'))
&& (l.locationType === 'VIRTUAL' ? l.status === 'ACTIVE' : (l.status === 'IDLE' || (same && l.status === 'OCCUPIED')))
}
export function createReceiptCommand(detail: WarehouseTransferDetailDto, sourceId: string, target: ReceiptTarget, key: string, previous?: WarehouseTransferReceiptCommandDto['target'] | null): ReceiveWarehouseTransferPayload {
if (!['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status)
|| !detail.allocations?.some(a => a.inventoryPositionId === sourceId && a.status === 'SHIPPED')
|| !detail.destinationWarehouseCode || !receiptTargetValid(detail, target, previous)) throw new Error('来源或目标收货资源不符合本票要求,请重新核对')
return { sourceInventoryPositionId: sourceId, destinationOverseasWarehouseId: target.warehouse.id,
destinationOverseasWarehouseCode: target.warehouse.warehouseCode, destinationOverseasWarehouseName: target.warehouse.warehouseName,
destinationWarehouseId: detail.destinationWarehouseId || undefined, destinationWarehouseCode: detail.destinationWarehouseCode,
destinationWarehouseName: detail.destinationWarehouseName || undefined, destinationPalletId: target.pallet.id, destinationPalletNo: target.pallet.palletCode,
warehouseLocationId: target.location.id, warehouseLocationCode: target.location.locationCode, batchNo: detail.transferNo, idempotencyKey: key }
}