Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test

This commit is contained in:
2026-11-04 14:39:23 +08:00
11 changed files with 292 additions and 22 deletions
@@ -51,6 +51,17 @@ describe('入库短少确认', () => {
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)
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()
})
@@ -22,7 +22,7 @@ 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) || props.detail.lines.every(line => !line.fbaWarehouseCode))
&& expected.value != null && received.value != null && received.value > 0 && shortage.value > 0)
function show() {
@@ -5,6 +5,7 @@ import { getInboundPlan } from '../api/inboundPlans'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: '9007199254741001' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/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>' } }))
@@ -2,10 +2,14 @@ import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getInboundPlan } from '../api/inboundPlans'
import { getPalletLabelPdf } from '../api/pallets'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { customerId: '9007199254741001' } as Record<string, string> }))
const access = vi.hoisted(() => ({ can: vi.fn(() => true) }))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => access }))
vi.mock('../api/pallets', () => ({ getPalletLabelPdf: vi.fn() }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { template: '<span />' } }))
@@ -15,6 +19,7 @@ vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
describe('InboundOrderDetailView fees', () => {
beforeEach(() => {
access.can.mockReturnValue(true)
route.query = { customerId: '9007199254741001' }
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({
id: '9007199254741002', planNo: 'PLAN-1', customerId: '9007199254741001', sourceOrderId: '9007199254741003',
@@ -41,4 +46,37 @@ describe('InboundOrderDetailView fees', () => {
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
wrapper.unmount()
})
it('prints the exact pre-reserved overseas pallet from the inbound plan before receiving', async () => {
vi.mocked(getInboundPlan).mockResolvedValue({
id: '1', planNo: 'PLAN-1', customerId: '100', sourceOrderId: '2', overseasWarehouseId: '900',
status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0,
lines: [{ id: '10', orderId: '2', inboundCode: 'IN-1', requiredPackageCount: 2, plannedPalletCount: 1 }],
pallets: [{ id: '20', warehousePalletId: '2001', palletNo: 'PLT-21', sequenceNo: 1, actualPackageCount: 0, version: 0 }],
allocations: [{ lineId: '10', palletId: '20', palletNo: 'PLT-21', palletOwnerPlanNo: 'PLAN-1', palletOwnedByCurrentPlan: true,
plannedPackageCount: 2, countedPackageCount: 0, pickedPackageCount: 0, version: 0 }],
})
vi.mocked(getPalletLabelPdf).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
URL.createObjectURL = vi.fn(() => 'blob:reserved-pallet')
URL.revokeObjectURL = vi.fn()
const downloads: string[] = []
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download) })
const options = { global: { plugins: [ElementPlus], stubs: {
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>' },
AppDialog: true, InboundShortageConfirmation: true,
} } }
const wrapper = mount(InboundOrderDetailView, options)
await flushPromises()
const print = wrapper.findAll('button').find(button => button.text() === '打印托盘码')!
expect(print.attributes('disabled')).toBeUndefined()
await print.trigger('click'); await flushPromises()
expect(getPalletLabelPdf).toHaveBeenCalledWith('900', '2001')
expect(downloads).toEqual(['PLT-21-托盘码.pdf'])
wrapper.unmount()
access.can.mockReturnValue(false)
const denied = mount(InboundOrderDetailView, options)
await flushPromises()
expect(denied.findAll('button').some(button => button.text() === '打印托盘码')).toBe(false)
denied.unmount(); click.mockRestore()
})
})
@@ -7,23 +7,30 @@ import { BusinessDocumentFees } from '@/modules/overseas-business'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
import { getPalletLabelPdf } from '../api/pallets'
import { overseasWarehousePermissions } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
const route = useRoute(); const router = useRouter()
const access = useAccessPreviewStore()
const planNo = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const moveOpen = ref(false); const moveSaving = ref(false); const moveRow = ref<{ palletNo: string; palletVersion: number } | null>(null); const targetLocationCode = ref(''); const moveReason = ref('入库托盘上架'); const locationOptions = ref<OverseasLocationDto[]>([])
const moveLocationsLoading = ref(false); const moveLocationError = ref<ApiErrorDetails | null>(null); const moveError = ref<ApiErrorDetails | null>(null)
const printingPalletId = ref('')
const printError = ref<ApiErrorDetails | null>(null)
const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value))
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function receivingWarehouse(code: unknown, name: unknown) {
@@ -47,7 +54,7 @@ const summaryGroups = [
]
const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注']
const palletColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 },
{ key: 'putawayStatus', label: '上架状态', width: 110, prop: 'putawayStatus', afterColumn: 'palletNo' },
@@ -67,11 +74,34 @@ const palletRows = computed(() => (detail.value?.allocations || []).map((allocat
palletNo: text(allocation.palletNo),
palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`,
packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0),
palletId: String(allocation.palletId), palletVersion: pallet?.version ?? null, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
palletId: String(allocation.palletId), warehousePalletId: pallet?.warehousePalletId || null, palletVersion: pallet?.version ?? null, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
sourceLocationVersion: null, location: locationCode || (pallet?.warehouseLocationId ? '—' : '虚拟收货位'), locationType: locationType === 'SHARED_OVERFLOW' ? 'A0共享溢出位' : locationType === 'NORMAL' ? '普通库位' : '虚拟收货位', putawayStatus: locationType === 'NORMAL' || locationType === 'SHARED_OVERFLOW' ? '已上架' : '待上架',
}
}))
const availableLocations = computed(() => locationOptions.value.filter(item => item.status !== 'DISABLED'))
function palletActions(row: typeof palletRows.value[number]): RowActionItem[] {
return [
{ key: 'print', label: '打印托盘码', visible: access.can(overseasWarehousePermissions.palletList),
disabled: !!printingPalletId.value || !row.warehousePalletId || !detail.value?.overseasWarehouseId,
loading: !!row.warehousePalletId && printingPalletId.value === row.warehousePalletId },
{ key: 'move', label: '移库', disabled: moveSaving.value || moveLocationsLoading.value || row.palletNo === '—' || row.palletVersion == null },
]
}
async function printPalletLabel(row: typeof palletRows.value[number]) {
const warehouseId = detail.value?.overseasWarehouseId
if (!access.can(overseasWarehousePermissions.palletList) || printingPalletId.value || !warehouseId || !row.warehousePalletId) return
printingPalletId.value = row.warehousePalletId; printError.value = null
try {
const blob = await getPalletLabelPdf(warehouseId, row.warehousePalletId)
const url = URL.createObjectURL(blob)
try {
const link = document.createElement('a')
link.href = url; link.download = `${row.palletNo}-托盘码.pdf`; link.click()
} finally { window.setTimeout(() => URL.revokeObjectURL(url), 0) }
ElMessage.success('已生成 1 张托盘标签 PDF')
} catch (cause) { printError.value = getApiErrorDetails(cause, '托盘标签打印失败,请重试') }
finally { printingPalletId.value = '' }
}
async function openMove(row: typeof palletRows.value[number]) {
if (moveSaving.value || moveLocationsLoading.value || row.palletVersion == null || row.palletNo === '—') return
if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return }
@@ -99,7 +129,9 @@ 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"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></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">
<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>
<section v-if="detail" class="detail-section fee-section" aria-label="入库单费用"><BusinessDocumentFees business-type="OVERSEAS_INBOUND" :business-no="detail.planNo" :customer-id="detail.customerId" :business-id="detail.id" /></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections">