fix: preserve required inputs and refine operations screens

This commit is contained in:
2026-11-05 16:30:28 +08:00
parent 2d1bb38ad5
commit a1721371e4
22 changed files with 267 additions and 86 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().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
expect(creationBody().text()).toContain('AP-001') 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) } 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) vi.mocked(createSourceAppointment).mockResolvedValue(saved)
const wrapper = await open(); await create(wrapper) const wrapper = await open(); await create(wrapper)
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
const receipt = creationBody().get('[aria-label="预约保存回执"]') 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.findAll('input, textarea, select')).toHaveLength(0)
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/) 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() const wrapper = render(); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup) expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号') 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() 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() await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).not.toHaveBeenCalled() expect(saveCargoVerification).not.toHaveBeenCalled()
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST') 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.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount() 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 () => { it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
const wrapper = render(false); await flushPromises() const wrapper = render(false); await flushPromises()
expect(resolvePlanningOrder).not.toHaveBeenCalled() 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"> <section v-show="active || editing" class="density-dialog-section" aria-label="正式确认前的货物资料" :aria-busy="loading">
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3> <h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button> <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> <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> <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)"> <tr v-for="row in rows" :key="key(row)">
@@ -129,7 +129,6 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
<table class="density-table"><tbody> <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>{{ 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.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><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.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> <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> <template>
<section aria-label="货物核实资料"> <section aria-label="货物核实资料">
<h3>{{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3> <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> <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> <p v-if="loading">正在读取货物资料…</p>
<template v-if="context"> <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> <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"> <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.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label> <label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p> </fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
@@ -63,7 +63,7 @@ onMounted(load)
</tbody></table> </tbody></table>
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p> <p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
</section> </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> <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> <div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
</template> </template>
+7 -4
View File
@@ -43,12 +43,15 @@ describe('LoginView authentication lifecycle', () => {
expect(loginSource).not.toContain('demo-tip') 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("import '@/styles/adminWorkbench.css'")
expect(loginSource).toContain('class="login-page admin-workbench"') expect(loginSource).toContain('class="login-page admin-workbench"')
expect(loginSource).toContain('<h1 class="workbench-title">柏悦物流管理后台</h1>') expect(loginSource).toContain('<h1>柏悦物流管理后台</h1>')
expect(loginSource).toContain('class="login-mobile-brand workbench-brand-mark"') expect(loginSource).toContain('class="login-mobile-brand"')
expect(loginSource).toContain('class="login-card workbench-panel"') 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('class="login-error workbench-notice workbench-tone" data-tone="rose"')
expect(loginSource).toContain('aria-label="平台员工登录"') expect(loginSource).toContain('aria-label="平台员工登录"')
expect(loginSource).toContain('label-position="top" size="large"') expect(loginSource).toContain('label-position="top" size="large"')
+25 -22
View File
@@ -57,23 +57,23 @@ async function submit() {
<template> <template>
<div class="login-page admin-workbench"> <div class="login-page admin-workbench">
<section class="login-intro"> <section class="login-intro">
<div class="login-brand workbench-brand-mark"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div> <div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="workbench-heading"> <div>
<h1 class="workbench-title">柏悦物流管理后台</h1> <h1>柏悦物流管理后台</h1>
<p class="login-intro-copy workbench-note">平台内部运营与管理系统</p> <p class="login-intro-copy">平台内部运营与管理系统</p>
</div> </div>
<div class="login-security-note workbench-meta"> <div class="login-security-note">
<ShieldCheck :size="22" aria-hidden="true" /> <ShieldCheck :size="22" aria-hidden="true" />
<span>平台内部系统,请使用授权账号登录</span> <span>平台内部系统,请使用授权账号登录</span>
</div> </div>
</section> </section>
<section class="login-panel"> <section class="login-panel">
<div class="login-card workbench-panel"> <div class="login-card">
<div class="login-mobile-brand workbench-brand-mark"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div> <div class="login-mobile-brand"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="login-card-icon workbench-icon"><LockKeyhole :size="24" aria-hidden="true" /></div> <div class="login-card-icon"><LockKeyhole :size="24" aria-hidden="true" /></div>
<h2 class="workbench-title">登录管理后台</h2> <h2>登录管理后台</h2>
<p class="workbench-note">使用平台内部员工账号登录</p> <p>使用平台内部员工账号登录</p>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" aria-label="平台员工登录" @submit.prevent="submit"> <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-form-item label="账号" prop="account" required>
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" autofocus /> <el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" autofocus />
@@ -92,17 +92,20 @@ async function submit() {
</template> </template>
<style scoped> <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); } /* Brand layout is local to login; workbench styles only supply shared controls and errors. */
.login-intro { display: flex; min-width: 0; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); } .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-brand { display: flex; width: fit-content; min-height: 64px; align-items: center; padding: 8px 20px; } .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-brand-logo { display: block; width: 205px; height: 64px; object-fit: contain; object-position: left center; }
.login-intro h1 { 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; } .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-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-panel { display: grid; min-width: 0; place-items: center; padding: 48px; background: var(--cc-surface-muted); }
.login-card.workbench-panel { width: min(410px, 100%); min-width: 0; padding: 32px; } .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 h2 { margin-top: 22px; } .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 > p { margin: 8px 0 30px; } .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-mobile-brand { display: none; }
.login-submit { width: 100%; min-height: 44px; } .login-submit { width: 100%; min-height: 44px; }
.login-submit :deep(> span) { display: flex; align-items: center; gap: 8px; } .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-page { grid-template-columns: 1fr; }
.login-intro { display: none; } .login-intro { display: none; }
.login-panel { align-items: start; padding: 24px; } .login-panel { align-items: start; padding: 24px; }
.login-card.workbench-panel { width: min(440px, 100%); margin: auto; padding: 28px; } .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; } .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; } .login-mobile-brand img { display: block; width: 154px; height: 48px; object-fit: contain; object-position: left center; }
} }
@media (max-width: 360px) { @media (max-width: 360px) {
.login-panel { padding: 12px; } .login-panel { padding: 12px; }
.login-card.workbench-panel { padding: 20px; } .login-card { padding: 20px; }
.login-mobile-brand { margin-bottom: 24px; } .login-mobile-brand { margin-bottom: 24px; }
} }
</style> </style>
@@ -245,6 +245,7 @@ const allColumns: DataTableColumn[] = [
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常', afterColumn: 'containerNo' }, { 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: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' }, { 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: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' }, { key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 }, { key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
@@ -267,8 +268,8 @@ const allColumns: DataTableColumn[] = [
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' }, { key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' }, { 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 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', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt']) 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 const columns = computed(() => allColumns
.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key))) .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 { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 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 { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth' 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 () => { 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(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) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mountPanel() const wrapper = mountPanel()
await flushPromises() await flushPromises()
@@ -379,10 +380,47 @@ describe('InboundFeePanel', () => {
await wrapper.get('[data-testid="send-fee-button"]').trigger('click') await wrapper.get('[data-testid="send-fee-button"]').trigger('click')
await flushPromises() await flushPromises()
expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/)) expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/))
expect(getInboundMeasurement).not.toHaveBeenCalled()
expect(wrapper.emitted('changed')).toHaveLength(2) expect(wrapper.emitted('changed')).toHaveLength(2)
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false) 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 () => { it('does not expose fee transitions without their independent permissions', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null }) vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) 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) 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 () => { it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' }) vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
const wrapper = mountPanel() const wrapper = mountPanel()
@@ -381,8 +381,8 @@ describe('InboundMeasurementPanel', () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ measurementId: null, items: [], weightResults: [] })) vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ measurementId: null, items: [], weightResults: [] }))
const wrapper = await mountPanel() const wrapper = await mountPanel()
expect(wrapper.find('.measurement-summary-header h4').text()).toBe('申报与实测对比') expect(wrapper.find('.measurement-summary-header h4').text()).toBe('申报与实测对比')
expect(wrapper.find('.measurement-declaration-header').text()).toContain('DOC-001') expect(wrapper.find('.measurement-declaration-header').text()).not.toContain('DOC-001')
expect(wrapper.find('.measurement-section__heading .measurement-goods-name input').exists()).toBe(true) expect(wrapper.find('.measurement-section__heading .measurement-goods-name').exists()).toBe(false)
expect(wrapper.find('.measurement-section__heading .measurement-section-actions').text()).toContain('手动录入箱规') expect(wrapper.find('.measurement-section__heading .measurement-section-actions').text()).toContain('手动录入箱规')
expect(wrapper.findAll('.el-alert').some(alert => alert.text().includes('请先完成箱规测量'))).toBe(false) expect(wrapper.findAll('.el-alert').some(alert => alert.text().includes('请先完成箱规测量'))).toBe(false)
expect(wrapper.find('.measurement-panel__footer').text()).toContain('请先完成箱规测量') expect(wrapper.find('.measurement-panel__footer').text()).toContain('请先完成箱规测量')
@@ -396,11 +396,11 @@ describe('InboundMeasurementPanel', () => {
expect(comparison.text()).toContain('118.5') expect(comparison.text()).toContain('118.5')
expect(comparison.text()).toContain('0.8') expect(comparison.text()).toContain('0.8')
expect(comparison.findAll('tbody tr')[0].text()).toContain('10') expect(comparison.findAll('tbody tr')[0].text()).toContain('10')
expect(declaration.text()).toContain('原单据号码') expect(declaration.text()).not.toContain('原单据号码')
expect(declaration.text()).toContain('DOC-001') expect(declaration.text()).not.toContain('DOC-001')
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(1) expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(0)
expect(declaration.findAll('input')).toHaveLength(0) expect(declaration.findAll('input')).toHaveLength(0)
expect(wrapper.find('.measurement-items-section .measurement-goods-name input').exists()).toBe(true) expect(wrapper.find('.measurement-items-section .measurement-goods-name').exists()).toBe(false)
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('蓝牙音箱') expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('蓝牙音箱')
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('50 × 40 × 30') expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('50 × 40 × 30')
}) })
@@ -418,18 +418,14 @@ describe('InboundMeasurementPanel', () => {
expect(declaration.find('input').exists()).toBe(false) expect(declaration.find('input').exists()).toBe(false)
expect(declaration.text()).toContain('蓝牙音箱') expect(declaration.text()).toContain('蓝牙音箱')
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(1) expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(0)
const actualGoods = wrapper.get('.measurement-items-section .measurement-goods-name') expect(wrapper.find('.measurement-items-section .measurement-goods-name').exists()).toBe(false)
expect(actualGoods.text()).toContain('蓝牙音箱')
expect(actualGoods.find('input').exists()).toBe(false)
}) })
it('saves the edited actual goods name without overriding declaration facts', async () => { it('preserves the hidden actual goods name without overriding declaration facts', async () => {
const wrapper = await mountPanel() const wrapper = await mountPanel()
expect(wrapper.get<HTMLInputElement>('input[aria-label="实际品名"]').element.value).toBe('蓝牙音箱') expect(wrapper.text()).not.toContain('实际品名')
await wrapper.get('input[aria-label="实际品名"]').setValue(' 实测蓝牙音箱 ') await wrapper.get('input[aria-label="仓库修改外箱长"]').setValue('55')
expect(wrapper.get('.measurement-declaration').text()).toContain('蓝牙音箱')
expect(wrapper.get('.measurement-declaration').text()).not.toContain('实测蓝牙音箱')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据')) const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
await save?.trigger('click') await save?.trigger('click')
await flushPromises() await flushPromises()
@@ -437,7 +433,7 @@ describe('InboundMeasurementPanel', () => {
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({ expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({
measurementId: '9001', measurementId: '9001',
version: 2, version: 2,
actualGoodsName: '实测蓝牙音箱', actualGoodsName: '蓝牙音箱',
items: expect.any(Array), items: expect.any(Array),
})) }))
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0]?.[2] const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0]?.[2]
@@ -446,6 +442,60 @@ describe('InboundMeasurementPanel', () => {
expect(payload).not.toHaveProperty('originalVolumeCbm') 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 () => { it('allows saving one warehouse outer dimension against the measured sequence', async () => {
const wrapper = await mountPanel() const wrapper = await mountPanel()
expect(wrapper.text()).toContain('001') expect(wrapper.text()).toContain('001')
@@ -634,7 +684,7 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel() const wrapper = await mountPanel()
expect(wrapper.find('.measurement-declaration input').exists()).toBe(false) expect(wrapper.find('.measurement-declaration input').exists()).toBe(false)
expect(wrapper.find('.measurement-items-section .measurement-expected-pallets input').exists()).toBe(true) expect(wrapper.find('.measurement-items-section .measurement-expected-pallets input').exists()).toBe(true)
expect(wrapper.find('.measurement-items-section .measurement-goods-name input').exists()).toBe(true) expect(wrapper.find('.measurement-items-section .measurement-goods-name').exists()).toBe(false)
}) })
it('requires at least one confirmed weight image before submitting for review', async () => { it('requires at least one confirmed weight image before submitting for review', async () => {
@@ -149,12 +149,21 @@ const packageCountDifference = computed(() => expectedPackageCount.value == null
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0) const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
const hasConfirmedWeightResult = computed(() => confirmedWeightAvailable.value) const hasConfirmedWeightResult = computed(() => confirmedWeightAvailable.value)
const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_DROPSHIP') 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 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(() => { const submissionBlockReason = computed(() => {
if (!canSubmit.value || !detail.value || loading.value) return '' if (!canSubmit.value || !detail.value || loading.value) return ''
if (detail.value.processStatus === 'PREPARING') return '订单尚待客服审核,审核通过后才能提交测量审核' if (detail.value.processStatus === 'PREPARING') return '订单尚待客服审核,审核通过后才能提交测量审核'
if (!submitEditableProcess.value) return `当前${processStatusLabel(detail.value.processStatus)},不能提交测量审核` if (!submitEditableProcess.value) return `当前${processStatusLabel(detail.value.processStatus)},不能提交测量审核`
if (goodsNameCorrectionError.value) return goodsNameCorrectionError.value
if (!form.measurementId) return hasConfirmedWeightResult.value ? '请先完成箱规测量并保存测量数据' : '请先完成箱规测量,并上传确认至少一张称重图片' if (!form.measurementId) return hasConfirmedWeightResult.value ? '请先完成箱规测量并保存测量数据' : '请先完成箱规测量,并上传确认至少一张称重图片'
if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核' if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核' if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
@@ -416,6 +425,7 @@ async function reloadLatest() {
function validateDraft() { function validateDraft() {
const errors: Record<string, string[]> = {} const errors: Record<string, string[]> = {}
const general: string[] = [] const general: string[] = []
if (goodsNameCorrectionError.value) general.push(goodsNameCorrectionError.value)
if (!form.items.length) general.push('请至少保留一条实际扫描箱测量记录') 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 的整数') 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('一件代发预计托数不能超过实测箱数') else if (isDropship.value && Number(form.expectedPalletCount) > confirmedPackageCount.value) general.push('一件代发预计托数不能超过实测箱数')
@@ -759,7 +769,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title"> <section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
<header class="measurement-declaration-header"> <header class="measurement-declaration-header">
<h4 id="measurement-declaration-title">客户申报明细</h4> <h4 id="measurement-declaration-title">客户申报明细</h4>
<div class="measurement-readonly-field measurement-document-number"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
</header> </header>
<el-table <el-table
v-if="detail.latestDeclaration?.items.length" v-if="detail.latestDeclaration?.items.length"
@@ -783,9 +792,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<div class="measurement-section__heading"> <div class="measurement-section__heading">
<div><h3>平台实测箱规</h3></div> <div><h3>平台实测箱规</h3></div>
<div class="measurement-data-grid measurement-platform-fields"> <div v-if="isDropship || (needsGoodsNameCorrection && mayMaintain)" class="measurement-data-grid measurement-platform-fields">
<label v-if="mayMaintain" class="measurement-goods-name"><span>实际品名</span><el-input v-model="form.actualGoodsName" aria-label="实际品名" placeholder="请输入实际品名" maxlength="255" /></label> <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>
<div v-else class="measurement-readonly-field measurement-goods-name"><span>实际品名</span><strong>{{ detail.actualGoodsName || '—' }}</strong></div>
<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> <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 v-else-if="isDropship" class="measurement-readonly-field measurement-expected-pallets"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
</div> </div>
@@ -20,9 +20,6 @@
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header h4, .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 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-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-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 { 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, .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" /> <ContainerSimulationCreate :dense="isPreload" v-if="canCreate" success-message="模拟柜及预装清单已创建" @created="onSimulationCreated" />
</template> </template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }"> <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> <el-button v-if="canView && !isPreload" class="density-action" size="small" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
</div></template> </div></template>
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template> <template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
@@ -24,9 +24,22 @@ it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous ma
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' }) 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(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true) expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
expect(wrapper.text()).toContain('预计任务结束')
expect(listVehicles).toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled() expect(listVehicles).toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(wrapper.emitted('update:driver')).toBeUndefined(); wrapper.unmount() 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 () => { 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 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() const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }); await flushPromises()
@@ -31,7 +31,7 @@ function confirmedAt(appointment: DispatchDestinationAppointment) {
<div class="density-dialog-sections"> <div class="density-dialog-sections">
<section class="density-dialog-section" aria-label="本车资源登记"> <section class="density-dialog-section" aria-label="本车资源登记">
<h3 class="density-dialog-section__title">车辆与司机</h3> <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" /> <DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
</section> </section>
<section class="density-dialog-section" aria-label="本车时间登记"> <section class="density-dialog-section" aria-label="本车时间登记">
@@ -60,6 +60,7 @@ describe('dispatch workflow native layout', () => {
const fields = dialog.findComponent(DispatchResourceFields) const fields = dialog.findComponent(DispatchResourceFields)
const selected = { ...resources().driver, mode: 'MASTER' as const, driverId: '22', evidence: { ...resources().driver.evidence!, fileIds: [], photoFileIds: [], masterLicenseFileId: '33' } } 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() fields.vm.$emit('update:driver', selected); await flushPromises()
expect(dialog.text()).toContain('预计任务结束')
const license = () => dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')! const license = () => dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')!
expect(license().find('img').attributes('alt')).toBe('驾驶证照片:驾驶证照片') expect(license().find('img').attributes('alt')).toBe('驾驶证照片:驾驶证照片')
expect(downloadDriverLicense).toHaveBeenCalledExactlyOnceWith('22', '33') expect(downloadDriverLicense).toHaveBeenCalledExactlyOnceWith('22', '33')
@@ -70,6 +71,17 @@ describe('dispatch workflow native layout', () => {
expect(license().find('img').exists()).toBe(false) expect(license().find('img').exists()).toBe(false)
expect(license().text()).toContain('暂无照片') 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 () => { it('reads an already frozen master license through the dispatch permission without a new driver directory request', async () => {
const saved = resources() const saved = resources()
saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, fileIds: ['33'], masterLicenseFileId: '33' } } saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, fileIds: ['33'], masterLicenseFileId: '33' } }
@@ -151,7 +163,7 @@ describe('dispatch workflow native layout', () => {
const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...source.dispatchInfo, 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', 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, '计划发车时间').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 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-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') expect(field(dialog, '预计任务结束').get('[aria-label="预计任务结束时区换算"]').text()).toBe('美东2026-11-01 05:00:00美西2026-11-01 02:00:00')
@@ -178,7 +190,8 @@ describe('dispatch workflow native layout', () => {
for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value) 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@example.test ')
await field(dialog, '预约账号').get('input').setValue(' fleet-account ') 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) expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises() dialog.vm.$emit('confirm'); await flushPromises()
@@ -3,9 +3,9 @@ import { expect, it } from 'vitest'
import { createAppointmentForm } from '../appointmentForm' import { createAppointmentForm } from '../appointmentForm'
import FbaAppointmentFields from './FbaAppointmentFields.vue' 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, { 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: { global: { stubs: {
ElForm: { template: '<form><slot /></form>' }, ElForm: { template: '<form><slot /></form>' },
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' }, 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.text()).toContain('计划送仓日期')
expect(wrapper.text()).toContain('补充计划时段') expect(wrapper.findAll('[data-picker]')).toHaveLength(1)
expect(wrapper.findAll('[data-picker]')).toHaveLength(3) expect(wrapper.text()).not.toMatch(/补充计划时段|计划开始时段|计划结束时段|对方确认开始时间|对方确认结束时间|确认开始时差|确认结束时差|夏令时/)
expect(wrapper.text()).not.toMatch(/对方确认开始时间|对方确认结束时间|确认开始时差|确认结束时差|夏令时/) await wrapper.setProps({ dateOnly: false })
await wrapper.setProps({ hideConfirmationTimes: false }) expect(wrapper.text()).toContain('计划开始时段')
expect(wrapper.text()).toContain('计划结束时段')
expect(wrapper.text()).toContain('对方确认开始时间') expect(wrapper.text()).toContain('对方确认开始时间')
expect(wrapper.findAll('[data-picker]')).toHaveLength(5) expect(wrapper.findAll('[data-picker]')).toHaveLength(5)
wrapper.unmount() wrapper.unmount()
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar' import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
import { ref, watchEffect } from 'vue' import { ref, watchEffect } from 'vue'
import type { AppointmentForm } from '../appointmentForm' 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 emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null) const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
watchEffect(() => { watchEffect(() => {
@@ -13,7 +13,7 @@ watchEffect(() => {
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) }) defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
</script> </script>
<template> <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)" /> <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"> <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> <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> <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'"> <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><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> </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> <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> </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> <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> </tbody></table></details></el-form>
</section> </section>
</div> </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> <el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'"> <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 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="!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="!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.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
</template> </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> <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"> <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.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> <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.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item>
<el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item> <el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item>
</template> </template>
@@ -91,7 +91,7 @@ function feePreview(overrides: Partial<InboundFeePreview> = {}): InboundFeePrevi
} }
function savedOuterMeasurement() { 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, items: [{ id: '9001', itemNo: 'B1', packageCount: 22, lengthCm: 62, widthCm: 52, heightCm: 52,
outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53, outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53,
calculatedOuterLengthCm: 63, calculatedOuterWidthCm: 53, calculatedOuterHeightCm: 53, calculatedOuterLengthCm: 63, calculatedOuterWidthCm: 53, calculatedOuterHeightCm: 53,
@@ -266,6 +266,22 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
wrapper.unmount() 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) => { 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' } const fee = { unitPrice: 13.6, chargeableQuantity: 650.298, chargeUnit: 'KG', roundingScale: 4, rateCode: 'RATE', feeStatus: 'CALCULATED' }
vi.mocked(getLatestInboundFee).mockResolvedValue(fee as never) vi.mocked(getLatestInboundFee).mockResolvedValue(fee as never)
@@ -359,7 +375,7 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
await flushPromises() await flushPromises()
const vm = wrapper.vm as unknown as FeeConfirmVm const vm = wrapper.vm as unknown as FeeConfirmVm
vm.measurementDetail = { 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, items: [{ id: '9001', itemNo: 'B1', packageCount: 10, lengthCm: 50, widthCm: 40, heightCm: 30,
outerLengthCm: null, outerWidthCm: null, outerHeightCm: null, outerLengthCm: null, outerWidthCm: null, outerHeightCm: null,
calculatedOuterLengthCm: 54, calculatedOuterWidthCm: 44, calculatedOuterHeightCm: 34, calculatedOuterLengthCm: 54, calculatedOuterWidthCm: 44, calculatedOuterHeightCm: 34,
@@ -1741,6 +1741,10 @@ async function openFeeConfirmDialog() {
ElMessage.warning('当前单据缺少入仓信息,暂不能发送费用') ElMessage.warning('当前单据缺少入仓信息,暂不能发送费用')
return return
} }
if (measurementDetail.value?.measureReviewStatus !== 'APPROVED') {
ElMessage.warning('测量数据尚未审核通过,请先完成测量审核后再发送客户')
return
}
if (!hasActualMeasurementData.value) { if (!hasActualMeasurementData.value) {
ElMessage.warning('暂无实际测量数据,不能确认并发送费用') ElMessage.warning('暂无实际测量数据,不能确认并发送费用')
return return
@@ -1866,6 +1870,10 @@ async function executeConfirmAndSendFee() {
if (confirmFeeSending.value) return if (confirmFeeSending.value) return
const summary = detail.value?.summary const summary = detail.value?.summary
if (!summary?.inboundId) return if (!summary?.inboundId) return
if (measurementDetail.value?.measureReviewStatus !== 'APPROVED') {
ElMessage.warning('测量数据尚未审核通过,请先完成测量审核后再发送客户')
return
}
const selectedWeightKg = Number(feeConfirmWeight.value) const selectedWeightKg = Number(feeConfirmWeight.value)
const selectedVolumeCbm = Number(feeConfirmVolume.value) const selectedVolumeCbm = Number(feeConfirmVolume.value)
const selectedUnitPrice = Number(feeConfirmUnitPrice.value) const selectedUnitPrice = Number(feeConfirmUnitPrice.value)
+1 -1
View File
@@ -1325,8 +1325,8 @@ describe('approved Admin density ownership', () => {
expect(sectionStyles.get('.inbound-density.admin-density-dialog .measurement-section')).toEqual({ expect(sectionStyles.get('.inbound-density.admin-density-dialog .measurement-section')).toEqual({
'padding-block': 'var(--admin-space-4, 10px)', '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 { 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; }') expect(layout).toContain('.measurement-platform-fields > .measurement-expected-pallets { flex: 0 0 180px; }')
for (const style of dialog.styles) { for (const style of dialog.styles) {
expect(style.content).not.toMatch(/\.measurement-(summary-section|metrics|declaration|items-section)\b/) expect(style.content).not.toMatch(/\.measurement-(summary-section|metrics|declaration|items-section)\b/)