Compare commits

...
Author SHA1 Message Date
liuyihe 8d80074266 优化 2026-11-05 17:46:01 +08:00
ouyangxq 40095a749b Merge remote-tracking branch 'origin/release/test' into release/test
# Conflicts:
#	src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts
2026-11-05 16:30:48 +08:00
ouyangxq a1721371e4 fix: preserve required inputs and refine operations screens 2026-11-05 16:30:28 +08:00
liuyihe d075ac7e03 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-11-05 16:03:06 +08:00
liuyihe 525392963c 优化 2026-11-05 16:03:04 +08:00
ouyangxq 2d1bb38ad5 fix(inbounds): restore approved measurement workspace 2026-11-05 14:21:32 +08:00
liuyihe b5f9537e21 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-11-04 19:27:18 +08:00
liuyihe f54d3ae8a5 优化 2026-11-04 19:27:16 +08:00
ouyangxq 1150f245be 优化 2026-11-04 18:08:28 +08:00
ouyangxq c17d355a69 明确私人地址人工报价及预装前置提示 2026-11-04 14:47:39 +08:00
43 changed files with 462 additions and 166 deletions
@@ -124,13 +124,16 @@ it('preserves real FBA fields and entered values when expanding the create dialo
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
expect(creationBody().text()).toContain('AP-001')
})
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
it('shows saved dates, confirmed offsets and long remarks without planned time ranges', async () => {
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
const wrapper = await open(); await create(wrapper)
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
const receipt = creationBody().get('[aria-label="预约保存回执"]')
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
for (const text of ['AP-001', saved.plannedDate, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
expect(receipt.text()).not.toMatch(/计划开始时段|计划结束时段/)
expect(receipt.text()).not.toContain(saved.plannedAt)
expect(receipt.text()).not.toContain(saved.plannedEndAt)
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
})
@@ -32,9 +32,9 @@ it('names the missing material and supplements the canonical booking inside the
const wrapper = render(); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
expect(wrapper.text()).not.toContain(source.orderId)
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
expect(wrapper.find('input[type=number]').exists()).toBe(false)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).not.toHaveBeenCalled()
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
@@ -44,6 +44,22 @@ it('names the missing material and supplements the canonical booking inside the
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
})
it('allows missing or changed quantities to be verified before saving shipment references', async () => {
for (const cargo of [
{ ...context, effectivePackages: null },
{ ...context, sourceChanged: true },
]) {
vi.mocked(getCargoVerification).mockResolvedValue(cargo)
vi.mocked(saveCargoVerification).mockClear()
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
await wrapper.get('input[type=number]').setValue('20')
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'] }))
wrapper.unmount()
}
})
it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
const wrapper = render(false); await flushPromises()
expect(resolvePlanningOrder).not.toHaveBeenCalled()
@@ -66,7 +66,6 @@ onBeforeUnmount(() => { ++generation; unregister?.() })
<section v-show="active || editing" class="density-dialog-section" aria-label="正式确认前的货物资料" :aria-busy="loading">
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>FBA 货件号 / 追踪号</th><th>需要补充的资料</th></tr></thead><tbody>
<tr v-for="row in rows" :key="key(row)">
@@ -129,7 +129,6 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
<table class="density-table"><tbody>
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
<tr v-if="createdForm.appointmentAt || createdForm.appointmentEndAt"><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
<tr v-if="createdForm.startOffset || createdForm.endOffset"><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
@@ -59,14 +59,13 @@ onMounted(() => load())
<template>
<section aria-label="货物核实资料">
<h3>{{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
<p v-if="confirmation">请向客户或货物负责人取得本票完整的 FBA 货件号或追踪号,每行一个;不要填写预约外部约号。下方保存只补充货物资料,完成后还需保存预约安排。</p>
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在读取货物资料…</p>
<template v-if="context">
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><template v-if="!confirmation"><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></template></dl>
<dl v-if="!confirmation" class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></dl>
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<form class="density-form work-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
<label>{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
<label v-if="!confirmation || context.effectivePackages == null || context.sourceChanged">{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
@@ -63,7 +63,7 @@ onMounted(load)
</tbody></table>
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
</section>
<FbaAppointmentFields continuous hide-confirmation-times ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<FbaAppointmentFields continuous date-only ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
</template>
+7 -4
View File
@@ -43,12 +43,15 @@ describe('LoginView authentication lifecycle', () => {
expect(loginSource).not.toContain('demo-tip')
})
it('uses the shared blue-white theme and preserves the existing compact brand layout', () => {
it('preserves the dark brand panel and white login card independently of workbench typography', () => {
expect(loginSource).toContain("import '@/styles/adminWorkbench.css'")
expect(loginSource).toContain('class="login-page admin-workbench"')
expect(loginSource).toContain('<h1 class="workbench-title">柏悦物流管理后台</h1>')
expect(loginSource).toContain('class="login-mobile-brand workbench-brand-mark"')
expect(loginSource).toContain('class="login-card workbench-panel"')
expect(loginSource).toContain('<h1>柏悦物流管理后台</h1>')
expect(loginSource).toContain('class="login-mobile-brand"')
expect(loginSource).toContain('class="login-card"')
expect(loginSource).toContain('--login-brand-background: #172a46')
expect(loginSource).toContain('background: var(--login-brand-background); color: #fff;')
expect(loginSource).not.toMatch(/class="[^"]*workbench-(?:title|heading|panel|brand-mark)/)
expect(loginSource).toContain('class="login-error workbench-notice workbench-tone" data-tone="rose"')
expect(loginSource).toContain('aria-label="平台员工登录"')
expect(loginSource).toContain('label-position="top" size="large"')
+25 -22
View File
@@ -57,23 +57,23 @@ async function submit() {
<template>
<div class="login-page admin-workbench">
<section class="login-intro">
<div class="login-brand workbench-brand-mark"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="workbench-heading">
<h1 class="workbench-title">柏悦物流管理后台</h1>
<p class="login-intro-copy workbench-note">平台内部运营与管理系统</p>
<div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div>
<h1>柏悦物流管理后台</h1>
<p class="login-intro-copy">平台内部运营与管理系统</p>
</div>
<div class="login-security-note workbench-meta">
<div class="login-security-note">
<ShieldCheck :size="22" aria-hidden="true" />
<span>平台内部系统,请使用授权账号登录</span>
</div>
</section>
<section class="login-panel">
<div class="login-card workbench-panel">
<div class="login-mobile-brand workbench-brand-mark"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="login-card-icon workbench-icon"><LockKeyhole :size="24" aria-hidden="true" /></div>
<h2 class="workbench-title">登录管理后台</h2>
<p class="workbench-note">使用平台内部员工账号登录</p>
<div class="login-card">
<div class="login-mobile-brand"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="login-card-icon"><LockKeyhole :size="24" aria-hidden="true" /></div>
<h2>登录管理后台</h2>
<p>使用平台内部员工账号登录</p>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" aria-label="平台员工登录" @submit.prevent="submit">
<el-form-item label="账号" prop="account" required>
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" autofocus />
@@ -92,17 +92,20 @@ async function submit() {
</template>
<style scoped>
.login-page { display: grid; min-width: 0; min-height: 100svh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: var(--workbench-surface); color: var(--workbench-text); }
.login-intro { display: flex; min-width: 0; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); }
.login-brand { display: flex; width: fit-content; min-height: 64px; align-items: center; padding: 8px 20px; }
/* Brand layout is local to login; workbench styles only supply shared controls and errors. */
.login-page { --login-brand-background: #172a46; display: grid; min-width: 0; min-height: 100svh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: var(--cc-surface); color: var(--cc-text); }
.login-intro { display: flex; min-width: 0; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); background: var(--login-brand-background); color: #fff; }
.login-brand { display: flex; width: fit-content; min-height: 64px; align-items: center; }
.login-brand-logo { display: block; width: 205px; height: 64px; object-fit: contain; object-position: left center; }
.login-intro h1 { max-width: 560px; }
.login-intro-copy { max-width: 560px; }
.login-intro h1 { max-width: 560px; margin: 16px 0 14px; font-size: 40px; line-height: 1.2; overflow-wrap: anywhere; }
.login-intro-copy { max-width: 560px; margin: 0; color: #cbd5e1; font-size: 15px; line-height: 1.7; overflow-wrap: anywhere; }
.login-security-note { display: flex; align-items: center; gap: 10px; color: #cbd5e1; font-size: 13px; line-height: 1.6; }
.login-security-note > svg { flex: 0 0 auto; }
.login-panel { display: grid; min-width: 0; place-items: center; padding: 48px; background: var(--workbench-surface-tint); }
.login-card.workbench-panel { width: min(410px, 100%); min-width: 0; padding: 32px; }
.login-card h2 { margin-top: 22px; }
.login-card > p { margin: 8px 0 30px; }
.login-panel { display: grid; min-width: 0; place-items: center; padding: 48px; background: var(--cc-surface-muted); }
.login-card { width: min(410px, 100%); min-width: 0; padding: 32px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); background: var(--cc-surface); box-shadow: var(--cc-shadow-md); }
.login-card-icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: var(--cc-radius-lg); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
.login-card h2 { margin: 22px 0 8px; font-size: 28px; line-height: 1.3; overflow-wrap: anywhere; }
.login-card > p { margin: 0 0 30px; color: var(--cc-muted); font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
.login-mobile-brand { display: none; }
.login-submit { width: 100%; min-height: 44px; }
.login-submit :deep(> span) { display: flex; align-items: center; gap: 8px; }
@@ -114,14 +117,14 @@ async function submit() {
.login-page { grid-template-columns: 1fr; }
.login-intro { display: none; }
.login-panel { align-items: start; padding: 24px; }
.login-card.workbench-panel { width: min(440px, 100%); margin: auto; padding: 28px; }
.login-mobile-brand { display: flex; width: fit-content; min-height: 52px; align-items: center; margin-bottom: 28px; padding: 8px 16px; }
.login-card { width: min(440px, 100%); margin: auto; padding: 28px; }
.login-mobile-brand { display: flex; width: fit-content; min-height: 52px; align-items: center; margin-bottom: 28px; padding: 8px 16px; border-radius: var(--cc-radius-lg); background: var(--login-brand-background); }
.login-mobile-brand img { display: block; width: 154px; height: 48px; object-fit: contain; object-position: left center; }
}
@media (max-width: 360px) {
.login-panel { padding: 12px; }
.login-card.workbench-panel { padding: 20px; }
.login-card { padding: 20px; }
.login-mobile-brand { margin-bottom: 24px; }
}
</style>
@@ -69,6 +69,22 @@ function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
}
describe('ContainerCustomsPanel', () => {
it('locks pre-entry uploads after customer confirmation before broker execution', async () => {
const value = customs('DECLARING', true)
value.documents[0]!.receiptStatus = 'CONFIRMED'
api.get.mockResolvedValue(value)
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.text()).toContain('客户已确认预录单,待发送报关行执行')
for (const label of ['选择预录单', '提交预录单']) {
expect(wrapper.findAll('button').find(button => button.text() === label)?.attributes('disabled')).toBeDefined()
}
expect(wrapper.findAll('button').find(button => button.text() === '发送报关行执行')?.attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
beforeEach(() => {
vi.clearAllMocks()
api.getDeclarationBatches.mockResolvedValue([])
@@ -264,7 +280,7 @@ describe('ContainerCustomsPanel', () => {
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '提交预录单')!.trigger('click')
await flushPromises()
expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
expect(api.upload).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -71,6 +71,7 @@ const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
const allReceiptsConfirmed = computed(() => Boolean(customs.value?.documents.length) && customs.value?.documents.every((document) => document.receipt && document.receiptStatus === 'CONFIRMED') === true)
const receiptUploadLocked = computed(() => brokerSubmitted.value || customs.value?.documents.some((document) => document.receiptStatus === 'CONFIRMED') === true)
const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null)
const statusText: Record<ContainerCustomsStatus, string> = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
@@ -85,7 +86,7 @@ function formatDateTime(value: string | null | undefined) {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function selectReceipt(upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || receiptUploadLocked.value || actionLoading.value) return
receiptFile.value = upload.raw || null
actionError.value = null
}
@@ -186,7 +187,7 @@ async function start() {
}
async function uploadReceipt() {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || receiptUploadLocked.value || actionLoading.value) return
const selected = receiptFile.value
if (!selected) { ElMessage.warning('请选择对应的预录单'); return }
const documents = customs.value.documents
@@ -325,12 +326,12 @@ onMounted(load)
<div class="workbench-evidence"><table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table></div>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<div class="documents-heading"><h3>已冻结退税资料、预录单与完税文件</h3><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="selectReceipt"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : '选择预录单' }}</el-button></el-upload><span v-if="receiptFile" class="selected-receipt" :title="receiptFile.name">{{ receiptFile.name }}</span><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFile" :loading="actionLoading" @click="uploadReceipt">提交预录单</el-button></div></div>
<div class="documents-heading"><h3>已冻结退税资料、预录单与完税文件</h3><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="actionLoading || !brokerSubmission || receiptUploadLocked" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="selectReceipt"><el-button class="density-action" size="small" :disabled="actionLoading || !brokerSubmission || receiptUploadLocked">{{ !brokerSubmission ? '首次发送后上传预录单' : '选择预录单' }}</el-button></el-upload><span v-if="receiptFile" class="selected-receipt" :title="receiptFile.name">{{ receiptFile.name }}</span><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="actionLoading || !brokerSubmission || receiptUploadLocked || !receiptFile" :loading="actionLoading" @click="uploadReceipt">提交预录单</el-button></div></div>
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>关联入仓码</th><th>报关行状态</th><th>客户退税资料</th><th>预录单</th><th>真实报关完税文件</th><th>客户</th><th>预录单情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td>
<td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td>
<td>{{ brokerSubmitted ? '已发送报关行执行' : document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单,待发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td><td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
</tr>
@@ -65,15 +65,16 @@ describe('ContainerDetailView empty return', () => {
expect(value).toBe('2026-10-23(默认推算)')
expect(value).not.toContain('00:00')
})
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
const wrapper = await mountDetail('DOMESTIC')
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
expect(panel.props('container').id).toBe('9001')
vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'REASSIGNED', voyageNo: 'V2', currentPortScheduleId: 'S1' } as never)
panel.vm.$emit('saved'); await flushPromises()
expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('REASSIGNED / V2')
expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).props('container').currentPortScheduleId).toBe('S1')
it.each(['DOMESTIC', 'OVERSEAS'] as const)('keeps voyage information without the temporary schedule maintenance entry in %s', async (workbench) => {
vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'VESSEL', voyageNo: 'V2', currentPortScheduleId: 'S1', estimatedSailingAt: '2026-11-05T10:55:00', estimatedPortArrivalAt: '2026-11-08T00:00:00' } as never)
const wrapper = await mountDetail(workbench)
const section = wrapper.get('[aria-labelledby="container-voyage-heading"]')
expect(section.text()).toContain('船名航次信息')
expect(section.text()).toContain('VESSEL / V2')
expect(section.text()).toContain('2026-11-05 10:55')
expect(section.text()).toContain('2026-11-08 00:00')
expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).exists()).toBe(false)
expect(section.text()).not.toContain('船期维护与历史')
})
beforeEach(() => {
routeState.query = {}
@@ -16,7 +16,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
import {
getContainerOutboundDispatch,
@@ -648,7 +648,7 @@ onMounted(async () => {
</section>
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense hide-technical-details :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
@@ -245,6 +245,7 @@ const allColumns: DataTableColumn[] = [
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常', afterColumn: 'containerNo' },
{ key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'createdAt', label: '创建时间', width: 165, formatter: (row: ContainerBookingResource) => formatTime(row.createdAt), afterColumn: 'cabinetNo' },
{ key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
@@ -267,8 +268,8 @@ const allColumns: DataTableColumn[] = [
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
]
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', 'channelGroupCode', 'channelName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'createdAt', 'oceanBooking', 'warehouseName', 'channelGroupCode', 'channelName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'createdAt', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
const columns = computed(() => allColumns
.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key)))
// 国内同时展示查看、运输信息、装柜流程和更多菜单,须为四个入口预留空间。
@@ -4,7 +4,7 @@ import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
@@ -367,6 +367,7 @@ describe('InboundFeePanel', () => {
it('confirms and then sends a calculated fee through the implemented backend transitions', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
vi.mocked(getInboundMeasurement).mockRejectedValue(new ApiResponseError('无测量查看权限', 'A0403', 403))
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mountPanel()
await flushPromises()
@@ -379,10 +380,47 @@ describe('InboundFeePanel', () => {
await wrapper.get('[data-testid="send-fee-button"]').trigger('click')
await flushPromises()
expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/))
expect(getInboundMeasurement).not.toHaveBeenCalled()
expect(wrapper.emitted('changed')).toHaveLength(2)
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
})
it.each(['confirm', 'push'])('shows the backend review rejection for the %s entry point without sending', async (entry) => {
const message = '测量数据尚未审核通过,请先完成测量审核后再确认费用'
const showError = vi.spyOn(ElMessage, 'error')
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
vi.mocked(confirmInboundFee).mockRejectedValue(new ApiResponseError(message, 'A0409', 200))
const wrapper = mountPanel()
await flushPromises()
const button = entry === 'confirm'
? wrapper.get('[data-testid="confirm-fee-button"]')
: wrapper.findAll('button').find(button => button.text().includes('确认推送费用'))!
await button.trigger('click')
await flushPromises()
expect(confirmInboundFee).toHaveBeenCalledTimes(1)
expect(sendInboundFee).not.toHaveBeenCalled()
expect(getInboundMeasurement).not.toHaveBeenCalled()
expect(showError).toHaveBeenCalledWith(expect.objectContaining({ message }))
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
})
it('shows the backend review rejection when sending an already confirmed fee', async () => {
const message = '测量数据尚未审核通过,请先完成测量审核后再发送客户'
const showError = vi.spyOn(ElMessage, 'error')
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CONFIRMED' })
vi.mocked(sendInboundFee).mockRejectedValue(new ApiResponseError(message, 'A0409', 200))
const wrapper = mountPanel()
await flushPromises()
await wrapper.get('[data-testid="send-fee-button"]').trigger('click')
await flushPromises()
expect(sendInboundFee).toHaveBeenCalledTimes(1)
expect(getInboundMeasurement).not.toHaveBeenCalled()
expect(wrapper.emitted('changed')).toBeUndefined()
expect(showError).toHaveBeenCalledWith(expect.objectContaining({ message }))
})
it('does not expose fee transitions without their independent permissions', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust'])
@@ -392,6 +430,20 @@ describe('InboundFeePanel', () => {
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
})
it('confirms and publishes without requiring measurement-view permission', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
vi.mocked(getInboundMeasurement).mockRejectedValue(new ApiResponseError('无测量查看权限', 'A0403', 403))
const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:confirm', 'firstleg:inbound:send-fee'])
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('确认推送费用'))!.trigger('click')
await flushPromises()
expect(confirmInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-confirm-/))
expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/))
expect(getInboundMeasurement).not.toHaveBeenCalled()
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
const wrapper = mountPanel()
+10 -3
View File
@@ -98,6 +98,13 @@ const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
const canCalculate = computed(() => access.can('firstleg:fba-pricing:calculate'))
const isPrivateAddress = computed(() => props.destinationType === 'PRIVATE')
const pricingHelp = computed(() => isPrivateAddress.value ? {
workflow: '私人地址完成测量审核后,请先进行人工头程报价,再确认预装。',
quantity: '计费量按客户计费方式和实际测量确定,人工填写单价,金额以服务端快照为准。',
} : {
workflow: '测量审核完成后,系统会自动生成初算费用,人工确认后发布。',
quantity: '实际重量与体积重量取大后计费,金额以服务端快照为准。',
})
const canManualQuote = computed(() => access.can('firstleg:inbound:confirm-send-fee'))
const canRunTrial = computed(() => isPrivateAddress.value
? canManualQuote.value && snapshot.value?.customerConfirmationStatus !== 'CONFIRMED'
@@ -459,11 +466,11 @@ defineExpose({ reload: loadFee })
<template>
<section class="inbound-fee-panel admin-workbench" :aria-busy="loading" aria-labelledby="inbound-fee-title">
<header class="fee-heading" :class="{ 'fee-heading--compact': compact }">
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">头程自动初算 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">{{ isPrivateAddress ? '头程人工报价' : '头程自动初算' }} <InlineHelp title="计费说明"><p>{{ pricingHelp.workflow }}</p><p>{{ pricingHelp.quantity }}</p></InlineHelp></h3>
<div v-else class="fee-heading-copy">
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
<div>
<h3 id="inbound-fee-title">入库费用快照 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
<h3 id="inbound-fee-title">入库费用快照 <InlineHelp title="计费说明"><p>{{ pricingHelp.workflow }}</p><p>{{ pricingHelp.quantity }}</p></InlineHelp></h3>
</div>
</div>
<div class="fee-heading-actions">
@@ -569,7 +576,7 @@ defineExpose({ reload: loadFee })
<el-button size="small" @click="loadFee()">重新加载</el-button>
</el-alert>
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
<el-empty v-else-if="notFound" :image-size="64" :description="isPrivateAddress ? '私人地址待人工头程报价' : '尚未生成费用快照'">
</el-empty>
<div v-else-if="snapshot" class="fee-content">
@@ -425,7 +425,7 @@ describe('InboundMeasurementPanel', () => {
it('preserves the hidden actual goods name without overriding declaration facts', async () => {
const wrapper = await mountPanel()
expect(wrapper.text()).not.toContain('实际品名')
await wrapper.find('input[aria-label="仓库修改外箱长"]').setValue('55')
await wrapper.get('input[aria-label="仓库修改外箱长"]').setValue('55')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
await save?.trigger('click')
await flushPromises()
@@ -442,6 +442,60 @@ describe('InboundMeasurementPanel', () => {
expect(payload).not.toHaveProperty('originalVolumeCbm')
})
it('preserves a 255-character goods name without exposing correction controls', async () => {
const actualGoodsName = '品'.repeat(255)
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ actualGoodsName }))
const wrapper = await mountPanel()
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(false)
await wrapper.get('input[aria-label="仓库修改外箱长"]').setValue('55')
await wrapper.findAll('button').find(button => button.text().includes('保存测量数据'))!.trigger('click')
await flushPromises()
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({ actualGoodsName }))
})
it('blocks an overlong or cleared goods name and keeps its correction editable until a successful retry', async () => {
const actualGoodsName = '品'.repeat(256)
vi.mocked(getInboundMeasurement)
.mockResolvedValueOnce(measurement({ actualGoodsName }))
.mockResolvedValueOnce(measurement({ actualGoodsName: '精简后的品名', version: 3 }))
vi.mocked(saveInboundMeasurementDraft).mockRejectedValueOnce(new ApiResponseError('资料保存失败', 'S0503'))
const wrapper = await mountPanel()
const input = () => wrapper.get<HTMLInputElement>('input[aria-label="实际品名"]')
const save = () => wrapper.findAll('button').find(button => button.text().includes('保存测量数据'))!
expect(input().element.value).toBe(actualGoodsName)
expect(wrapper.get('.measurement-panel__footer').text()).toContain('实际品名超过255字')
await save().trigger('click'); await flushPromises()
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
await (wrapper.vm as unknown as { submitForReview(): Promise<void> }).submitForReview()
expect(submitInboundMeasurement).not.toHaveBeenCalled()
await input().setValue('')
await save().trigger('click'); await flushPromises()
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
expect(wrapper.get('.measurement-panel__footer').text()).toContain('请填写精简后的实际品名')
await input().setValue(' 精简后的品名 ')
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(true)
expect(wrapper.get('.measurement-declaration').text()).toContain('蓝牙音箱')
await save().trigger('click'); await flushPromises()
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({ actualGoodsName: '精简后的品名' }))
expect(input().element.value).toBe(' 精简后的品名 ')
expect(wrapper.emitted('saved')).toBeUndefined()
await save().trigger('click'); await flushPromises()
expect(saveInboundMeasurementDraft).toHaveBeenCalledTimes(2)
expect(updateConfirmedPackageCount).not.toHaveBeenCalled()
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(false)
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('does not expose goods name correction without measurement edit permission', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ actualGoodsName: '品'.repeat(256) }))
const wrapper = await mountPanel(['platform:firstleg:inbound:measure:view'])
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(false)
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
})
it('allows saving one warehouse outer dimension against the measured sequence', async () => {
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('001')
@@ -149,12 +149,21 @@ const packageCountDifference = computed(() => expectedPackageCount.value == null
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
const hasConfirmedWeightResult = computed(() => confirmedWeightAvailable.value)
const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
// 以服务端原值决定修正入口,避免输入缩短后立即收起;保存成功重读后才隐藏。
const needsGoodsNameCorrection = computed(() => (detail.value?.actualGoodsName?.trim().length ?? 0) > 255)
const goodsNameCorrectionError = computed(() => {
const length = form.actualGoodsName.trim().length
if (length > 255) return '实际品名超过255字,请先精简后再保存'
if (needsGoodsNameCorrection.value && !length) return '请填写精简后的实际品名,最多255字'
return ''
})
const expectedPalletCountValid = computed(() => !isDropship.value || (Number.isInteger(form.expectedPalletCount) && Number(form.expectedPalletCount) >= 1))
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !goodsNameCorrectionError.value && !loading.value && !saving.value)
const submissionBlockReason = computed(() => {
if (!canSubmit.value || !detail.value || loading.value) return ''
if (detail.value.processStatus === 'PREPARING') return '订单尚待客服审核,审核通过后才能提交测量审核'
if (!submitEditableProcess.value) return `当前${processStatusLabel(detail.value.processStatus)},不能提交测量审核`
if (goodsNameCorrectionError.value) return goodsNameCorrectionError.value
if (!form.measurementId) return hasConfirmedWeightResult.value ? '请先完成箱规测量并保存测量数据' : '请先完成箱规测量,并上传确认至少一张称重图片'
if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
@@ -416,6 +425,7 @@ async function reloadLatest() {
function validateDraft() {
const errors: Record<string, string[]> = {}
const general: string[] = []
if (goodsNameCorrectionError.value) general.push(goodsNameCorrectionError.value)
if (!form.items.length) general.push('请至少保留一条实际扫描箱测量记录')
if (isDropship.value && (!Number.isInteger(form.expectedPalletCount) || Number(form.expectedPalletCount) < 1 || Number(form.expectedPalletCount) > 1000)) general.push('一件代发预计托数必须是 1 至 1,000 的整数')
else if (isDropship.value && Number(form.expectedPalletCount) > confirmedPackageCount.value) general.push('一件代发预计托数不能超过实测箱数')
@@ -736,7 +746,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<p v-for="warning in measurementDeviationWarnings" :key="warning.key">{{ warning.text }}</p>
</el-alert>
<div class="workbench-evidence"><table class="measurement-comparison" aria-label="客户申报与平台实测对比">
<table class="measurement-comparison" 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="metric in comparisonMetrics" :key="metric.label">
@@ -748,7 +758,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
</td>
</tr>
</tbody>
</table></div>
</table>
<div class="measurement-extra-facts">
<span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
<span>计费材积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span>
@@ -782,7 +792,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<div class="measurement-section__heading">
<div><h3>平台实测箱规</h3></div>
<div v-if="isDropship" class="measurement-data-grid measurement-platform-fields">
<div v-if="isDropship || (needsGoodsNameCorrection && mayMaintain)" class="measurement-data-grid measurement-platform-fields">
<label v-if="needsGoodsNameCorrection && mayMaintain" class="measurement-goods-name"><span>实际品名(最多255字)</span><el-input v-model="form.actualGoodsName" aria-label="实际品名" placeholder="请精简汇总品名后保存" maxlength="255" show-word-limit :disabled="saving" /></label>
<label v-if="isDropship && mayMaintain" class="measurement-expected-pallets"><span>预计托数</span><el-input-number v-model="form.expectedPalletCount" placeholder="请输入预计托数" :min="1" :max="1000" :step="1" step-strictly controls-position="right" /></label>
<div v-else-if="isDropship" class="measurement-readonly-field measurement-expected-pallets"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
</div>
@@ -927,9 +938,9 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<style scoped>
.measurement-comparison { width: 100%; border-collapse: collapse; table-layout: fixed; background: var(--admin-surface, #fff); font-size: var(--admin-font-body, 13px); }
.measurement-comparison th, .measurement-comparison td { padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 1px solid var(--workbench-border); text-align: left; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.measurement-comparison thead th { background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); }
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
.measurement-comparison thead th:nth-child(3) { background: var(--admin-surface, #fff); color: #18795d; }
.measurement-comparison thead th { background: var(--admin-label-bg); color: var(--workbench-text); }
.measurement-comparison thead th:nth-child(2) { background: var(--admin-dialog-source-bg); color: #465fc4; }
.measurement-comparison thead th:nth-child(3) { background: var(--admin-dialog-summary-bg); color: #18795d; }
.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-extra-facts strong { color: var(--workbench-text); }
@@ -1,14 +1,15 @@
/* Measurement workspace: keep all sections inside the panel's content bounds. */
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
/* Approved measurement-only palette; shared dialog styling must preserve these sections and gutters. */
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-summary-border); border-left: 4px solid #20a477; border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-summary-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header { flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-work-border); border-left: 4px solid #c79643; border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-work-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert { background: var(--admin-surface, #fff); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-left: 4px solid #647fec; border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-table th.el-table__cell { background: var(--admin-label-bg); color: var(--workbench-muted); }
@media (min-width: 961px) {
.inbound-density.admin-density-dialog .measurement-dialog-weight { padding-bottom: var(--admin-space-4, 10px); }
@@ -19,9 +20,6 @@
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header h4,
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-header h4 { flex-shrink: 0; margin: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-header { display: flex; align-items: baseline; gap: var(--admin-space-6, 16px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number { display: flex; flex: 1; align-items: baseline; gap: var(--admin-space-3, 8px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number > span { flex-shrink: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number strong { min-width: 0; min-height: 0; padding: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section .measurement-section__heading { flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields { display: flex; flex: 1 1 520px; align-items: center; flex-wrap: wrap; margin: 0; gap: var(--admin-space-5, 12px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields > label,
@@ -226,7 +226,7 @@ onMounted(() => {
<ContainerSimulationCreate :dense="isPreload" v-if="canCreate" success-message="模拟柜及预装清单已创建" @created="onSimulationCreated" />
</template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }">
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">查看</el-button>
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">{{ isPreload ? '编辑' : '查看' }}</el-button>
<el-button v-if="canView && !isPreload" class="density-action" size="small" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
</div></template>
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
@@ -6,6 +6,7 @@
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
- 当前散板需求:正式列表使用`pages/DispatchCapacityDemandsView.vue`、`api/dispatchCapacityDemands.ts`,只读消费日常派送排车的`/dispatches/capacity-demands`摘要;发布选项仅在智能排车建单时选择,默认不勾选;维护车辆不提供发布修改,预约日历建单不自动发布,重发先取消原排车再重新创建,规则见拼车主契约当前页面范围。不得以实托数推算标准托位,未知余位明确待确认,不读取客户或司机私人资料。原登记和旧车次入口代码保留隐藏。
- 散板加货从上述列表的 `components/LooseboardCargoDialog.vue` 进入,通过 `../overseas-warehouse` 公开的 `listLooseboardCargo/addLooseboardCargo` 调用 `dispatch/controller/LooseboardCargoController.java`;区别于客户本土散板下单及排车提货/派送执行。
- 保留的内部拼车实现:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
@@ -0,0 +1,32 @@
import { shallowMount, flushPromises } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import Dialog from './LooseboardCargoDialog.vue'
import { candidate, detail } from '../../overseas-warehouse/dispatchTestFixtures'
import * as api from '@/modules/overseas-warehouse'
vi.mock('@/modules/overseas-warehouse', async () => ({
getDispatch: vi.fn(), listLooseboardCargo: vi.fn(), addLooseboardCargo: vi.fn(),
expandSelectionGroups: (await import('../../overseas-warehouse/dispatchContracts')).expandSelectionGroups,
}))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() } }))
beforeEach(() => { vi.clearAllMocks() })
it('selects one looseboard order and submits all original pallet selections together', async () => {
const first = candidate({ orderNo: 'LB1', availablePackages: 9 })
const second = candidate({ sourceKey: 'source-2', sourceVersion: 'version-2', selection: { ...first.selection, planPalletId: '2', sourceVersion: 'version-2' } })
first.selectionGroup = { ...first.selectionGroup, sources: [...first.selectionGroup.sources, ...second.selectionGroup.sources], pallets: [...first.selectionGroup.pallets, { warehousePalletId: null, palletNo: 'P2', sourceKeys: [second.sourceKey], packageCount: 2 }] }
const dispatch = detail(); dispatch.actions.ADD_CARGO = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(api.getDispatch).mockResolvedValue(dispatch)
vi.mocked(api.listLooseboardCargo).mockResolvedValue({ candidates: [first], total: 1, page: 1, size: 20, pages: 1, complete: true, snapshotAt: '2026-11-05T00:00:00Z' })
const wrapper = shallowMount(Dialog, { props: { modelValue: false, dispatchNo: 'PC/1' }, global: { renderStubDefaultSlot: true, stubs: { ElAlert: true } } })
await wrapper.setProps({ modelValue: true }); await flushPromises()
const table = wrapper.getComponent({ name: 'DataTable' })
expect(table.props('data')).toHaveLength(1)
expect(table.props('columns').map((column: { label?: string }) => column.label)).toContain('托数')
expect(table.props('columns').map((column: { label?: string }) => column.label)).not.toContain('计划 / 实物托盘')
table.vm.$emit('selection-change', [first]); await flushPromises()
wrapper.getComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.addLooseboardCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first.selection, second.selection] }))
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
wrapper.unmount()
})
@@ -15,7 +15,7 @@ const page=ref(1), size=ref(20), total=ref(0), loading=ref(false), saving=ref(fa
const columns:DataTableColumn[]=[
{key:'selection',type:'selection',width:45}, {key:'orderNo',label:'订单号',minWidth:180},
{key:'customerName',label:'客户',minWidth:140}, {key:'inboundCode',label:'入仓号',minWidth:150},
{key:'sourceStage',label:'货物阶段',width:110,formatter:(row:Candidate)=>!row.selection.warehousePalletId ? '申报待到仓' : '已实收'}, {key:'palletNo',label:'计划 / 实物托盘',minWidth:150}, {key:'destination',label:'目的地',minWidth:180,formatter:(row:Candidate)=>row.destination.warehouseCode || row.destination.address || '—'},
{key:'sourceStage',label:'货物阶段',width:110,formatter:(row:Candidate)=>row.selectionGroup?.sources.every(source=>source.selection.warehousePalletId) ? '已实收' : '申报待到仓'}, {key:'palletCount',label:'托数',width:80,formatter:(row:Candidate)=>row.selectionGroup?.pallets.length ?? '—'}, {key:'destination',label:'目的地',minWidth:180,formatter:(row:Candidate)=>row.destination.warehouseCode || row.destination.address || '—'},
{key:'availablePackages',label:'计划 / 可用箱数',width:100},
{key:'blockReasons',label:'资格',minWidth:200,formatter:(row:Candidate)=>row.blockReasons.length?row.blockReasons.join(';'):'可添加'},
]
+4 -2
View File
@@ -25,8 +25,10 @@
| 仓储转运审核、审核通过 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `permissions.ts` 的 `warehouseTransferApprove`;仅 `WAREHOUSE_TRANSFER + PENDING_REVIEW` 行开放。当前弹框仅提交 `APPROVE`,携带该行客户和转运单号;无驳回按钮 |
| 转运驳回、REJECT 接口能力 | `api/warehouseTransfers.ts` 的 `reviewWarehouseTransfer` | API 支持 `APPROVE/REJECT` 与 `remark`;不据此推定当前弹框提供驳回操作,界面能力沿上一行组件核对 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | `api/lastMileOrders.ts`;[入仓计划页面约束](../../../../../docs/业务实现边界.md#admin-入仓计划页面约束),仅计划/状态/请求行为变化时查 |
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 入仓单、私人地址计划与 FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
| 散板提货/派送执行与取证 | `components/DispatchDocumentsDialog.vue` → `DispatchLooseboardExecution.vue` | `api/dispatchLooseboardExecution.ts`;Backend `dispatch/controller/DispatchLooseboardExecutionController.java`,区别于客户散板下单 |
| 派送异常回仓、退回货物与实收入库 | `components/DispatchReturnCargo.vue`、`pages/ExceptionOrdersView.vue` | 候选 `api/dispatches.ts`,退回建单 `api/exceptionOrders.ts` 的 `generateExceptionReturn`;PDA 实收查 `ExceptionReturnWork.vue` 与 Lastleg 异常回仓契约 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;[出库与派送页面约束](../../../../../docs/业务实现边界.md#admin-出库与派送页面约束),候选选择另查下表 |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
@@ -41,7 +43,7 @@
- 托盘标签从 `pages/PalletsView.vue` → `api/pallets.ts` 的 `getPalletLabelPdf` 下载服务端 PDF,传该行仓库与托盘 ID,复用列表权限;不在前端生成标签或维护模板、尺寸、编码。模板/接口变化才查 [Print 指引](../../../../../baiyue-firstleg/print/AGENTS.md)的托盘入口及[标签维护](../../../../../docs/打印标签维护/README.md),纯按钮文案不追加打印资料。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。出库/派送建单时机按[排车现行契约](../../../../../docs/海外仓业务/排车列表统一作业改造契约.md)执行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
- 异常回仓先从原出库事实选择可退货物,由平台生成关联异常的退回尾程单/计划;建单不恢复库存。PDA 经异常回仓任务分批实收并恢复原来源库存,不走普通接柜或客户退货预报链。版本、证据、目标资源及完成状态以工作区 `baiyue-firstleg/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml` 为准;回访仍走 `api/customerVisits.ts`。
## 作业细则按需入口
@@ -476,3 +476,13 @@
2026-10-29 用户截图调整:排车列表查看路线更名查看轨迹。已完成本土卡车详情补充货物数量卡片、起讫地址与联系人、司机/车辆及证照照片、实际发车和完成时间。维护车辆/司机候选排除其他车次实际占用,当前车次已选资源允许保留。尾程查询占位提示字体12px。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
## 2026-11-05 PDA拆柜收货与维护车辆问题修复
| 页面/问题 | 修复 | 验收 |
|---|---|---|
| PDA拆柜收货按钮重叠 | 底部操作区改为随内容正常排列,解析、校验、保存及结束按钮全部保留;照片上传后返回页面不再清理同一签收会话的未签收草稿。 | PDA定向35项通过,App资源构建通过;真实设备截图待确认 |
| 维护车辆字段与附件 | 移除不可维护的发车时间及主档拖车公司展示;保留可编辑的外配拖车公司字段;预约号回显已登记编号;驾驶证展示优先本趟上传文件,避免保存后重复主档或空白图。 | Admin定向67项通过,修改文件Scoped TypeScript检查通过;真实页面截图待确认 |
| 后端预约号 | 已登记预约号可回显,只有有效收货确认提供确认时间。 | Lastleg定向8项通过;未部署 |
本轮修改未部署到测试或生产环境;需在真实PDA和Admin页面进行人工截图及业务数据验收。
@@ -54,7 +54,7 @@ it('hides the single delivery selector and saves receipt files to that delivery
await wrapper.get('.save').trigger('click'); await flushPromises()
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101', expect.objectContaining({ bolNo: 'BOL-ORIGINAL', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [] }, { category: 'RECEIPT', fileIds: ['99'] }] }))
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('modelValue')).toBe(true)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
wrapper.unmount()
})
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
@@ -91,6 +91,18 @@ it('switches customer scope exactly and keeps unsaved delivery edits when switch
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
confirmation.mockRestore(); wrapper.unmount()
})
it('keeps the management dialog and draft open when saving fails', async () => {
vi.mocked(saveDeliveryDocuments).mockRejectedValue(new Error('保存失败'))
const wrapper = setup(); await flushPromises()
const receipt = wrapper.getComponent(DeliveryDocumentsDialog)
await receipt.findAll('button').find(button => button.text() === '删除')!.trigger('click')
await wrapper.get('.save').trigger('click'); await flushPromises()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(receipt.text()).not.toContain('existing-pod.pdf')
expect(wrapper.text()).toContain('保存失败')
wrapper.unmount()
})
it('does not request delivery documents without delivery list permission', async () => {
can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list')
const wrapper = setup(); await flushPromises()
@@ -55,7 +55,7 @@ async function canLeave() {
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
async function beforeClose(done: () => void) { if (await canLeave()) done() }
function selectTab(value: string) { if (!busy.value) { tab.value = value } }
function saved() { emit('saved') }
function saved() { open.value = false; emit('saved') }
function pickupCompleted() { open.value = false; emit('saved') }
async function loadingSaved() { emit('saved'); await load() }
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
@@ -23,13 +23,33 @@ it.each(['VEHICLE', 'DRIVER_PHOTO'] as const)('does not count a PDF or failed up
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
wrapper.unmount()
})
it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => {
it('uses the uploaded license before the master fallback and restores the fallback after removal', async () => {
vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['301'], referenceIds: ['33'], masterLicense: { driverId: '2', fileId: '33' } } }); await flushPromises()
expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33')
expect(downloadDriverLicense).not.toHaveBeenCalled()
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER', '301')
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
expect(wrapper.findAll('figure img')).toHaveLength(2); wrapper.unmount()
expect(wrapper.findAll('figure img')).toHaveLength(1)
await wrapper.setProps({ modelValue: [] }); await flushPromises()
expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33')
expect(wrapper.findAll('figure img')).toHaveLength(1); wrapper.unmount()
})
it('keeps saved trip licenses without an additional master image or changes to stored references', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['33', '301'], referenceIds: ['33'], fallbackFileId: '33' } }); await flushPromises()
expect(wrapper.findAll('figure img')).toHaveLength(1)
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each(['tiny', 'broken'])('shows a recovery message instead of a blank %s thumbnail', async failure => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true } }); await flushPromises()
const image = wrapper.get('figure img')
Object.defineProperties(image.element, { naturalWidth: { value: 1 }, naturalHeight: { value: 1 } })
await image.trigger(failure === 'tiny' ? 'load' : 'error')
expect(wrapper.find('figure img').exists()).toBe(false)
expect(wrapper.text()).toContain(failure === 'tiny' ? '原图仅 1 × 1 像素' : '照片加载失败')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
@@ -11,14 +11,21 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind } from '@/modules/overseas-master-data'
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; fallbackFileId?: string | null; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
const ids = defineModel<string[]>({ required: true })
const profilePhotoId = ref<string | null>(null)
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])])
// 主档仅作缺图时的默认来源;已有本趟驾驶证时不再额外展示主档图片。
const selectedIds = computed(() => {
const values = [...new Set([...(props.referenceIds || []), ...ids.value])]
const fallback = props.fallbackFileId || props.masterLicense?.fileId
return props.kind === 'DRIVER' && fallback && values.some(id => id !== fallback) ? values.filter(id => id !== fallback) : values
})
const allIds = computed(() => [...new Set([...selectedIds.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])])
const emit = defineEmits<{ busy: [value: boolean]; available: [value: boolean] }>()
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
const thumbnails = ref<Record<string, string>>({})
const thumbnailFailures = ref<Record<string, string>>({})
let masterBlob: { fileId: string; blob: Blob } | null = null
const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
@@ -45,9 +52,9 @@ function imageFailed(event: Event) {
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
}
async function load() {
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...new Set([...(props.referenceIds || []), ...ids.value])]
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...selectedIds.value]
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
thumbnails.value = {}
thumbnails.value = {}; thumbnailFailures.value = {}
closePreview(); metadata.value = {}; error.value = null; masterBlob = null; profilePhotoId.value = null; emit('available', false)
if (!no) return
// 未保存且没有本趟照片时展示档案图片;保存由服务端复制,不提交跨归属文件ID。
@@ -89,6 +96,14 @@ async function load() {
} catch (cause) { if (current === generation) fail(cause, '照片读取失败,请重试') }
}))
}
function thumbnailLoaded(id: string, event: Event) {
const image = event.target as HTMLImageElement
if (image.getAttribute('src') !== thumbnails.value[id]) return
if (image.naturalWidth <= 1 && image.naturalHeight <= 1) thumbnailFailures.value[id] = '原图仅 1 × 1 像素,请替换有效照片'
}
function thumbnailFailed(id: string, event: Event) {
if ((event.target as HTMLImageElement).getAttribute('src') === thumbnails.value[id]) thumbnailFailures.value[id] = '照片加载失败,请重新读取或替换'
}
function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() }
async function upload(event: Event) {
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
@@ -133,7 +148,7 @@ async function preview(id: string) {
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
finally { if (current === previewGeneration) previewLoading.value = false }
}
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.masterLicense, props.masterResource]), load, { immediate: true })
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.fallbackFileId, props.masterLicense, props.masterResource]), load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
</script>
<template>
@@ -142,7 +157,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
<figure v-if="photoCard" class="evidence-photo-card">
<div v-if="allIds.length" class="evidence-photo-list">
<div v-for="id in allIds" :key="id" class="evidence-photo-item">
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id] && !thumbnailFailures[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName" @load="thumbnailLoaded(id, $event)" @error="thumbnailFailed(id, $event)"><span v-else>{{ thumbnailFailures[id] || (metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载') }}</span></button>
<p class="density-dialog-note evidence-photo-name" :title="metadata[id]?.fileName">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
<div v-if="!readonly && ids.includes(id)" class="density-actions evidence-photo-actions"><el-button size="small" :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button size="small" :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
</div>
@@ -22,11 +22,24 @@ it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous ma
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'; value.vehicle.carrierName = carrierName; value.driver.mode = 'MASTER'; value.driver.driverId = 'driver-1'; value.driver.phone = phone
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } }); await flushPromises()
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' })
expect(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).not.toContain('拖车公司名称')
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
expect(wrapper.text()).toContain('预计任务结束')
expect(listVehicles).toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(wrapper.emitted('update:driver')).toBeUndefined(); wrapper.unmount()
})
it.each(['MASTER', 'EXTERNAL'] as const)('lets a %s driver fill a missing trip end and respects the disabled state', async mode => {
const value = resources(); value.driver.mode = mode; value.driver.evidence!.expectedTaskEndAt = null
const wrapper = mount(DispatchResourceFields, { props: { ...value, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } })
const endField = wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '预计任务结束')!
const picker = endField.getComponent({ name: 'ElDatePicker' })
picker.vm.$emit('update:modelValue', '2030-01-01T17:00:00Z'); await flushPromises()
expect(value.driver.evidence!.expectedTaskEndAt).toBe('2030-01-01T17:00:00Z')
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).some(item => item.props('label') === '驾驶证到期日')).toBe(mode === 'EXTERNAL')
await wrapper.setProps({ disabled: true })
expect(picker.props('disabled')).toBe(true)
wrapper.unmount()
})
it('keeps master company and phone fields out of the existing non-continuous creation form', async () => {
const value = resources(); value.vehicle.mode = 'MASTER'; value.driver.mode = 'MASTER'
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }); await flushPromises()
@@ -43,8 +56,8 @@ it('shows the newly selected authorized vehicle company without copying it into
const selected = wrapper.emitted('update:vehicle')!.at(-1)![0] as typeof value.vehicle
expect(selected).toMatchObject({ vehicleId: 'vehicle-2', carrierName: null })
await wrapper.setProps({ vehicle: selected })
const company = wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '拖车公司名称')!.getComponent({ name: 'ElInput' })
expect(company.props('modelValue')).toBe('新承运公司'); expect(company.props('readonly')).toBe(true); wrapper.unmount()
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).not.toContain('拖车公司名称')
wrapper.unmount()
})
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
@@ -10,7 +10,6 @@ const driver = defineModel<Driver>('driver', { required: true })
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
/** 维护弹窗只读展示本趟快照或已读取的当前主档资料,不回写主档字段。 */
const masterCarrierName = computed(() => vehicle.value.carrierName?.trim() || vehicles.value.find(row => row.id === vehicle.value.vehicleId)?.driverOrDispatchCompany?.trim() || '—')
const masterDriverPhone = computed(() => driver.value.phone?.trim() || drivers.value.find(row => row.id === driver.value.driverId)?.details.phone?.trim() || '—')
const currentVehicle = ref<VehicleDetail | null>(null), vehicleUnavailable = ref(false)
const vehicleOptions = computed(() => [...vehicles.value, ...(currentVehicle.value && !vehicles.value.some(row => row.id === currentVehicle.value?.id) ? [currentVehicle.value] : [])])
@@ -63,7 +62,6 @@ function selectDriver(id: string) {
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
</template>
<tr v-if="vehicle.mode === 'MASTER'"><td colspan="2"><el-form-item label="拖车公司名称"><el-input :model-value="masterCarrierName" readonly /></el-form-item></td></tr>
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
<tr v-if="driver.mode === 'MASTER'"><td colspan="2"><el-form-item label="联系电话"><el-input :model-value="masterDriverPhone" readonly /></el-form-item></td></tr>
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
@@ -31,7 +31,7 @@ function confirmedAt(appointment: DispatchDestinationAppointment) {
<div class="density-dialog-sections">
<section class="density-dialog-section" aria-label="本车资源登记">
<h3 class="density-dialog-section__title">车辆与司机</h3>
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /><DispatchTimeZonePreview :value="detail.dispatchInfo.departedAt" label="发车时间" /></el-form-item></td></tr></tbody></table>
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td colspan="2"><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td></tr></tbody></table>
<DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
</section>
<section class="density-dialog-section" aria-label="本车时间登记">
@@ -60,6 +60,7 @@ describe('dispatch workflow native layout', () => {
const fields = dialog.findComponent(DispatchResourceFields)
const selected = { ...resources().driver, mode: 'MASTER' as const, driverId: '22', evidence: { ...resources().driver.evidence!, fileIds: [], photoFileIds: [], masterLicenseFileId: '33' } }
fields.vm.$emit('update:driver', selected); await flushPromises()
expect(dialog.text()).toContain('预计任务结束')
const license = () => dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')!
expect(license().find('img').attributes('alt')).toBe('驾驶证照片:驾驶证照片')
expect(downloadDriverLicense).toHaveBeenCalledExactlyOnceWith('22', '33')
@@ -70,6 +71,17 @@ describe('dispatch workflow native layout', () => {
expect(license().find('img').exists()).toBe(false)
expect(license().text()).toContain('暂无照片')
})
it('saves the entered task end for a master driver in the maintenance command', async () => {
const saved = resources()
saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, expectedTaskEndAt: null } }
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: saved })
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2030-01-01T17:00:00Z')
await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({
driver: expect.objectContaining({ mode: 'MASTER', driverId: '22', evidence: expect.objectContaining({ expectedTaskEndAt: '2030-01-01T17:00:00Z' }) }),
}))
})
it('reads an already frozen master license through the dispatch permission without a new driver directory request', async () => {
const saved = resources()
saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, fileIds: ['33'], masterLicenseFileId: '33' } }
@@ -151,7 +163,8 @@ describe('dispatch workflow native layout', () => {
const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...source.dispatchInfo,
departedAt: '2026-07-01T15:00:00Z', plannedDepartureAt: '2026-07-01T16:00:00Z', estimatedArrivalAt: '2026-07-01T17:00:00Z',
} })
expect(field(dialog, '发车时间').get('[aria-label="发车时间时区换算"]').text()).toBe('美东2026-07-01 11:00:00美西2026-07-01 08:00:00')
expect(dialog.find('[aria-label="发车时间时区换算"]').exists()).toBe(false)
expect(field(dialog, '发车时间')).toBeUndefined()
expect(field(dialog, '计划发车时间').get('[aria-label="计划发车时间时区换算"]').text()).toBe('美东2026-07-01 12:00:00美西2026-07-01 09:00:00')
expect(field(dialog, '预计到达时间').get('[aria-label="预计到达时间时区换算"]').text()).toBe('美东2026-07-01 13:00:00美西2026-07-01 10:00:00')
expect(field(dialog, '预计任务结束').get('[aria-label="预计任务结束时区换算"]').text()).toBe('美东2026-11-01 05:00:00美西2026-11-01 02:00:00')
@@ -178,7 +191,8 @@ describe('dispatch workflow native layout', () => {
for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
await field(dialog, '车队邮箱').get('input').setValue(' fleet@example.test ')
await field(dialog, '预约账号').get('input').setValue(' fleet-account ')
expect(field(dialog, '发车时间').get('input').attributes('readonly')).toBeDefined()
expect(field(dialog, '发车时间')).toBeUndefined()
expect(field(dialog, '排车单号').get('input').attributes('readonly')).toBeDefined()
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
@@ -202,7 +216,7 @@ describe('dispatch workflow native layout', () => {
await field(dialog,'车牌号').get('input').setValue('CA-PRESERVED')
dialog.vm.$emit('confirm');await flushPromises()
const original=structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
expect(dialog.text()).toContain('workflow-request')
expect((wrapper.vm as unknown as { error: { requestId: string } }).error.requestId).toBe('workflow-request')
expect(field(dialog,'车牌号').get('input').attributes('disabled')).toBeUndefined()
expect((field(dialog,'车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
await field(dialog,'车牌号').get('input').setValue('CA-CHANGED')
@@ -275,7 +275,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车牌图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driver.mode === 'MASTER' && driver.driverId ? { kind: 'DRIVER', id: driver.driverId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :fallback-file-id="masterLicenseReference" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</div>
</template>
</DispatchVehicleMaintenanceForm>
@@ -3,9 +3,9 @@ import { expect, it } from 'vitest'
import { createAppointmentForm } from '../appointmentForm'
import FbaAppointmentFields from './FbaAppointmentFields.vue'
it('removes confirmation time and offset inputs only for the new appointment flow while retaining planned dates', async () => {
it('keeps only the planned date for new appointments and allows time fields in other flows', async () => {
const wrapper = mount(FbaAppointmentFields, {
props: { model: createAppointmentForm(), continuous: true, hideConfirmationTimes: true, timezone: 'America/New_York' },
props: { model: createAppointmentForm(), continuous: true, dateOnly: true, timezone: 'America/New_York' },
global: { stubs: {
ElForm: { template: '<form><slot /></form>' },
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
@@ -16,10 +16,11 @@ it('removes confirmation time and offset inputs only for the new appointment flo
})
expect(wrapper.text()).toContain('外部约号')
expect(wrapper.text()).toContain('计划送仓日期')
expect(wrapper.text()).toContain('补充计划时段')
expect(wrapper.findAll('[data-picker]')).toHaveLength(3)
expect(wrapper.text()).not.toMatch(/对方确认开始时间|对方确认结束时间|确认开始时差|确认结束时差|夏令时/)
await wrapper.setProps({ hideConfirmationTimes: false })
expect(wrapper.findAll('[data-picker]')).toHaveLength(1)
expect(wrapper.text()).not.toMatch(/补充计划时段|计划开始时段|计划结束时段|对方确认开始时间|对方确认结束时间|确认开始时差|确认结束时差|夏令时/)
await wrapper.setProps({ dateOnly: false })
expect(wrapper.text()).toContain('计划开始时段')
expect(wrapper.text()).toContain('计划结束时段')
expect(wrapper.text()).toContain('对方确认开始时间')
expect(wrapper.findAll('[data-picker]')).toHaveLength(5)
wrapper.unmount()
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
import { ref, watchEffect } from 'vue'
import type { AppointmentForm } from '../appointmentForm'
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; hideConfirmationTimes?: boolean; sources?: PlanningSourceLookup[] }>()
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; dateOnly?: boolean; sources?: PlanningSourceLookup[] }>()
const emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
watchEffect(() => {
@@ -13,7 +13,7 @@ watchEffect(() => {
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
</script>
<template>
<p v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
<p v-if="!dateOnly && model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
<AppointmentConfirmationCargo v-if="sources?.length" ref="cargo" :sources="sources" :active="model.appointmentStatus === 'SUCCESS'" :disabled="disabled" @ready="emit('confirmationReady', $event)" />
<div v-if="continuous" class="density-dialog-sections">
<section class="density-dialog-section" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
@@ -22,13 +22,13 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item></td></tr>
<template v-if="model.appointmentStatus === 'SUCCESS'">
<tr><td colspan="2"><el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item></td></tr>
<tr v-if="!hideConfirmationTimes"><td><el-form-item label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
<tr v-if="!dateOnly"><td><el-form-item label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
</template>
<tr><td colspan="2"><el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" aria-label="预约备注" maxlength="1000" /></el-form-item></td></tr>
</tbody></table>
<details><summary>{{ hideConfirmationTimes ? '补充计划时段' : '补充计划时段与夏令时信息' }}</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
<details v-if="!dateOnly"><summary>补充计划时段与夏令时信息</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
<tr><td><el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item></td><td><el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
<template v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
<template v-if="model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
</tbody></table></details></el-form>
</section>
</div>
@@ -39,15 +39,15 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
<el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'">
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
<el-form-item v-if="!hideConfirmationTimes" label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
<el-form-item v-if="!hideConfirmationTimes" label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
<el-form-item v-if="!dateOnly" label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
<el-form-item v-if="!dateOnly" label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
</template>
<el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" aria-label="预约备注" maxlength="1000" /></el-form-item>
<details class="appointment-fields__extra"><summary>{{ hideConfirmationTimes ? '补充计划时段' : '补充计划时段与夏令时信息' }}</summary>
<details v-if="!dateOnly" class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
<div class="appointment-fields__extra-grid">
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<template v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
<template v-if="model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
<el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item>
<el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item>
</template>
@@ -58,6 +58,19 @@ describe('DeliveryOrderDetailView', () => {
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
wrapper.unmount()
})
it('renders document and receipt operation nodes in Chinese', async () => {
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
value.operations = ['FIRSTLEG_COMPLETION_PENDING', 'SAVE_DOCUMENTS', 'RECEIPT_AUTO_COMPLETE'].map((operationNode, index) => ({ id: String(index), operationNode, operatedAt: '2026-11-05T16:59:55', result: '已保存' }))
vi.mocked(getDeliveryOrder).mockResolvedValue(value)
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.find('.el-tabs__item:nth-child(4)').trigger('click')
expect(wrapper.text()).toContain('来源货物签收待齐')
expect(wrapper.text()).toContain('保存派送资料')
expect(wrapper.text()).toContain('签收文件自动完成派送')
for (const operation of value.operations) expect(wrapper.text()).not.toContain(operation.operationNode)
wrapper.unmount()
})
it('shows frozen original coverage separately from new fees', async () => {
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
value.feeCoverage = { cargoHash: 'hash', cargo: [], confirmedAt: '2026-10-03T00:00:00Z', references: [{
@@ -29,7 +29,7 @@ const methodLabels: Record<string, string> = { PICKUP: '普通自提', AMAZON_PI
const pickupLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
const loadingLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费', SAVE_DOCUMENTS: '保存派送资料', RECEIPT_AUTO_COMPLETE: '签收文件自动完成派送', FIRSTLEG_COMPLETION_PENDING: '来源货物签收待齐' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
@@ -57,7 +57,6 @@ beforeEach(() => {
})
async function listPage() { const wrapper = mount(InboundOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function popup(wrapper: VueWrapper, title: string) { return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
async function openEditPlan() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '编辑')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑入库单') } }
async function openEditAppointment() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑预约') } }
async function openMove() { const wrapper = mount(InboundOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '移库')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '托盘移库') } }
@@ -140,40 +139,17 @@ describe('inbound appointment native dialogs', () => {
})
describe('inbound plan and pallet move native dialogs', () => {
it('shows plan identity and saves the selected master warehouse with the internal version through fullscreen', async () => {
const { dialog } = await openEditPlan()
expect(dialog.get('[aria-label="入库计划来源"]').findAll('input')).toHaveLength(0)
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('入库单号')
expect(dialog.get('[aria-label="入库计划来源"]').text()).not.toContain('版本')
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledExactlyOnceWith('IP-1', { customerId: 'C-1', overseasWarehouseId: warehouse.id, warehouseCode: 'LA2', warehouseName: '洛杉矶二仓', version: 0 })
})
it('locks a pending plan edit and keeps the warehouse and diagnostic context after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(updateInboundPlan).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { dialog } = await openEditPlan()
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(select.find('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('版本已变化', 'A0409', 409, null, 'plan-popup-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(select.props('modelValue')).toBe(warehouse.id)
expect(dialog.text()).toContain('plan-popup-request')
})
it('does not save while the plan warehouse candidates are loading', async () => {
let resolve!: (value: Awaited<ReturnType<typeof listReceivingOverseasWarehouses>>) => void
vi.mocked(listReceivingOverseasWarehouses).mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await openEditPlan()
expect(dialog.props('loading')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(updateInboundPlan).not.toHaveBeenCalled()
resolve({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 }); await flushPromises()
expect(dialog.props('loading')).toBe(false)
it.each(['ONE_CLICK_DROPSHIP', 'FIRST_LEG', 'RELABEL_APPLICATION', 'WAREHOUSE_TRANSFER', 'RETURN_RELABEL', 'DELIVERY_RETURN', 'LOCAL_LOOSEBOARD', 'CARGO_DISCARD', 'REJECTED', 'EXPRESS', 'FUTURE_TYPE', null])('hides plan editing for inbound order type %s even with edit permission', async (orderType) => {
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ ...order, orderType }], total: 1, page: 1, size: 10, pages: 1 })
const wrapper = await listPage()
const actions = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text())
expect(actions).not.toContain('编辑')
expect(actions).toContain('查看')
expect(actions).toContain('查看预约')
expect(actions).toContain('预约')
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title').startsWith('编辑入库单'))).toBe(false)
expect(listReceivingOverseasWarehouses).not.toHaveBeenCalled()
expect(updateInboundPlan).not.toHaveBeenCalled()
})
it('loads only physical usable locations and preserves a valid zero pallet version in the command', async () => {
const { dialog } = await openMove()
@@ -19,8 +19,7 @@ import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
import type { PlanningSourceLookup } from '@/modules/appointment-calendar'
import { createAppointmentForm, appointmentPayload, appointmentFormError } from '../appointmentForm'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
@@ -68,7 +67,6 @@ const appointmentViewOnly = ref(false)
const appointmentReplacementNos = ref<string[]>([])
const appointmentReadOnly = computed(() => appointmentViewOnly.value || !access.can(permission.inboundOrderAppointment))
const appointmentTitle = computed(() => `${appointmentReadOnly.value ? '查看预约' : appointmentMode.value === 'edit' ? '编辑预约' : '预约'} · ${editingAppointmentNo.value || `${appointmentSelectedCount.value} 条入库单`}`)
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
function selectableRow(source: object) {
const row = source as InboundOrderListItem
@@ -234,8 +232,6 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.destinationWarehouseName === '—' ? '' : row.destinationWarehouseName, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
async function submitPlanEdit() { if (planEditSaving.value || planEditLoading.value || !access.can(permission.inboundOrderEdit)) return; const target = planEditTarget.value; if (!target || !planEditForm.warehouseId) { ElMessage.warning('请选择目的仓'); return } planEditSaving.value = true; mutationError.value = null; try { await updateInboundPlan(target.orderNo, { customerId: target.customerId, overseasWarehouseId: planEditForm.warehouseId, warehouseCode: planEditForm.warehouseCode, warehouseName: planEditForm.warehouseName, version: planEditForm.version }); planEditOpen.value = false; ElMessage.success('入库单已更新'); await load() } catch (error) { mutationError.value = getApiErrorDetails(error, '入库单更新失败,请重试') } finally { planEditSaving.value = false } }
onMounted(() => { void load() })
</script>
@@ -250,7 +246,7 @@ onMounted(() => { void load() })
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="canCreateDispatch" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button><el-button v-if="(row as InboundOrderListItem).appointmentStatus === '多批预约' && access.can('platform:appointment:list')" class="density-action" @click="openMultipleAppointments(row as InboundOrderListItem)">管理多批预约</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button><el-button v-if="(row as InboundOrderListItem).appointmentStatus === '多批预约' && access.can('platform:appointment:list')" class="density-action" @click="openMultipleAppointments(row as InboundOrderListItem)">管理多批预约</el-button></div></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></div></section>
</ListPageLayout>
@@ -263,11 +259,6 @@ onMounted(() => { void load() })
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section class="density-dialog-section" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
</div></AppDialog>
</AdminPage>
</template>
@@ -297,7 +297,7 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 330, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110, afterColumn: 'orderNo' },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '出库排车状态', width: 110, afterColumn: 'orderNo' },
{ key: 'relabelStatus', slotName: 'relabelStatus', label: '换标子状态', minWidth: 180, afterColumn: 'orderNo' },
{ key: 'status', prop: 'status', label: '当前状态', width: 110, afterColumn: 'orderNo' },
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100, afterColumn: 'orderNo' },
@@ -91,7 +91,7 @@ function feePreview(overrides: Partial<InboundFeePreview> = {}): InboundFeePrevi
}
function savedOuterMeasurement() {
return { actualPackageCount: 22, actualGrossWeightKg: 570.5, actualVolumeCbm: 3.6894,
return { measureReviewStatus: 'APPROVED', actualPackageCount: 22, actualGrossWeightKg: 570.5, actualVolumeCbm: 3.6894,
items: [{ id: '9001', itemNo: 'B1', packageCount: 22, lengthCm: 62, widthCm: 52, heightCm: 52,
outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53,
calculatedOuterLengthCm: 63, calculatedOuterWidthCm: 53, calculatedOuterHeightCm: 53,
@@ -266,6 +266,22 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
wrapper.unmount()
})
it.each(['NOT_SUBMITTED', 'PENDING', 'REJECTED'])('blocks fee publication before measurement approval: %s', async (measureReviewStatus) => {
const wrapper = mountDetail()
await flushPromises()
const vm = wrapper.vm as unknown as FeeConfirmVm & { feeConfirmOpen: boolean }
vm.measurementDetail = { ...savedOuterMeasurement(), measureReviewStatus }
await vm.openFeeConfirmDialog()
expect(vm.feeConfirmOpen).toBe(false)
await vm.executeConfirmAndSendFee()
expect(previewInboundFee).not.toHaveBeenCalled()
expect(calculateInboundFee).not.toHaveBeenCalled()
expect(confirmInboundFee).not.toHaveBeenCalled()
expect(sendInboundFee).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each([3.894, 4.25])('defaults billing to the saved outer volume and sends the confirmed value %s', async (confirmedVolume) => {
const fee = { unitPrice: 13.6, chargeableQuantity: 650.298, chargeUnit: 'KG', roundingScale: 4, rateCode: 'RATE', feeStatus: 'CALCULATED' }
vi.mocked(getLatestInboundFee).mockResolvedValue(fee as never)
@@ -359,7 +375,7 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
await flushPromises()
const vm = wrapper.vm as unknown as FeeConfirmVm
vm.measurementDetail = {
actualPackageCount: 10, actualGrossWeightKg: 100, actualVolumeCbm: 0.81,
measureReviewStatus: 'APPROVED', actualPackageCount: 10, actualGrossWeightKg: 100, actualVolumeCbm: 0.81,
items: [{ id: '9001', itemNo: 'B1', packageCount: 10, lengthCm: 50, widthCm: 40, heightCm: 30,
outerLengthCm: null, outerWidthCm: null, outerHeightCm: null,
calculatedOuterLengthCm: 54, calculatedOuterWidthCm: 44, calculatedOuterHeightCm: 34,
@@ -1741,6 +1741,10 @@ async function openFeeConfirmDialog() {
ElMessage.warning('当前单据缺少入仓信息,暂不能发送费用')
return
}
if (measurementDetail.value?.measureReviewStatus !== 'APPROVED') {
ElMessage.warning('测量数据尚未审核通过,请先完成测量审核后再发送客户')
return
}
if (!hasActualMeasurementData.value) {
ElMessage.warning('暂无实际测量数据,不能确认并发送费用')
return
@@ -1866,6 +1870,10 @@ async function executeConfirmAndSendFee() {
if (confirmFeeSending.value) return
const summary = detail.value?.summary
if (!summary?.inboundId) return
if (measurementDetail.value?.measureReviewStatus !== 'APPROVED') {
ElMessage.warning('测量数据尚未审核通过,请先完成测量审核后再发送客户')
return
}
const selectedWeightKg = Number(feeConfirmWeight.value)
const selectedVolumeCbm = Number(feeConfirmVolume.value)
const selectedUnitPrice = Number(feeConfirmUnitPrice.value)
+14 -3
View File
@@ -431,7 +431,11 @@ describe('approved Admin density ownership', () => {
expect(rules.get(':is(.admin-density, .admin-density-dialog) .density-table--editable .el-form-item.is-error')).toMatchObject({ 'margin-bottom': 'var(--admin-space-5)' })
for (const selector of rules.keys()) if (selector.includes('density-dialog-section')) expect(selector.startsWith(':is(.admin-density-dialog, .admin-density-drawer:where(.admin-density-drawer--detail, .admin-density-drawer--editor)) ')).toBe(true)
const measured = read('modules/inbounds/measurement/measurementDensity.css')
expect(measured).not.toMatch(/border-left(?:-color|-width)?\s*:/)
cssRoot(measured).walkRules(rule => {
rule.walkDecls(/^border-left/, () => {
expect(rule.selector).toMatch(/^\.inbound-measurement-workspace-dialog\.inbound-density \.measurement-(summary-section|declaration|items-section)$/)
})
})
for (const color of ['#f4fbf8', '#f7f9ff', '#fffbf4']) expect(measured).not.toContain(color)
for (const file of ['unified-orders/pages/UnifiedOrdersView.vue', 'unified-orders/components/OrderDeclarationEditorDialog.vue', 'unified-orders/components/InboundFeeDetailManager.vue', 'inbounds/measurement/InboundMeasurementPanel.vue', 'inbounds/weight/components/WeightOcrPanel.vue', 'domestic-containers/pages/DomesticContainersView.vue', 'sailing-calendar/pages/SailingCalendarView.vue', 'ocean-bookings/components/OceanBookingPrintDialog.vue', 'ocean-bookings/pages/OceanBookingDetailView.vue', 'trucking/components/TruckAttachmentDialog.vue', 'container-loading/pages/ContainersView.vue', 'container-loading/pages/ContainerDetailView.vue', 'container-loading/components/OceanBookingLinkDialog.vue']) {
const source = read('modules/' + file)
@@ -1302,20 +1306,27 @@ describe('approved Admin density ownership', () => {
sectionStyles.set(rule.selector, declarations)
})
for (const [section, background] of [
['summary-section', 'var(--admin-surface, #fff)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-surface, #fff)'],
['summary-section', 'var(--admin-dialog-summary-bg)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-dialog-work-bg)'],
]) {
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)).toMatchObject({
padding: 'var(--admin-space-4, 10px) var(--admin-space-5, 12px)',
background,
})
}
for (const [section, accent] of [['summary-section', '#20a477'], ['declaration', '#647fec'], ['items-section', '#c79643']]) {
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)?.['border-left']).toBe(`4px solid ${accent}`)
}
expect(differenceStyles.get('.measurement-comparison thead th')).toMatchObject({ background: 'var(--admin-label-bg)' })
expect(differenceStyles.get('.measurement-comparison thead th:nth-child(2)')).toMatchObject({ background: 'var(--admin-dialog-source-bg)' })
expect(differenceStyles.get('.measurement-comparison thead th:nth-child(3)')).toMatchObject({ background: 'var(--admin-dialog-summary-bg)' })
expect(sectionStyles.get('.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-table th.el-table__cell')).toMatchObject({ background: 'var(--admin-label-bg)', color: 'var(--workbench-muted)' })
expect(sectionStyles.get('.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert')?.background).toBe('var(--admin-surface, #fff)')
// Shared density must not reset the sections' horizontal gutters.
expect(sectionStyles.get('.inbound-density.admin-density-dialog .measurement-section')).toEqual({
'padding-block': 'var(--admin-space-4, 10px)',
})
expect(layout).toContain('.measurement-document-number { display: flex; flex: 1;')
expect(layout).toContain('.measurement-platform-fields { display: flex; flex: 1 1 520px;')
expect(layout).toContain('.measurement-platform-fields > .measurement-goods-name { flex: 1 1 340px; }')
expect(layout).toContain('.measurement-platform-fields > .measurement-expected-pallets { flex: 0 0 180px; }')
for (const style of dialog.styles) {
expect(style.content).not.toMatch(/\.measurement-(summary-section|metrics|declaration|items-section)\b/)