fix(admin): add inbound shortage confirmation action

This commit is contained in:
2026-10-26 15:46:14 +08:00
parent 952abd5629
commit 9de72cbc5c
10 changed files with 180 additions and 4 deletions
@@ -317,6 +317,7 @@
| 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 | | 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 |
| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 已接真实分页、显式客户、筛选、状态、加载/错误/空态;排车字段后端未提供,保持只读空值 | | 入库单 | 列表/筛选 | `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`;接口未提供的预约/排车时间、备注和费用继续显示空值 | | 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue` | 已接计划详情和 `sourceOrderId`;接口未提供的预约/排车时间、备注和费用继续显示空值 |
| 入库单 | 确认短少入库 | 用户确认实际短少后的处理要求;Admin规范“简单确认、原因填写” | 详情托盘明细标题区操作,560px居中确认弹框;应收、实收和短少只读,原因必填 | `InboundShortageConfirmation.vue`;仅待差异核对的FBA短少和入库编辑权限可用 | 定向组件/API/页面测试与类型检查通过;1920×1080、100%待人工截图确认 |
| 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 | | 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 |
| 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 | | 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 |
| 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 | | 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 |
@@ -1,10 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { getInboundPlan, listInboundPlans } from './inboundPlans' import { confirmInboundShortage, getInboundPlan, listInboundPlans } from './inboundPlans'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } })) 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', () => { describe('inbound plan overseas API', () => {
beforeEach(() => vi.mocked(http.get).mockReset()) beforeEach(() => vi.mocked(http.get).mockReset())
it('confirms the current shortage with customer, version and idempotency key', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { status: 'PARTIAL_INBOUND' } })
const payload = { customerId: '190000000000000001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '短少1箱' }
await confirmInboundShortage('IP/1', payload, 'request-key')
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/confirm-shortage', payload, { headers: { 'Idempotency-Key': 'request-key' } })
})
it('pages plans with explicit customer scope', async () => { 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 } }) vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' }) await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' })
@@ -45,6 +45,7 @@ export interface InboundPlanSummaryDto {
} }
export interface InboundPlanDetailDto { export interface InboundPlanDetailDto {
version?: number
id: string id: string
planNo: string planNo: string
customerId: string customerId: string
@@ -81,6 +82,21 @@ 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 } })) 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 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,
{ headers: { 'Idempotency-Key': idempotencyKey } },
))
}
export interface PlatformInventoryMoveRequest { export interface PlatformInventoryMoveRequest {
overseasWarehouseId: string overseasWarehouseId: string
sourcePalletCode: string sourcePalletCode: string
@@ -0,0 +1,57 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
import InboundShortageConfirmation from './InboundShortageConfirmation.vue'
const access = vi.hoisted(() => ({ can: vi.fn(() => true) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => access }))
vi.mock('../api/inboundPlans', () => ({ confirmInboundShortage: vi.fn() }))
const dialog = { name: 'AppDialog', props: ['modelValue', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<div v-if="modelValue"><slot /><button data-confirm :disabled="confirmDisabled || confirmLoading" @click="$emit(\'confirm\')">确认并按实收入库</button></div>' }
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 }],
pallets: [], allocations: [], ...overrides }
}
function render(value = detail()) {
return mount(InboundShortageConfirmation, { props: { detail: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialog } } })
}
describe('入库短少确认', () => {
beforeEach(() => { access.can.mockReturnValue(true); vi.mocked(confirmInboundShortage).mockReset().mockResolvedValue({ status: 'PARTIAL_INBOUND' }) })
it('requires a reason and submits actual counts without overwriting the expected count', async () => {
const wrapper = render()
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('21')
expect(wrapper.text()).toContain('20')
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
await wrapper.get('textarea').setValue('现场清点短少1箱')
await wrapper.get('[data-confirm]').trigger('click')
await flushPromises()
expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', { customerId: '9007199254741001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '现场清点短少1箱' }, expect.any(String))
expect(wrapper.emitted('refresh')).toHaveLength(1)
expect(wrapper.find('textarea').exists()).toBe(false)
wrapper.unmount()
})
it('preserves the reason on failure and uses fresh facts and a new key for retry', async () => {
vi.mocked(confirmInboundShortage).mockRejectedValueOnce(new Error('记录已变化'))
const wrapper = render()
await wrapper.find('button').trigger('click'); await wrapper.get('textarea').setValue('短少1箱')
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('短少1箱')
await wrapper.setProps({ detail: detail({ version: 5 }) })
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
const calls = vi.mocked(confirmInboundShortage).mock.calls
expect(calls[1]?.[1].version).toBe(5)
expect(calls[1]?.[2]).not.toBe(calls[0]?.[2])
wrapper.unmount()
})
it('hides the action without edit permission', () => {
access.can.mockReturnValue(false)
const wrapper = render(); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
})
it.each([{ status: 'PARTIAL_INBOUND' }, { pickedPackageCount: 21 }, { pickedPackageCount: 22 }, { pickedPackageCount: 0 }, { version: undefined }, { orderType: 'ONE_CLICK_DROPSHIP' }])('rejects ineligible facts %o', (overrides) => {
const wrapper = render(detail(overrides)); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
})
})
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed, ref } 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 { overseasWarehousePermissions } from '../permissions'
const props = defineProps<{ detail: InboundPlanDetailDto }>()
const emit = defineEmits<{ refresh: [] }>()
const access = useAccessPreviewStore()
const open = ref(false)
const saving = ref(false)
const reason = ref('')
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)
&& expected.value != null && received.value != null && received.value > 0 && shortage.value > 0)
function show() {
if (!eligible.value || saving.value) return
reason.value = ''; error.value = null; open.value = true
}
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
try {
await confirmInboundShortage(props.detail.planNo, {
customerId: props.detail.customerId, version: props.detail.version,
expectedPackageCount: expected.value, receivedPackageCount: received.value, reason: reason.value.trim(),
}, crypto.randomUUID())
ElMessage.success(`已确认短少,${received.value} 箱实收已入库`)
open.value = false
emit('refresh')
} catch (cause) {
error.value = getApiErrorDetails(cause, '短少确认失败,请核对最新收货记录后重试')
emit('refresh')
} finally { saving.value = false }
}
</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">
<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>
</div>
</AppDialog>
</template>
@@ -129,6 +129,7 @@ describe('last-mile order presentation adapter', () => {
['SIGNED', '已签收'], ['SIGNED', '已签收'],
['PICKING', '拣货中'], ['PICKING', '拣货中'],
['PARTIAL_INBOUND', '部分入库'], ['PARTIAL_INBOUND', '部分入库'],
['PENDING_RECONCILIATION', '待差异核对'],
['COMPLETED', '全部入库'], ['COMPLETED', '全部入库'],
])('maps inbound plan status %s to %s', (status, expected) => { ])('maps inbound plan status %s to %s', (status, expected) => {
expect(inboundPlanStatusLabel(status)).toBe(expected) expect(inboundPlanStatusLabel(status)).toBe(expected)
@@ -46,6 +46,7 @@ const inboundPlanStatusLabels: Record<string, string> = {
PDA_PENDING: '待入库', PDA_PENDING: '待入库',
SIGNED: '已签收', SIGNED: '已签收',
PICKING: '拣货中', PICKING: '拣货中',
PENDING_RECONCILIATION: '待差异核对',
PARTIAL_INBOUND: '部分入库', PARTIAL_INBOUND: '部分入库',
COMPLETED: '全部入库', COMPLETED: '全部入库',
} }
@@ -0,0 +1,25 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { getInboundPlan } from '../api/inboundPlans'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: '9007199254741001' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '<button data-shortage @click="$emit(\'refresh\')">确认短少入库</button>' } }))
it('provides the scoped plan facts and reloads after shortage confirmation', async () => {
const detail = { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4,
status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20,
lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] }
vi.mocked(getInboundPlan).mockResolvedValue(detail)
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } })
await flushPromises()
expect(wrapper.findComponent({ name: 'InboundShortageConfirmation' }).props('detail')).toEqual(detail)
expect(wrapper.text()).toContain('待差异核对')
await wrapper.get('[data-shortage]').trigger('click'); await flushPromises()
expect(getInboundPlan).toHaveBeenCalledTimes(2)
expect(getInboundPlan).toHaveBeenLastCalledWith('IP-1', '9007199254741001')
wrapper.unmount()
})
@@ -14,6 +14,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans' import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations' import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
import OverseasDetailShell from '../components/OverseasDetailShell.vue' import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation' import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
const route = useRoute(); const router = useRouter() const route = useRoute(); const router = useRouter()
@@ -98,7 +99,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"> <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> <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> <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><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"><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 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 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 class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template> <section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></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"> </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">
@@ -42,7 +42,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' }, { key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' }, { key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' }, { key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' },
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] }, { key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }, { label: '待差异核对', value: 'PENDING_RECONCILIATION' }] },
{ key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' }, { key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' },
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' }, { key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' },
{ key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }, { label: '多批预约', value: 'MULTIPLE' }] }, { key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }, { label: '多批预约', value: 'MULTIPLE' }] },