Compare commits

...
Author SHA1 Message Date
ouyangxq 61e9cfe196 fix(admin): use ECharts pies and compact trip statistics with supplier menu restored 2026-10-19 17:17:50 +08:00
ouyangxq e2f9023156 merge: retain current TEST admin changes with FBA appointments 2026-10-19 17:13:08 +08:00
liuyihe d2f2ecf57a 优化 2026-10-19 17:07:55 +08:00
ouyangxq 5a46198bf0 feat(appointments): add FBA creation and destination filtering 2026-10-19 17:02:27 +08:00
liuyihe ada663128a 优化 2026-10-19 15:59:33 +08:00
liuyihe 17cb1373e1 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-19 15:59:17 +08:00
liuyihe 29f887496a 优化 2026-10-19 15:59:14 +08:00
ouyangxq b92e5379bc feat(transport): align admin demand form with client and select customer separately 2026-10-19 15:50:39 +08:00
ouyangxq 767c6b7589 feat(looseboard): align platform route contacts and required times 2026-10-19 15:11:15 +08:00
ouyangxq f7f6637c45 feat(trucking): align platform truck contacts vehicle and service times 2026-10-19 14:38:47 +08:00
ouyangxq 05cf82dbbc feat(appointments): restore simplified four-type calendar 2026-10-19 12:20:13 +08:00
liuyihe 8ae1e5d0d4 优化 2026-10-19 10:23:38 +08:00
ouyangxq 4bb21641d9 fix(appointments): resume existing batch dispatch after refresh 2026-10-19 08:44:26 +08:00
ouyangxq 67d917010d fix(appointments): handle synchronization and transport continuation 2026-10-18 22:29:06 +08:00
ouyangxq a5a1976924 fix(dispatch): align dispatch actions and native return workflows 2026-10-18 16:37:37 +08:00
liuyihe 4806f88d7c 优化 2026-10-16 18:25:49 +08:00
liuyihe 2705fdbc49 优化 2026-10-16 17:43:36 +08:00
liuyihe ce7f6cee02 优化 2026-10-16 16:52:43 +08:00
liuyihe fbee53c9de 优化 2026-10-16 16:19:22 +08:00
ouyangxq 45f662ecf2 feat(appointment): complete customer receiving and trip continuation workflows 2026-10-16 15:41:56 +08:00
ouyangxq 148085286d fix(delivery): show complete delivery numbers and clarify pickup routing 2026-10-15 21:40:39 +08:00
ouyangxq f6638ca6c6 fix(delivery): display and filter all pickup fulfillment methods 2026-10-15 21:05:58 +08:00
ouyangxq baee81f459 fix(appointment): improve multiline action button spacing 2026-10-15 19:36:13 +08:00
ouyangxq 9f51392754 fix(appointment): restore overlay panel and compact calendar controls 2026-10-15 19:23:16 +08:00
liuyihe 8dc0d2d3b8 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-15 18:25:24 +08:00
liuyihe 538970797d 优化 2026-10-15 18:25:23 +08:00
140 changed files with 5879 additions and 1210 deletions
+5 -4
View File
@@ -36,11 +36,11 @@ describe('adminNavigation', () => {
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
@@ -196,12 +196,13 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
})
it('retains the supplier route permission while omitting its entry from the latest sidebar', () => {
it('shows the restored supplier entry under general only with its list permission', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
const missing = filterAdminNavigation(adminNavigation, new Set())
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard'])
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
})
+1 -1
View File
@@ -204,7 +204,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
@@ -14,7 +14,7 @@ vi.mock('./workApi', async original => ({ ...await original<typeof import('./wor
let guard: LeaveGuard
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true, CargoVerificationForm: true,
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true, CargoVerificationForm: true,
} } }) }
beforeEach(() => {
vi.clearAllMocks(); vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
@@ -34,6 +34,21 @@ it('submits two conservative allocations without fake confirmation times and ren
expect(wrapper.text()).toContain('AP-NEW-1'); expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('splits only the entered 60-box later batch into 40/20, never reallocating its original 160-box source', async () => {
const context = batchContext('AP-LATER-60', 'FBA_APPOINTMENT:1')
context.appointment.items = [{ ...context.appointment.items[0]!, packageCount: 60 }]
context.sources = [{ ...context.sources[0]!, packageCount: 60, originalPackages: 160 }]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
await wrapper.findAll('textarea')[0]!.setValue('本批40先安排,20待排')
await wrapper.get('form').trigger('submit'); await flushPromises()
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
expect(request.originals).toEqual([{ appointmentNo: 'AP-LATER-60', version: 3 }])
expect(request.batches.map(batch => batch.allocations.map(row => row.packageCount))).toEqual([[40], [20]])
expect(request.batches.flatMap(batch => batch.allocations).every(row => row.customerId === 'customer-a' && row.sourceKey === 'ORDER:1')).toBe(true)
wrapper.unmount()
})
it('locks an unknown outcome and retries the same immutable repartition command', async () => {
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new Error('unknown outcome'))
const wrapper = render(); await flushPromises()
@@ -71,3 +86,98 @@ it('loads a candidate context before merging and preserves both original version
expect(repartitionAppointments).toHaveBeenCalledWith(expect.objectContaining({ originals: [{ appointmentNo: 'AP-001', version: 3 }, { appointmentNo: 'AP-002', version: 6 }], batches: [expect.objectContaining({ allocations: [expect.objectContaining({ customerId: 'customer-a', packageCount: 140 }), expect.objectContaining({ customerId: 'customer-b', packageCount: 60 })] })] }))
wrapper.unmount()
})
it('shows every terminal branch with only the original customer/source/destination quantities and opens the real batch', async () => {
const context = batchContext()
const source = context.appointment.items[0]!
context.appointment.items = [source]
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2', 'FBA_APPOINTMENT:3']
context.appointment.replacementAppointmentNos = ['AP-INTERMEDIATE', 'AP-20']
context.sources = []; context.allowedActions = []
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:4').appointment
forty.appointmentStatus = 'SUCCESS'
forty.items = [
{ ...source, packageCount: 40 },
{ ...source, id: 'foreign-customer', customerId: 'other-customer', inboundCode: 'FOREIGN-CUSTOMER', packageCount: 900 },
{ ...source, id: 'foreign-source', sourceKey: 'ORDER:OTHER', inboundCode: 'FOREIGN-SOURCE', packageCount: 800 },
{ ...source, id: 'foreign-destination', destinationKey: 'FBA:OTHER', inboundCode: 'FOREIGN-DESTINATION', packageCount: 700 },
]
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:3').appointment
twenty.appointmentStatus = 'CANCELLED'; twenty.items = [{ ...source, packageCount: 20 }]
context.currentBatches = [forty, twenty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.findAll('article')).toHaveLength(2)
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
expect(destinations.text()).toContain('已取消,分配保留')
expect(destinations.text()).not.toContain('FOREIGN-'); expect(destinations.text()).not.toContain('AP-INTERMEDIATE')
expect(destinations.text()).not.toContain('FBA_APPOINTMENT:')
await destinations.findAll('button').find(button => button.text() === '查看本批预约')!.trigger('click')
expect(wrapper.emitted('appointment')).toEqual([['AP-40']])
await destinations.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:4')
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
})
it('does not create zero-quantity destinations or convert unknown quantities to zero', async () => {
const context = batchContext()
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
context.sources = []; context.allowedActions = []
const unknown = batchContext('AP-UNKNOWN', 'FBA_APPOINTMENT:2').appointment
unknown.items = [{ ...context.appointment.items[0]!, packageCount: null }]
const empty = batchContext('AP-EMPTY', 'FBA_APPOINTMENT:3').appointment
empty.items = [{ ...context.appointment.items[0]!, packageCount: 0 }]
context.currentBatches = [unknown, empty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.findAll('article')).toHaveLength(1)
expect(destinations.text()).toContain('箱数待核实'); expect(destinations.text()).not.toContain('0 箱')
expect(destinations.text()).not.toContain('AP-EMPTY'); wrapper.unmount()
})
it('does not fall back to historical replacement links when current destinations are empty', async () => {
const context = batchContext()
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
context.appointment.replacementAppointmentNos = ['AP-OLD-REPLACEMENT']
context.sources = []; context.allowedActions = []; context.currentBatches = []
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.findAll('article')).toHaveLength(0)
expect(destinations.text()).toContain('原快照不代表当前待办')
expect(destinations.text()).not.toContain('AP-OLD-REPLACEMENT'); wrapper.unmount()
})
it('keeps every 40/20 destination after the entire customer has left the original current items', async () => {
const context = batchContext(), originalItem = context.appointment.items[0]!
context.appointment.items = [context.appointment.items[1]!]
context.sources = [context.sources[1]!]
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment
forty.items = [{ ...originalItem, packageCount: 40 }, { ...originalItem, id: 'unrelated', sourceKey: 'ORDER:OTHER', inboundCode: 'IN-UNRELATED', packageCount: 999 }]
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
twenty.items = [{ ...originalItem, packageCount: 20 }]
context.currentBatches = [context.appointment, forty, twenty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
expect(destinations.text()).not.toContain('IN-UNRELATED')
expect(wrapper.findAll('[aria-label="来源1分出箱数"]')).toHaveLength(1)
expect((wrapper.get('[aria-label="来源1分出箱数"]').attributes('max'))).toBe('30')
wrapper.unmount()
})
it('retries a new destination without exposing the prior batch or retaining its allocation inputs', async () => {
const original = batchContext()
original.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']; original.sources = []; original.allowedActions = []
const current = batchContext('AP-CURRENT', 'FBA_APPOINTMENT:2')
current.appointment.plannedDate = '2026-10-22'
original.currentBatches = [current.appointment]
vi.mocked(getAppointmentBatch).mockResolvedValueOnce(original).mockRejectedValueOnce(new Error('新批次暂不可用')).mockResolvedValue(current)
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('AP-CURRENT')
expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0')
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2')
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -20,16 +20,28 @@ const baseline = ref(JSON.stringify(form)), verification = ref<CargoVerification
const canLeave = useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
const candidates = ref<WorkPage | null>(null), candidatePage = ref(1), keyword = ref(''), candidateLoading = ref(false), candidateError = ref<ApiErrorDetails | null>(null)
let candidateRequest = 0
let initializedKey: string | null = null
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
const replaced = computed(() => !!context.value?.appointment.replacementBatchKeys?.length || !!context.value?.appointment.replacementAppointmentNos?.length)
const hasCurrentDestinations = computed(() => replaced.value || context.value?.currentBatches.some(batch => batch.batchKey !== context.value?.appointment.batchKey))
const currentDestinations = computed(() => {
if (!context.value) return []
const sourceIdentity = (item: FbaAppointmentBatchContext['appointment']['items'][number]) => JSON.stringify([item.customerId, item.sourceKey, item.destinationKey])
const originalSources = new Set(context.value.originalSourceRefs.map(allocationKey))
return context.value.currentBatches.map(appointment => ({ appointment, items: appointment.items.filter(item => item.destinationKey && originalSources.has(sourceIdentity(item)) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
})
function bookingStatus(status: string) { return ({ PENDING: '待预约', SUCCESS: '已确认预约', FAILED: '未约成', CANCELLED: '已取消,分配保留' } as Record<string, string>)[status] || '预约状态待核实' }
async function openAppointment(number: string) { if (await canLeave()) emit('appointment', number) }
const canSubmit = computed(() => canWrite.value && ready.value && !loading.value && !error.value && !!context.value?.allowedActions.includes(mode.value === 'split' ? 'SPLIT' : 'MERGE') && (mode.value !== 'merge' || !!other.value?.allowedActions.includes('MERGE')))
async function load(preserve = false) {
loading.value = true; ready.value = false; error.value = null
try {
context.value = await getAppointmentBatch(activeKey.value)
if (other.value?.appointment.batchKey) other.value = await getAppointmentBatch(other.value.appointment.batchKey)
if (!preserve) {
if (!preserve || initializedKey !== activeKey.value) {
Object.assign(form, { firstDate: context.value.appointment.plannedDate || '', nextDate: '', reason: '', remark: '', moved: Object.fromEntries(context.value.sources.map(source => [allocationKey(source), 0])) })
baseline.value = JSON.stringify(form)
initializedKey = activeKey.value
}
ready.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '批次资料读取失败') }
@@ -62,7 +74,7 @@ function sourceLabel(source: CargoVerificationSource) {
}
async function verify(source: CargoVerificationSource) { if (await canLeave()) { baseline.value = JSON.stringify(form); verification.value = source } }
async function verified() { verification.value = null; await load(); emit('updated') }
async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; result.value = null; other.value = null; mode.value = 'split'; await load() }
async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; context.value = null; result.value = null; other.value = null; mode.value = 'split'; await load() }
async function save() {
if (!context.value || !canSubmit.value || saving.value) return
if (!pending.value) {
@@ -92,7 +104,20 @@ onMounted(() => load())
<template v-if="result"><p>原批次 {{ result.replacedAppointmentNos.join('、') }} 已保留历史并被替代。</p><div v-for="appointment in result.appointments" :key="appointment.appointmentNo" class="batch-result"><strong>{{ appointment.appointmentNo }}</strong><span>{{ appointment.plannedDate || '日期未定' }} · 待安排</span><el-button @click="emit('appointment', appointment.appointmentNo)">登记安排</el-button><el-button v-if="appointment.batchKey" @click="openBatch(appointment.batchKey)">继续分批</el-button></div></template>
<template v-else-if="context">
<p>{{ context.appointment.fbaWarehouseCode }} · {{ context.appointment.items.length }} 条货物来源;客户与原来源逐票保留。</p>
<template v-if="context.appointment.replacementAppointmentNos?.length"><p>该批次已被替代,当前只读。{{ context.appointment.repartitionReason }}</p><div v-for="(number, index) in context.appointment.replacementAppointmentNos" :key="number"><el-button @click="emit('appointment', number)">{{ number }}</el-button><el-button v-if="context.appointment.replacementBatchKeys?.[index]" @click="openBatch(context.appointment.replacementBatchKeys[index]!)">查看当前批次</el-button></div></template>
<template v-if="hasCurrentDestinations">
<p v-if="replaced">该批次已被替代,原货物快照仅供追溯。{{ context.appointment.repartitionReason }}</p>
<p v-else>本批仍有保留货物,以下同时列出服务端返回的当前后送去向。</p>
<section aria-label="当前货物去向">
<h4>当前货物去向</h4><p>以下仅展示原批次客户、来源及目的仓对应的当前分配;合并批次可能还含其他货物,进入完整批次后可查看。</p>
<article v-for="destination in currentDestinations" :key="destination.appointment.batchKey" class="allocation-row">
<strong>{{ destination.appointment.appointmentNo }}</strong><p>{{ bookingStatus(destination.appointment.appointmentStatus) }} · {{ destination.appointment.plannedDate || '日期未定' }}</p>
<div v-for="item in destination.items" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · {{ item.fbaWarehouseCode }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div>
<el-button :disabled="loading || !!error || saving || !!pending" @click="openAppointment(destination.appointment.appointmentNo)">查看本批预约</el-button>
<el-button v-if="destination.appointment.batchKey" :disabled="loading || !!error || saving || !!pending" @click="openBatch(destination.appointment.batchKey)">查看完整批次</el-button>
</article>
<p v-if="!currentDestinations.length && !loading && !error">当前没有与原货物范围匹配的分配;原快照不代表当前待办。</p>
</section>
</template>
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
@@ -1,120 +1,80 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
import { scheduleTypes, type ScheduleEvent } from './schedule'
describe('appointment calendar interactions', () => {
it('keeps creation beside the title and record actions beside data notices', () => {
const wrapper = mount(AppointmentCalendarPanel, {
props: { canCreate: true, events: [], notices: ['数据范围不完整'] },
slots: { 'toolbar-actions': '<button>未定时安排</button><button>取消记录</button>' },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
})
expect(wrapper.get('.calendar-label').text()).toContain('新增预约')
const actions = wrapper.get('[aria-label="预约记录与数据提示"]')
expect(actions.text()).toContain('未定时安排')
expect(actions.text()).toContain('取消记录')
expect(actions.get('summary').text()).toContain('数据提示')
wrapper.unmount()
const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] })
const render = (events: ScheduleEvent[] = []) => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-10-19', events },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
describe('four-type appointment calendar', () => {
it('shows all four types by default with a calendar and persistent daily detail', () => {
const wrapper = render(scheduleTypes.map(type => event(type)))
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
expect(wrapper.findAll('.calendar-day')).toHaveLength(35)
expect(wrapper.find('[data-date="2026-11-01"]').exists()).toBe(true)
expect(wrapper.find('[data-date="2026-11-02"]').exists()).toBe(false)
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
})
const render = () => mount(AppointmentCalendarPanel, {
props: { initialDate: '2026-09-24', canCreate: true, events: [
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
] },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
})
it('opens a readable appointment without exposing write actions', async () => {
const wrapper = render()
const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false}
await wrapper.setProps({events:[event]})
const actions=wrapper.get('.detail-event footer')
expect(actions.text()).toBe('查看预约')
await actions.get('button').trigger('click')
expect(wrapper.emitted('view')?.[0]).toEqual([event])
expect(wrapper.emitted('edit')).toBeUndefined()
wrapper.unmount()
})
it('hides optional references by default and counts only appointments after enabling them', async () => {
const wrapper = render()
await wrapper.setProps({ events: [
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00', title: '收货预约', fields: [] },
{ key: 'b', type: 'arrival', date: '2026-09-24', time: '08:00', title: '到港参考', fields: [] },
{ key: 'c', type: 'trip', date: '2026-09-24', time: '07:00', title: '运输计划', fields: [] },
] })
it('filters locally without requesting another range', async () => {
const wrapper = render([event('arrival'), event('dispatch')])
await wrapper.get('[aria-label="安排类型"] input').setValue(false)
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
const switches = wrapper.get('[aria-label="参考图层"]').findAll('input')
await switches[0]!.setValue(true); await switches[1]!.setValue(true)
expect(wrapper.findAll('.detail-event')).toHaveLength(3)
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('1 项预约')
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('2 条参考计划')
await wrapper.get('[aria-label="搜索当前日历"]').setValue('missing')
expect(wrapper.text()).toContain('当天暂无安排')
expect(wrapper.emitted('range')).toBeUndefined()
})
it('selects an already loaded date without requesting data again', async () => {
it('offers creation in the header only when authorized', async () => {
const wrapper = render()
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
expect(wrapper.findAll('button').some(button => button.text() === '新增预约')).toBe(false)
await wrapper.setProps({ canCreate: true })
await wrapper.findAll('.calendar-controls button').find(button => button.text() === '新增预约')!.trigger('click')
expect(wrapper.emitted('create')).toEqual([[]])
expect(wrapper.emitted('range')).toBeUndefined()
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约')
wrapper.unmount()
})
it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => {
it('changes the selected date locally and requests one range per mode or period change', async () => {
const wrapper = render()
await wrapper.setProps({ events: [
{ key: 'overnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T01:00:00', time: '23:00–01:00', title: '跨日送仓', fields: [] },
{ key: 'midnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T00:00:00', time: '23:00–00:00', title: '午夜结束', fields: [] },
] })
expect(wrapper.find('[aria-label="2026-09-23,2 项安排"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="2026-09-24,1 项安排"]').exists()).toBe(true)
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('跨日送仓')
expect(wrapper.get('[aria-label="当天安排"]').text()).not.toContain('午夜结束')
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
expect(wrapper.emitted('range')).toBeUndefined()
})
it('requests exactly one range for a view switch and navigates seven days', async () => {
const wrapper = render()
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('2026-10-20')
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]])
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-10-19', to: '2026-10-26' }]])
await wrapper.get('[aria-label="下一周"]').trigger('click')
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }])
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-10-26', to: '2026-11-02' }])
})
it('keeps busy days to three previews in both views and exposes the full count', async () => {
const wrapper = render()
await wrapper.setProps({ events: Array.from({ length: 233 }, (_, index) => ({
key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00',
title: `船舶 ${index}`, fields: [],
})) })
await wrapper.get('[aria-label="参考图层"] input').setValue(true)
for (const view of ['month', 'week']) {
if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]')
expect(day.findAll('.event-chip')).toHaveLength(3)
expect(day.attributes('title')).toContain('共 233 条安排')
expect(day.get('.event-more').text()).toBe('还有 230 条')
expect(wrapper.findAll('.detail-event')).toHaveLength(233)
}
it('limits previews to three while retaining every event in the daily details', () => {
const wrapper = render(Array.from({ length: 23 }, (_, index) => event('arrival', index)))
expect(wrapper.get('[data-date="2026-10-19"]').findAll('.event-chip')).toHaveLength(3)
expect(wrapper.get('.event-more').text()).toBe('还有 20 条')
expect(wrapper.findAll('.detail-event')).toHaveLength(23)
})
it('uses the selected day for creation and emits edit/delete only from detail actions', async () => {
const wrapper = render()
await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click')
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
it('keeps undated records outside date cells and exposes them explicitly', async () => {
const wrapper = render([{ ...event('warehouse'), date: null }])
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
await wrapper.get('.undated-button').trigger('click')
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('日期未定')
})
it('renders many inbound codes outside the two-column fields with only three previews', async () => {
it('retains business links and exposes editing only with authoritative eligibility', async () => {
const row = { ...event('warehouse'), sourcePath: '/container-loading/overseas/1', canEdit: true }
const wrapper = render([row])
await wrapper.get('.detail-event footer').findAll('button')[0]!.trigger('click')
await wrapper.get('.detail-event footer').findAll('button')[1]!.trigger('click')
expect(wrapper.emitted('open')).toEqual([[row]])
expect(wrapper.emitted('edit')).toEqual([[row]])
await wrapper.setProps({ events: [{ ...row, canEdit: false, sourcePath: undefined }] })
expect(wrapper.find('.detail-event footer').exists()).toBe(false)
})
it('distinguishes loading and partial failure from a complete empty day', async () => {
const wrapper = render()
await wrapper.setProps({ events: [{
key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓',
fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }],
inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true,
}] })
const card = wrapper.get('.detail-event')
expect(card.get('dl').text()).not.toContain('WT-INBOUND')
expect(card.findAll('.inbound-codes li')).toHaveLength(3)
expect(card.get('.inbound-codes').text()).toContain('20 个')
expect(card.get('footer').text()).toContain('编辑')
await wrapper.setProps({ loading: true })
expect(wrapper.text()).toContain('正在读取安排')
expect(wrapper.text()).not.toContain('当天暂无安排')
await wrapper.setProps({ loading: false, incomplete: true })
expect(wrapper.text()).toContain('部分数据不可用')
expect(wrapper.text()).not.toContain('当天暂无安排')
})
})
@@ -1,82 +1,41 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, occursOnDate, type CalendarMode } from './calendar'
import { Plus } from '@lucide/vue'
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
export interface CalendarEvent {
key: string
date: string
endAt?: string
time: string
type: 'arrival' | 'delivery' | 'pickup' | 'trip'
title: string
subtitle?: string
fields: Array<{ label: string; value?: string | null }>
inboundCodes?: Array<string | null | undefined>
canEdit?: boolean
canView?: boolean
canDelete?: boolean
sourcePath?: string
}
const props = withDefaults(defineProps<{
notices?: string[]
events: CalendarEvent[]
loading?: boolean
incomplete?: boolean
canCreate?: boolean
initialDate?: string
}>(), { notices: () => [], loading: false, incomplete: false, canCreate: false, initialDate: '' })
const emit = defineEmits<{
layers: [layers: { arrivals: boolean; trips: boolean }]
range: [range: { from: string; to: string }]
create: [date: string]
edit: [event: CalendarEvent]
view: [event: CalendarEvent]
delete: [event: CalendarEvent]
open: [event: CalendarEvent]
}>()
const showArrivals = ref(false), showTrips = ref(false)
function updateLayers() { emit('layers', { arrivals: showArrivals.value, trips: showTrips.value }) }
const visibleEvents = computed(() => props.events.filter(event => (event.type !== 'arrival' || showArrivals.value) && (event.type !== 'trip' || showTrips.value)))
const selectedDate = ref(props.initialDate || dateKey(new Date()))
const mode = ref<CalendarMode>('month')
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode; canCreate?: boolean }>(),
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month', canCreate: false })
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent]; create: [] }>()
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
const visibleEvents = computed(() => props.events.filter(event => types.value.includes(event.type) &&
`${event.title} ${event.fields.map(field => field.value || '').join(' ')}`.toLowerCase().includes(keyword.value.trim().toLowerCase())))
const grouped = computed(() => {
const result = new Map<string, CalendarEvent[]>()
const result = new Map<string, ScheduleEvent[]>()
for (const event of visibleEvents.value) {
for (const date of dates.value) {
if (!occursOnDate(event.date, event.endAt, date)) continue
const group = result.get(date) || []
group.push(event)
result.set(date, group)
}
const date = event.date || 'undated'
const rows = result.get(date) || []; rows.push(event); result.set(date, rows)
}
for (const group of result.values()) group.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
for (const rows of result.values()) rows.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
return result
})
const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || [])
const appointmentCount = computed(() => selectedEvents.value.filter(event => event.type === 'delivery' || event.type === 'pickup').length)
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' }
const heading = computed(() => mode.value === 'month'
? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月`
: `${dates.value[0]} — ${addDays(calendarRange(selectedDate.value, mode.value).to, -1)}`)
const selectedEvents = computed(() => grouped.value.get(undated.value ? 'undated' : selectedDate.value) || [])
const heading = computed(() => mode.value === 'month' ? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月` : `${dates.value[0]} — ${dates.value.at(-1)}`)
function select(date: string) {
const before = calendarRange(selectedDate.value, mode.value)
selectedDate.value = date
selectedDate.value = date; undated.value = false
const after = calendarRange(date, mode.value)
emit('select', date, mode.value)
if (before.from !== after.from || before.to !== after.to) emit('range', after)
}
function changeMode(next: CalendarMode) {
if (mode.value === next) return
mode.value = next
emit('range', calendarRange(selectedDate.value, next))
mode.value = next; emit('select', selectedDate.value, next); emit('range', calendarRange(selectedDate.value, next))
}
defineExpose({ selectedDate, mode })
</script>
<template>
<section class="appointment-calendar admin-density" :aria-busy="loading">
<div class="calendar-main">
@@ -84,126 +43,76 @@ defineExpose({ selectedDate, mode })
<div class="calendar-label">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<strong>预约日历</strong>
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
</div>
<div class="calendar-controls">
<div class="view-toggle" role="group" aria-label="预约视图">
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
<button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button>
</div>
<el-button v-if="canCreate" type="primary" @click="emit('create')"><Plus :size="16" aria-hidden="true" />新增预约</el-button>
<div class="view-toggle" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
<strong class="calendar-period">{{ heading }}</strong>
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
<el-button @click="select(dateKey(new Date()))">今天</el-button>
</div>
<div class="calendar-record-actions" role="group" aria-label="预约记录与数据提示">
<slot name="toolbar-actions" />
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
</div>
</header>
<div class="calendar-options">
<div class="calendar-legend"><span v-if="showArrivals" class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span v-if="showTrips" class="legend trip">用车计划</span></div>
<div class="calendar-layers" role="group" aria-label="参考图层"><label><input v-model="showArrivals" type="checkbox" @change="updateLayers">显示到港参考</label><label><input v-model="showTrips" type="checkbox" @change="updateLayers">显示用车计划</label></div>
<div class="calendar-layers" role="group" aria-label="安排类型"><label v-for="type in scheduleTypes" :key="type" class="legend" :class="type"><input v-model="types" type="checkbox" :value="type">{{ scheduleLabels[type] }}</label></div>
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input v-model="keyword" type="search" maxlength="128" placeholder="柜号、车次号、仓库" aria-label="搜索当前日历"></label>
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
</div>
<div class="weekdays"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
<div v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
<button v-for="date in dates" :key="date" type="button" class="calendar-day"
:class="{ selected: selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
:title="`${date},${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-pressed="selectedDate === date" @click="select(date)">
<span class="day-number">{{ mode === 'week' ? `${Number(date.slice(5, 7))}月` : '' }}{{ Number(date.slice(8)) }}</span>
<span class="day-events">
<span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="`${event.time} ${event.title} · 当天${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`">
{{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small>
</span>
<span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span>
</span>
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
<div class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
<button v-for="date in dates" :key="date" type="button" class="calendar-day" :data-date="date"
:class="{ selected: !undated && selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
:aria-label="`${date},${loading ? '加载中' : `${(grouped.get(date) || []).length} 项安排`}`" :aria-pressed="!undated && selectedDate === date" @click="select(date)">
<span class="day-number">{{ Number(date.slice(8)) }}</span>
<span v-if="!loading" class="day-events"><span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="event.title">{{ event.title }}</span><span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span></span>
</button>
</div>
</div>
<aside class="calendar-detail" aria-label="当天安排">
<header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ appointmentCount }}</b> 项预约<span v-if="selectedEvents.length > appointmentCount">,{{ selectedEvents.length - appointmentCount }} 条参考计划</span><span v-if="incomplete">(当前范围不完整)</span></p></header>
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
<div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</p>
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
<div class="event-heading"><span class="event-type" :class="event.type">{{ eventTypeLabels[event.type] }}</span><span>{{ event.time || '—' }}</span></div>
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
<dl><div v-for="field in event.fields" :key="field.label" :class="{ 'wide-field': ['系统单号', '完整时段', '预约备注'].includes(field.label) }"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
<footer v-if="event.canView || event.canEdit || event.canDelete || event.sourcePath" class="density-actions">
<el-button v-if="event.canView" class="density-action" :disabled="loading" @click="emit('view', event)">查看预约</el-button>
<el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button>
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button>
</footer>
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
<footer v-if="event.sourcePath || event.canEdit"><el-button v-if="event.sourcePath" link type="primary" :disabled="loading" @click="emit('open', event)">{{ event.type === 'dispatch' ? '查看排车' : '查看货柜' }}</el-button><el-button v-if="event.canEdit" link type="primary" :disabled="loading" @click="emit('edit', event)">调整计划到仓</el-button></footer>
</article>
</div>
</aside>
</section>
</template>
<style scoped>
.calendar-data-notices { position: relative; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.calendar-data-notices summary { cursor: pointer; padding: var(--admin-space-2) var(--admin-space-3); }
.calendar-notice-content { position: absolute; top: 100%; right: 0; z-index: 5; width: 320px; padding: var(--admin-space-5); border: 1px solid var(--admin-table-border); border-radius: var(--admin-radius); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); }
.calendar-notice-content p { margin: 0; line-height: 1.6; }
.calendar-notice-content p + p { margin-top: var(--admin-space-3); }
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: #344054; }
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: var(--el-text-color-regular); }
.calendar-main, .calendar-detail { background: var(--admin-surface); border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
.calendar-record-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.calendar-record-actions :deep(.el-button + .el-button) { margin-left: 0; }
.calendar-label > svg { color: #617ce9; }
.calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); }
.calendar-legend, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.calendar-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-1); cursor: pointer; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.calendar-layers input { margin: 0; accent-color: var(--el-color-primary); }
.legend { font-size: 12px; white-space: nowrap; }
.legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; }
.legend.delivery::before { background: #27a87a; }
.legend.pickup::before { background: #c17a20; }
.legend.trip::before { background: #8562b0; }
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
.calendar-label > svg { color: #617ce9; }.calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-options, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.legend { display: inline-flex; align-items: center; gap: var(--admin-space-1); font-size: var(--admin-font-caption); cursor: pointer; color: var(--event-fg); }.legend input { accent-color: var(--event-fg); }
.calendar-search { margin-left: auto; }.calendar-search input { box-sizing: border-box; width: 190px; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); font: inherit; }
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
.view-toggle button, .nav-button { border: 0; border-radius: 5px; background: transparent; cursor: pointer; color: #64748b; min-width: 30px; height: 30px; }
.view-toggle button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 700; }
.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
.calendar-period { font-size: 14px; white-space: nowrap; }
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3) var(--admin-space-5); font-size: var(--admin-font-caption); }
.view-toggle button, .nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
.view-toggle button[aria-pressed='true'], .undated-button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 600; }.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
.calendar-period { font-size: var(--admin-font-section); white-space: nowrap; }
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 112px; min-width: 0; padding: var(--admin-space-2); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }
.calendar-day:hover { border-color: #bac8ed; background: #f8faff; }
.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
.calendar-day:focus-visible, .nav-button:focus-visible, .view-toggle button:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
.day-number { display: block; font-size: 12px; font-weight: 600; }
.day-events { display: flex; flex-direction: column; gap: 2px; margin-top: var(--admin-space-1); }
.event-chip { display: block; padding: 1px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }
.delivery { --event-bg: #e8f8f1; --event-fg: #18845f; }
.pickup { --event-bg: #fff4e3; --event-fg: #995a10; }
.trip { --event-bg: #f2edfa; --event-fg: #725199; }
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }
.event-chip small { display: block; margin-top: 2px; font-size: 11px; opacity: .8; }
.event-more { padding: 1px 6px; line-height: 16px; }
.event-more, .empty-day { color: #667085; font-size: 12px; }
.week-grid .calendar-day { height: 220px; }
.calendar-detail { align-self: start; }
.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-detail > header > strong { font-size: var(--admin-font-title); }
.calendar-detail header p { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-caption); color: #8491a5; }
.calendar-detail header b { color: #617ce9; font-size: var(--admin-font-title); }
.detail-events { padding: var(--admin-space-5); max-height: 660px; overflow-y: auto; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; }
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }.calendar-day:hover { border-color: #bac8ed; }.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
button:focus-visible, input:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
.day-number { font-size: var(--admin-font-caption); font-weight: 600; }.day-events { display: flex; flex-direction: column; gap: var(--admin-space-1); margin-top: var(--admin-space-2); }
.event-chip { display: block; padding: 2px 6px; line-height: 18px; border-radius: var(--admin-radius); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--admin-font-caption); }
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }.warehouse { --event-bg: #e8f8f1; --event-fg: #18845f; }.return { --event-bg: #fff4e3; --event-fg: #995a10; }.dispatch { --event-bg: #f2edfa; --event-fg: #725199; }
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }.event-more, .empty-day { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: #617ce9; }
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; }
.event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; }
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.event-subtitle { color: #667085; font-size: 13px; overflow-wrap: anywhere; }
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }
.detail-event .wide-field { grid-column: 1 / -1; }
.detail-event dt { font-size: 12px; color: #8491a5; }
.detail-event dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; }
.detail-event footer { display: flex; justify-content: flex-end; border-top: 1px solid #edf0f6; padding-top: 10px; }
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); font-size: var(--admin-font-caption); }.event-type { padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); }
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }.event-subtitle { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }.detail-event dt { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.detail-event dd { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-body); overflow-wrap: anywhere; }
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid #edf0f6; padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (max-width: 1100px) { .appointment-calendar { grid-template-columns: minmax(0, 1fr); } }
</style>
@@ -1,126 +1,143 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
import { listArrivalCalendar } from './api'
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true }))
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
import { listTripCalendar } from './tripCalendar'
import type { ScheduleEvent } from './schedule'
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
function render() {
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
export function renderSchedule() {
return mount(AppointmentCalendarView, { global: { stubs: {
AdminPage: { template: '<main><slot /></main>' },
ElButton: { template: '<button><slot /></button>' },
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference'], template: '<div data-calendar />' },
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], template: '<div data-detail>{{ itemKey }}</div>' },
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
ApiErrorMeta: true,
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '<section data-create-panel />' },
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
} } })
}
describe('appointment work calendar', () => {
beforeEach(() => {
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
vi.mocked(listArrivalCalendar).mockResolvedValue([])
})
it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('预约工作日历')
expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal))
expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled()
beforeEach(() => {
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
})
describe('simplified appointment entry', () => {
it('opens creation lazily with the original read and write permissions and refreshes after creation', async () => {
const wrapper = renderSchedule(); await flushPromises()
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('canCreate')).toBe(true)
calendar.vm.$emit('create'); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCreatePanel' })
expect(panel.props('canWrite')).toBe(true)
vi.mocked(getCalendarSources).mockClear()
panel.vm.$emit('created'); await flushPromises()
expect(getCalendarSources).toHaveBeenCalledTimes(1)
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
wrapper.unmount()
})
it('sends an explicit safe return context with external business navigation', async () => {
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' }
const wrapper = render(); await flushPromises()
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises()
expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) })
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
it.each(['platform:overseas-warehouse:inbound-order:fba-appointment', 'platform:overseas-warehouse:last-mile-order:list'])('does not open creation without %s', async permission => {
routing.denied.add(permission)
const wrapper = renderSchedule(); await flushPromises()
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('canCreate')).toBe(false)
calendar.vm.$emit('create'); await flushPromises()
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
wrapper.unmount()
})
it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
calendar.vm.$emit('anchor', 1); await flushPromises()
calendar.vm.$emit('flow', 'DIRECT'); await flushPromises()
const calls = vi.mocked(getWorkCalendar).mock.calls.length
calendar.vm.$emit('types', []); await flushPromises()
expect(getWorkCalendar).toHaveBeenCalledTimes(calls)
expect(getWorkSummary).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型')
it('keeps unsaved input when the operator declines to leave', async () => {
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input').setValue('2026-10-22T09:30')
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalled()
expect(wrapper.find('[data-dialog]').exists()).toBe(true)
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
wrapper.unmount()
})
it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => {
const wrapper = render(); await flushPromises()
wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal))
await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises()
expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED')
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal))
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
const wrapper = renderSchedule(); await flushPromises()
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-02' }, ['1'], expect.any(AbortSignal))
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
wrapper.unmount()
})
it('shows a failed source as an error, not zero work or all complete', async () => {
vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable'))
vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable'))
vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable'))
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败')
expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true)
expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项')
it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => {
vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline'))
const wrapper = renderSchedule(); await flushPromises()
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival'])
expect(wrapper.text()).toContain('计划到仓读取失败')
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true)
wrapper.unmount()
})
it('ignores a late response after the global scope changes', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof getWorkItems>>) => void
vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="OVERDUE"]').trigger('click')
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises()
expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001')
it('does not request unreadable domains and describes the incomplete scope', async () => {
routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
const wrapper = renderSchedule(); await flushPromises()
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少车次读取权限')
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
wrapper.unmount()
})
it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction)
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises()
expect(wrapper.text()).toContain('返回原事项')
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
it('ignores stale calendar responses when the date range changes', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof getCalendarSources>>) => void
vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises()
resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises()
expect(wrapper.text()).not.toContain('缺少还柜读取权限')
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
wrapper.unmount()
})
it('goes directly to the current container unfinished list after route confirmation', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises()
expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false)
expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal))
expect(getWorkSummary).toHaveBeenCalledTimes(2)
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('提交结果尚未确认')
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
expect(await guard()).toBe(false)
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
wrapper.unmount()
})
it('opens explicit source verification without forging an appointment or guessing a customer', async () => {
routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
const wrapper = render(); await flushPromises()
expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' })
it('requires an explicit reread after a conflict and retains the entered date', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String))
wrapper.unmount()
})
it('keeps the selected date and view in the source return link', async () => {
const wrapper = renderSchedule(); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
panel.vm.$emit('select', '2026-10-21', 'week')
panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
routing.canReadSource = false
const denied = render(); await flushPromises()
expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false)
expect(denied.text()).toContain('没有货源读取权限'); denied.unmount()
})
})
@@ -1,213 +1,200 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { isAxiosError } from 'axios'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { addDays, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
import { listArrivalCalendar, type ArrivalCalendarEntry } from './api'
import { getWorkCalendar, getWorkItems, getWorkSummary, workTypes, type WorkAction, type WorkCalendar, type WorkCommand, type WorkCommandResult, type WorkBucket, type WorkFlow, type WorkItemsQuery, type WorkPage, type WorkSummary, type WorkType, type WorkView } from './workApi'
import { flowLabels, typeLabels, viewLabels, workDate, workStatus } from './workPresentation'
import type { LeaveGuard } from './useWorkDraft'
import WorkCalendarPanel from './WorkCalendarPanel.vue'
import WorkDetailPanel from './WorkDetailPanel.vue'
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
import { calendarRange, dateKey, type CalendarMode } from './calendar'
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
import type { PlanningSourceLookup } from './planningApi'
import type { WorkAction } from './workApi'
import { appointmentExternalTarget } from './workNavigation'
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
const AppointmentCreatePanel = defineAsyncComponent(() => import('./AppointmentCreatePanel.vue'))
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const stringQuery = (key: string) => typeof route.query[key] === 'string' ? route.query[key] as string : ''
const savedAnchor = stringQuery('workAnchor')
function initialAnchor() { try { calendarRange(savedAnchor, 'month'); return savedAnchor } catch { return dateKey(new Date()) } }
const anchor = ref(initialAnchor())
const mode = ref<CalendarMode>(stringQuery('workMode') === 'week' ? 'week' : 'month')
const flow = ref<WorkFlow | ''>(['TRANSFER', 'DIRECT', 'UNKNOWN'].includes(stringQuery('workFlow')) ? stringQuery('workFlow') as WorkFlow : '')
const types = ref<WorkType[]>(route.query.workTypes !== undefined ? workTypes.filter(type => stringQuery('workTypes').split(',').includes(type)) : [...workTypes])
const reference = ref(false), arrivals = ref<ArrivalCalendarEntry[]>([]), referenceReady = ref(false)
const calendar = ref<WorkCalendar | null>(null), summary = ref<WorkSummary | null>(null), items = ref<WorkPage | null>(null)
const calendarLoading = ref(false), summaryLoading = ref(false), itemsLoading = ref(false), referenceLoading = ref(false)
const calendarError = ref<ApiErrorDetails | null>(null), summaryError = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), referenceError = ref<ApiErrorDetails | null>(null)
type Scope = { kind: 'global'; view: WorkView } | { kind: 'day'; date: string; types: WorkType[]; flow: WorkFlow | ''; bucket?: WorkBucket } | { kind: 'container'; containerBookingId: string; label: string } | { kind: 'search'; keyword: string } | { kind: 'arrivals'; date: string }
const scope = ref<Scope | null>(null), page = ref(1), detailKey = ref<string | null>(null), initialAction = ref<WorkAction | null>(null)
const origins = ref<string[]>([]), search = ref(''), sourceLink = ref<PlanningSourceLookup | null>(null), linkError = ref('')
const sourceLinkKey = computed(() => sourceLink.value ? `${sourceLink.value.customerId}:${'sourceKey' in sourceLink.value ? sourceLink.value.sourceKey : sourceLink.value.orderId}:${sourceLink.value.destinationKey || ''}` : '')
const navigationError = ref('')
const summaryButtons: Array<{ view: WorkView; field: keyof WorkSummary }> = [{ view: 'ALL', field: 'all' }, { view: 'FOLLOW_TODAY', field: 'followToday' }, { view: 'OVERDUE', field: 'overdue' }, { view: 'UNDATED', field: 'undated' }, { view: 'REVIEW', field: 'review' }]
const buckets: Array<{ key?: WorkBucket; label: string }> = [{ label: '全部' }, { key: 'PENDING', label: '待安排' }, { key: 'CONFIRMED', label: '已落实' }, { key: 'NO_BOOKING_REQUIRED', label: '无需预约' }, { key: 'REVIEW', label: '需复核' }, { key: 'COMPLETED', label: '已完成' }]
const range = computed(() => calendarRange(anchor.value, mode.value))
const heading = computed(() => {
const value = scope.value
if (!value) return ''
if (value.kind === 'global') return viewLabels[value.view]
if (value.kind === 'day') return `${value.date} · ${value.types.length === 1 ? typeLabels[value.types[0]!] : '当天工作'}`
if (value.kind === 'container') return `${value.label} · 本柜待办`
if (value.kind === 'arrivals') return `${value.date} · 到港参考`
return `查找:${value.keyword}`
})
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => { guards.delete(guard) } })
const canLeave: LeaveGuard = async () => { for (const guard of guards) if (!await guard()) return false; return true }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate((to, from) => to.query.sourceKey !== from.query.sourceKey || to.query.orderId !== from.query.orderId || to.query.customerId !== from.query.customerId || to.query.destinationKey !== from.query.destinationKey || to.query.workItem !== from.query.workItem ? canLeave() : true)
let calendarRequest = 0, summaryRequest = 0, itemsRequest = 0, referenceRequest = 0
let calendarController: AbortController | null = null, summaryController: AbortController | null = null, itemsController: AbortController | null = null
async function loadCalendar() {
const request = ++calendarRequest; calendarController?.abort(); calendarController = new AbortController(); calendarError.value = null; calendar.value = null
if (!types.value.length) { calendarLoading.value = false; return }
calendarLoading.value = true
const creating = ref(false)
const canCreate = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && access.can('platform:overseas-warehouse:last-mile-order:list'))
function initialDate() {
const value = typeof route.query.date === 'string' ? route.query.date : ''
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
}
const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mode === 'week' ? 'week' : 'month')
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
let generation = 0, controller: AbortController | null = null
async function load(next = range.value) {
range.value = next
const request = ++generation; controller?.abort(); controller = new AbortController()
const signal = controller.signal
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
await Promise.allSettled([
(async () => {
let result: CalendarSources | null = null
try {
result = await getCalendarSources(next, signal)
if (request !== generation) return
sources.value = result
if (!result.emptyReturnsReadable) notices.value.push('缺少还柜读取权限,当前未显示还柜安排。')
} catch (cause) { fail('到港与还柜读取失败', cause) }
if (request !== generation) return
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) {
notices.value.push('缺少尾程订单读取权限,当前未显示计划到仓。'); return
}
try {
const rows = await getWarehouseArrivals(next, [...new Set(result?.containers.map(row => row.containerBookingId) || [])], signal)
if (request === generation) warehouses.value = rows
} catch (cause) { fail('计划到仓读取失败', cause) }
})(),
(async () => {
if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return }
try {
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
if (request !== generation) return
trips.value = result.events
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 个车次缺少起运仓或时区资料,请到车辆管理核对。`)
} catch (cause) { fail('排车读取失败', cause) }
})(),
])
if (request === generation) loading.value = false
}
function select(date: string, nextMode: CalendarMode) { selectedDate.value = date; mode.value = nextMode }
async function openSource(event: ScheduleEvent) {
if (!event.sourcePath || !await canLeave()) return
const context = { date: selectedDate.value, mode: mode.value }
await router.replace({ query: context })
await router.push(appointmentExternalTarget(event.sourcePath, context))
}
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
function edit(event: ScheduleEvent) {
if (!event.canEdit || !event.warehouse) return
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
}
async function save() {
const row = editor.value?.warehouse
if (!row || saving.value || conflict.value || !plannedAt.value) return
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
key: `calendar-arrival-${crypto.randomUUID()}` }
saving.value = true; saveError.value = null
try {
const result = await getWorkCalendar({ from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }, calendarController.signal)
if (request === calendarRequest) calendar.value = result
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
finally { if (request === calendarRequest) calendarLoading.value = false }
await saveWarehouseArrival(pending.id, pending.body, pending.key)
pending = null; unknownResult.value = false; editor.value = null
ElMessage.success('计划到仓已保存')
await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
unknownResult.value = !rejectedCommand(cause)
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
if (!unknownResult.value) pending = null
} finally { saving.value = false }
}
async function loadSummary() {
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
try { const result = await getWorkSummary(summaryController.signal); if (request === summaryRequest) summary.value = result }
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
finally { if (request === summaryRequest) summaryLoading.value = false }
async function rereadEditor() {
const row = editor.value?.warehouse
if (!row) return
saving.value = true
try {
const rows = await getWarehouseArrivals(range.value, [row.sourceContainerBookingId])
const latest = rows.find(value => value.sourceContainerBookingId === row.sourceContainerBookingId)
if (!latest?.canEdit) { saveError.value = { message: '该柜已不能调整计划到仓,请关闭并查看最新货柜记录。' }; return }
editor.value = { ...editor.value!, warehouse: latest }; conflict.value = false; saveError.value = null
} catch (cause) { saveError.value = getApiErrorDetails(cause, '最新计划读取失败') }
finally { saving.value = false }
}
function itemQuery(): WorkItemsQuery | null {
const value = scope.value
if (!value || value.kind === 'arrivals') return null
const query: WorkItemsQuery = { view: value.kind === 'global' ? value.view : 'ALL', page: page.value, size: 8 }
if (value.kind === 'day') Object.assign(query, { date: value.date, types: value.types, flow: value.flow || undefined, bucket: value.bucket })
if (value.kind === 'container') query.containerBookingId = value.containerBookingId
if (value.kind === 'search') query.keyword = value.keyword
return query
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() {
if (saving.value || unknownResult.value) return false
for (const guard of guards) if (!await guard()) return false
if (editor.value && plannedAt.value !== originalAt.value) {
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm' }) }
catch { return false }
}
return true
}
async function loadItems() {
const request = ++itemsRequest; itemsController?.abort(); itemsController = new AbortController(); itemsError.value = null; items.value = null
const query = itemQuery(); if (!query) { itemsLoading.value = false; return }
itemsLoading.value = true
try { const result = await getWorkItems(query, itemsController.signal); if (request === itemsRequest) items.value = result }
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
finally { if (request === itemsRequest) itemsLoading.value = false }
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
async function openCreate() { if (canCreate.value && await canLeave()) creating.value = true }
async function closeCreate(done: () => void) { if (await canLeave()) { done(); creating.value = false } }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
// 仅承接原业务仍在使用的精确来源/事项深链,不恢复旧全局待办、分页和筛选协议。
const workKey = ref(''), origins = ref<string[]>([]), sourceLink = ref<PlanningSourceLookup | null>(null), initialAction = ref<WorkAction | null>(null)
watch(() => [route.query.workItem, route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
workKey.value = typeof route.query.workItem === 'string' ? route.query.workItem : ''
const customerId = typeof route.query.customerId === 'string' ? route.query.customerId : ''
const destinationKey = typeof route.query.destinationKey === 'string' ? route.query.destinationKey : undefined
sourceLink.value = customerId && typeof route.query.sourceKey === 'string' ? { customerId, sourceKey: route.query.sourceKey, destinationKey }
: customerId && typeof route.query.orderId === 'string' ? { customerId, orderId: route.query.orderId, destinationKey } : null
origins.value = []
initialAction.value = null
}, { immediate: true })
async function openWork(key: string, action?: WorkAction) {
if (await canLeave()) { if (workKey.value) origins.value.push(workKey.value); workKey.value = key; initialAction.value = action || null }
}
async function loadArrivals() {
const request = ++referenceRequest; referenceError.value = null; referenceReady.value = false; arrivals.value = []
if (!reference.value) { referenceLoading.value = false; return }
if (!access.can('platform:firstleg:port-schedule:list')) { referenceError.value = { message: '当前账号没有到港参考读取权限,工作事项仍可正常查看。' }; return }
referenceLoading.value = true
try { const result = await listArrivalCalendar({ ...range.value }); if (request === referenceRequest) { arrivals.value = result; referenceReady.value = true } }
catch (cause) { if (request === referenceRequest) referenceError.value = getApiErrorDetails(cause, '到港参考读取失败') }
finally { if (request === referenceRequest) referenceLoading.value = false }
}
async function setScope(value: Scope) {
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
async function closeBusiness(done: () => void) {
if (!await canLeave()) return
scope.value = value; detailKey.value = null; initialAction.value = null; origins.value = []; sourceLink.value = null; page.value = 1; await loadItems()
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
}
async function openItem(key: string, action?: WorkAction) {
if (!await canLeave()) return
navigationError.value = ''
if (action && origins.value.includes(key)) { navigationError.value = '前置事项关系需要核对,请返回原事项并刷新后重试。'; return }
if (detailKey.value && detailKey.value !== key) origins.value.push(detailKey.value)
detailKey.value = key; initialAction.value = action || null; sourceLink.value = null
}
async function back() {
if (!await canLeave()) return
const origin = origins.value.pop()
detailKey.value = origin || null; initialAction.value = null; sourceLink.value = null
if (!origin && !scope.value) scope.value = { kind: 'global', view: 'ALL' }
if (!origin) await loadItems()
}
async function closePanel() {
if (!await canLeave()) return
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
}
async function filterCalendar(change: () => void) {
if (!await canLeave()) return
change()
if (scope.value?.kind === 'day' || scope.value?.kind === 'arrivals') { scope.value = null; detailKey.value = null; initialAction.value = null; origins.value = [] }
await Promise.all([loadCalendar(), loadArrivals()])
}
async function toggleReference(value: boolean) { reference.value = value; await loadArrivals() }
async function searchItems() { if (search.value.trim()) await setScope({ kind: 'search', keyword: search.value.trim() }) }
async function changePage(next: number) { page.value = next; await loadItems() }
async function setBucket(bucket?: WorkBucket) { if (scope.value?.kind === 'day') { scope.value = { ...scope.value, bucket }; page.value = 1; await loadItems() } }
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
if (command === 'CONFIRM_ROUTE' && result?.item.containers[0]) {
const container = result.item.containers[0]
scope.value = { kind: 'container', containerBookingId: container.containerBookingId, label: container.containerNo || container.bookingNo || '本柜' }; page.value = 1; detailKey.value = null; origins.value = []; initialAction.value = null
} else { detailKey.value = origins.value.pop() || null; initialAction.value = null; sourceLink.value = null }
await Promise.all([loadCalendar(), loadSummary(), loadItems()])
}
async function refreshWork() { await Promise.all([loadCalendar(), loadSummary(), loadItems()]) }
async function external(path: string) {
if (!await canLeave()) return
const value = scope.value
const context = { workAnchor: anchor.value, workMode: mode.value, workFlow: flow.value || undefined, workTypes: types.value.join(','), workItem: detailKey.value || undefined, workOrigins: origins.value.length ? JSON.stringify(origins.value) : undefined, workScope: value?.kind, workView: value?.kind === 'global' ? value.view : undefined, workDate: value?.kind === 'day' ? value.date : undefined, workDayTypes: value?.kind === 'day' ? value.types.join(',') : undefined, workBucket: value?.kind === 'day' ? value.bucket : undefined, workContainer: value?.kind === 'container' ? value.containerBookingId : undefined, workContainerLabel: value?.kind === 'container' ? value.label : undefined, workKeyword: value?.kind === 'search' ? value.keyword : undefined, workPage: String(page.value) }
await router.replace({ query: context })
await router.push(appointmentExternalTarget(path, context))
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
}
onMounted(() => {
const scopeKind = stringQuery('workScope'), restoredView = stringQuery('workView')
if (scopeKind === 'container' && stringQuery('workContainer')) scope.value = { kind: 'container', containerBookingId: stringQuery('workContainer'), label: stringQuery('workContainerLabel') || '本柜' }
else if (scopeKind === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(stringQuery('workDate'))) scope.value = { kind: 'day', date: stringQuery('workDate'), flow: flow.value, types: workTypes.filter(type => stringQuery('workDayTypes').split(',').includes(type)), bucket: buckets.find(bucket => bucket.key === stringQuery('workBucket'))?.key }
else if (scopeKind === 'search') scope.value = { kind: 'search', keyword: stringQuery('workKeyword') }
else if (scopeKind === 'global') scope.value = { kind: 'global', view: summaryButtons.some(button => button.view === restoredView) ? restoredView as WorkView : 'ALL' }
const restoredPage = Number(stringQuery('workPage')); page.value = Number.isInteger(restoredPage) && restoredPage > 0 ? restoredPage : 1
detailKey.value = stringQuery('workItem') || null
try { const savedOrigins: unknown = JSON.parse(stringQuery('workOrigins') || '[]'); if (Array.isArray(savedOrigins) && savedOrigins.every(value => typeof value === 'string' && value.length < 256)) origins.value = savedOrigins.slice(-10) } catch { origins.value = [] }
void loadCalendar(); void loadSummary(); if (scope.value) void loadItems()
})
watch(() => [route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
sourceLink.value = null; linkError.value = ''
const sourceKey = stringQuery('sourceKey'), orderId = stringQuery('orderId'), customerId = stringQuery('customerId'), destinationKey = stringQuery('destinationKey')
if (sourceKey || orderId || customerId || destinationKey) {
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) linkError.value = '当前账号没有货源读取权限,请联系预约负责人处理。'
else if (sourceKey && orderId) linkError.value = '货物链接同时包含两种来源,请从原业务重新打开。'
else if (sourceKey && customerId) sourceLink.value = { sourceKey, customerId, destinationKey: destinationKey || undefined }
else if (orderId && customerId) sourceLink.value = { orderId, customerId, destinationKey: destinationKey || undefined }
else linkError.value = '货物链接缺少客户或原来源,请从原业务重新打开。'
}
}, { immediate: true })
onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++referenceRequest; calendarController?.abort(); summaryController?.abort(); itemsController?.abort() })
onMounted(() => { void load() })
onActivated(() => { const returning = !active.value; active.value = true; if (returning) void load() })
onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
onBeforeUnmount(() => { ++generation; controller?.abort() })
</script>
<template>
<AdminPage class="admin-density appointment-work" spacing="compact">
<header class="work-heading"><h2 class="density-detail-title">预约工作日历</h2><span>已开船事项 · 中转与直送统一查看</span></header>
<nav class="work-inbox" aria-label="全局待办">
<button v-for="button in summaryButtons" :key="button.view" type="button" :data-view="button.view" :aria-pressed="scope?.kind === 'global' && scope.view === button.view" @click="setScope({ kind: 'global', view: button.view })">{{ viewLabels[button.view] }} <strong>{{ summaryLoading ? '…' : summaryError ? '加载失败' : summary?.[button.field] ?? '—' }}</strong></button><span>不受日历筛选影响 · 数量可重叠</span>
</nav>
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
<form class="work-search" role="search" @submit.prevent="searchItems"><label class="sr-only" for="appointment-work-search">查找工作事项</label><input id="appointment-work-search" v-model="search" type="search" maxlength="80" placeholder="柜号、送仓批次、车次、目的仓"><button type="submit">查找事项</button><span>跨日期查找未完成事项 · 含未定日期</span></form>
<p v-if="linkError" role="alert">{{ linkError }}</p>
<LoadErrorState v-if="calendarError" title="工作日历读取失败" :message="calendarError.message" :loading="calendarLoading" @retry="loadCalendar"><template #details><ApiErrorMeta :details="calendarError" /></template></LoadErrorState>
<LoadErrorState v-if="referenceError" title="到港参考不可用" :message="referenceError.message" :loading="referenceLoading" @retry="loadArrivals"><template #details><ApiErrorMeta :details="referenceError" /></template></LoadErrorState>
<div class="work-layout" :class="{ 'has-panel': panelOpen }">
<WorkCalendarPanel :anchor="anchor" :mode="mode" :flow="flow" :types="types" :calendar="calendar" :loading="calendarLoading" :failed="!!calendarError" :reference="reference" :arrivals="arrivals" :reference-ready="referenceReady" @anchor="offset => filterCalendar(() => anchor = moveCalendar(anchor, mode, offset))" @today="filterCalendar(() => anchor = dateKey(new Date()))" @mode="value => filterCalendar(() => mode = value)" @flow="value => filterCalendar(() => flow = value)" @types="value => filterCalendar(() => types = value)" @reference="toggleReference" @day="(date, type) => setScope({ kind: 'day', date, types: type ? [type] : [...types], flow })" @arrivals="date => setScope({ kind: 'arrivals', date })" />
<aside v-if="panelOpen" class="work-panel" aria-label="工作明细与事项操作">
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><button type="button" @click="closePanel">关闭 ×</button></div>
<p v-if="origins.length" class="origin-context">正在处理关联前置事项,完成后可继续原事项。</p>
<p v-if="navigationError" role="alert">{{ navigationError }}</p>
<SourceAppointmentPanel v-if="sourceLink" :key="sourceLinkKey" :source="sourceLink" :can-leave="canLeave" @updated="refreshWork" />
<WorkDetailPanel v-else-if="detailKey" :key="detailKey" :item-key="detailKey" :initial-action="initialAction" :can-leave="canLeave" @open="openItem" @external="external" @saved="saved" @updated="refreshWork" @container="(id, label) => setScope({ kind: 'container', containerBookingId: id, label })" />
<template v-else>
<h3>{{ heading }}</h3><p class="scope-description">{{ scopeDescription }}</p>
<template v-if="scope?.kind === 'arrivals'"><p v-for="arrival in arrivals.filter(row => row.arrivalAt.slice(0, 10) === (scope?.kind === 'arrivals' ? scope.date : ''))" :key="arrival.containerBookingId">{{ arrival.containerNo || arrival.cabinetNo || '—' }} · {{ arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' }} {{ arrival.arrivalAt.replace('T', ' ') }}</p><p>到港时间沿用原船节点事实,不用于生成工作安排或实际执行记录。</p></template>
<template v-else>
<div v-if="scope?.kind === 'day'" class="bucket-buttons"><button v-for="bucket in buckets" :key="bucket.label" type="button" :aria-pressed="scope.bucket === bucket.key" @click="setBucket(bucket.key)">{{ bucket.label }}</button></div>
<LoadErrorState v-if="itemsError" title="工作明细读取失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
<p v-if="itemsLoading">正在读取明细…</p>
<template v-else-if="items && !itemsError"><p>共 {{ items.total }} 项</p><article v-for="item in items.records" :key="item.workItemKey" class="work-row"><div class="work-row-action"><el-button v-if="item.nextAction" type="primary" @click="openItem(item.workItemKey, item.nextAction)">{{ item.nextAction.label }}</el-button><el-button v-else @click="openItem(item.workItemKey)">查看详情</el-button></div><div class="work-row-content"><button type="button" class="item-title" @click="openItem(item.workItemKey)">{{ item.title }}</button><p>{{ typeLabels[item.type] }} · {{ workStatus(item) }} · {{ workDate(item) }} · {{ item.timezone || '地点时区待核实' }}</p><p>{{ item.destinationName || item.originName || '地点待核实' }} · {{ flowLabels[item.flow] }} · {{ item.owner?.name || '待分派' }}</p><p v-if="item.followupDate">跟进 {{ item.followupDate }}</p><p v-for="reason in [...item.overdueReasons, ...item.blockReasons]" :key="reason" class="work-warning">{{ reason }}</p></div></article><p v-if="!items.records.length">当前范围没有工作事项。</p><div v-if="items.total > 8" class="work-pagination"><button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button><span>{{ page }} / {{ Math.max(1, Math.ceil(items.total / 8)) }}</span><button type="button" :disabled="page * 8 >= items.total" @click="changePage(page + 1)">下一页</button></div></template>
</template>
</template>
</aside>
</div>
<AdminPage class="admin-density appointment-schedule" spacing="compact">
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" :can-create="canCreate" @create="openCreate" @range="load" @select="select" @open="openSource" @edit="edit" />
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
</AppDialog>
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
<template v-if="editor">
<p>{{ editor.title }}</p>
<p class="data-notice">自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p>
<label class="arrival-field">计划到仓时间<input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required></label>
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
</template>
</AppDialog>
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
</AppDialog>
</AdminPage>
</template>
<style scoped>
.work-heading, .work-inbox, .work-search { display: flex; align-items: center; gap: var(--admin-space-5); flex-wrap: wrap; }.work-heading h2 { margin: 0; }.work-heading span, .work-inbox > span, .work-search > span, .scope-description { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.work-inbox { padding: var(--admin-space-3) var(--admin-space-5); border: 1px solid var(--cc-border); background: var(--admin-surface); }.work-inbox button { display: flex; gap: var(--admin-space-3); align-items: center; }.work-search input { width: 320px; }
.appointment-work button:not(.el-button), .appointment-work input { font: inherit; }.appointment-work button:not(.el-button) { cursor: pointer; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); padding: 0 var(--admin-space-3); }.appointment-work button:not(.el-button)[aria-pressed=true] { color: var(--el-color-primary); border-color: var(--el-color-primary); }.appointment-work input { min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); }.appointment-work button:not(.el-button):focus-visible, .appointment-work input:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
.work-layout { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: var(--admin-space-5); }.work-layout.has-panel { grid-template-columns: minmax(0, 1fr) minmax(420px, 490px); }.work-panel { border: 1px solid var(--cc-border); padding: var(--admin-space-5); background: var(--admin-surface); min-width: 0; overflow-wrap: anywhere; max-height: 900px; overflow-y: auto; }.panel-nav { display: flex; justify-content: space-between; gap: var(--admin-space-3); position: sticky; top: 0; background: var(--admin-surface); z-index: 1; }.panel-nav > button:last-child { margin-left: auto; }.origin-context { background: var(--el-fill-color-light); padding: var(--admin-space-3); }
.work-row { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: var(--admin-space-3); padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--cc-border); }.work-row-action .el-button { white-space: normal; width: 100%; min-height: var(--admin-control-height); height: auto; padding: var(--admin-space-3); }.work-row-content p { margin: var(--admin-space-2) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }.work-row-content .item-title { border: 0; padding: 0; text-align: left; min-height: 24px; color: var(--el-text-color-primary); }.work-warning { color: var(--el-color-warning-dark-2); }.work-pagination, .bucket-buttons { display: flex; align-items: center; justify-content: center; gap: var(--admin-space-3); flex-wrap: wrap; margin-top: var(--admin-space-3); }.bucket-buttons { justify-content: flex-start; }.bucket-buttons button { font-size: var(--admin-font-caption); }
.work-panel :deep(.work-form fieldset) { border: 0; margin: 0; padding: 0; min-width: 0; }.work-panel :deep(.work-form label:not(.el-form-item__label)) { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-bottom: var(--admin-space-5); font-size: var(--admin-font-caption); }.work-panel :deep(.work-form input:not(.el-input__inner)), .work-panel :deep(.work-form textarea:not(.el-textarea__inner)), .work-panel :deep(.work-form select) { box-sizing: border-box; width: 100%; font: inherit; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); }.work-panel :deep(.work-form input[type=checkbox]) { width: auto; align-self: flex-start; }.work-panel :deep(.work-form textarea:not(.el-textarea__inner)) { min-height: 64px; resize: vertical; }.work-panel :deep(.appointment-fields), .work-panel :deep(.appointment-fields__extra-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media(max-width: 1100px) { .work-layout.has-panel { grid-template-columns: minmax(0, 1fr); } }
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.arrival-field { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-block: var(--admin-space-5); }
.arrival-field input { box-sizing: border-box; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); font: inherit; }
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
</style>
@@ -0,0 +1,131 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import AppointmentCreatePanel from './AppointmentCreatePanel.vue'
import { createSourceAppointment, listPlanningCargo, listPlanningDestinations, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
import type { QueryField } from '@/shared/components/QueryToolbar.vue'
import { batchContext } from './batch.test-support'
import type { LeaveGuard } from './useWorkDraft'
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), listPlanningDestinations: vi.fn(), resolvePlanningSource: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740999', orderId: '9007199254740999', customerId: '9007199254740998', customerName: '测试客户', inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
const guards = new Set<LeaveGuard>()
function render(canWrite = true) {
return mount(AppointmentCreatePanel, { props: { canWrite, canLeave: async () => { for (const guard of guards) if (!await guard()) return false; return true } }, global: {
provide: { 'appointment-work-leave-guard': (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) } },
stubs: {
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters" /><slot name="toolbar-actions" /><slot name="notices" /><div v-for="row in data" :key="row.sourceKey"><span>{{ row.inboundCode }}</span><slot name="select" :row="row" /><slot name="eligibility" :row="row" /></div><button data-page @click="$emit(\'update:page\', 2)">下一页</button></div>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], template: '<button data-query @click="$emit(\'search\')">查询</button>' },
ElCheckbox: { props: ['modelValue', 'disabled'], emits: ['change'], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'change\', $event.target.checked)">' },
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot /></button>' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<input data-date type="date" v-model="model.plannedDate" :disabled="disabled">' },
LoadErrorState: { props: ['message'], emits: ['retry'], template: '<p role="alert">{{ message }}<button data-retry @click="$emit(\'retry\')">重试</button><slot name="details" /></p>' }, ApiErrorMeta: true,
},
} })
}
async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
await flushPromises()
}
beforeEach(() => {
vi.clearAllMocks(); guards.clear()
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 21, page: 1, size: 20, pages: 2 })
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
vi.mocked(listPlanningDestinations).mockResolvedValue(['ABE4','ONT8'])
})
it('loads searchable destination options lazily and keeps the submitted filter across pages until reset', async () => {
const wrapper = render(); await flushPromises()
const toolbar = wrapper.getComponent({name:'QueryToolbar'})
const destination = () => (toolbar.props('fields') as QueryField[]).find(field=>field.key==='destination')!
expect(destination().props?.filterable).toBe(true)
expect(listPlanningDestinations).not.toHaveBeenCalled()
const open = destination().props!.onVisibleChange as (visible:boolean)=>void
open(true); await flushPromises()
expect(destination().options).toEqual([{label:'ABE4',value:'ABE4'},{label:'ONT8',value:'ONT8'}])
open(true); await flushPromises(); expect(listPlanningDestinations).toHaveBeenCalledTimes(1)
toolbar.vm.$emit('update:modelValue',{keyword:'IN-TEST',destination:'ABE4'})
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:2,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
toolbar.vm.$emit('update:modelValue',{keyword:'',destination:undefined})
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:undefined},expect.any(AbortSignal))
wrapper.unmount()
})
it('reports a destination directory failure and allows retry without hiding loaded cargo', async () => {
vi.mocked(listPlanningDestinations).mockRejectedValueOnce(new Error('offline'))
const wrapper = render(); await flushPromises()
const field = (wrapper.getComponent({name:'QueryToolbar'}).props('fields') as QueryField[]).find(field=>field.key==='destination')!
;(field.props!.onVisibleChange as (visible:boolean)=>void)(true); await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true)
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
expect(listPlanningDestinations).toHaveBeenCalledTimes(2)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
wrapper.unmount()
})
it('does not read or create per-row details until the operator selects cargo and proceeds', async () => {
const wrapper = render(); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
expect(resolvePlanningSource).not.toHaveBeenCalled(); expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
await selectAndPrepare(wrapper)
expect(resolvePlanningSource).toHaveBeenCalledWith({ sourceKey: cargo.sourceKey, customerId: cargo.customerId })
expect(previewPlanningBatch).toHaveBeenCalledWith([{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }])
expect(wrapper.text()).not.toContain(cargo.orderId)
wrapper.unmount(); expect(guards.size).toBe(0)
})
it('submits the real form, shows the saved appointment, and prevents another submit of the same completed form', async () => {
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
await wrapper.get('[data-date]').setValue('2026-10-25')
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null }), idempotencyKey: expect.any(String) }))
expect(wrapper.emitted('created')).toEqual([[batchContext().appointment]])
expect(wrapper.get('[role="status"]').text()).toContain(batchContext().appointment.appointmentNo)
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
expect(wrapper.get('[data-date]').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('preserves the original request and blocks returning or closing after an unknown creation result', async () => {
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
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.findComponent({ name: 'DataTable' }).exists()).toBe(false)
expect((await Promise.all([...guards].map(guard => guard()))).includes(false)).toBe(true)
await wrapper.findAll('button').find(button => button.text() === '重试本次创建')!.trigger('click'); await flushPromises()
expect(vi.mocked(createSourceAppointment).mock.calls[1]).toEqual(vi.mocked(createSourceAppointment).mock.calls[0])
wrapper.unmount()
})
it('disables blocked cargo and keeps selected cargo across pages', async () => {
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ records: [cargo, { ...cargo, sourceKey: 'ORDER:blocked', detained: true }], total: 21, page: 1, size: 20, pages: 2 })
const wrapper = render(); await flushPromises()
expect(wrapper.findAll('input[type="checkbox"]')[1]!.attributes('disabled')).toBeDefined()
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
expect(wrapper.findAll('button').some(button => button.text() === '下一步:填写预约(1)')).toBe(true)
wrapper.unmount()
})
it('reports source failures and does not offer a write path without permission', async () => {
const readonly = render(false); await flushPromises()
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount()
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('unavailable'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
wrapper.unmount()
})
it('ignores a source resolution that arrives after the dialog is closed', async () => {
let complete!: (value: Awaited<ReturnType<typeof resolvePlanningSource>>) => void
vi.mocked(resolvePlanningSource).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
wrapper.unmount(); complete({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }); await flushPromises()
expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
})
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { FbaAppointmentFields, createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
import type { SourceReference } from './workApi'
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
let destinationsRequest = 0, destinationsController: AbortController | null = null
const fields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
options: destinations.value.map(code => ({ label: code, value: code })),
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
if (visible && !destinationsLoaded.value) void loadDestinations()
} } },
])
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
const selectedRows = computed(() => Object.values(selected.value))
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
const columns: DataTableColumn[] = [
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
{ prop: 'customerName', label: '客户', minWidth: 140 },
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140 },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '箱数', width: 80 },
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
]
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
function reason(row: PlanningCargo) {
if (row.detained) return '货物暂扣中'
if (row.mixedDestinations) return '多个目的地,需先核对'
if (row.hasActiveAppointment) return '已有有效预约'
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
return ''
}
function toggle(row: PlanningCargo, checked: boolean | string | number) {
if (!props.canWrite || resolving.value) return
if (!checked) delete selected.value[key(row)]
else if (!reason(row)) selected.value[key(row)] = row
selectionError.value = null
}
async function load(nextPage = page.value) {
if (!props.canWrite) return
page.value = nextPage
const current = ++generation; controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
if (current === generation) { rows.value = result.records; total.value = result.total }
} catch (cause) {
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
} finally { if (current === generation) loading.value = false }
}
function search() {
keyword.value = String(query.value.keyword || '').trim()
destination.value = String(query.value.destination || '').trim()
void load(1)
}
async function loadDestinations() {
if (!props.canWrite || destinationsLoading.value) return
const current = ++destinationsRequest
destinationsController?.abort(); destinationsController = new AbortController()
destinationsLoading.value = true; destinationError.value = null
try {
const result = await listPlanningDestinations(destinationsController.signal)
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
} catch (cause) {
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
}
async function prepare() {
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
resolving.value = true; selectionError.value = null
try {
const sources: SourceReference[] = []
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
for (const row of snapshot) {
if (current !== selectionGeneration) return
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
if (current !== selectionGeneration) return
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
}
if (current === selectionGeneration) references.value = sources
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
finally { if (current === selectionGeneration) resolving.value = false }
}
async function back() { if (await props.canLeave()) references.value = null }
function saved(appointment: FbaAppointmentDto) {
created.value = appointment; references.value = null; selected.value = {}
emit('created', appointment)
}
async function again() {
if (!await props.canLeave()) return
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
await load()
}
onMounted(() => { void load() })
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
</script>
<template>
<section aria-label="新增FBA送仓预约">
<template v-if="created">
<p role="status">预约已保存:<strong>{{ created.appointmentNo || '预约编号未返回' }}</strong></p>
<p>预约保存后仍需在原业务安排车辆,日历中的排车以真实车次为准。</p>
<el-button v-if="canWrite" @click="again">继续新增预约</el-button>
<FbaAppointmentFields :model="createdForm" :appointment-no="created.appointmentNo" :timezone="created.timezone" :disabled="true" />
</template>
<template v-else-if="references">
<el-button @click="back">返回选择货物</el-button>
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
</template>
<template v-else>
<p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p>
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" empty-text="暂无可预约货物" @update:page="load($event)">
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
<template #toolbar-actions><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
</DataTable>
</template>
</section>
</template>
@@ -0,0 +1,31 @@
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h, KeepAlive, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
import { listTripCalendar } from './tripCalendar'
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
describe('calendar cache lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
})
it('aborts reads when cached and reloads on return without creating background work polling', async () => {
const active = ref(true)
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div') }) })
const wrapper = mount(host, { global: { stubs: { AdminPage: { template: '<main><slot /></main>' }, AppointmentCalendarPanel: { template: '<section />' }, AppDialog: true } } })
await flushPromises()
expect(getCalendarSources).toHaveBeenCalledTimes(1)
const signal = vi.mocked(getCalendarSources).mock.calls[0]![1]!
active.value = false; await flushPromises()
expect(signal.aborted).toBe(true)
active.value = true; await flushPromises()
expect(getCalendarSources).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
})
@@ -1,10 +1,188 @@
# 预约工作日历逐操作矩阵
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(v10)与预约工作日历开发协作契约 v1。下表取代旧三视图、整行选货和日历辅助方案;不改变原业务实际执行入口。
## 2026-10-19 四类日历恢复(当前结论)
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
| 页面 | 操作 | 当前确认承载与布局 | 实现结论 |
|---|---|---|---|
| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 |
| 预约列表 | 月视图周数 | 用户截图 `codex-clipboard-372a5dc5-0c82-4298-93db-089b6c97c8ca.png`;周一至周日,只覆盖当月所需完整周 | 4/5/6周自适应;2026年10月五周,保留末周11月1日,不追加11月2—8日整周;待人工截图确认 |
| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 |
| 预约列表 | 新增预约 | 用户本轮要求恢复顶部按钮,随后确认先支持FBA;1080px居中弹框,先选择已有货物,再登记预约时间 | 复用真实来源预览及预约保存流程,原权限/幂等/未保存保护;保存成功显示预约编号和只读内容;普通客户仓不在本次范围;待人工截图确认 |
| 新增FBA送仓预约 | 按目的仓查询 | 用户截图 `codex-clipboard-44b338fe-af49-45e2-abf2-3bc0bd27224a.png`;在原查询栏增加可搜索、可清空的目的FBA仓下拉框 | 选项使用同货物查询权限的权威美国FBA目录;提交查询后从第一页按目的仓过滤,翻页沿用条件;待人工截图确认 |
| 预约列表 | 审核、删除 | 不恢复通用入口 | 在原业务处理,不在日历改业务状态 |
| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 |
| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 |
| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 |
2026-10-19 目的FBA仓筛选补修:原查询栏增加可搜索、可清空的下拉框,首次展开读取完整权威美国FBA目录;复用原货物读取权限,不新增仓库管理权限。查询回第一页,翻页沿用目的仓条件,重置清除条件;选项失败独立提示并可重试,不隐藏已加载货物或伪装为空结果。预约模块37文件/276项及生产构建(含类型检查)通过,Gateway路由合同5项通过。后端生产源码编译通过,本次两个测试文件单独javac编译并经Surefire执行13项通过;常规Maven流程仍被派送模块三份测试中的5项编译错误阻挡,不能据定向结果宣称后端完整门禁通过。Admin全量405文件/3388项通过、1项输入提示合同失败,原因是其他在途运输单表单的7个输入框缺少placeholder;单独复查相同失败,未修改其他模块。日志为`.logs/appointment-destination-{test,build,full-test,placeholder-check}.log`,后端为同名前缀的`route`、`backend`、`javac`及`targeted-backend`日志。本补修未提交或部署,人工1920×1080/100%视觉待确认。
2026-10-19 新增FBA预约入口补修:顶部按钮要求原FBA预约写权限及尾程订单列表权限,点击后才读取候选;支持同起运仓、同目的仓的跨客户货物选择,核对最新真实来源后复用原创建表单。未知结果保留原请求并阻止关闭/返回,成功后移除创建表单、显示真实预约编号和只读内容,刷新原日历;不生成虚拟车次或第五类日历。本次已明确普通客户仓暂不加入。
验证:新增入口直接5文件/59项通过;`pnpm build`含类型检查通过,仅既有AnalyticsChart大分块提示。工作区首次全量为405文件/3385项通过、1项排车详情布局合同失败,后续该项单独6项复查通过;再次全量出现9文件/11项失败(以5秒超时为主),在不修改超时阈值和其他业务代码的情况下,以2个工作进程复查整个预约模块、密度及上述全部失败文件,47文件/385项全过。不将这些分次结果记录为单次全量绿灯。证据为`.logs/appointment-create-{focused-final,build,full-test,full-test-final,layout-recheck,bounded-recheck}.log`。额外临时候选核对未作为通过证据;工作区其他业务在途修改保留。本次未提交、发布或新增真实业务记录,人工1920×1080/100%视觉待确认。
2026-10-19 月视图周数补修:按月实际周数生成日期格,并同步查询开区间;覆盖2026年10月五周、跨年五周、完整二月四周与确需六周的八月。Admin全量405文件/3375项、类型检查及生产构建通过,`git diff --check`通过;仅既有AnalyticsChart大分块提示。本补修尚未提交或发布,人工视觉待确认;证据为`.logs/appointment-calendar-month-weeks-{test,build}.log`。
2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。
发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。
本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
2026-10-16 当前承载调整:用户反馈“点击外面关闭不了,也看不清”。日期/类型/全局明细、事项详情、原侧边编辑表单和到港参考统一改用带遮罩的居中 `AppDialog`,常规宽度 1120px、高度 88dvh,支持全屏;标题与分页固定、仅正文滚动。遮罩、Esc 和关闭按钮统一经过未保存/提交结果待确认保护。此结论优先于下表早期侧边承载描述;原业务独立页面跳转、确认流程和统计口径保持原样。改后待人工截图确认。
V13 日历布局:按已确认定高要求,月日格固定 146px,周日格固定 270px;只展示五类服务端计数和独立到港参考,不追加逐事项卡片、不依明细条数扩高。替代下表早期“随内容自然增高”的结论;不裁掉五类入口,仍需人工核对 1920×1080、100% 下完整内容。
## V13 前端本轮最终收口(2026-10-16)
已完成 cargo-refresh 按客户最新资料预览/明确采用、60+40 原排车单共车续办,以及原车当前/历史稳定批次引用。当前合同与具体范围以下述对应章节为准,早期分批检查数字仅为过程记录。
- 最新完整受影响组合:41 文件、357 项通过(18.64 秒),覆盖全部 appointment-calendar、Fleet trips API/页面/安排/交接、排车预排/详情/列表及密度合同。
- 全量前端 `pnpm test`:397 文件、3290 项通过(137.44 秒),无失败;存在既有 Vue const reactive v-model 编译提示,本批未扩展修改无关模块。
- 最终 `pnpm typecheck` 通过;`pnpm build`(另含 vue-tsc)通过,Vite 16.24 秒,仅既有 AnalyticsChart 588.19 kB 分块警告。`git diff --check` 通过。
- 本地日志:`.logs/appointment-v13-final-combined.log`、`appointment-v13-final-full-test.log`、`appointment-v13-final-typecheck.log`、`appointment-v13-final-build.log`;冻结文件与 SHA-256 清单为 `.logs/appointment-v13-frontend-freeze.json`。日志为本地证据,不提交。
- 本轮前端冻结;没有提交、推送、部署、SQL 或真实 HTTP。人工 1920×1080、100% 截图与真实 60+40 装载、客户移出后重进、后送 40/20、cargo-refresh 持久事实仍待总控组织验收。后端客户间 FAILED/PENDING 排车资格隔离由总控收口,不改变本轮前端合同。
## V13 第一批实现与接口依赖
### 本批收货覆盖合同接入
已按生产者 `FbaReceivingContracts.java`、`FbaReceivingController.java`、`FbaReceivingService.java` 和 `appointment-receiving-v1.yaml` 接入 `receivingApi.ts`,浏览器前缀为 `/platform/v1/last-leg/appointment-receiving`(公共 HTTP 层统一提供 `/api`)。真实入口如下:
- GET `/batches/{batchKey}` 和 `/agreements/{agreementKey}`:读取批次、当前覆盖、待确认变化、车辆范围版本及重约 eligible/excluded。
- POST `/changes` 和 `/changes/{changeKey}/resolve`:逐来源先送 0..当前量,持久 WAITING;明确回复 CONFIRM 或撤回 WITHDRAW。保留原批次/来源/核实/transport 版本,未知结果重试原幂等命令。
- POST `/bookings`:一份共同 booking 或每成员独立 booking,沿用当前批次,不新建货物;取消组携带原 agreementKey/version,只提交 eligible 成员。
- POST `/agreements/{agreementKey}/reschedule`、`/cancel`:展示整份当前范围,提交真实范围版本;取消需要 delete 权限及二次确认,普通编辑和客户移出分别遵循 Controller 的独立权限组合。
接入文件:`WorkReceivingPanel.vue`、只读 `ReceivingScope.vue`;入口为 `WorkAppointmentForm.vue` 的“客户调整 / 共同预约范围”,由 `WorkDetailPanel.vue` 在原居中弹框内装配并保留结果。Member.customerName、Source.inboundCode 已消费,无名无号显示“—”,不以内部标识回退。原批次快照未提供客户名时只能从已返回的覆盖成员匹配或显示“—”,不猜客户名称。
结果只报告服务端实际 steps;本地原子事务失败不包装成部分成功。待确认结果明确原安排未变;保存成功但刷新失败只重试 GET。范围变化后保留输入并重新确认;重约成员集合变化时禁止把原输入套给新成员,显式重新填写。已返回的真实车次可跳原详情核对,后送步骤按 ALLOCATE_CARGO.targetKey 匹配成功批次,不取任意第一批;无业务号不拼车辆链接。未知箱数不替换为 0 或加入可预约范围。
本批前端直接验证:`receivingApi.test.ts`、`WorkReceivingPanel.test.ts`、`WorkDetailPanel.test.ts`、`WorkAppointmentForm.test.ts` 共 4 文件、36 项通过,`pnpm typecheck` 通过。未重复全模块测试或全量构建。生产者已通知新源码编译通过,但本批后端回归和真实环境验收尚无通过结论;不能据前端模拟 HTTP 的定向测试宣称端到端通过。
后续闭合情况见下节:子批真实事项解析、原车按需客户卡片、原来源过滤及单批真实托盘预选均已接入;重约按最新 executionScope/executionVersion 判断客户资格;普通调整和整份操作继续使用 transportVersion。以下早期依赖表用于逐场景定位,新合同的实际实现与剩余缺口以本节及下节为准。
已消费的合同为 `workApi.ts`、`batchApi.ts`、`planningApi.ts` 及原预约/排车 API。本批不新增接口或推导数量。已接核心冻结的 `FbaAppointmentBatchContext.currentBatches`:服务端解析全部替代分支、按稳定批次去重,前端只显示与 originalSourceRefs 的 customerId/sourceKey/destinationKey 完全匹配的当前明细,合并后的其他来源不算入原范围。未知箱数不显示 0,零量不生成去向卡片,空结果不回退历史替代链接。`replacementBatchKeys` 只用于识别历史快照,不能据此推导当前 20/40 箱去向。取消保留分配后的续办通过原预约更新接口提交版本和幂等键,不整票重建或重新分配。当前前端 `splitBatch` 与表单一次拆两批;服务端普通 repartition 的 DTO/OpenAPI 已支持 1 至 20 个非空目标批次,普通多批能力不构成接口依赖。本轮 100/60、40/20 保持两批交互。先送 0、已确认且有车的客户移出、共约范围变更仍需专用命令,不能以普通分批代替;尚未冻结的共享范围命令继续等待生产者。
| V13 场景 | 当前前端与直接检查 | 依赖/剩余验收 |
|---|---|---|
| 01 整客户 160 移出续办 | `WorkDetailPanel.test.ts` 保存后读取主要下一步;`WorkExistingDispatchForm.test.ts` 沿用真实排车单继续安排本批车辆 | 单客户移出命令、当前数量/车辆版本、原车 120 保留结果及后续目标;真实发车签收待集成 |
| 02 100 先送、60 后送 | `AppointmentBatchForm.test.ts` 保留普通分批与来源版本;`WorkDetailPanel.test.ts` 区分未定作业日和跟进日 | 已确认预约/装载关系的部分移出与调整回复语义,不能以普通分批代替 |
| 03 后送 60 加另一客户 40 共车 | 本批与另一客户按实际托盘各建排车单;结果页“与同仓其他排车单共车”沿业务号核对两单后复用 TripPreparationDialog/Fleet prepare,可加入已有计划车次;WorkDispatchForm、WorkSharedTripForm、TripPreparationDialog、trips API 测试覆盖传递与导航 | 实物须能对应本次数量,不能对应时先走原库存拆托;共车后的路线、容量、审核及发车须真实 HTTP 验收,非任意箱级拆托开发缺口 |
| 04 后送 40 新批、20 待排 | `AppointmentBatchForm.test.ts` 消费 `currentBatches` 的 40/20 全部分支;逐客户/来源/目的仓过滤,排除合并的其他货物;未知量不当 0 | 已接客户变化确认及真实批次续办;已接当前/历史排车全量批次引用,实际装载链待集成;实物拆托是原库存前置 |
| 05 全部再分配后的旧入口 | `AppointmentBatchForm.test.ts` 只显示当前终点,不显示中间替代批次;零量无卡片,空结果不回退旧链接,原快照明确仅供追溯 | 服务端负责图遍历及稳定键去重;真实多次再分配与跨页返回待集成 |
| 06 待回复先保存再续办 | `WorkAppointmentForm` 保留计划/确认分开;`WorkDetailPanel` 保存后留在当前项 | 单客户调整草稿、仓库回复范围、待确认变化、原车阻断条件 DTO/命令 |
| 07 共约旧约号冲突 | 原预约错误继续保留表单;不按文本约号判断共约 | 生产者共约身份、当前覆盖成员、唯一性冲突合同;真实冲突验收待集成 |
| 08 整份改期、无变化保存 | `WorkAppointmentForm.test.ts` 无变化及空白字符归一化后不重复提交;跨日/偏移原样保留 | 整份范围、成员版本、车辆影响与无变化后端幂等;不能仅凭前端禁用证明不重复复核 |
| 09 整份取消后共同重约 | `WorkAppointmentForm.test.ts` 已接取消保留分配后的原批更新续办,可先保存日期未定/PENDING;保留历史、版本及幂等键,不再创建整票;取消不能重复提交,只读权限不能续办 | 整组共同/分别重约已接 receiving Book,消费 eligible/excluded;新关联及原车实际资格待后端/集成验收 |
| 10 分别重约与排除已另约成员 | 不复制现有逐票新建接口实现假整组成功 | 当前组资格、排除原因、分别重约结果、冲突/部分失败续办及所有时段核对 |
| 11 保留量为 0、原车无货 | 当前前端普通拆分保留两批均非空的交互约束,不绕过服务端业务资格 | “取消本次安排/全部移出”专用语义、无货原车权威处理结果;不能用空批次代替 |
| 12 未定日期、换月、图层、分页 | `AppointmentCalendarView.test.ts` 全局范围不带日历条件,保存后留在当前详情、返回保持原分页;既有前置返回/外部路由上下文继续检查;`WorkDetailPanel.test.ts` 跟进日不作作业日 | 新去向与新范围的上下文等待对应稳定标识;人工截图与真实再进入待验 |
| 13 部分失败、超时、并发 | `WorkDispatchForm.test.ts` 重试原草稿、不重锁已提交实物;`WorkDetailPanel.test.ts` 保存成功但下一步读取失败仅重试 GET、旧操作禁用;`WorkAppointmentForm.test.ts` 原批重约结果未知同键重试,明确冲突保留输入、读取新版本后显式再提交 | 多步骤逐项结果/可重试动作及范围版本合同;现有草稿恢复不代表跨域 V13 全部已完成 |
| 14 权限、失败、时区、DST | 既有 work/form/presentation/API 直接测试保留;来源失败显示明确错误 | 新增合同对应权限/版本/持久幂等与真实时区场景待生产者和集成验证 |
| 15 五类计数、真实车次与执行门槛 | `WorkCalendarPanel.test.ts` 五类聚合固定数量入口,服务端 2 批送仓/1 车原样展示;日格定高,不在浏览器重算车辆数;原实际执行入口保留 | 去重由后端保证;直送卸货、还空、共车发车资格及接手结果继续真实验收 |
当前定向检查(含首批与 currentBatches/原批重约新增 6 项):`pnpm test src/modules/appointment-calendar src/styles/adminDensity.test.ts`,29 文件、210 项通过;`pnpm typecheck` 通过。完整测试/构建窗口按开发协作契约交总控安排,本批未执行。以上是本地实现与检查,不表示部署或 15 项全链已验收;居中弹框及新增去向/续办内容仍待人工截图确认。
### 60+40 两步共车入口
`WorkSharedTripForm.vue` 仅按明确排车业务号读取两单,复用原 Fleet 安排弹框。真实 60/40 数量从两单读取,不自行生成装载结论;已关联、异仓、重复、错误业务号均不进入安排。子弹框打开时保护父工作面板,成功导航服务端返回的车次。增量联通检查覆盖 WorkDispatchForm、WorkSharedTripForm、既有 TripPreparationDialog 与 trips API;完整本轮验证见下一节。
### 最新货物资料显式采用
已消费 RefreshCargo/REFRESH_CARGO:WorkReceivingPanel 按客户提供“核对并采用本客户最新货物资料”,只按该客户原来源读取既有 cargo-verification(预约及尾程列表读权限),展示原票、本批箱数、最新核实/有效/已分配量及货件号。读取不会保存核实资料或自动采用。源/核实版本、身份、目的地、起运仓、未知分配、已分配超量及来源阻断均检查;任何一票失败不提交部分范围。
明确确认后 POST `/appointment-receiving/cargo-refresh` 只携带该客户成员及显示的 batchVersion、executionVersion、各来源最新版本;不提交箱数或正式确认。已确认、待变化、已完成客户禁用。冲突后重读并清除确认,未知结果保持原请求键,成功刷新事项并可继续本客户计划/正式预约,不跳回会影响其他客户的旧整批 PUT。
本轮共车与资料采用最初组合为 8 文件、81 项通过;已被下述当前/历史引用收口后的完整组合覆盖。未执行 HTTP、部署或人工截图。
### 客户级普通计划补齐(V13 06 / 09 / 10)
- 已消费实际 `Context.plans`、`CustomerPlan` 和 `Plan`,通过 POST `/appointment-receiving/plans` 提交明确 batchKey/customerId/batchVersion、executionVersion 与幂等键。`WorkReceivingPanel` 客户卡片和本批重约范围提供“保存目标 / 未约成”;不走旧整批 PUT,不改变甲的确认或借分批保存日期。
- 只允许 PENDING/FAILED,FAILED 必须有原因;普通计划无外部约号和正式确认时间。资格同时要求 PLAN_RECEIVING、当前执行资格、无活动确认和无待变化记录。时区来自该客户计划(未有计划时来自批次目的仓);非空目标缺时区阻止提交。计划窗口支持跨日及 startOffset/endOffset,DST 歧义或不存在时间由权威接口校验;只填日期或全空目标不带偏移。
- 回显优先当前 batch/customer 的 plans,明确空日期保持空;无独立客户计划时,仅无任何有效独立覆盖且 header 为 PENDING/FAILED 才带入旧计划和原因,剔除正式确认。混合覆盖不借甲日期,CANCELLED 不回填旧目标。混合状态测试为同一稳定批次中甲已有独立确认、乙与丁未确认;覆盖乙保存日期、未约成、清空日期、丁从空白开始、只读/已完成/待变化禁用、版本冲突保留输入、未知结果同键重试、UNCHANGED、跨日/DST 和未知时区。保存后刷新当前工作事项,保留 receiving 面板及真实结果,原“记录回复与跟进”入口保留。
- 整组取消后支持按客户勾选子集,共同或分别确认只提交所选成员。通过实际只读 POST `/appointment-receiving/execution-scope` 查询所选 batch/customer 的执行资格,使用返回版本,不复用全组或猜测 token;保留展示时的 batchVersion/groupVersion。选择变化清除范围确认,读失败禁止提交,可单独重读;过期异步响应不覆盖新选择。新增同批仅甲独立确认、跨批仅乙子集、分别表单与客户匹配、查询失败及迟到响应回归。
- 直接测试:`WorkReceivingPanel.test.ts`、`receivingApi.test.ts`、`WorkDetailPanel.test.ts`、`TripAppointmentPanel.test.ts` 共 4 文件、56 项通过;`pnpm typecheck` 和最终 `pnpm build`(含 vue-tsc)通过,构建 4.78 秒;仅既有 AnalyticsChart 约 588 kB 分块警告。`git diff --check` 通过。本批前端冻结。未执行真实 HTTP、部署或人工截图验收;服务端甲确认不变、客户计划工作域投影及日历归属仍由总控集成核对。
### 本批续办、原车入口和历史来源收敛
- `batchApi.ts` 必需字段 `originalSourceRefs` 已与生产者 SourceRef/OpenAPI 同步;`AppointmentBatchForm` 与 `TripAppointmentPanel` 均按完整客户/来源/目的地过滤当前全部去向。原批仍保留货物但另有后送批时也显示当前去向。全客户移出后不再依赖原批当前 items,且不拿历史来源授权可分配数量;新增全移出、40/20、合并其他来源隔离回归。
- `workApi.ts`、`WorkBatchContinuation.vue` 接真实客户级解析;保存原/后送预约后继续当前子批事项,原事项留作返回上下文,多客户不默认取第一项。
- `TripDetailView.vue` 按需装配公开 `TripAppointmentPanel`:本车当前 appointmentBatches 与 previousDispatches 稳定引用→批次/currentBatches/receiving 范围;客户卡片列当前所有匹配去向,直接进入对应客户调整或当前批次续办。新弹框复用未保存/未知结果保护,刷新车次和路由离开也经过保护,业务提交后刷新本车事实。
- `WorkDispatchForm.vue` 从单客户单批入口仅预选当前合格实物,候选跨批/客户、重复实物、未知箱数均不误选;提交仍复制服务端 allocations。可直接进入本批逐来源分批,60→40/20 测试只提交该 60 的原批和来源版本,不把原总量 160 再分配。多客户、超车型容量不自动全选;真实整托无法覆盖的部分箱量不伪造实物。
- `WorkReceivingPanel.test.ts` 新增跨批 A 已另约、仅 B/D eligible 的重约回归:提交 B/D 的真实 batchKeys 与 GroupContext.executionScope.version(Book.executionVersion),不带回 A,也不使用原 A/B/D 范围版本或运输版本。待确认 WITHDRAW 仍可提交,不以普通货物可变性条件禁用撤回。
- 最新接收合同已消费:ChangeIntent.ownerId 由本人、当前事项及关联事项负责人候选选择,父事项传入候选,不新增用户目录或用自由姓名充当账号;Pending.followup 展示服务端 ownerName/nextFollowupDate。普通正式预约按 Context.executionScope 排除 completed 或 canRebook=false 的客户,整组按服务端 eligibleMembers;Book 只提交对应 executionVersion,运输展示或无关客户的运输阻断不再一刀切禁止重约。货物调整、确认/撤回和整份改期/取消仍使用 transportVersion。新增负责人候选/真实姓名、已完成 A 不挡未完成 B、缺失执行资格不擅自放行等回归。
当前合同接入、实物前置与真实验收边界:
1. 原车当前/历史引用已按冻结 DTO 接入:`DispatchResponse.appointmentBatches`、`TripBinding.appointmentBatches` 与 `Detail.previousDispatches` 均为必需数组,`batchNo` 允许 null,不依赖可空 `fbaAppointmentNo` 或内部排车 ID。`TripAppointmentPanel` 按批次/客户聚合并对排车业务号、来源和当前终点去重,精确匹配客户/来源/目的地,只展示 `currentBatches` 当前量。历史卡片只提供追溯和权威解析后的当前工作入口,不使用历史数量授权调整或排车。`appointmentHistoryUnavailableReason` 单独提示,缺乏证据的引用不开入口,当前车次仍可读。已补空原约号、多批引用、历史客户移出后重进、40/20 全去向、其他客户/来源隔离、重复记录、缺失历史提示和错误身份回归。真实持久历史及装载链待集成验收。
2. 60+40 按真实两步流程收口:各客户按本批分配及实际托盘生成排车单,WorkDispatchForm 结果页进入 WorkSharedTripForm,按另一张业务号重读两单,核对当前状态、未绑定及真实起运仓一致后交给既有 TripPreparationDialog;Fleet prepare 复用多个 dispatchNos,可新建或加入原计划车次。未新增共车命令、未扫描全局待办、未按名称推仓。数量不能映射完整实物时必须先走原库存拆托,这属于实物前置,不列为待开发任意拆托;仍待真实 60+40 HTTP 和装载链验收。
3. receiving 仍以当前源码接入,待核心与工作事项后端回归及真实场景。UI 本地通过不证明服务端初始化、同步及最终执行门槛已经部署验收。
验证:最新完整定向命令 `pnpm test src/modules/appointment-calendar src/modules/overseas-master-data/pages/TripsBehavior.test.ts src/styles/adminDensity.test.ts` 34 文件、260 项通过;`pnpm typecheck` 通过。`pnpm build`(含 vue-tsc)通过,Vite 构建 3.92 秒;既有 AnalyticsChart 约 588 kB 分块警告保留,无构建失败。`git diff --check` 通过;未提交、发布或部署,真实页面待人工截图确认。
### 下一批精确接入点与合同门槛
下表路径除特别标注外相对 `src/modules/appointment-calendar/`。新增语义仅列需求,不预定义字段名、HTTP 路径或动作代码。生产者交付真实 DTO/OpenAPI、权限、直接测试后再接写入;不在现有普通分批或跟进命令中夹带未实现的共约调整。
| 接入点与现有真实入口 | 可用部分 | 尚需生产者提供的精确语义 |
|---|---|---|
| `AppointmentBatchForm.vue`、`batchApi.ts`;GET `/appointment-planning/batches/{batchKey}`、POST `/appointment-planning/appointments/repartition` | 原来源核实、当前前端一次拆两批(服务端支持最多 20 批)、全部当前终点 | 车次/排车单到逐客户逐来源当前装载的引用;当前可调整量及版本;先送 0/全量/中间量各自资格;移出确认后原车保留结果与后送批次,是否有待确认草稿。不能把现有 splitBatch 的非空限制直接删除 |
| `WorkAppointmentForm.vue`、`overseas-warehouse/api/inboundOrders.ts` 的 get/updateFbaAppointment;`api.ts` 的 deleteAppointment | 单批计划、确认、取消、原批续办、同键重试 | 独立共同预约身份、范围版本、当前成员/箱数/原确认时段/关联车辆、仅本客户或整份的允许动作;读取待回复变化及保存/确认/拒绝语义。旧事实与待确认输入分别返回,不能写普通计划来冒充移出成功 |
| `WorkAppointmentForm.vue` 的重约衔接,后续独立共约表单只在合同冻结后装配 | 已取消单批沿原号 update,保留分配 | 整组重约的当前候选、已退出/已另约/已完成/其他处理中成员与原因、各成员当前去向;共用/独立结果、新有效关系身份、仓库覆盖确认依据、原车复用资格及范围并发校验 |
| `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue`;`overseas-warehouse/api/dispatches.ts` 的 getBatchDispatchCandidates、草稿锁定/预排;既有 TripPreparationDialog | 按真实托盘、每客户排车,再沿已有排车单安排车辆 | 已按当前真实整托候选分别创建客户排车单,再从结果页使用另一张已备排车单业务号共车;本批默认只选当前合格实物。部分箱量无法映射时先走原库存拆托,非本轮新建任意拆托接口需求;真实资源资格和装载链待 HTTP |
| `overseas-master-data/pages/TripDetailView.vue` 本车排车单及 `overseas-warehouse/pages/DispatchesView.vue` | 原业务排车、审核、装载、发车规则继续使用;当前任务未修改这些页面 | 本车客户卡片的客户业务名称、batchKey/来源/数量/当前预约覆盖、未确认变化、当前去向与可调整目标;对应允许动作;原车无货的权威关闭结果。不能以解除排车单关联替代客户移出 |
| `WorkDetailPanel.vue`、`workApi.ts` 的 GET `/appointment-work/items/{key}` | 消费 allowedActions、nextAction、blockReasons、relatedItems;保存成功后刷新 | 新命令的稳定目标与白名单动作、受影响工作键、原批到当前批次的工作项引用。当前 currentBatches 只有预约快照,子批保存后不能凭业务号或拼接 batchKey 猜新工作键;目前仍刷新进入时的原事项 |
| `WorkDetailPanel.vue`、`AppointmentCalendarView.vue`、`workNavigation.ts` | 日期、月份、流程、类型、范围、分页及原事项返回已有支持 | 新结果逐步骤成功/失败/待补、已持久保存对象、失败步骤允许的重试动作和幂等身份;用于只重试失败部分,并明确原车和后送需求都存在 |
独立修复:`WorkAppointmentForm` 首次读取失败后重试须初始化真实确认资料和偏移;版本冲突后仍保留已编辑输入。`AppointmentBatchForm` 切换当前去向先清除旧快照,首次读新批失败再重试须按新批初始化日期/数量,不能复用旧批草稿。对应新增直接测试分别在同名 `.test.ts`;不涉及新合同或环境写入。本次仅运行上述两个测试文件,16 项通过(新增 2 项);未重复运行上一批完整模块检查。
只读联审的子批续办缺口已接入真实 GET `/appointment-work/items/resolve?batchKey=...&customerId=...`:`BatchWorkResolution` 返回唯一 detail 或多客户 candidates。`WorkBatchContinuation.vue` 多候选必须明确选客户,再读权威 detail;读失败只重试 GET。`WorkAppointmentForm` 保存返回真实预约快照,`WorkDetailPanel` 用其 batchKey 定位子批,刷新摘要但不再先刷新旧事项/弹回旧事项。`WorkDetailPanel.test.ts` 与 `AppointmentCalendarView.test.ts` 已覆盖子批下一步、车辆续办、返回旧事项及原未定日期分页;工作键不在浏览器拼接。
### V13 集成操作与判定清单(待合同与集成窗口)
以下是验收设计,不是通过记录。01–11 每项恢复独立的甲 80、乙 160、丁 40 基准,均按客户+来源+目的地核对,不跨场景累计;涉及环境写入时由总控按授权窗口执行。
| 场景 | 操作与必须观察到的结果 |
|---|---|
| 01 | 原车乙卡片先送 0,确认仓库回复后原车 120、乙后送 160;沿乙入口重新约仓/用车,其他客户确认不变,无新增整票占量 |
| 02 | 乙先送 100、后送 60,后送不填日期;原车 220,60 在全局未定日期可续办,跟进日不填入送货日 |
| 03 | 从 60 入口按实际托盘形成客户排车单;另一客户 40 同理。结果页核对两单业务号与真实同仓资格后,沿 Fleet 安排同一车次;分别/共同约仓范围可辨,一车 100,客户归属保留。实物不匹配先走原库存拆托 |
| 04 | 后送 60 再选 40,原车客户卡片列 40 新批与 20 待排各一次;两者可直达,不把合并的其他客户数算入乙 |
| 05 | 后送量全部再分配,旧入口显示全部当前终点;中间历史、0 箱卡片和重复后送待办均不出现 |
| 06 | 调整后先保存待仓库回复,退出再进入仍有待确认输入,旧有效安排保留;未解决前发车被阻断,继续明确回复后只影响对应范围 |
| 07 | 退出客户填写他人有效旧约号,提交冲突后保留号码、时段及输入;不自动加入共同预约;重新读取范围后明确再提交 |
| 08 | 选择整份共同改期,先核对完整当前成员和车辆;保存同一新时段只影响相关已安排工作,再次无变化保存与复核不新生复核 |
| 09 | 整份取消后旧确认和失效目标清除、历史保留;共同重约只选仍需履约成员,仓库确认覆盖后产生新有效关联,沿可用原车续办 |
| 10 | 一名成员先独立另约,再进入整组重约;其被排除并显示去向,退出/已完成/其他处理中成员同样不可静默加入;其他成员可分别重约 |
| 11 | 先送为 0 合法,全量先送不产生空后送;原车所有有效货物移出后按原车命令关闭待办,已发车则进入原异常处理,不能普通移出 |
| 12 | 未定日期分页进入→切月份/图层→当前批续办→返回;原范围、分页、日期及筛选恢复,全局待办不因日历筛选丢失 |
| 13 | 分别在货物保存、车辆关联、读取下一步失败处中断;只重试失败步骤,未知结果同键,已成功批次/排车/车次不重复创建;显示真实部分结果 |
| 14 | 只读权限、来源不可用、版本变化、约号冲突、跨日和 DST 逐项验证;首次 GET 失败重试不得出现空白可写旧事实,冲突后保留输入,未知数量不作 0 |
| 15 | 五类高计数仍固定日格,同车多批只计一辆车;中转与直送分别走真实备货/卸货/还空门槛,未完成卸货或未接手责任不能借复核结束工作 |
阻塞场景不以按钮禁用、静态截图或 Mock 成功响应记为已验收。合同冻结后先扩充对应组件/API 定向测试,再由总控安排真实场景及 1920×1080、100% 人工截图。本次读取恢复修复后的 `pnpm typecheck` 和 `git diff --check` 均通过;未执行全量构建、真实 HTTP 或部署。
| 本次操作 | 证据与现状 | 调整结论 |
|---|---|---|
| 列表信息层级与日历视觉细化 | 用户 2026-10-16 截图 `codex-clipboard-6858cde3-eda3-4a02-a043-e1a47143f67c.png`、`codex-clipboard-5a68965d-cdbd-4ce9-b8b3-385d9d76b4f2.png`,并明确要求按建议优化 | 查看详情/历史使用普通描边按钮,办理动作保留主色且仍在左侧;完整标题、状态、作业时间、时区、地点、流程、负责人、跟进日期及异常分层显示。日历类型改浅色底与弱边框,保留语义色、今天/选中态;周末表头与到港参考保持辅助层级,零值弱化、超期数字提示。保留现有覆盖面板、字号、统计和动作链;改后待人工截图确认 |
| 工作明细左侧长操作按钮 | Admin #621 真实 1920×1080 截图 `baiyue-firstleg/reports/appointment-browser-20261015/admin-621-follow-today-1920.png`;按钮与文本实际行高仅 13px | 保留完整文案和 108px 操作列,统一使用 20px 行高、自然换行和自适应高度;普通/展开面板及主操作/查看详情使用同一规则 |
| 流程与工具栏分组 | 用户后续流程下拉、日期工具栏及上下操作区截图(`115df974`、`d217350b`、`80dd9fa1`) | 流程改为四个直接切换按钮;全局待办、流程、日期与类型筛选收进连续白底工作区,各行靠左,保留全局与区间统计边界 |
| 日历浏览与日期选择 | 主页面截图;原型 `renderCalendar()`、`.day.selected`、`.todaymark` | 缩小日格空白,今天与选择态分别标识;零项日期只显示日期;显示实际查询起止日期,保留加载失败状态 |
| 日期/类型/全局明细 | 展开截图中日历被挤窄;原型第 11 行 `.workspace`、`.panel` 为右侧覆盖布局 | 恢复覆盖式侧边工作面板,不压缩日历;增加展开/收起,标题和关闭固定,正文滚动,分页固定;仍保留左侧业务操作 |
| 详情与编辑 | 原型 `renderTask()`、`followForm()` | 保留原侧边详情/表单和原业务跳转;关闭、切换和返回仍经过未保存保护,扩宽不重建表单 |
| 今日需跟进 | 仅核对 `AppointmentWorkPolicy.followToday` 和 `AppointmentWorkQueryService.summary` | 保留服务端统计、接口参数及前端数字,不在本次视觉调整中改口径 |
| 页面/操作 | 原型证据 | 承载与布局 | 实现入口及核对范围 |
|---|---|---|---|
| 日历首屏、五类计数 | `.calendar`、`renderCalendar()` | 月/周日历,固定高度日格,五类勾选与类型计数;到港参考单列且不计工作 | `AppointmentCalendarView.vue`、`WorkCalendarPanel.vue`;服务端聚合,实际日期优先由服务端给出 |
| 日历首屏、五类计数 | `.calendar`、`renderCalendar()` | 月/周日历,紧凑日格随内容自然增高,五类勾选与类型计数;到港参考单列且不计工作 | `AppointmentCalendarView.vue`、`WorkCalendarPanel.vue`;服务端聚合,实际日期优先由服务端给出 |
| 全部待办、今日、超期、未定日期、复核 | `.inboxbar`、`backlog()`、`renderList()` | 日历上方全局入口;侧边分页明细,操作在左,范围明确 | `workApi.ts`;summary 不带月历条件,全关图层不影响全局查询 |
| 日期、类型与本柜连续工作 | `openDay()`、`renderList()` 的 container 模式 | 同一侧边面板;确认路线后回本柜未完成事项 | `WorkDetailPanel.vue`;保存刷新摘要、日历和当前范围 |
| 下一步、前置事项与返回 | `nextAction()`、`renderTask()`、`.origin-context` | 主按钮直接处理当前下一步;前置项保留原事项返回入口 | 动作 code/target 白名单,重新读取目标资格;原业务执行仍回原入口 |
@@ -20,4 +198,9 @@
| 本柜接仓计划与记录 | `executeForm()`、原入库单详情 | 单计划进入对应客户原单,多计划列出本柜实际关联清单;缺计划时先预览整柜分组 | `WorkReceiptPlans.vue`;复用 container-summaries、原整柜预览/生成 API 和原入库单路由,快照与原资源重试键保持一致;实际收货仍由原仓库作业流程登记 |
| 失败、无权限、未保存 | 真实接口与应用验收规则 | 明确失败和重试;错误不改为0项,保留输入和原上下文 | 各组件直接回归;未知命令不执行,未确认保存结果禁止离开 |
| 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 |
| 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 |
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ReceivingMember, ReceivingTransport } from './receivingApi'
defineProps<{ members: ReceivingMember[]; transport?: ReceivingTransport }>()
</script>
<template>
<div aria-label="当前覆盖范围">
<div v-for="(member, index) in members" :key="`${member.batchKey}:${member.customerId}`" class="receiving-member">
<b>{{ member.appointmentNo }} · {{ member.customerName || '—' }} · 范围 {{ index + 1 }}</b>
<p v-for="source in member.sources" :key="`${source.sourceKey}:${source.destinationKey}`">{{ source.inboundCode || '—' }} · {{ source.packageCount }} 箱</p>
</div>
<p v-if="!members.length">当前无可纳入的货物。</p>
<template v-if="transport"><p v-for="(vehicle, index) in transport.vehicles" :key="index">车辆:{{ vehicle.tripNo || '车次未关联' }} · 排车单 {{ vehicle.dispatchNo || '未关联' }} · {{ vehicle.packageCount }} 箱</p><p v-for="reason in transport.blockReasons" :key="reason" role="status">{{ reason }}</p><p v-if="!transport.vehicles.length">当前未关联车辆。</p></template>
</div>
</template>
<style scoped>.receiving-member { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }</style>
@@ -0,0 +1,102 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import TripAppointmentPanel from './TripAppointmentPanel.vue'
import { getDispatch, getFbaAppointment, type DispatchBatchReference } from '@/modules/overseas-warehouse'
import { getAppointmentBatch } from './batchApi'
import { getReceivingContext } from './receivingApi'
import { batchContext } from './batch.test-support'
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getFbaAppointment: vi.fn() }))
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn() }))
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
const reference = (batchKey = 'FBA_APPOINTMENT:1', batchNo: string | null = 'AP-001'): DispatchBatchReference => ({ batchKey, batchNo, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70 }] })
function render(overrides: Partial<InstanceType<typeof TripAppointmentPanel>['$props']> = {}) { return mount(TripAppointmentPanel, { props: { dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }], previousDispatches: [], historyUnavailableReason: null, canWrite: true, ...overrides }, global: { stubs: {
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
WorkReceivingPanel: { name: 'WorkReceivingPanel', props: ['batchKey', 'initialCustomerId'], template: '<div data-receiving />' },
WorkBatchContinuation: { name: 'WorkBatchContinuation', props: ['batchKey', 'customerId'], template: '<div data-continuation />' },
AppointmentBatchForm: true, WorkAppointmentForm: true,
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' },
} } }) }
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
vi.mocked(getReceivingContext).mockResolvedValue({ batch: batchContext().appointment, agreements: [], pendingChanges: [], plans: [], executionScope: { version: 'execution-v1', members: [] }, transport: { version: 'scope-v1', vehicles: [], blockReasons: [] }, allowedActions: [], blockReasons: [] })
})
it('renders all current 40/20 destinations for the bound customer and directly opens the exact customer adjustment', async () => {
const context = batchContext(), forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment, twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
forty.items[0]!.packageCount = 40; forty.items[1]!.packageCount = 999; twenty.items = [{ ...context.appointment.items[0]!, packageCount: 20 }]
context.currentBatches = [forty, twenty]; vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).toContain('20 箱'); expect(wrapper.text()).not.toContain('999')
expect(wrapper.text()).not.toContain('internal-dispatch'); expect(wrapper.text()).not.toContain('customer-a')
await wrapper.findAll('button').find(button => button.text() === '调整本客户先送 / 后送')!.trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkReceivingPanel' }).props()).toMatchObject({ batchKey: 'FBA_APPOINTMENT:1', initialCustomerId: 'customer-a' })
wrapper.unmount()
})
it('continues a current destination by its batch and customer rather than guessing a work key', async () => {
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '继续本批当前工作')!.trigger('click'); await flushPromises()
const continuation = wrapper.getComponent({ name: 'WorkBatchContinuation' })
expect(continuation.props()).toMatchObject({ batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a' })
continuation.vm.$emit('open', 'real-work-key'); expect(wrapper.emitted('work')).toEqual([['real-work-key']]); wrapper.unmount()
})
it('uses historical source refs when the bound customer has completely left the original batch', async () => {
const context = batchContext(), moved = context.appointment.items[0]!
context.appointment.items = [context.appointment.items[1]!]
const current = batchContext('AP-LATER', 'FBA_APPOINTMENT:2').appointment
current.items = [{ ...moved, packageCount: 70 }]; context.currentBatches = [context.appointment, current]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('AP-LATER')
expect(wrapper.text()).toContain('IN-A · 70 箱')
expect(wrapper.text()).not.toContain('范围当前不可匹配'); wrapper.unmount()
})
it('reports missing references and read failures without looking up legacy appointment numbers', async () => {
const wrapper = render({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [] }] }); await flushPromises()
expect(wrapper.text()).toContain('没有预约批次引用')
expect(getDispatch).not.toHaveBeenCalled(); expect(getFbaAppointment).not.toHaveBeenCalled(); expect(getAppointmentBatch).not.toHaveBeenCalled()
vi.mocked(getAppointmentBatch).mockRejectedValueOnce(new Error('read failed'))
await wrapper.setProps({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }] }); await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('读取失败'); wrapper.unmount()
})
it('reads all stable references without a legacy appointment number and deduplicates repeated bindings', async () => {
vi.mocked(getAppointmentBatch).mockImplementation(async key => batchContext(key === 'FBA_APPOINTMENT:1' ? 'AP-001' : 'AP-002', key))
vi.mocked(getReceivingContext).mockImplementation(async key => ({ batch: batchContext('AP', key).appointment, agreements: [], pendingChanges: [], plans: [], executionScope: { version: 'v1', members: [] }, transport: { version: 'v1', vehicles: [], blockReasons: [] }, allowedActions: [], blockReasons: [] }))
const refs = [reference(), reference('FBA_APPOINTMENT:2', 'AP-002')]
const wrapper = render({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 100, appointmentBatches: refs }, { dispatchNo: 'DS-B', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }] }); await flushPromises()
expect(wrapper.findAll('article')).toHaveLength(2)
expect(wrapper.text()).toContain('DS-A、DS-B'); expect(wrapper.text()).toContain('AP-002')
expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(getReceivingContext).toHaveBeenCalledTimes(2)
expect(getDispatch).not.toHaveBeenCalled(); expect(getFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
})
it('reenters a removed customer through history and displays only current 40/20 source destinations', async () => {
const context = batchContext(), source = context.appointment.items[0]!
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment, twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
forty.items = [{ ...source, packageCount: 40 }, { ...source, id: 'other', sourceKey: 'ORDER:OTHER', inboundCode: 'OTHER-SOURCE', packageCount: 888 }]
twenty.items = [{ ...source, packageCount: 20 }, { ...context.appointment.items[1]!, packageCount: 777 }]
context.originalSourceRefs.push({ customerId: 'customer-a', sourceKey: 'ORDER:OTHER', destinationKey: 'FBA:ONT8' })
context.appointment.items = []; context.currentBatches = [forty, twenty, forty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const previous = { dispatchNo: 'DS-REMOVED', customerId: 'customer-a', recordVersion: 4, recordedAt: '2026-10-16T01:00:00Z', appointmentBatches: [{ ...reference(), sources: [{ ...reference().sources[0]!, packageCount: 9999 }] }] }
const wrapper = render({ dispatches: [], previousDispatches: [previous, { ...previous, recordVersion: 5 }] }); await flushPromises()
expect(wrapper.findAll('article')).toHaveLength(1)
expect(wrapper.text()).toContain('仅供追溯'); expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).toContain('20 箱')
for (const unexpected of ['9999', '888', '777', 'OTHER-SOURCE', '调整本客户先送 / 后送']) expect(wrapper.text()).not.toContain(unexpected)
const buttons = wrapper.findAll('button').filter(button => button.text() === '继续本批当前工作'); expect(buttons).toHaveLength(2)
await buttons[1]!.trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkBatchContinuation' }).props()).toMatchObject({ batchKey: 'FBA_APPOINTMENT:20', customerId: 'customer-a' })
expect(getAppointmentBatch).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('keeps current cards readable with incomplete history and never fabricates historical entries', async () => {
const wrapper = render({ historyUnavailableReason: '部分原绑定记录缺少来源证据' }); await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('部分原绑定记录缺少来源证据')
expect(wrapper.findAll('article')).toHaveLength(1); expect(wrapper.text()).toContain('当前关联排车单:DS-A')
expect(wrapper.text()).not.toContain('历史关联排车单:'); expect(wrapper.text()).toContain('70 箱'); wrapper.unmount()
})
it('blocks invalid reference customer identity before reading or opening current work', async () => {
const wrapper = render({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'another-customer', packageCount: 70, appointmentBatches: [reference()] }] }); await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('客户或来源范围不完整')
expect(getAppointmentBatch).not.toHaveBeenCalled(); expect(wrapper.text()).not.toContain('继续本批当前工作'); wrapper.unmount()
})
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { type DispatchBatchReference, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getAppointmentBatch, type FbaAppointmentBatchContext } from './batchApi'
import { getReceivingContext, type ReceivingContext } from './receivingApi'
import WorkReceivingPanel from './WorkReceivingPanel.vue'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
import WorkAppointmentForm from './WorkAppointmentForm.vue'
import WorkBatchContinuation from './WorkBatchContinuation.vue'
import type { TripPreviousDispatch } from '@/modules/overseas-master-data'
import type { LeaveGuard } from './useWorkDraft'
const props = defineProps<{ dispatches: Array<{ dispatchNo: string; customerId: string; packageCount: number; appointmentBatches: DispatchBatchReference[] }>; previousDispatches: TripPreviousDispatch[]; historyUnavailableReason: string | null; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; work: [key: string]; external: [path: string] }>()
type Card = { key: string; batchKey: string; batchNo: string | null; customerId: string; current: boolean; dispatchNos: string[]; historyNos: string[]; sources: DispatchBatchReference['sources']; batch: FbaAppointmentBatchContext | null; receiving: ReceivingContext | null; error: ApiErrorDetails | null }
const access = useAccessPreviewStore(), cards = ref<Card[]>([]), loading = ref(false)
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
const selected = ref<{ batchKey: string; customerId: string } | null>(null), mode = ref<'receiving' | 'batch' | 'appointment' | 'continue'>('receiving'), appointmentNo = ref('')
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() { for (const guard of guards) if (!await guard()) return false; return true }
defineExpose({ canLeave })
onBeforeRouteLeave(canLeave)
let generation = 0
async function load() {
if (!readable.value) return
const current = ++generation; loading.value = true
const batches = new Map<string, Promise<FbaAppointmentBatchContext>>(), receiving = new Map<string, Promise<ReceivingContext>>()
const references = new Map<string, Card>()
const add = (binding: { dispatchNo: string; customerId: string; appointmentBatches: DispatchBatchReference[] }, historical: boolean) => {
for (const reference of binding.appointmentBatches) {
const key = JSON.stringify([reference.batchKey, reference.customerId])
let card = references.get(key)
if (!card) { card = { key, batchKey: reference.batchKey, batchNo: reference.batchNo, customerId: reference.customerId, current: false, dispatchNos: [], historyNos: [], sources: [], batch: null, receiving: null, error: null }; references.set(key, card) }
if (reference.customerId !== binding.customerId || !reference.batchKey || !reference.sources.length) card.error = { message: '排车引用的客户或来源范围不完整,请核对原排车单。' }
card.current ||= !historical
const numbers = historical ? card.historyNos : card.dispatchNos
if (!numbers.includes(binding.dispatchNo)) numbers.push(binding.dispatchNo)
for (const source of reference.sources) if (!card.sources.some(existing => existing.sourceKey === source.sourceKey && existing.destinationKey === source.destinationKey)) card.sources.push(source)
}
}
props.dispatches.forEach(binding => add(binding, false))
props.previousDispatches.forEach(binding => add(binding, true))
const loaded = await Promise.all([...references.values()].map(async card => {
if (card.error) return card
try {
if (!batches.has(card.batchKey)) batches.set(card.batchKey, getAppointmentBatch(card.batchKey))
if (!receiving.has(card.batchKey)) receiving.set(card.batchKey, getReceivingContext(card.batchKey))
const [batch, scope] = await Promise.all([batches.get(card.batchKey)!, receiving.get(card.batchKey)!])
if (batch.appointment.batchKey !== card.batchKey || scope.batch.batchKey !== card.batchKey) throw new Error('批次范围不匹配')
card.batch = batch; card.receiving = scope
} catch (cause) { card.error = getApiErrorDetails(cause, '本客户预约与去向读取失败') }
return card
}))
if (current === generation) { cards.value = loaded; loading.value = false }
}
function customerName(card: Card) { return card.receiving?.agreements.flatMap(row => row.members).find(member => member.customerId === card.customerId)?.customerName || '—' }
function destinations(card: Card) {
if (!card.batch) return []
const original = new Set(card.batch.originalSourceRefs.filter(item => item.customerId === card.customerId && card.sources.some(source => source.sourceKey === item.sourceKey && source.destinationKey === item.destinationKey)).map(item => JSON.stringify([item.sourceKey, item.destinationKey])))
return [...new Map(card.batch.currentBatches.map(batch => [batch.batchKey, batch])).values()].map(batch => ({ batch, items: batch.items.filter(item => item.customerId === card.customerId && original.has(JSON.stringify([item.sourceKey, item.destinationKey])) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
}
async function open(batchKey: string, customerId: string, next: 'receiving' | 'batch' | 'continue') { if (await canLeave()) { selected.value = { batchKey, customerId }; mode.value = next } }
async function openAppointment(number: string) { if (await canLeave()) { appointmentNo.value = number; mode.value = 'appointment' } }
async function close(done: () => void) { if (await canLeave()) { done(); selected.value = null } }
function saved(appointment: FbaAppointmentDto) { if (appointment.batchKey && selected.value) { selected.value = { ...selected.value, batchKey: appointment.batchKey }; mode.value = 'continue' }; updated() }
function updated() { emit('updated'); void load() }
watch(() => JSON.stringify([props.dispatches, props.previousDispatches]), () => { void load() })
onMounted(load)
onBeforeUnmount(() => { ++generation })
</script>
<template>
<section aria-label="本车客户预约与后送去向">
<p v-if="!readable">需要预约及排车列表权限才能读取客户预约范围。</p>
<p v-if="loading">正在核对本车客户的预约与当前去向…</p>
<el-button :loading="loading" @click="load">刷新客户预约与去向</el-button>
<p v-if="historyUnavailableReason" role="alert">历史预约引用读取不完整:{{ historyUnavailableReason }}。当前车次仍可查看,请重试读取后再核对历史去向。</p><p v-for="binding in dispatches.filter(row => !row.appointmentBatches.length)" :key="binding.dispatchNo" role="status">{{ binding.dispatchNo }}:本排车单没有预约批次引用,请从原排车单核对业务来源。</p><article v-for="card in cards" :key="card.key" class="trip-customer-card">
<h4>{{ customerName(card) }} · {{ card.batchNo || '批次业务号未提供' }}</h4><p v-if="card.current">当前关联排车单:{{ card.dispatchNos.join('、') }}</p><p v-if="card.historyNos.length">历史关联排车单:{{ card.historyNos.join('、') }} · 仅供追溯,不代表当前装载或可排数量。</p>
<LoadErrorState v-if="card.error" title="本客户范围不可用" :message="card.error.message" @retry="load" />
<template v-if="card.batch && card.receiving">
<p v-for="pending in card.receiving.pendingChanges.filter(row => row.intent.customerId === card.customerId)" :key="pending.changeKey" role="status">待仓库回复:{{ pending.intent.reason }}。原安排保留,请继续处理。</p>
<el-button v-if="card.current" :disabled="loading || !canWrite" @click="open(card.batch.appointment.batchKey!, card.customerId, 'receiving')">调整本客户先送 / 后送</el-button>
<el-button v-if="card.current" :disabled="loading" @click="open(card.batch.appointment.batchKey!, card.customerId, 'batch')">查看批次与完整去向</el-button>
<div v-for="destination in destinations(card)" :key="destination.batch.batchKey"><b>{{ destination.batch.appointmentNo }}</b><p>{{ destination.batch.plannedDate || '日期未定' }}</p><p v-for="item in destination.items" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><el-button :disabled="loading" @click="open(destination.batch.batchKey!, card.customerId, 'continue')">继续本批当前工作</el-button></div>
<p v-if="!destinations(card).length">原客户来源范围当前不可匹配,请核对完整批次去向;此处不代表已完成或没有后送需求。</p>
</template>
</article>
<p v-if="readable && !loading && !cards.length">当前没有可读取的预约批次引用。</p>
<AppDialog :model-value="!!selected" title="客户预约与后送安排" width="min(1120px, calc(100vw - 48px))" class="admin-density-dialog admin-density" fullscreenable append-to-body :show-footer="false" :before-close="close" :close-on-click-modal="true" :close-on-press-escape="true" destroy-on-close>
<template v-if="selected">
<WorkReceivingPanel v-if="mode === 'receiving'" :key="`${selected.batchKey}:${selected.customerId}`" :batch-key="selected.batchKey" :initial-customer-id="selected.customerId" :can-write="canWrite" @updated="updated" @appointment="openAppointment" @batch="key => open(key, selected!.customerId, 'batch')" @external="path => emit('external', path)" />
<AppointmentBatchForm v-else-if="mode === 'batch'" :key="selected.batchKey" :batch-key="selected.batchKey" @updated="updated" @appointment="openAppointment" />
<WorkAppointmentForm v-else-if="mode === 'appointment'" :key="appointmentNo" :appointment-no="appointmentNo" :cancel="false" :can-write="canWrite" @saved="saved" @batch="key => open(key, selected!.customerId, 'batch')" @receiving="key => open(key, selected!.customerId, 'receiving')" />
<WorkBatchContinuation v-else :key="`${selected.batchKey}:${selected.customerId}`" :batch-key="selected.batchKey" :customer-id="selected.customerId" @open="key => emit('work', key)" />
</template>
</AppDialog>
</section>
</template>
<style scoped>.trip-customer-card { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }</style>
@@ -3,19 +3,42 @@ import { beforeEach, expect, it, vi } from 'vitest'
import WorkAppointmentForm from './WorkAppointmentForm.vue'
import { getFbaAppointment, updateFbaAppointment, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import { deleteAppointment } from './api'
import { ApiResponseError } from '@/shared/api/http'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), deleteAppointment: vi.fn() }))
const detail: FbaAppointmentDto = { id: '1', appointmentNo: 'AP-001', batchKey: 'FBA_APPOINTMENT:1', version: 3, originWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', selectedCount: 1, items: [], appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-001', appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', appointmentStartUtc: '2026-10-16T06:30:00Z', appointmentEndUtc: '2026-10-16T08:00:00Z', timezone: 'America/Los_Angeles' }
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: true, ApiErrorMeta: true } } }) }
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) })
it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => {
vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).exists()).toBe(false)
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
expect(model.confirmationNo).toBe('EXT-001'); expect(model.appointmentStatus).toBe('SUCCESS')
expect(model.appointmentAt).toBe('2026-10-15T23:30:00'); expect(model.startOffset).toBe('-07:00')
expect(wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.attributes('disabled')).toBeDefined()
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
})
it('preserves both dates of an existing cross-day appointment and its explicit offsets in the original business command', async () => {
const wrapper = render(); await flushPromises()
wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').appointmentRemark = '已核对仓库回复'
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 3, details: expect.objectContaining({ appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', startOffset: '-07:00', endOffset: '-07:00' }) }))
wrapper.unmount()
})
it('does not resubmit unchanged appointment facts, including whitespace-only edits', async () => {
const wrapper = render(); await flushPromises()
const save = wrapper.findAll('button').find(button => button.text() === '保存预约安排')!
expect(save.attributes('disabled')).toBeDefined()
wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo = ' EXT-001 '
await flushPromises(); await save.trigger('click')
expect(updateFbaAppointment).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('无需重复保存'); wrapper.unmount()
})
it('does not edit or cancel replaced snapshots and offers the stable batch context for replacement history', async () => {
vi.mocked(getFbaAppointment).mockResolvedValue({ ...detail, replacementAppointmentNos: ['AP-NEW'], replacementBatchKeys: ['FBA_APPOINTMENT:2'], repartitionReason: '分批送仓' })
const wrapper = render(true); await flushPromises()
@@ -25,3 +48,44 @@ it('does not edit or cancel replaced snapshots and offers the stable batch conte
await wrapper.findAll('button').find(button => button.text() === '查看替代批次')!.trigger('click')
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']]); wrapper.unmount()
})
const cancelled: FbaAppointmentDto = { ...detail, version: 4, appointmentStatus: 'CANCELLED', confirmationNo: null, plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentAt: null, appointmentEndAt: null, appointmentStartUtc: null, appointmentEndUtc: null }
it('rebooks the cancelled original batch as undated pending without creating or reallocating cargo', async () => {
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 5, appointmentStatus: 'PENDING' })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('货物分配仍保留')
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
expect(model.appointmentStatus).toBe('PENDING'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'PENDING', plannedDate: null, confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
const payload = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
expect(payload).not.toHaveProperty('items'); expect(payload).not.toHaveProperty('sources')
expect(deleteAppointment).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('retries an uncertain rebooking using the identical command, and preserves input on an explicit conflict', async () => {
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 6 })
vi.mocked(updateFbaAppointment).mockRejectedValueOnce(new Error('unknown outcome')).mockRejectedValueOnce(new ApiResponseError('外部约号已占用', 'A0409', 409))
const wrapper = render(); await flushPromises()
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
Object.assign(fields.props('model'), { appointmentStatus: 'SUCCESS', confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00' })
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
const first = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
expect(fields.props('disabled')).toBe(true)
await wrapper.findAll('button').find(button => button.text() === '重试本次保存')!.trigger('click'); await flushPromises()
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2, 'AP-001', first)
expect(fields.props('model').confirmationNo).toBe('NEW-EXT'); expect(fields.props('disabled')).toBe(false)
expect(wrapper.emitted('saved')).toBeUndefined()
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
const next = vi.mocked(updateFbaAppointment).mock.calls[2]![1]
expect(next.version).toBe(6); expect(next.idempotencyKey).not.toBe(first.idempotencyKey)
wrapper.unmount()
})
it('cannot cancel twice or rebook with read-only access', async () => {
vi.mocked(getFbaAppointment).mockResolvedValue(cancelled)
const wrapper = render(true); await flushPromises()
expect(wrapper.findAll('button').find(button => button.text() === '确认取消并继续跟进')!.attributes('disabled')).toBeDefined()
await wrapper.setProps({ cancel: false, canWrite: false }); await flushPromises()
expect(wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.attributes('disabled')).toBeDefined()
expect(updateFbaAppointment).not.toHaveBeenCalled(); expect(deleteAppointment).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -9,23 +9,33 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { deleteAppointment } from './api'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
const emit = defineEmits<{ saved: []; batch: [key: string] }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(form))
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(appointmentPayload(form)))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
let initialized = false
type SaveRequest = { kind: 'update'; payload: { details: ReturnType<typeof appointmentPayload>; version: number; idempotencyKey: string } } | { kind: 'cancel'; version: number; reason: string }
const pending = ref<SaveRequest | null>(null)
useWorkDraft(computed(() => JSON.stringify(form) !== baseline.value || !!reason.value), computed(() => saving.value || !!pending.value))
const changed = computed(() => JSON.stringify(appointmentPayload(form)) !== baseline.value)
useWorkDraft(computed(() => changed.value || !!reason.value), computed(() => saving.value || !!pending.value))
const replaced = computed(() => !!detail.value?.replacementAppointmentNos?.length || !!detail.value?.replacementBatchKeys?.length)
const writable = computed(() => props.canWrite && !replaced.value && detail.value?.appointmentStatus !== 'CANCELLED' && access.can(props.cancel ? 'platform:appointment:delete' : 'platform:overseas-warehouse:inbound-order:fba-appointment'))
const rebooking = computed(() => !props.cancel && detail.value?.appointmentStatus === 'CANCELLED' && !replaced.value)
const writable = computed(() => props.canWrite && !replaced.value && (!props.cancel || detail.value?.appointmentStatus !== 'CANCELLED') && access.can(props.cancel ? 'platform:appointment:delete' : 'platform:overseas-warehouse:inbound-order:fba-appointment'))
async function load(preserve = false) {
loading.value = true; error.value = null
try { detail.value = await getFbaAppointment(props.appointmentNo); if (!preserve) { Object.assign(form, createAppointmentForm(detail.value)); baseline.value = JSON.stringify(form) } }
try {
detail.value = await getFbaAppointment(props.appointmentNo)
if (!preserve || !initialized) {
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'PENDING'
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
}
}
catch (cause) { error.value = getApiErrorDetails(cause, '预约详情读取失败') }
finally { loading.value = false }
}
async function save() {
if (!detail.value || !writable.value || loading.value || error.value || saving.value) return
if (!props.cancel && !pending.value && !changed.value && !rebooking.value) return
if (!pending.value) {
if (props.cancel) {
if (!reason.value.trim()) { ElMessage.warning('请填写取消原因'); return }
@@ -38,9 +48,9 @@ async function save() {
saving.value = true; saveError.value = null
try {
if (pending.value.kind === 'cancel') await deleteAppointment(props.appointmentNo, pending.value.version, pending.value.reason)
else await updateFbaAppointment(props.appointmentNo, pending.value.payload)
pending.value = null; reason.value = ''; baseline.value = JSON.stringify(form)
ElMessage.success(props.cancel ? '原预约已取消,工作需求继续跟进' : '预约安排已保存'); emit('saved'); await load()
else detail.value = await updateFbaAppointment(props.appointmentNo, pending.value.payload)
pending.value = null; reason.value = ''; baseline.value = JSON.stringify(appointmentPayload(form))
ElMessage.success(props.cancel ? '原预约已取消,工作需求继续跟进' : '预约安排已保存'); emit('saved', detail.value); await load()
} catch (cause) { saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
finally { saving.value = false }
}
@@ -48,18 +58,21 @@ onMounted(() => load())
</script>
<template>
<section class="work-form" aria-label="预约安排">
<h3>{{ cancel ? '取消本次预约' : '登记预约安排' }}</h3>
<h3>{{ cancel ? '取消本次预约' : rebooking ? '沿原批次重新预约' : '登记预约安排' }}</h3>
<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="detail">
<p>{{ detail.fbaWarehouseCode }} · {{ detail.appointmentNo }}</p>
<p v-if="rebooking">原预约已取消,货物分配仍保留。重新预约将更新本批安排,保留取消历史;请重新填写目标日期或仓库确认,日期未定也可先保存待预约。</p>
<p v-if="replaced">原预约已被替代,保留历史只读。当前批次:{{ detail.replacementAppointmentNos?.join('、') || '—' }}。{{ detail.repartitionReason }}</p>
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" /></label></template>
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
<p v-if="!writable">当前预约仅可查看。</p>
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<el-button :type="cancel ? 'danger' : 'primary'" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次保存' : cancel ? '确认取消并继续跟进' : '保存预约安排' }}</el-button>
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
<el-button :type="cancel ? 'danger' : 'primary'" :disabled="!writable || loading || !!error || (!cancel && !rebooking && !pending && !changed)" :loading="saving" @click="save">{{ pending ? '重试本次保存' : cancel ? '确认取消并继续跟进' : rebooking ? '保存本批重新预约' : '保存预约安排' }}</el-button>
</template>
</section>
</template>
@@ -0,0 +1,36 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import WorkBatchContinuation from './WorkBatchContinuation.vue'
import { resolveBatchWork } from './workApi'
import { workDetail, workItem } from './work.test-support'
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
function render() { return mount(WorkBatchContinuation, { props: { batchKey: 'FBA_APPOINTMENT:40', labels: [{ customerId: 'customer-a', label: 'IN-A' }] }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></div>' }, ApiErrorMeta: true } } }) }
beforeEach(() => vi.clearAllMocks())
afterEach(() => vi.useRealTimers())
it('shows synchronization after a successful save and continues by reading without resubmitting', async () => {
vi.useFakeTimers()
vi.mocked(resolveBatchWork).mockRejectedValueOnce(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: workDetail({ item: workItem({ workItemKey: 'actual-key' }) }), candidates: [] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('同步中')
expect(wrapper.text()).toContain('无需重复提交')
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
expect(wrapper.emitted('open')).toEqual([['actual-key']])
wrapper.unmount()
})
it('requires a customer selection for multiple real work candidates and uses the selected resolver result', async () => {
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: null, candidates: [{ customerId: 'customer-a', workItemKey: 'real-work-a', batchNo: 'AP-40' }, { customerId: 'customer-b', workItemKey: 'real-work-b', batchNo: 'AP-40' }] }).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: 'customer-a', detail: workDetail({ item: workItem({ workItemKey: 'current-work-a' }) }), candidates: [] })
const wrapper = render(); await flushPromises()
expect(wrapper.emitted('open')).toBeUndefined(); expect(wrapper.text()).toContain('IN-A'); expect(wrapper.text()).not.toContain('customer-a')
await wrapper.findAll('button')[0]!.trigger('click'); await flushPromises()
expect(resolveBatchWork).toHaveBeenLastCalledWith('FBA_APPOINTMENT:40', 'customer-a', expect.any(AbortSignal))
expect(wrapper.emitted('open')).toEqual([['current-work-a']]); wrapper.unmount()
})
it('keeps saved results on resolution failure and only retries the read', async () => {
vi.mocked(resolveBatchWork).mockRejectedValueOnce(new Error('同步未完成')).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: workDetail({ item: workItem({ workItemKey: 'actual-key' }) }), candidates: [] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('无需重复提交'); expect(wrapper.emitted('open')).toBeUndefined()
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(resolveBatchWork).toHaveBeenCalledTimes(2); expect(wrapper.emitted('open')).toEqual([['actual-key']]); wrapper.unmount()
})
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { resolveBatchWork, type BatchWorkResolution } from './workApi'
import { useWorkSyncRead } from './useWorkSyncRead'
const props = defineProps<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> }>()
const emit = defineEmits<{ open: [key: string] }>()
const resolution = ref<BatchWorkResolution | null>(null), error = ref<ApiErrorDetails | null>(null), loading = ref(false)
const controller = new AbortController()
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
async function load(customerId = props.customerId) {
loading.value = true; error.value = null
try { const value = await syncRead.read(() => resolveBatchWork(props.batchKey, customerId, controller.signal), controller.signal); if (controller.signal.aborted) return; resolution.value = value; if (value.detail) emit('open', value.detail.item.workItemKey) }
catch (cause) { if (!controller.signal.aborted) error.value = getApiErrorDetails(cause, '当前批次下一步读取失败') }
finally { loading.value = false }
}
onMounted(() => load())
onBeforeUnmount(() => controller.abort())
</script>
<template>
<section aria-label="当前批次续办">
<p role="status">{{ syncing ? '预约工作清单同步中,正在自动读取当前批次下一步;已保存内容无需重复提交。' : '正在定位当前批次的工作事项;已保存的内容无需重复提交。' }}</p>
<LoadErrorState v-if="error" title="当前批次下一步读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="resolution && !resolution.detail && !error"><p>本批有多个客户,请选择本次继续办理的货物。</p><el-button v-for="(candidate, index) in resolution.candidates" :key="candidate.workItemKey" :disabled="loading" @click="load(candidate.customerId)">{{ candidate.batchNo || '—' }} · {{ labels?.find(row => row.customerId === candidate.customerId)?.label || `客户范围 ${index + 1}` }}</el-button><p v-if="!resolution.candidates.length">当前没有可定位的送仓事项,请核对服务端同步结果。</p></template>
</section>
</template>
@@ -1,12 +1,63 @@
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import { afterEach, expect, it, vi } from 'vitest'
import WorkCalendarPanel from './WorkCalendarPanel.vue'
import { workCalendar } from './work.test-support'
import { workTypes } from './workApi'
function render() { return mount(WorkCalendarPanel, { props: { anchor: '2026-10-15', mode: 'month', flow: '', types: [...workTypes], calendar: workCalendar, loading: false, failed: false, reference: false, arrivals: [], referenceReady: false } }) }
afterEach(() => vi.useRealTimers())
it('keeps a busy day at five type summaries and consumes authoritative transport counts unchanged', async () => {
const wrapper = render()
const counts = { PICKUP: 300, WAREHOUSE_RECEIPT: 300, DELIVERY: 2, TRANSPORT: 1, EMPTY_RETURN: 300 }
await wrapper.setProps({ calendar: { ...workCalendar, days: [{ ...workCalendar.days[0]!, total: 903, typeCounts: counts }] } })
const day = wrapper.get('[data-date="2026-10-15"]')
expect(day.findAll('.type-counts button')).toHaveLength(5)
expect(day.get('[data-type="TRANSPORT"]').text()).toContain('1')
expect(day.get('[data-type="DELIVERY"]').text()).toContain('2')
await day.get('[data-type="TRANSPORT"]').trigger('click')
expect(wrapper.emitted('day')).toEqual([['2026-10-15', 'TRANSPORT']]); wrapper.unmount()
})
it('offers all four flows directly and emits the original filter values with an exclusive selection', async () => {
const wrapper = render()
const buttons = wrapper.findAll('.flow-controls button')
expect(buttons.map(button => button.text())).toEqual(['全部流程', '海外仓中转', '整柜直送', '路线待确认'])
expect(wrapper.find('select').exists()).toBe(false)
for (const [index, flow] of (['', 'TRANSFER', 'DIRECT', 'UNKNOWN'] as const).entries()) {
await buttons[index]!.trigger('click')
expect(wrapper.emitted('flow')?.at(-1)).toEqual([flow])
await wrapper.setProps({ flow })
expect(wrapper.findAll('.flow-controls button[aria-pressed=true]')).toHaveLength(1)
expect(buttons[index]!.attributes('aria-pressed')).toBe('true')
}
wrapper.unmount()
})
it('distinguishes today from the selected day and type without changing the selected scope', async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-15T12:00:00'))
const wrapper = render()
await wrapper.setProps({ selectedDate: '2026-10-16' })
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').attributes('aria-current')).toBe('date')
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').attributes('aria-pressed')).toBe('false')
expect(wrapper.get('[data-date="2026-10-16"]').classes()).toContain('is-selected')
await wrapper.setProps({ selectedDate: '2026-10-15', selectedType: 'DELIVERY' })
expect(wrapper.get('[data-date="2026-10-15"] [data-type="DELIVERY"]').attributes('aria-pressed')).toBe('true')
expect(wrapper.get('[data-date="2026-10-15"] [data-type="PICKUP"]').attributes('aria-pressed')).toBe('false')
wrapper.unmount()
})
it('shows the actual cross-month interval and hides empty-day zeros while keeping dates clickable', async () => {
const wrapper = render()
expect(wrapper.get('.calendar-counts').text()).toContain('2026-09-28 ~ 2026-11-08')
const empty = wrapper.get('[data-date="2026-10-01"] .day-heading')
expect(empty.text()).toBe('1')
expect(empty.attributes('disabled')).toBeUndefined()
await empty.trigger('click'); expect(wrapper.emitted('day')).toEqual([['2026-10-01']])
expect(wrapper.get('footer').text()).toContain('未定日期请从上方待办查看')
await wrapper.setProps({ mode: 'week' })
expect(wrapper.findAll('.work-day')).toHaveLength(7)
expect(wrapper.get('.view-switch button:last-child').attributes('aria-pressed')).toBe('true')
wrapper.unmount()
})
it('renders server type counts instead of individual event cards and opens exact date/type details', async () => {
const wrapper = render()
expect(wrapper.findAll('.work-day')).toHaveLength(42)
expect(wrapper.findAll('.work-day')).toHaveLength(35)
const day = wrapper.get('[data-date="2026-10-15"]')
expect(day.get('.day-heading').text()).toContain('2 项')
await day.get('[data-type="DELIVERY"]').trigger('click')
@@ -1,61 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { calendarDates, type CalendarMode } from './calendar'
import { calendarDates, dateKey, type CalendarMode } from './calendar'
import { workTypes, type WorkCalendar, type WorkFlow, type WorkType } from './workApi'
import { typeLabels } from './workPresentation'
import { flowLabels, typeLabels } from './workPresentation'
import type { ArrivalCalendarEntry } from './api'
const props = defineProps<{ anchor: string; mode: CalendarMode; flow: WorkFlow | ''; types: WorkType[]; calendar: WorkCalendar | null; loading: boolean; failed: boolean; reference: boolean; arrivals: ArrivalCalendarEntry[]; referenceReady: boolean }>()
const props = defineProps<{ anchor: string; mode: CalendarMode; flow: WorkFlow | ''; types: WorkType[]; calendar: WorkCalendar | null; loading: boolean; failed: boolean; reference: boolean; arrivals: ArrivalCalendarEntry[]; referenceReady: boolean; selectedDate?: string; selectedType?: WorkType }>()
const emit = defineEmits<{ anchor: [offset: number]; today: []; mode: [value: CalendarMode]; flow: [value: WorkFlow | '']; types: [value: WorkType[]]; reference: [value: boolean]; day: [date: string, type?: WorkType]; arrivals: [date: string] }>()
const days = computed(() => calendarDates(props.anchor, props.mode))
const today = dateKey(new Date())
const flows: Array<{ value: WorkFlow | ''; label: string }> = [{ value: '', label: '全部流程' }, { value: 'TRANSFER', label: flowLabels.TRANSFER }, { value: 'DIRECT', label: flowLabels.DIRECT }, { value: 'UNKNOWN', label: flowLabels.UNKNOWN }]
const aggregates = computed(() => new Map(props.calendar?.days.map(day => [day.date, day]) || []))
const heading = computed(() => props.mode === 'month' ? `${props.anchor.slice(0, 4)}年${Number(props.anchor.slice(5, 7))}月` : `${days.value[0]} — ${days.value.at(-1)}`)
function toggle(type: WorkType, checked: boolean) { emit('types', checked ? [...props.types, type] : props.types.filter(value => value !== type)) }
function arrivalCount(date: string) { return props.arrivals.filter(row => row.arrivalAt.slice(0, 10) === date).length }
</script>
<template>
<section class="work-calendar" aria-label="预约工作日历" :aria-busy="loading">
<section class="work-calendar" :class="{ 'week-view': mode === 'week' }" aria-label="预约工作日历" :aria-busy="loading">
<div v-if="$slots.inbox" class="global-work"><slot name="inbox" /></div>
<header class="calendar-toolbar">
<label>流程 <select aria-label="日历流程" :value="flow" @change="emit('flow', ($event.target as HTMLSelectElement).value as WorkFlow | '')"><option value="">全部流程</option><option value="TRANSFER">海外仓中转</option><option value="DIRECT">整柜直送</option><option value="UNKNOWN">路线待确认</option></select></label>
<div class="period-controls"><button type="button" :aria-pressed="mode === 'month'" @click="emit('mode', 'month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="emit('mode', 'week')">周</button><button type="button" aria-label="上一时段" @click="emit('anchor', -1)">‹</button><strong>{{ heading }}</strong><button type="button" aria-label="下一时段" @click="emit('anchor', 1)">›</button><button type="button" @click="emit('today')">今天</button></div>
<div class="flow-controls" role="group" aria-label="日历流程"><span class="muted">流程</span><button v-for="option in flows" :key="option.value" type="button" :data-flow="option.value" :aria-pressed="flow === option.value" @click="emit('flow', option.value)">{{ option.label }}</button></div>
<div class="period-controls"><div class="view-switch" aria-label="日历视图"><button type="button" :aria-pressed="mode === 'month'" @click="emit('mode', 'month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="emit('mode', 'week')">周</button></div><button type="button" aria-label="上一时段" @click="emit('anchor', -1)">‹</button><strong>{{ heading }}</strong><button type="button" aria-label="下一时段" @click="emit('anchor', 1)">›</button><button type="button" title="定位到浏览器当前日期" @click="emit('today')">今天</button></div>
<span class="muted">作业地点当地时间</span>
</header>
<div class="work-layers"><span class="muted">显示类型</span><label v-for="type in workTypes" :key="type" :data-type="type"><input type="checkbox" :checked="types.includes(type)" @change="toggle(type, ($event.target as HTMLInputElement).checked)">{{ typeLabels[type] }}</label><label><input type="checkbox" :checked="reference" @change="emit('reference', ($event.target as HTMLInputElement).checked)">到港参考</label></div>
<div class="calendar-counts" aria-live="polite">
<span v-if="loading">正在读取工作统计…</span><span v-else-if="failed">工作统计加载失败</span><span v-else-if="!types.length">未勾选工作类型,全局待办仍可查看</span>
<template v-else-if="calendar"><strong>当前显示区间 · {{ calendar.total }} 项工作</strong><span>待安排 {{ calendar.counts.pending }}</span><span>已落实 {{ calendar.counts.confirmed }}</span><span>无需预约 {{ calendar.counts.noBookingRequired }}</span><span>需复核 {{ calendar.counts.review }}</span><span>已完成 {{ calendar.counts.completed }}</span></template>
<template v-else-if="calendar"><strong>当前显示区间 {{ calendar.from }} ~ {{ calendar.to }} · {{ calendar.total }} 项工作</strong><span :class="{ 'is-zero': calendar.counts.pending === 0 }">待安排 {{ calendar.counts.pending }}</span><span :class="{ 'is-zero': calendar.counts.confirmed === 0 }">已落实 {{ calendar.counts.confirmed }}</span><span :class="{ 'is-zero': calendar.counts.noBookingRequired === 0 }">无需预约 {{ calendar.counts.noBookingRequired }}</span><span :class="{ 'is-zero': calendar.counts.review === 0 }">需复核 {{ calendar.counts.review }}</span><span :class="{ 'is-zero': calendar.counts.completed === 0 }">已完成 {{ calendar.counts.completed }}</span></template>
</div>
<div class="weekdays"><span v-for="day in ['一', '二', '三', '四', '五', '六', '日']" :key="day">周{{ day }}</span></div>
<div class="calendar-days">
<div v-for="date in days" :key="date" class="work-day" :class="{ outside: mode === 'month' && !date.startsWith(anchor.slice(0, 7)) }" :data-date="date">
<button class="day-heading" type="button" :disabled="loading || failed || !types.length" :aria-label="`${date} 工作明细`" @click="emit('day', date)"><span>{{ Number(date.slice(8)) }}</span><span>{{ !loading && !failed && types.length ? `${aggregates.get(date)?.total ?? 0} 项` : '—' }}</span></button>
<div class="type-counts"><template v-if="!loading && !failed && types.length"><button v-for="type in workTypes.filter(type => types.includes(type) && (aggregates.get(date)?.typeCounts[type] ?? 0) > 0)" :key="type" type="button" :data-type="type" :aria-label="`${date} ${typeLabels[type]} ${aggregates.get(date)?.typeCounts[type]} 项`" @click="emit('day', date, type)"><span>{{ typeLabels[type] }}</span><b>{{ aggregates.get(date)?.typeCounts[type] }}</b></button></template></div>
<div v-for="date in days" :key="date" class="work-day" :class="{ outside: mode === 'month' && !date.startsWith(anchor.slice(0, 7)), 'is-today': date === today, 'is-selected': date === selectedDate }" :data-date="date">
<button class="day-heading" type="button" :disabled="loading || failed || !types.length" :aria-current="date === today ? 'date' : undefined" :aria-pressed="date === selectedDate" :aria-label="`${date} 工作明细`" @click="emit('day', date)"><span class="date-number">{{ Number(date.slice(8)) }}</span><small v-if="date === today" class="today-mark" title="浏览器当前日期">今天</small><span v-if="loading || failed || !types.length" class="day-total">—</span><span v-else-if="(aggregates.get(date)?.total ?? 0) > 0" class="day-total">{{ aggregates.get(date)?.total }} 项</span></button>
<div class="type-counts"><template v-if="!loading && !failed && types.length"><button v-for="type in workTypes.filter(type => types.includes(type) && (aggregates.get(date)?.typeCounts[type] ?? 0) > 0)" :key="type" type="button" :data-type="type" :aria-pressed="date === selectedDate && type === selectedType" :aria-label="`${date} ${typeLabels[type]} ${aggregates.get(date)?.typeCounts[type]} 项`" @click="emit('day', date, type)"><span>{{ typeLabels[type] }}</span><b>{{ aggregates.get(date)?.typeCounts[type] }}</b></button></template></div>
<button v-if="reference && referenceReady && arrivalCount(date)" type="button" class="arrival-reference" @click="emit('arrivals', date)">到港参考 {{ arrivalCount(date) }} 柜</button>
</div>
</div>
<footer class="muted">点击日期或类型查看明细;到港参考不计工作量。已执行按实际日期展示。</footer>
<footer class="muted">日历只展示有作业日期的事项,未定日期请从上方待办查看。已执行按实际日期展示;到港参考不计工作量。</footer>
</section>
</template>
<style scoped>
.work-calendar { min-width: 0; border: 1px solid var(--cc-border); background: var(--admin-surface); }
.calendar-toolbar, .period-controls, .work-layers, .calendar-counts { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.calendar-toolbar { justify-content: space-between; padding: var(--admin-space-5); }
.calendar-toolbar label { display: flex; align-items: center; gap: var(--admin-space-3); }
.global-work { border-bottom: 1px solid var(--cc-border); }
.calendar-toolbar { justify-content: flex-start; column-gap: var(--admin-space-6); padding: var(--admin-space-2) var(--admin-space-5); }
.flow-controls { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-2); }
.period-controls strong { min-width: 100px; text-align: center; }
.work-layers, .calendar-counts { padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--cc-border); }
.work-layers, .calendar-counts { padding: var(--admin-space-2) var(--admin-space-5); border-top: 1px solid var(--cc-border); }
.work-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-2); cursor: pointer; }
.calendar-counts { min-height: 32px; font-size: var(--admin-font-caption); }
.calendar-counts { min-height: 24px; font-size: var(--admin-font-caption); }
.calendar-counts .is-zero { color: var(--el-text-color-secondary); }
.weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.weekdays { text-align: center; padding-block: var(--admin-space-3); background: var(--el-fill-color-light); }
.work-day { height: 140px; min-height: 140px; max-height: 140px; box-sizing: border-box; overflow: hidden; border-top: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); padding: var(--admin-space-2); }
.weekdays span:nth-last-child(-n+2) { color: var(--el-text-color-secondary); }
.work-day { height: 146px; min-height: 146px; max-height: 146px; box-sizing: border-box; border-top: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); padding: var(--admin-space-1); }
.week-view .work-day { height: 270px; min-height: 270px; max-height: 270px; }
.work-day:nth-child(7n) { border-right: 0; }
.outside { background: var(--el-fill-color-lighter); }
.day-heading { display: flex; justify-content: space-between; width: 100%; border: 0; padding: 0 var(--admin-space-2); min-height: 24px; background: transparent; }
.type-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 24px; gap: 2px; min-height: 76px; }
.type-counts button { display: flex; align-items: center; justify-content: space-between; gap: 2px; border: 1px solid currentColor; padding: 0 3px; font-size: var(--admin-font-caption); background: transparent; min-width: 0; min-height: 24px; }
.work-calendar .day-heading { display: flex; align-items: center; gap: var(--admin-space-1); width: 100%; border: 0; padding: 0; min-height: 24px; background: transparent; margin-bottom: var(--admin-space-1); }
.date-number { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; border-radius: 50%; font-weight: 600; font-variant-numeric: tabular-nums; }
.is-today .date-number { color: var(--el-color-white); background: var(--el-color-primary); font-weight: 600; }
.today-mark { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
.day-total { margin-left: auto; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.work-day.is-selected { box-shadow: inset 0 0 0 2px var(--el-color-primary); background: var(--el-color-primary-light-9); }
.outside .day-heading { color: var(--el-text-color-secondary); }
.type-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: minmax(24px, auto); gap: var(--admin-space-1); }
.work-calendar .type-counts button { display: flex; align-items: center; justify-content: space-between; gap: 2px; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); padding: 0 var(--admin-space-1); font-size: var(--admin-font-caption); background: color-mix(in srgb, currentColor 7%, var(--admin-surface)); min-width: 0; min-height: 24px; white-space: nowrap; }
.work-calendar .type-counts button:hover:not(:disabled) { border-color: currentColor; background: color-mix(in srgb, currentColor 12%, var(--admin-surface)); }
.work-calendar .type-counts button[aria-pressed=true] { border-color: currentColor; background: color-mix(in srgb, currentColor 14%, var(--admin-surface)); box-shadow: inset 0 0 0 1px currentColor; }
.type-counts b { font-variant-numeric: tabular-nums; }
[data-type=PICKUP] { color: var(--el-color-primary); } [data-type=WAREHOUSE_RECEIPT] { color: #8356ad; } [data-type=DELIVERY] { color: #27815c; } [data-type=TRANSPORT] { color: #b26a12; } [data-type=EMPTY_RETURN] { color: #a64f69; }
.arrival-reference { border: 0; background: transparent; padding: 0; min-height: 18px; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.work-calendar .arrival-reference { display: block; border: 0; background: transparent; padding: 0 var(--admin-space-1); margin-top: var(--admin-space-1); min-height: 24px; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.work-calendar .arrival-reference:hover { color: var(--el-text-color-regular); background: var(--el-fill-color-light); }
footer { padding: var(--admin-space-3) var(--admin-space-5); }
button, select { font: inherit; cursor: pointer; border-radius: var(--admin-radius); min-height: var(--admin-control-height); color: inherit; }
button:disabled { cursor: default; opacity: .6; } button:focus-visible, select:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
button { font: inherit; cursor: pointer; border-radius: var(--admin-radius); min-height: var(--admin-control-height); color: inherit; border: 1px solid var(--cc-border); background: var(--admin-surface); padding: 0 var(--admin-space-3); }
.view-switch { display: inline-flex; }
.view-switch button { border-radius: 0; min-width: 36px; }
.view-switch button:first-child { border-radius: var(--admin-radius) 0 0 var(--admin-radius); }
.view-switch button:last-child { border-radius: 0 var(--admin-radius) var(--admin-radius) 0; margin-left: -1px; }
.view-switch button[aria-pressed=true], .flow-controls button[aria-pressed=true] { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary); color: var(--el-color-primary); position: relative; }
button:hover:not(:disabled) { border-color: var(--el-color-primary); }
button:disabled { cursor: default; opacity: .6; } button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
.muted { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
</style>
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -11,7 +11,7 @@ import type { SourceReference } from './workApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
import { workReason } from './workPresentation'
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(form))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
@@ -35,7 +35,7 @@ async function save() {
try {
const result = await createSourceAppointment(pending.value)
pending.value = null; baseline.value = JSON.stringify(form)
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved')
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved', result)
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '创建结果待确认,请重试本次请求')
if (rejectedCommand(cause)) { pending.value = null; await load() }
@@ -1,24 +1,75 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDetailPanel from './WorkDetailPanel.vue'
import { getWorkDetail, type WorkAction, type WorkDetail } from './workApi'
import { getWorkDetail, resolveBatchWork, type WorkAction, type WorkDetail } from './workApi'
import { batchContext } from './batch.test-support'
import { followAction, workDetail, workItem } from './work.test-support'
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn(), resolveBatchWork: vi.fn() }))
function render(detail: WorkDetail, initialAction?: WorkAction) {
vi.mocked(getWorkDetail).mockResolvedValue(detail)
return mount(WorkDetailPanel, { props: { itemKey: detail.item.workItemKey, initialAction, canLeave: async () => true }, global: { stubs: {
ElButton: { template: '<button><slot /></button>' },
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
CargoVerificationForm: { name: 'CargoVerificationForm', props: ['source'], template: '<div data-cargo />' },
WorkCommandForm: { name: 'WorkCommandForm', props: ['detail', 'action'], template: '<div data-command />' },
WorkAppointmentForm: { name: 'WorkAppointmentForm', props: ['appointmentNo', 'canWrite'], template: '<div data-appointment />' },
WorkReceivingPanel: { name: 'WorkReceivingPanel', props: ['batchKey', 'canWrite'], template: '<div data-receiving-scope />' },
WorkCreateAppointmentForm: { name: 'WorkCreateAppointmentForm', props: ['sources', 'canWrite'], template: '<div data-create />' },
WorkDispatchForm: { name: 'WorkDispatchForm', props: ['batchKeys', 'canWrite'], template: '<div data-dispatch />' },
WorkExistingDispatchForm: { name: 'WorkExistingDispatchForm', props: ['dispatches', 'canWrite'], template: '<div data-existing-dispatch />' },
WorkReceiptPlans: { name: 'WorkReceiptPlans', props: ['containerBookingId'], template: '<div data-receiving />' },
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true,
} } })
}
beforeEach(() => vi.clearAllMocks())
it('navigates the server-selected transport next step outside the delivery write permissions', async () => {
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
const wrapper = render(workDetail({ item: workItem({ nextAction: transport, allowedActions: [followAction] }) }))
await flushPromises()
await wrapper.get('.primary-next-action').trigger('click'); await flushPromises()
expect(wrapper.emitted('open')).toEqual([['transport-current', transport]])
expect(wrapper.text()).not.toContain('操作资格已变化')
expect(wrapper.find('[data-dispatch]').exists()).toBe(false)
wrapper.unmount()
const target = render(workDetail({ item: workItem({ workItemKey: 'transport-current', type: 'TRANSPORT', allowedActions: [], nextAction: null }) }), transport)
await flushPromises()
expect(target.find('[data-dispatch]').exists()).toBe(false)
expect(target.emitted('open')).toBeUndefined()
target.unmount()
})
it('stays on the saved item and offers only the newly fetched primary next step', async () => {
const wrapper = render(workDetail(), followAction); await flushPromises()
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ nextAction: transport, allowedActions: [transport, followAction] }) }))
wrapper.getComponent({ name: 'WorkCommandForm' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
expect(wrapper.find('[data-command]').exists()).toBe(false)
expect(wrapper.text()).toContain('本次保存已完成')
expect(wrapper.get('.primary-next-action').text()).toBe('继续安排本批车辆')
expect(wrapper.findAll('.more-actions button').map(button => button.text())).toEqual(['记录回复'])
await wrapper.get('.primary-next-action').trigger('click'); await flushPromises()
expect(wrapper.emitted('open')).toEqual([['transport-current', transport]])
wrapper.unmount()
})
it('does not repeat a saved command or enable stale actions when the next-step read fails', async () => {
const wrapper = render(workDetail(), followAction); await flushPromises()
vi.mocked(getWorkDetail).mockRejectedValueOnce(new Error('下一步服务暂不可用'))
wrapper.getComponent({ name: 'WorkCommandForm' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
expect(wrapper.text()).toContain('无需重复提交已保存内容')
expect(wrapper.get('.primary-next-action').attributes('disabled')).toBeDefined()
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(wrapper.get('.primary-next-action').attributes('disabled')).toBeUndefined()
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it('shows an undated job separately from its follow-up date and flags missing follow-up ownership', async () => {
const wrapper = render(workDetail()); await flushPromises()
expect(wrapper.get('.followup-summary').text()).toContain('2026-10-15')
expect(wrapper.get('.followup-summary').text()).toContain('作业日期未定,跟进日不作为作业日期')
wrapper.unmount()
const missing = render(workDetail({ item: workItem({ owner: null, followupDate: null }) })); await flushPromises()
expect(missing.text()).toContain('负责人待分派')
expect(missing.text()).toContain('请补充下一次跟进安排')
missing.unmount()
})
it('never sends customer-warehouse dispatch batches to the FBA candidate endpoint', async () => {
const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['DISPATCH:9007199254740999'] } }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
@@ -81,6 +132,41 @@ it('uses the matched appointment business number and never passes the internal a
expect(missing.findComponent({ name: 'WorkAppointmentForm' }).exists()).toBe(false)
expect(missing.text()).toContain('尚未关联可操作的预约记录'); missing.unmount()
})
it('opens the receiving scope for the exact batch without changing the current work identity or losing result context', async () => {
const action: WorkAction = { code: 'CHANGE_BOOKING', label: '调整预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: 'internal-id', batchKey: 'FBA_APPOINTMENT:1', sources: [] } }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }), references: [{ kind: 'FBA_APPOINTMENT', key: 'FBA_APPOINTMENT:1', businessNo: 'AP-TEST', label: '预约' }] }), action)
await flushPromises(); wrapper.getComponent({ name: 'WorkAppointmentForm' }).vm.$emit('receiving', 'FBA_APPOINTMENT:1'); await flushPromises()
const scope = wrapper.getComponent({ name: 'WorkReceivingPanel' })
expect(scope.props('batchKey')).toBe('FBA_APPOINTMENT:1')
scope.vm.$emit('updated'); await flushPromises()
expect(wrapper.emitted('updated')).toHaveLength(1)
expect(getWorkDetail).toHaveBeenCalledTimes(2)
expect(getWorkDetail).toHaveBeenLastCalledWith('work-delivery-1', expect.any(AbortSignal))
expect(wrapper.findComponent({ name: 'WorkReceivingPanel' }).exists()).toBe(true)
expect(wrapper.emitted('saved')).toBeUndefined()
scope.vm.$emit('appointment', 'AP-LATER'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkAppointmentForm' }).props('appointmentNo')).toBe('AP-LATER')
wrapper.unmount()
})
it('resolves the saved later batch and offers its real vehicle next action instead of refreshing the historical work', async () => {
const edit: WorkAction = { code: 'CHANGE_BOOKING', label: '调整预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: '1', batchKey: 'FBA_APPOINTMENT:1', sources: [] } }
const original = workDetail({ item: workItem({ allowedActions: [edit], nextAction: edit }), references: [{ kind: 'FBA_APPOINTMENT', key: 'FBA_APPOINTMENT:1', businessNo: 'AP-OLD', label: '预约' }] })
const wrapper = render(original, edit); await flushPromises()
wrapper.getComponent({ name: 'WorkAppointmentForm' }).vm.$emit('receiving', 'FBA_APPOINTMENT:1'); await flushPromises()
wrapper.getComponent({ name: 'WorkReceivingPanel' }).vm.$emit('appointment', 'AP-LATER-40'); await flushPromises()
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'real-transport-later', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:40'] } }
const childDetail = workDetail({ item: workItem({ workItemKey: 'real-work-child', batchKey: 'FBA_APPOINTMENT:40', nextAction: transport, allowedActions: [transport] }) })
vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: 'customer-a', detail: childDetail, candidates: [] })
wrapper.getComponent({ name: 'WorkAppointmentForm' }).vm.$emit('saved', batchContext('AP-LATER-40', 'FBA_APPOINTMENT:40').appointment); await flushPromises()
expect(resolveBatchWork).toHaveBeenCalledWith('FBA_APPOINTMENT:40', undefined, expect.any(AbortSignal))
expect(getWorkDetail).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('open')).toEqual([['real-work-child']]); wrapper.unmount()
const current = render(childDetail); await flushPromises()
expect(current.get('.primary-next-action').text()).toBe('继续安排本批车辆')
await current.get('.primary-next-action').trigger('click'); await flushPromises()
expect(current.emitted('open')).toEqual([['real-transport-later', transport]]); current.unmount()
})
it('displays all associated containers of a shared trip without creating duplicate transport work', async () => {
const wrapper = render(workDetail({ item: workItem({ type: 'TRANSPORT', containers: [{ containerBookingId: '1', bookingNo: 'D-1', containerNo: 'TEST-CN-A' }, { containerBookingId: '2', bookingNo: 'D-2', containerNo: 'TEST-CN-B' }] }) })); await flushPromises()
expect(wrapper.findAll('.container-links button')).toHaveLength(2)
@@ -4,7 +4,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getWorkDetail, type WorkAction, type WorkDetail, type WorkCommand, type WorkCommandResult } from './workApi'
import { actionAllowed, actionRoute, referenceRoute, commandName, flowLabels, typeLabels, workDate, workStatus, workReason } from './workPresentation'
import { actionAllowed, actionRoute, referenceRoute, commandName, flowLabels, typeLabels, workDate, workStatus, workReason, sameAction } from './workPresentation'
import CargoVerificationForm from './CargoVerificationForm.vue'
import WorkCommandForm from './WorkCommandForm.vue'
import WorkAppointmentForm from './WorkAppointmentForm.vue'
@@ -13,17 +13,27 @@ import WorkDispatchForm from './WorkDispatchForm.vue'
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
import WorkReceiptPlans from './WorkReceiptPlans.vue'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
import WorkReceivingPanel from './WorkReceivingPanel.vue'
import WorkBatchContinuation from './WorkBatchContinuation.vue'
import type { FbaAppointmentDto } from '@/modules/overseas-warehouse'
import type { LeaveGuard } from './useWorkDraft'
import { useWorkSyncRead } from './useWorkSyncRead'
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
const receivingOwners = computed(() => [detail.value?.item.owner, ...(detail.value?.relatedItems.map(item => item.owner) || [])].filter((person): person is { id: string; name: string } => !!person?.id))
const actionNotice = ref('')
const saveCompleted = ref(false)
const creatingFromCargo = ref(false)
const batchKey = ref<string | null>(null), openedAppointmentNo = ref<string | null>(null)
const receivingKey = ref<string | null>(null)
const continuation = ref<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> } | null>(null)
let controller: AbortController | null = null, request = 0
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
async function load() {
const generation = ++request; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = null
try { const result = await getWorkDetail(props.itemKey, controller.signal); if (generation === request) detail.value = result }
const signal = controller.signal
try { const result = await syncRead.read(() => getWorkDetail(props.itemKey, signal), signal); if (generation === request) detail.value = result }
catch (cause) { if (generation === request) error.value = getApiErrorDetails(cause, '工作事项读取失败') }
finally { if (generation === request) loading.value = false }
}
@@ -49,24 +59,40 @@ const existingDispatches = computed(() => {
return /^DISPATCH:\d+$/.test(batchKey) && item?.batchKey === batchKey && item.batchNo ? [{ batchKey, dispatchNo: item.batchNo }] : []
})
const canAct = computed(() => !!detail.value && !!action.value && actionAllowed(detail.value.item, action.value) && !error.value && !loading.value)
const secondaryActions = computed(() => detail.value?.item.allowedActions.filter(candidate => candidate.code !== action.value?.code && (action.value || !detail.value?.item.nextAction || !sameAction(candidate, detail.value.item.nextAction))) || [])
async function choose(next: WorkAction) {
if (!detail.value || loading.value || error.value || !await props.canLeave()) return
actionNotice.value = ''
if (!actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
// 跨事项的主要下一步只用于导航,目标面板会重新读取并校验自己的写入资格。
const linkedNext = next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey && detail.value.item.nextAction && sameAction(next, detail.value.item.nextAction)
if (!linkedNext && !actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
saveCompleted.value = false
if (next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey) { emit('open', next.targetWorkItemKey, next); return }
const path = actionRoute(next, detail.value.references, detail.value.item)
if (path) { emit('external', path); return }
action.value = next; batchKey.value = null; openedAppointmentNo.value = null; creatingFromCargo.value = false
action.value = next; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; continuation.value = null; creatingFromCargo.value = false
if (next.code === 'EDIT_BATCH' && next.target.kind === 'FBA_APPOINTMENT' && next.target.batchKey) { batchKey.value = next.target.batchKey; return }
if (next.target.kind === 'WORK_COMMAND' && !commandName(next)) actionNotice.value = '当前操作尚未提供可用入口,请联系负责人核对。'
if (next.target.kind === 'FBA_APPOINTMENT' && !fbaNumber.value && !createSources.value) actionNotice.value = '尚未关联可操作的预约记录,请先核实批次与预约关系。'
if (next.target.kind === 'FLEET_TRIP' && !dispatchBatchKeys.value?.length && !existingDispatches.value.length) actionNotice.value = '尚未关联可进入的车次或可排批次,请核对运输来源。'
}
function saved(result?: WorkCommandResult, command?: WorkCommand['command']) { emit('saved', result, command) }
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
action.value = null; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; creatingFromCargo.value = false; actionNotice.value = ''; saveCompleted.value = true
emit('saved', result, command)
await load()
}
async function savedAppointment(appointment: FbaAppointmentDto) {
if (!appointment.batchKey) { await saved(); return }
action.value = null; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; saveCompleted.value = true
continuation.value = { batchKey: appointment.batchKey, labels: [...new Set(appointment.items.map(item => item.customerId))].map(customerId => ({ customerId, label: appointment.items.filter(item => item.customerId === customerId).map(item => item.inboundCode || '—').join('、') })) }
emit('updated')
}
async function receivingUpdated() { emit('updated'); await load() }
async function cargoSaved() { emit('updated'); await load() }
async function createFromCargo() { if (canAct.value && await props.canLeave()) creatingFromCargo.value = true }
async function openBatch(key: string) { if (await props.canLeave()) { batchKey.value = key; openedAppointmentNo.value = null } }
async function openAppointment(number: string) { if (await props.canLeave()) { openedAppointmentNo.value = number; batchKey.value = null } }
async function openBatch(key: string) { if (await props.canLeave()) { batchKey.value = key; openedAppointmentNo.value = null; receivingKey.value = null } }
async function openAppointment(number: string) { if (await props.canLeave()) { openedAppointmentNo.value = number; batchKey.value = null; receivingKey.value = null } }
async function openReceiving(key: string) { if (await props.canLeave()) { receivingKey.value = key; batchKey.value = null; openedAppointmentNo.value = null } }
onMounted(async () => {
await load()
// A predecessor's action is only a hint: use the newly fetched item's own qualification.
@@ -81,29 +107,34 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
<template>
<div :aria-busy="loading">
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在读取当前事项与操作资格…</p>
<p v-if="loading" role="status">{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}</p>
<template v-if="detail">
<h3>{{ detail.item.title }}</h3><p>{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}</p>
<p>{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}</p><p>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}</p>
<p class="followup-summary">下次跟进:{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'"> · 作业日期未定,跟进日不作为作业日期</span></p>
<p v-if="detail.item.executionStatus !== 'COMPLETED' && (!detail.item.owner || !detail.item.followupDate)" class="work-warning">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</p>
<div class="container-links"><button v-for="container in detail.item.containers" :key="container.containerBookingId" type="button" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</button></div>
<p v-for="reason in [...detail.item.blockReasons, ...detail.item.missingFields, ...detail.item.overdueReasons]" :key="reason" class="work-warning">{{ workReason(reason) }}</p>
<p v-if="detail.returnHandoff">还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}</p>
<template v-if="!action">
<el-button v-if="detail.item.nextAction" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
<template v-if="!action && !continuation">
<p v-if="saveCompleted" role="status" class="save-result">本次保存已完成。{{ loading ? '正在读取当前下一步…' : error ? '下一步读取失败,请重试读取事项,无需重复提交已保存内容。' : detail.item.executionStatus === 'COMPLETED' ? '当前事项已完成,可返回查看其他待办。' : '请按当前下一步继续处理。' }}</p>
<el-button v-if="detail.item.nextAction" class="primary-next-action" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
<p v-else-if="detail.item.executionStatus !== 'COMPLETED'">当前没有可执行的下一步,请根据缺项或前置说明联系负责人。</p>
</template>
<p v-if="actionNotice" role="status" class="work-warning">{{ actionNotice }}</p>
<AppointmentBatchForm v-if="batchKey" :key="batchKey" :batch-key="batchKey" @updated="emit('updated')" @appointment="openAppointment" />
<WorkAppointmentForm v-else-if="openedAppointmentNo" :key="openedAppointmentNo" :appointment-no="openedAppointmentNo" :cancel="false" :can-write="true" @saved="saved()" @batch="openBatch" />
<WorkBatchContinuation v-if="continuation" :key="JSON.stringify(continuation)" v-bind="continuation" @open="key => emit('open', key)" />
<WorkReceivingPanel v-else-if="receivingKey" :key="receivingKey" :batch-key="receivingKey" :owners="receivingOwners" :can-write="!loading && !error" @updated="receivingUpdated" @appointment="openAppointment" @batch="openBatch" @external="path => emit('external', path)" />
<AppointmentBatchForm v-else-if="batchKey" :key="batchKey" :batch-key="batchKey" @updated="emit('updated')" @appointment="openAppointment" />
<WorkAppointmentForm v-else-if="openedAppointmentNo" :key="openedAppointmentNo" :appointment-no="openedAppointmentNo" :cancel="false" :can-write="true" @saved="savedAppointment" @batch="openBatch" @receiving="openReceiving" />
<WorkCommandForm v-else-if="action && commandName(action)" :key="`${itemKey}:${action.code}`" :detail="detail" :action="action" :refreshing="loading || !!error" @refresh="load" @saved="saved" />
<template v-else-if="cargoSource && !creatingFromCargo"><CargoVerificationForm :key="`${cargoSource.customerId}:${cargoSource.sourceKey}:${cargoSource.destinationKey}`" :source="cargoSource" @saved="cargoSaved" /><el-button :disabled="!canAct" @click="createFromCargo">核对货物并新建原预约</el-button></template>
<WorkAppointmentForm v-else-if="fbaNumber && action" :key="`${fbaNumber}:${action.code}`" :appointment-no="fbaNumber" :cancel="action.code === 'CANCEL_BOOKING'" :can-write="canAct" @saved="saved()" @batch="openBatch" />
<WorkAppointmentForm v-else-if="fbaNumber && action" :key="`${fbaNumber}:${action.code}`" :appointment-no="fbaNumber" :cancel="action.code === 'CANCEL_BOOKING'" :can-write="canAct" @saved="savedAppointment" @batch="openBatch" @receiving="openReceiving" />
<WorkCreateAppointmentForm v-else-if="createSources" :key="JSON.stringify(createSources)" :sources="createSources" :can-write="canAct" @saved="saved()" />
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" @batch="openBatch" />
<WorkExistingDispatchForm v-else-if="existingDispatches.length" :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
<WorkReceiptPlans v-else-if="action?.target.kind === 'WAREHOUSE_RECEIPT'" :key="action.target.containerBookingId" :container-booking-id="action.target.containerBookingId" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
<section v-else-if="action?.target.kind === 'READ_ONLY'" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
<details v-if="detail.item.allowedActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in detail.item.allowedActions.filter(candidate => candidate.code !== action?.code)" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
<details v-if="secondaryActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in secondaryActions" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button @click="emit('open', related.workItemKey)">查看</el-button></div></details>
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p></details>
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><b>{{ entry.action }}</b><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }} · {{ entry.occurredAt }}</small></div><p v-if="!detail.history.length">暂无记录</p></details>
@@ -1,23 +1,75 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDispatchForm from './WorkDispatchForm.vue'
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
import { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
import { getReceivingContext, type ReceivingContext } from './receivingApi'
import { batchContext } from './batch.test-support'
import type { LeaveGuard } from './useWorkDraft'
const access = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
function receiving(batchKey: string, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', batchKey).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'v1', vehicles, blockReasons: [] }, executionScope: { version: 'e1', members: [] }, allowedActions: [], blockReasons: [] } }
const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
let guard: LeaveGuard
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
beforeEach(() => {
vi.clearAllMocks(); access.allowed = true
vi.mocked(getReceivingContext).mockImplementation(async batchKey => receiving(batchKey))
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
vi.mocked(listDispatchDrafts).mockResolvedValue([])
vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
})
it('restores the original FBA dispatch after remount and opens trip preparation without any draft or creation', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', overseasWarehouseId: 'origin-1', packageCount: 4, appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
for (let mountNumber = 0; mountNumber < 2; mountNumber++) {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('DS-EXISTING')
expect(wrapper.text()).not.toContain('创建本客户排车单')
await button(wrapper, '继续安排本批车辆').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-EXISTING'] })
await button(wrapper, '查看原排车单').trigger('click')
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches?dispatchNo=DS-EXISTING']])
wrapper.unmount()
}
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
expect(createDispatchDraftContainer).not.toHaveBeenCalled()
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
})
it('fails closed when transport relations cannot be read', async () => {
vi.mocked(getReceivingContext).mockRejectedValue(new Error('unavailable'))
const wrapper = render(); await flushPromises()
expect(wrapper.text()).not.toContain('创建本客户排车单')
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('does not offer remaining-cargo creation when the original dispatch no longer owns this batch', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
expect(wrapper.text()).not.toContain('创建本客户排车单')
expect(wrapper.text()).not.toContain('核对本批剩余可排实物')
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
wrapper.unmount()
})
it('loads remaining eligible cargo only after an explicit choice while retaining the existing dispatch', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
const wrapper = render(); await flushPromises()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises()
expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith('FBA_APPOINTMENT:1')
expect(wrapper.text()).toContain('DS-EXISTING')
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
@@ -29,6 +81,23 @@ it('copies exact server allocations into a real draft group and continues into o
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-001']])
wrapper.unmount()
})
it('opens quantity splitting for only the current batch without locking physical cargo or creating a new full-source appointment', async () => {
const wrapper = render(); await flushPromises()
await button(wrapper, '按当前批次箱数分批').trigger('click')
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']])
expect(lockDispatchDraftPallet).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
})
it('preselects only eligible physical cargo of the entered batch, never unrelated allocations or unknown quantity', async () => {
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: 'other-batch', allocations: [{ ...candidate.allocations[0]!, batchKey: 'FBA_APPOINTMENT:999' }] }, { ...candidate, planPalletId: 'unknown', packageCount: null }] })
const wrapper = render(); await flushPromises()
const inputs = wrapper.findAll('input[type=checkbox]')
expect(inputs.map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false])
expect(inputs[1]!.attributes('disabled')).toBeDefined()
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId])
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(candidate.allocations)
wrapper.unmount()
})
it('replays a submitted draft without re-locking already dispatched pallets after an unknown response', async () => {
vi.mocked(preArrangeDraftDispatch).mockRejectedValueOnce(new Error('lost response'))
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
@@ -49,7 +118,7 @@ it('recovers a draft container from the same token when its creation response wa
expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('shows server blockers and prevents mixing customers or selecting blocked physical cargo', async () => {
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b' }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] })
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b', allocations: [{ ...candidate.allocations[0]!, customerId: 'customer-b', planPalletId: '2' }] }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] })
const wrapper = render(); await flushPromises(); const inputs = wrapper.findAll('input[type=checkbox]')
expect(inputs[2]!.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('本托货量超过本批剩余可排量')
await inputs[0]!.setValue(true); await inputs[1]!.setValue(true)
@@ -61,3 +130,17 @@ it('does not read or write scoped cargo without all original permissions', async
access.allowed = false; const wrapper = render(); await flushPromises()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
})
it('continues the actual later 60-box dispatch through the shared-trip entry without re-creating or reallocating cargo', async () => {
const sixty = { ...candidate, packageCount: 60, allocations: [{ ...candidate.allocations[0]!, packageCount: 60 }] }
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [sixty], blockReasons: [] })
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-later', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'origin-1', packageCount: 60 } }])
const wrapper = render(); await flushPromises(); await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
await button(wrapper, '与同仓其他排车单共车').trigger('click'); await flushPromises()
const shared = wrapper.getComponent({ name: 'WorkSharedTripForm' })
expect(shared.props()).toMatchObject({ dispatchNo: 'DS-60', canWrite: true })
expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(sixty.allocations)
shared.vm.$emit('busy', true); await flushPromises(); expect(button(wrapper, '继续安排其他实物').attributes('disabled')).toBeDefined()
shared.vm.$emit('external', '/overseas-master-data/trips/TR-100'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-100']])
wrapper.unmount()
})
@@ -6,33 +6,61 @@ import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchD
import { TripPreparationDialog } from '@/modules/overseas-master-data'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
import { getReceivingContext } from './receivingApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string] }>()
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
const access = useAccessPreviewStore(), contexts = ref<BatchDispatchCandidates[]>([]), selected = ref<string[]>([])
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const capacity = ref(30), result = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false), rejected = ref(false)
const sharedTripOpen = ref(false), sharedTripBusy = ref(false)
const existingDispatches = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), arrangingRemaining = ref(false)
const existingReady = ref(false)
const selectionBaseline = ref('[]')
const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null)
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission)))
const writable = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
const writable = computed(() => props.canWrite && readable.value && (!existingDispatches.value.length || existingReady.value) && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] }))))
function reasons(row: BatchDispatchCandidate) {
const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1
return [...row.blockReasons, ...(!row.allocations.length ? ['缺少逐来源批次分配'] : []), ...(duplicates ? ['同一实物出现在多个批次,请核对分批范围'] : [])]
return [...row.blockReasons, ...(row.packageCount == null || row.packageCount <= 0 ? ['实物箱数待核实'] : []), ...(!row.allocations.length ? ['缺少逐来源批次分配'] : []), ...(row.allocations.some(allocation => !props.batchKeys.includes(allocation.batchKey) || allocation.customerId !== row.customerId) ? ['候选不属于当前批次和客户范围'] : []), ...(duplicates ? ['同一实物出现在多个批次,请核对分批范围'] : [])]
}
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.planPalletId)))
useWorkDraft(computed(() => selected.value.length > 0 && !result.value), computed(() => saving.value || !!pending.value))
useWorkDraft(computed(() => JSON.stringify(selected.value) !== selectionBaseline.value && !result.value), computed(() => saving.value || !!pending.value))
async function load() {
if (!readable.value || pending.value) return
loading.value = true; error.value = null
try { contexts.value = await Promise.all([...new Set(props.batchKeys)].map(getBatchDispatchCandidates)) }
loading.value = true; error.value = null; existingReady.value = false
try {
// 从服务端当前运输关系恢复排车单;读取失败时禁止退回创建流程。
const batches = [...new Set(props.batchKeys)]
const receiving = await Promise.all(batches.map(batch => getReceivingContext(batch)))
const references: typeof existingDispatches.value = []
receiving.forEach((context, index) => {
const batchKey = batches[index]!
if (context.batch.batchKey !== batchKey) throw new ApiResponseError('运输关系与当前预约批次不一致,请重新读取', 'WORK_DISPATCH_MISMATCH')
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
if (!vehicle.dispatchNo) throw new ApiResponseError('本批已有运输安排但缺少排车单号,请核对原车次', 'WORK_DISPATCH_MISMATCH')
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
}
})
existingDispatches.value = references
contexts.value = []; selected.value = []; selectionBaseline.value = '[]'
if (references.length && !arrangingRemaining.value) return
contexts.value = await Promise.all(batches.map(batch => getBatchDispatchCandidates(batch)))
const eligible = rows.value.filter(row => !reasons(row).length && row.packageCount != null && row.packageCount > 0)
selected.value = props.batchKeys.length === 1 && eligible.length <= capacity.value && new Set(eligible.map(row => row.customerId)).size === 1 ? eligible.map(row => row.planPalletId) : []
selectionBaseline.value = JSON.stringify(selected.value)
}
catch (cause) { error.value = getApiErrorDetails(cause, '批次可排实物读取失败') }
finally { loading.value = false }
}
function select(row: BatchDispatchCandidate, checked: boolean) {
if (!writable.value || pending.value || saving.value || loading.value || error.value || reasons(row).length) return
if (!checked) { selected.value = selected.value.filter(id => id !== row.planPalletId); return }
if (selected.value.includes(row.planPalletId)) return
if (chosen.value.some(current => current.customerId !== row.customerId)) { ElMessage.warning('一张排车单只选择同一客户实物;其余客户可继续分别安排'); return }
if (selected.value.length >= capacity.value) { ElMessage.warning('已达到所选车型托位上限'); return }
selected.value = [...selected.value, row.planPalletId]
@@ -72,17 +100,22 @@ async function refreshRejected() {
finally { saving.value = false }
}
function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
async function continueArranging() { if (!pending.value && !saving.value) { result.value = null; await load() } }
async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; arrangingRemaining.value = true; await load() } }
onMounted(load)
</script>
<template>
<section class="work-form" aria-label="按预约批次排车">
<h3>按批次选择真实托盘</h3><p>每次选择同一客户的实物创建排车单,再安排车辆和司机;逐来源数量由服务端候选确定。</p>
<h3>{{ existingDispatches.length ? '沿用本批已有排车单' : '按批次选择真实托盘' }}</h3><p>已有排车单沿用原实物安排车辆;只有尚未分配的实物才需要创建排车单。</p>
<el-button v-if="batchKeys.length === 1 && !result && !existingDispatches.length" :disabled="loading || saving || !!pending" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p v-if="batchKeys.length === 1 && !result && !existingDispatches.length">只安排部分箱数时,先核对本批逐来源数量并分批;新的当前批次继续使用原排车资格。若箱数无法对应完整实物,请先在原库存作业中完成拆托;不把整票重新选入。</p>
<p v-if="!readable">读取批次实物需要预约、尾程订单和入仓单列表权限。</p>
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在核对批次、客户与实物来源…</p>
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" @click="prepareOpen = true">安排车次、车辆与司机</el-button><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button @click="continueArranging">继续安排其他实物</el-button></template>
<template v-else-if="readable"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
<template v-if="existingDispatches.length && !loading && !error && !result">
<WorkExistingDispatchForm :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
<el-button v-if="!arrangingRemaining && writable" @click="continueArranging">核对本批剩余可排实物</el-button>
</template>
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" :disabled="sharedTripBusy" @click="prepareOpen = true">继续安排本批车辆</el-button><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list') && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" @click="sharedTripOpen = true">与同仓其他排车单共车</el-button><WorkSharedTripForm v-if="sharedTripOpen" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="sharedTripBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button :disabled="sharedTripBusy" @click="continueArranging">继续安排其他实物</el-button></template>
<template v-else-if="readable && !loading && !error && (!existingDispatches.length || arrangingRemaining && existingReady)"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
<table class="density-table"><thead><tr><th>选择</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.planPalletId}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.planPalletId)" :disabled="!writable || loading || saving || !!pending || !!error || !!reasons(row).length" @change="changeSelection(row, $event)"></td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table>
<p v-if="!loading && !error && !rows.length">当前批次尚无可排实物。</p><el-button type="primary" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次预排' : '创建本客户排车单' }}</el-button>
</template>
@@ -5,23 +5,37 @@ import { getDispatch } from '@/modules/overseas-warehouse'
const state = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.allowed }) }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
function render() { return mount(WorkExistingDispatchForm, { props: { dispatches: [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }], canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
it.each(['customer-b', undefined])('rejects FBA dispatch references outside the exact batch customer: %s', async customerId => {
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: 'DS-ORIGINAL', customerId }]); await flushPromises()
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
expect(wrapper.find('table').exists()).toBe(false)
wrapper.unmount()
})
it('reads shared dispatch once and verifies every FBA batch reference', async () => {
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: ['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, batchNo: `AP-${id}`, customerId: 'customer-a', sources: [] })) })
const wrapper = render(['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, dispatchNo: 'DS-ORIGINAL', customerId: 'customer-a' }))); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
wrapper.unmount()
})
it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => {
const wrapper = render(); await flushPromises()
expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL')
await wrapper.findAll('button').find(button => button.text() === '安排原排车单车次')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === '继续安排本批车辆')!.trigger('click')
const dialog = wrapper.getComponent({ name: 'TripPreparationDialog' }); expect(dialog.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-ORIGINAL'] })
dialog.vm.$emit('saved', { trip: { tripNo: 'TR/001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR%2F001']])
expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
})
it('rejects mismatched dispatch identity instead of arranging unrelated cargo', async () => {
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740998', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE' })
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740998', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], status: 'PENDING_DEPARTURE' })
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
})
it('opens an already bound real trip instead of preparing another one', async () => {
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', workflow: { tripNo: 'TR-EXISTING', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } })
const wrapper = render(); await flushPromises(); expect(wrapper.text()).not.toContain('安排原排车单车次')
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], workflow: { tripNo: 'TR-EXISTING', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } })
const wrapper = render(); await flushPromises(); expect(wrapper.text()).not.toContain('继续安排本批车辆')
await wrapper.findAll('button').find(button => button.text() === 'TR-EXISTING')!.trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-EXISTING']]); wrapper.unmount()
})
it('does not read the dispatch without its original list permission', async () => {
@@ -6,21 +6,27 @@ import { TripPreparationDialog } from '@/modules/overseas-master-data'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string }>; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string] }>()
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }>; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string]; ready: [valid: boolean] }>()
const access = useAccessPreviewStore(), rows = ref<DispatchSummaryDto[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const selected = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false)
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
const writable = computed(() => props.canWrite && readable.value && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit'))
async function load() {
if (!readable.value) return
loading.value = true; error.value = null
loading.value = true; error.value = null; emit('ready', false)
try {
rows.value = await Promise.all(props.dispatches.map(async reference => {
const row = await getDispatch(reference.dispatchNo)
if (`DISPATCH:${row.id}` !== reference.batchKey || row.dispatchNo !== reference.dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
rows.value = await Promise.all([...new Set(props.dispatches.map(reference => reference.dispatchNo))].map(async dispatchNo => {
const row = await getDispatch(dispatchNo)
// 预约批次必须仍由这张原排车单持有同一客户的分配,不能仅凭展示业务号续办。
const matches = props.dispatches.filter(reference => reference.dispatchNo === dispatchNo).every(reference =>
reference.batchKey.startsWith('FBA_APPOINTMENT:')
? !!reference.customerId && row.status !== 'CANCELLED' && row.appointmentBatches.some(batch => batch.batchKey === reference.batchKey && batch.customerId === reference.customerId)
: `DISPATCH:${row.id}` === reference.batchKey)
if (!matches || row.dispatchNo !== dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
return row
}))
emit('ready', true)
} catch (cause) { error.value = getApiErrorDetails(cause, '原排车单读取失败') }
finally { loading.value = false }
}
@@ -29,10 +35,10 @@ function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false;
onMounted(load)
</script>
<template>
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>客户仓批次沿用已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>沿用本批已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
<p v-if="!readable">查看原排车单需要排车列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>起运仓</th><th>箱数</th><th>车次</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="!row.workflow" :disabled="!writable || row.status !== 'PENDING_DEPARTURE' || !row.overseasWarehouseId" @click="prepare(row)">安排原排车单车次</el-button><el-button @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(row.dispatchNo)}`)">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.overseasWarehouseName || '起运仓待核实' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td><el-button v-if="row.workflow?.tripNo && access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(row.workflow.tripNo)}`)">{{ row.workflow.tripNo }}</el-button><span v-else>尚未关联车次</span></td></tr></tbody></table>
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>起运仓</th><th>箱数</th><th>车次</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="!row.workflow" :disabled="!writable || row.status !== 'PENDING_DEPARTURE' || !row.overseasWarehouseId" @click="prepare(row)">继续安排本批车辆</el-button><el-button @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(row.dispatchNo)}`)">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.overseasWarehouseName || '起运仓待核实' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td><el-button v-if="row.workflow?.tripNo && access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(row.workflow.tripNo)}`)">{{ row.workflow.tripNo }}</el-button><span v-else>尚未关联车次</span></td></tr></tbody></table>
<TripPreparationDialog v-if="selected?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="selected.overseasWarehouseId" :warehouse-name="selected.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[selected.dispatchNo]" @saved="saved" />
</section>
</template>
@@ -0,0 +1,510 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import { createAppointmentForm, appointmentPayload } from '@/modules/overseas-warehouse'
import { ApiResponseError } from '@/shared/api/http'
import WorkReceivingPanel from './WorkReceivingPanel.vue'
import { batchContext } from './batch.test-support'
import * as api from './receivingApi'
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
import type { LeaveGuard } from './useWorkDraft'
const access = vi.hoisted(() => ({ denied: [] as string[] }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner-current', name: '当前本人' } }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !access.denied.includes(permission) }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn(), getReceivingGroup: vi.fn(), proposeReceivingChange: vi.fn(), resolveReceivingChange: vi.fn(), bookReceiving: vi.fn(), changeReceivingGroup: vi.fn(), refreshReceivingCargo: vi.fn(), saveReceivingPlan: vi.fn(), getReceivingExecutionScope: vi.fn() }))
let guard: LeaveGuard
const detail = () => appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-OLD', appointmentAt: '2026-10-20T10:00:00' })
function context(): api.ReceivingContext {
const batch = batchContext().appointment
batch.items = batch.items.map(item => ({ ...item, sourceVersion: 'source-v3', cargoVerificationVersion: 2 }))
return { batch, executionScope: { version: 'execution-v1', members: batch.items.map(item => ({ member: { batchKey: batch.batchKey!, customerId: item.customerId }, completed: false, canRebook: true, reason: null })) }, agreements: [], pendingChanges: [], plans: [], allowedActions: ['PROPOSE_CUSTOMER_CHANGE', 'BOOK_RECEIVING', 'PLAN_RECEIVING', 'REFRESH_CARGO'], blockReasons: [], transport: { version: 'transport-v1', vehicles: [], blockReasons: [] } }
}
function members(): api.ReceivingMember[] { const c = context(); return c.batch.items.map(item => ({ batchKey: c.batch.batchKey!, appointmentNo: c.batch.appointmentNo, batchVersion: c.batch.version, customerId: item.customerId, customerName: null, sources: [{ sourceKey: item.sourceKey, inboundCode: item.inboundCode || null, destinationKey: item.destinationKey!, packageCount: item.packageCount!, sourceVersion: item.sourceVersion!, verificationVersion: item.cargoVerificationVersion! }] })) }
function agreement(status = 'ACTIVE'): api.ReceivingAgreement { return { agreementKey: 'GROUP-1', version: 4, status, destinationKey: 'FBA:ONT8', details: detail(), timezone: 'America/Los_Angeles', members: members(), warehouseEvidence: '原仓库确认' } }
function pendingChange(): api.ReceivingPending { return { changeKey: 'CHANGE-1', version: 1, status: 'WAITING', intent: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }], laterDate: null, ownerId: 'owner-related', nextFollowupDate: '2026-10-21', reason: '本客户后送', transportVersion: 'transport-v1' }, originalMembers: [members()[0]!], originalAgreements: [agreement()], followup: { ownerId: 'owner-related', ownerName: '已核实负责人', nextFollowupDate: '2026-10-21' } } }
function result(status = 'APPLIED'): api.ReceivingResult { return { changeKey: null, status, batches: [context().batch], agreements: [], steps: [{ step: 'BOOK_RECEIVING', status: 'SUCCESS', targetKey: 'GROUP-1', message: '已保存真实预约' }], nextActions: ['REVIEW_TRANSPORT'] } }
function render() { return mount(WorkReceivingPanel, { props: { batchKey: 'FBA_APPOINTMENT:1', canWrite: true, owners: [{ id: 'owner-related', name: '关联负责人' }] }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div data-booking-fields />' },
LoadErrorState: { props: ['message', 'showRetry'], template: '<section role="alert">{{ message }}<button v-if="showRetry !== false" @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true,
} } }) }
function button(wrapper: ReturnType<typeof render>, label: string) { return wrapper.findAll('button').find(row => row.text() === label)! }
beforeEach(() => { vi.clearAllMocks(); access.denied = []; vi.mocked(api.getReceivingExecutionScope).mockImplementation(async members => ({ version: 'execution-v1', members: members.map(member => ({ member, completed: false, canRebook: true, reason: null })) })); vi.mocked(api.getReceivingContext).mockResolvedValue(context()); vi.mocked(api.proposeReceivingChange).mockResolvedValue(result('WAITING')); vi.mocked(api.resolveReceivingChange).mockResolvedValue(result()); vi.mocked(api.bookReceiving).mockResolvedValue(result()); vi.mocked(api.refreshReceivingCargo).mockResolvedValue({ ...result(), steps: [], nextActions: [] }); vi.mocked(api.saveReceivingPlan).mockResolvedValue({ ...result(), steps: [], nextActions: [] }); vi.mocked(api.changeReceivingGroup).mockResolvedValue(result()); vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: agreement(), eligibleMembers: members(), excludedMembers: [], transport: context().transport, executionScope: context().executionScope }) })
async function prepareAdjustment(wrapper: ReturnType<typeof render>, quantity = 0) {
await flushPromises(); await button(wrapper, '调整本客户先送 / 后送').trigger('click'); await flushPromises()
await wrapper.get('[aria-label="本次先送箱数"]').setValue(quantity)
await wrapper.get('[aria-label="调整负责人"]').setValue('owner-related')
await wrapper.get('[aria-label="调整原因"]').setValue('本客户后送')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
}
it('allows zero retained quantity for one customer and only persists a waiting change with all original versions', async () => {
const wrapper = render(); await prepareAdjustment(wrapper)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.proposeReceivingChange).toHaveBeenCalledWith(expect.objectContaining({ intent: expect.objectContaining({ batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', ownerId: 'owner-related', laterDate: null, transportVersion: 'transport-v1', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }] }) }))
expect(wrapper.text()).toContain('原安排尚未改变'); expect(api.resolveReceivingChange).not.toHaveBeenCalled(); expect(api.bookReceiving).not.toHaveBeenCalled()
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('rejects unchanged, fractional or excessive retained quantities without creating empty later batches', async () => {
const wrapper = render(); await prepareAdjustment(wrapper, 70)
await wrapper.get('form').trigger('submit'); await flushPromises(); expect(wrapper.text()).toContain('本次先送量未改变')
for (const quantity of [71, -1, 0.5]) { await wrapper.get('[aria-label="本次先送箱数"]').setValue(quantity); await wrapper.get('form').trigger('submit'); await flushPromises() }
expect(api.proposeReceivingChange).not.toHaveBeenCalled(); wrapper.unmount()
})
it('locks unknown outcomes and retries the identical change command without applying or recreating cargo', async () => {
vi.mocked(api.proposeReceivingChange).mockRejectedValueOnce(new Error('network timeout'))
const wrapper = render(); await prepareAdjustment(wrapper, 30); await wrapper.get('form').trigger('submit'); await flushPromises()
const first = vi.mocked(api.proposeReceivingChange).mock.calls[0]![0]
expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.proposeReceivingChange).toHaveBeenNthCalledWith(2, first); expect(api.resolveReceivingChange).not.toHaveBeenCalled(); wrapper.unmount()
})
it('resumes persisted waiting input, requires warehouse evidence and sends the current change and transport versions', async () => {
const c = context(); c.pendingChanges = [pendingChange()]; c.transport.version = 'transport-v2'; c.allowedActions = []
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises(); await button(wrapper, '继续本次变化').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('本次先送 0 箱'); expect(wrapper.text()).toContain('EXT-OLD')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.resolveReceivingChange).not.toHaveBeenCalled()
await wrapper.get('[aria-label="仓库回复依据"]').setValue('仓库同意该客户全部后送,其他客户确认保留')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.resolveReceivingChange).toHaveBeenCalledWith('CHANGE-1', expect.objectContaining({ decision: 'CONFIRM', version: 1, transportVersion: 'transport-v2' }))
expect(api.proposeReceivingChange).not.toHaveBeenCalled(); wrapper.unmount()
})
it('withdraws only the persisted change without claiming a cancellation or requiring confirmation evidence', async () => {
const c = context(); c.pendingChanges = [pendingChange()]; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
vi.mocked(api.resolveReceivingChange).mockResolvedValue(result('WITHDRAWN'))
const wrapper = render(); await flushPromises(); await button(wrapper, '继续本次变化').trigger('click'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await button(wrapper, '撤回变化并保留原安排').trigger('click'); await flushPromises()
expect(api.resolveReceivingChange).toHaveBeenCalledWith('CHANGE-1', expect.objectContaining({ decision: 'WITHDRAW', warehouseEvidence: null }))
expect(wrapper.text()).toContain('已撤回变化,原安排保留'); wrapper.unmount()
})
it('rebooks only eligible cancelled-group members and submits one shared booking with a new command identity', async () => {
const c = context(); c.agreements = [agreement('CANCELLED')]; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: c.agreements[0]!, eligibleMembers: [members()[0]!], excludedMembers: [{ member: members()[1]!, reason: '已另行预约', currentAgreementKey: 'GROUP-OTHER' }], transport: c.transport, executionScope: c.executionScope })
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('已另行预约'); expect(wrapper.text()).not.toContain('GROUP-OTHER')
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'EXT-NEW', appointmentAt: '2026-10-23T11:00:00' })
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仓库确认当前剩余客户货量'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
const request = vi.mocked(api.bookReceiving).mock.calls[0]![0]
expect(request.cancelledAgreementKey).toBe('GROUP-1'); expect(request.cancelledAgreementVersion).toBe(4)
expect(request.bookings).toHaveLength(1); expect(request.bookings[0]!.members).toEqual([{ batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a' }])
expect(request.bookings[0]!.details.confirmationNo).toBe('EXT-NEW'); wrapper.unmount()
})
it('preserves per-customer booking input on an old-number conflict and requires renewed scope confirmation', async () => {
vi.mocked(api.bookReceiving).mockRejectedValueOnce(new ApiResponseError('当前目的仓约号已使用', 'A0409', 409))
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
Object.assign(model, { confirmationNo: 'EXT-IN-USE', appointmentAt: '2026-10-23T11:00:00' })
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('明确覆盖'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(model.confirmationNo).toBe('EXT-IN-USE'); expect(wrapper.text()).toContain('当前目的仓约号已使用')
expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined(); expect(wrapper.emitted('updated')).toBeUndefined()
wrapper.unmount()
})
it('requires explicit whole-scope cancellation confirmation and uses the group version and delete permission', async () => {
const c = context(); c.agreements = [agreement()]; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
access.denied = ['platform:overseas-warehouse:inbound-order:fba-appointment']
const wrapper = render(); await flushPromises(); await button(wrapper, '核对整份取消范围').trigger('click'); await flushPromises()
await wrapper.get('[aria-label="整份回复依据"]').setValue('仓库确认整份取消'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalled()
expect(api.changeReceivingGroup).toHaveBeenCalledWith('GROUP-1', expect.objectContaining({ action: 'CANCEL', version: 4, details: null, transportVersion: 'transport-v1' })); wrapper.unmount()
})
it('keeps a successful result when refresh fails and retries only reads', async () => {
const wrapper = render(); await prepareAdjustment(wrapper)
vi.mocked(api.getReceivingContext).mockRejectedValueOnce(new Error('读取暂不可用'))
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(wrapper.text()).toContain('原安排尚未改变'); expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(api.proposeReceivingChange).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('does not expose internal customer IDs or replace unknown cargo with zero or permit read-only writes', async () => {
const c = context(); c.batch.items[0]!.packageCount = null; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises()
expect(wrapper.text()).not.toContain('customer-a'); expect(wrapper.text()).toContain('箱数待核实')
await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('货物数量或来源资料待核实'); expect(wrapper.find('[data-booking-fields]').exists()).toBe(false)
expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined()
await wrapper.setProps({ canWrite: false }); await wrapper.get('form').trigger('submit'); expect(api.bookReceiving).not.toHaveBeenCalled(); wrapper.unmount()
})
it('sends independent confirmations as distinct bookings while preserving original batch identities', async () => {
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
await wrapper.findAll('label').find(label => label.text().includes('所选客户分别登记预约'))!.get('input').setValue(true)
await flushPromises()
wrapper.findAllComponents({ name: 'FbaAppointmentFields' }).forEach((fields, index) => Object.assign(fields.props('model'), { confirmationNo: `EXT-${index}`, appointmentAt: `2026-10-${22 + index}T10:00:00` }))
for (const textarea of wrapper.findAll('[aria-label="预约覆盖依据"]')) await textarea.setValue('本客户仓库明确确认')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
const request = vi.mocked(api.bookReceiving).mock.calls[0]![0]
expect(request.bookings.map(row => row.members)).toEqual(members().map(({ batchKey, batchVersion, customerId }) => [{ batchKey, batchVersion, customerId }]))
expect(request.bookings.map(row => row.details.confirmationNo)).toEqual(['EXT-0', 'EXT-1'])
expect(request.cancelledAgreementKey).toBeNull(); wrapper.unmount()
})
it('does not apply existing booking inputs to different members after a range conflict', async () => {
const original = context(); original.agreements = [agreement('CANCELLED')]
vi.mocked(api.getReceivingContext).mockResolvedValue(original)
vi.mocked(api.getReceivingGroup).mockResolvedValueOnce({ agreement: original.agreements[0]!, eligibleMembers: members(), excludedMembers: [], transport: original.transport, executionScope: original.executionScope }).mockResolvedValue({ agreement: original.agreements[0]!, eligibleMembers: [members()[1]!], excludedMembers: [{ member: members()[0]!, reason: '已另行预约', currentAgreementKey: 'OTHER' }], transport: original.transport, executionScope: original.executionScope })
vi.mocked(api.bookReceiving).mockRejectedValueOnce(new ApiResponseError('范围版本变化', 'A0409', 409))
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'OLD-DRAFT', appointmentAt: '2026-10-22T10:00:00' })
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('原范围依据'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(wrapper.text()).toContain('原输入不会自动套用给其他客户')
expect(wrapper.find('[data-booking-fields]').exists()).toBe(false)
expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined()
expect(api.bookReceiving).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('treats unchanged whole-group rescheduling as unchanged rather than a new successful mutation', async () => {
const c = context(); c.agreements = [agreement()]; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
vi.mocked(api.changeReceivingGroup).mockResolvedValue({ ...result('UNCHANGED'), steps: [] })
const wrapper = render(); await flushPromises(); await button(wrapper, '核对整份改期范围').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo).toBe('EXT-OLD')
await wrapper.get('[aria-label="整份回复依据"]').setValue('核对无变化'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.changeReceivingGroup).toHaveBeenCalledWith('GROUP-1', expect.objectContaining({ action: 'RESCHEDULE', details: detail() }))
expect(wrapper.text()).toContain('安排没有变化'); expect(wrapper.text()).not.toContain('已保存真实预约'); wrapper.unmount()
})
it('uses only the eligible B/D cross-batch transport scope after A booked separately', async () => {
const a = { ...members()[0]!, batchKey: 'FBA_APPOINTMENT:10', appointmentNo: 'AP-A' }
const b = { ...members()[1]!, batchKey: 'FBA_APPOINTMENT:20', appointmentNo: 'AP-B' }
const d = { ...members()[1]!, batchKey: 'FBA_APPOINTMENT:30', appointmentNo: 'AP-D', customerId: 'customer-d' }
const cancelled = { ...agreement('CANCELLED'), members: [a, b, d] }
const c = context(); c.agreements = [cancelled]; c.transport.version = 'old-A-B-D'
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: cancelled, eligibleMembers: [b, d], excludedMembers: [{ member: a, reason: '已另行预约', currentAgreementKey: 'OTHER-A' }], transport: { version: 'current-B-D', vehicles: [], blockReasons: ['无关客户已完成运输'] }, executionScope: { version: 'execution-B-D', members: [b, d].map(member => ({ member, completed: false, canRebook: true, reason: null })) } })
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'NEW-B-D', appointmentAt: '2026-10-22T10:00:00' })
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅覆盖B/D'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
const request = vi.mocked(api.bookReceiving).mock.calls[0]![0]
expect(api.getReceivingExecutionScope).toHaveBeenCalledWith([{ batchKey: b.batchKey, customerId: b.customerId }, { batchKey: d.batchKey, customerId: d.customerId }]); expect(request.executionVersion).toBe('execution-v1'); expect(request).not.toHaveProperty('transportVersion')
expect(request.bookings[0]!.members.map(member => member.batchKey)).toEqual(['FBA_APPOINTMENT:20', 'FBA_APPOINTMENT:30'])
expect(request.bookings[0]!.members.some(member => member.customerId === a.customerId)).toBe(false); wrapper.unmount()
})
it('uses verified owner candidates and renders authoritative pending followup names instead of account IDs', async () => {
const c = context(); c.pendingChanges = [pendingChange()]
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('负责人 已核实负责人'); expect(wrapper.text()).not.toContain('owner-related')
await button(wrapper, '调整本客户先送 / 后送').trigger('click'); await flushPromises()
const owner = wrapper.get('[aria-label="调整负责人"]')
expect(owner.element.tagName).toBe('SELECT')
expect(owner.findAll('option').map(option => option.text())).toEqual(['请选择负责人', '关联负责人', '当前本人'])
await wrapper.get('[aria-label="本次先送箱数"]').setValue(0)
await owner.setValue('not-an-account'); await wrapper.get('[aria-label="调整原因"]').setValue('后送')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.proposeReceivingChange).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择有效负责人')
await owner.setValue('owner-current'); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.proposeReceivingChange).toHaveBeenCalledWith(expect.objectContaining({ intent: expect.objectContaining({ ownerId: 'owner-current' }) }))
expect(vi.mocked(api.proposeReceivingChange).mock.calls[0]![0].intent).not.toHaveProperty('owner')
wrapper.unmount()
})
it('books unfinished B using execution eligibility even when completed A blocks the shared transport and lacks cargo details', async () => {
const c = context()
c.executionScope.members[0] = { member: c.executionScope.members[0]!.member, completed: true, canRebook: false, reason: '本客户已完成,不再重约' }
c.batch.items[0]!.packageCount = null
c.transport.blockReasons = ['已完成客户禁止调整车辆']
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('本客户已完成,不再重约'); expect(wrapper.text()).not.toContain('货物数量或来源资料待核实')
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'ONLY-B', appointmentAt: '2026-10-23T11:00:00' })
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅当前未完成客户'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
const command = vi.mocked(api.bookReceiving).mock.calls[0]![0]
expect(command.executionVersion).toBe('execution-v1'); expect(command).not.toHaveProperty('transportVersion')
expect(command.bookings[0]!.members.map(member => member.customerId)).toEqual(['customer-b'])
wrapper.unmount()
})
it('does not offer members missing execution eligibility or marked completed even if canRebook is true', async () => {
const c = context(); c.executionScope.members = [{ ...c.executionScope.members[0]!, completed: true, canRebook: true }]
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
expect(wrapper.find('[data-booking-fields]').exists()).toBe(false)
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.bookReceiving).not.toHaveBeenCalled(); expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
function mixedPlans(): api.ReceivingContext {
const c = context()
c.batch.plannedDate = '2026-10-20'; c.batch.confirmationNo = 'A-CONFIRMED'; c.batch.appointmentStatus = 'SUCCESS'
c.agreements = [{ ...agreement(), members: [members()[0]!] }]
c.batch.items.push({ ...c.batch.items[1]!, id: 'item-d', customerId: 'customer-d', sourceKey: 'ORDER:D', inboundCode: 'IN-D' })
c.executionScope.members.push({ member: { batchKey: c.batch.batchKey!, customerId: 'customer-d' }, completed: false, canRebook: true, reason: null })
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
return c
}
async function enterBPlan(wrapper: ReturnType<typeof render>) {
await flushPromises()
const buttons = wrapper.findAll('button').filter(row => row.text() === '保存目标 / 未约成')
expect(buttons[0]!.attributes('disabled')).toBeDefined()
await buttons[1]!.trigger('click'); await flushPromises()
}
it('saves only B pending plan in the same stable batch where A is independently confirmed and D is still unbooked', async () => {
const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await enterBPlan(wrapper)
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('2026-10-24')
expect(wrapper.findAll('[aria-label="客户计划状态"] option').map(row => row.attributes('value'))).toEqual(['PENDING', 'FAILED'])
await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-10-26'); await wrapper.get('[aria-label="客户计划备注"]').setValue('乙等待回复')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ member: { batchKey: c.batch.batchKey, batchVersion: 3, customerId: 'customer-b' }, executionVersion: 'execution-v1', details: expect.objectContaining({ appointmentStatus: 'PENDING', plannedDate: '2026-10-26', appointmentRemark: '乙等待回复', confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
expect(api.bookReceiving).not.toHaveBeenCalled(); expect(api.proposeReceivingChange).not.toHaveBeenCalled(); expect(api.changeReceivingGroup).not.toHaveBeenCalled()
expect(c.agreements[0]!.details.confirmationNo).toBe('EXT-OLD'); expect(wrapper.emitted('updated')).toHaveLength(1)
wrapper.unmount()
})
it('requires a failed-plan reason and allows an explicitly cleared date without inheriting A or the old header date', async () => {
const c = mixedPlans(); c.plans[0]!.details.plannedDate = null; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await enterBPlan(wrapper)
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
await wrapper.get('[aria-label="客户计划状态"]').setValue('FAILED'); await wrapper.get('[aria-label="客户计划备注"]').setValue('')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写未约成的原因')
await wrapper.get('[aria-label="客户计划备注"]').setValue('仓库暂未提供时段'); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentStatus: 'FAILED', appointmentRemark: '仓库暂未提供时段', confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
wrapper.unmount()
})
it('keeps customer plan drafts after a version conflict and resubmits explicitly with current batch and execution versions', async () => {
const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c)
vi.mocked(api.saveReceivingPlan).mockRejectedValueOnce(new ApiResponseError('批次版本变化', 'A0409', 409))
const wrapper = render(); await enterBPlan(wrapper)
await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-10-28'); await wrapper.get('[aria-label="客户计划备注"]').setValue('保留乙的输入')
const fresh = structuredClone(c); fresh.batch.version = 4; fresh.executionScope.version = 'execution-v2'; fresh.plans[0]!.details.plannedDate = '2026-10-25'
vi.mocked(api.getReceivingContext).mockResolvedValue(fresh)
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
const first = vi.mocked(api.saveReceivingPlan).mock.calls[0]![0]
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('2026-10-28')
expect(button(wrapper, '保存本客户计划').attributes('disabled')).toBeDefined()
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
const second = vi.mocked(api.saveReceivingPlan).mock.calls[1]![0]
expect(second.details).toEqual(first.details); expect(second.member.batchVersion).toBe(4); expect(second.executionVersion).toBe('execution-v2'); expect(second.idempotencyKey).not.toBe(first.idempotencyKey)
wrapper.unmount()
})
it('retries the identical unknown customer plan and shows unchanged without creating a confirmation', async () => {
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
vi.mocked(api.saveReceivingPlan).mockRejectedValueOnce(new Error('timeout')).mockResolvedValueOnce({ ...result('UNCHANGED'), steps: [], nextActions: [] })
const wrapper = render(); await enterBPlan(wrapper)
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
const first = vi.mocked(api.saveReceivingPlan).mock.calls[0]![0]
expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).toHaveBeenNthCalledWith(2, first); expect(wrapper.text()).toContain('安排没有变化'); expect(api.bookReceiving).not.toHaveBeenCalled()
wrapper.unmount()
})
it('blocks plans for confirmed, waiting-change, completed and unauthorized customers', async () => {
const c = mixedPlans(); c.pendingChanges = [{ ...pendingChange(), intent: { ...pendingChange().intent, customerId: 'customer-b' } }]
c.executionScope.members.find(row => row.member.customerId === 'customer-d')!.completed = true
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises()
expect(wrapper.findAll('button').filter(row => row.text() === '保存目标 / 未约成').every(row => row.attributes('disabled') !== undefined)).toBe(true)
wrapper.unmount()
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
const readonly = render(); await enterBPlan(readonly); await readonly.setProps({ canWrite: false })
await readonly.get('[aria-label="确认当前范围"]').setValue(true); await readonly.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).not.toHaveBeenCalled(); readonly.unmount()
})
it('starts D plan empty without copying confirmed A or another customer plan and clears an existing B date explicitly', async () => {
const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').filter(row => row.text() === '保存目标 / 未约成')[2]!.trigger('click'); await flushPromises()
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
await button(wrapper, '返回范围概览').trigger('click'); await flushPromises(); await enterBPlan(wrapper)
await wrapper.get('[aria-label="客户计划日期"]').setValue('')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ member: expect.objectContaining({ customerId: 'customer-b' }), details: expect.objectContaining({ plannedDate: null }) }))
wrapper.unmount()
})
it('preserves cross-day planned windows and explicit DST offsets in the customer plan without confirmation fields', async () => {
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
const wrapper = render(); await enterBPlan(wrapper)
await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-10-31')
await wrapper.get('[aria-label="客户计划开始"]').setValue('2026-10-31T23:30:00')
await wrapper.get('[aria-label="客户计划结束"]').setValue('2026-11-01T01:30:00')
await wrapper.get('[aria-label="客户计划开始时差"]').setValue('-07:00')
await wrapper.get('[aria-label="客户计划结束时差"]').setValue('-08:00')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ plannedDate: '2026-10-31', plannedAt: expect.stringContaining('2026-10-31T23:30'), plannedEndAt: expect.stringContaining('2026-11-01T01:30'), startOffset: '-07:00', endOffset: '-08:00', appointmentAt: null, appointmentEndAt: null }) }))
wrapper.unmount()
})
it('blocks dated plans without authoritative timezone but permits undated failure notes without a browser timezone', async () => {
const c = mixedPlans(); c.plans[0]!.timezone = null
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await enterBPlan(wrapper)
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('目的仓时区待核实,请先补齐')
await wrapper.get('[aria-label="客户计划日期"]').setValue(''); await wrapper.get('[aria-label="客户计划状态"]').setValue('FAILED')
await wrapper.get('[aria-label="客户计划备注"]').setValue('待核实仓库地点')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ plannedDate: null, appointmentStatus: 'FAILED', startOffset: null, endOffset: null }) }))
wrapper.unmount()
})
it('preserves a DST-rejected plan for correction and never copies it into a formal booking', async () => {
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
vi.mocked(api.saveReceivingPlan).mockRejectedValueOnce(new ApiResponseError('当地时刻存在歧义,请明确时差', 'A0400', 400))
const wrapper = render(); await enterBPlan(wrapper)
await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-11-01'); await wrapper.get('[aria-label="客户计划开始"]').setValue('2026-11-01T01:30:00')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(wrapper.text()).toContain('当地时刻存在歧义')
expect((wrapper.get('[aria-label="客户计划开始"]').element as HTMLInputElement).value).toContain('2026-11-01T01:30')
expect(button(wrapper, '保存本客户计划').attributes('disabled')).toBeDefined()
await wrapper.get('[aria-label="客户计划开始时差"]').setValue('-08:00'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).toHaveBeenCalledTimes(2); expect(api.bookReceiving).not.toHaveBeenCalled()
wrapper.unmount()
})
it('inherits unconfirmed legacy header plans only without active independent coverage and strips stale confirmation fields', async () => {
for (const status of ['PENDING', 'FAILED'] as const) {
const c = context(); Object.assign(c.batch, { timezone: 'America/Los_Angeles', plannedDate: '2026-10-27', plannedAt: '2026-10-27T10:00:00', appointmentStatus: status, appointmentRemark: '原联系原因', confirmationNo: 'STALE', appointmentAt: '2026-10-20T09:00:00' })
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('2026-10-27')
expect((wrapper.get('[aria-label="客户计划备注"]').element as HTMLTextAreaElement).value).toBe('原联系原因')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.saveReceivingPlan).toHaveBeenLastCalledWith(expect.objectContaining({ details: expect.objectContaining({ appointmentStatus: status, plannedDate: '2026-10-27', confirmationNo: null, appointmentAt: null, appointmentEndAt: null, startOffset: null, endOffset: null }) }))
wrapper.unmount()
}
})
it('never inherits cancelled targets and keeps an explicit empty customer plan ahead of a pending header', async () => {
for (const ownPlan of [false, true]) {
const c = context(); Object.assign(c.batch, { appointmentStatus: ownPlan ? 'PENDING' : 'CANCELLED', plannedDate: '2026-10-27', appointmentRemark: '旧批原因' })
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload(createAppointmentForm()) }]
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
expect((wrapper.get('[aria-label="客户计划备注"]').element as HTMLTextAreaElement).value).toBe('')
wrapper.unmount()
}
})
it('selects only A for independent rebooking from a cancelled group sharing one stable batch with B and D', async () => {
const c = mixedPlans(); c.agreements = [{ ...agreement('CANCELLED'), members: [...members(), { ...members()[1]!, customerId: 'customer-d', customerName: '丁' }] }]
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: c.agreements[0]!, eligibleMembers: c.agreements[0]!.members, excludedMembers: [], transport: c.transport, executionScope: c.executionScope })
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
await wrapper.get('[aria-label="选择预约客户 2"]').setValue(false); await wrapper.get('[aria-label="选择预约客户 3"]').setValue(false)
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'A-ONLY', appointmentAt: '2026-10-24T10:00:00' })
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅甲取得确认'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
const request = vi.mocked(api.bookReceiving).mock.calls[0]![0]
expect(request.bookings).toHaveLength(1); expect(request.bookings[0]!.members).toEqual([{ batchKey: c.batch.batchKey!, batchVersion: 3, customerId: 'customer-a' }])
expect(request.executionVersion).toBe(c.executionScope.version); expect(request.cancelledAgreementKey).toBe('GROUP-1')
wrapper.unmount()
})
it('keeps independent booking fields matched to the selected customer and blocks an empty selection', async () => {
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
await wrapper.findAll('label').find(label => label.text().includes('所选客户分别登记预约'))!.get('input').setValue(true)
wrapper.findAllComponents({ name: 'FbaAppointmentFields' }).forEach((fields, index) => Object.assign(fields.props('model'), { confirmationNo: `FOR-${index}`, appointmentAt: '2026-10-24T10:00:00' }))
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false)
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo).toBe('FOR-1')
await wrapper.get('[aria-label="选择预约客户 2"]').setValue(false)
expect(wrapper.get('[aria-label="确认当前范围"]').attributes('disabled')).toBeDefined(); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.bookReceiving).not.toHaveBeenCalled()
await wrapper.get('[aria-label="选择预约客户 2"]').setValue(true); await flushPromises(); await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅乙确认')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(vi.mocked(api.bookReceiving).mock.calls[0]![0].bookings[0]).toEqual(expect.objectContaining({ members: [expect.objectContaining({ customerId: 'customer-b' })], details: expect.objectContaining({ confirmationNo: 'FOR-1' }) }))
wrapper.unmount()
})
it('reads an exact cross-batch subset execution token without changing the visible batch or cancelled-group versions', async () => {
const c = context(); const a = members()[0]!, b = { ...members()[1]!, batchKey: 'FBA_APPOINTMENT:2', batchVersion: 7, appointmentNo: 'AP-B' }
c.agreements = [{ ...agreement('CANCELLED'), members: [a, b] }]
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: c.agreements[0]!, eligibleMembers: [a, b], excludedMembers: [], transport: c.transport, executionScope: { ...c.executionScope, version: 'whole-A-B' } })
vi.mocked(api.getReceivingExecutionScope).mockImplementation(async selected => ({ version: selected.length === 1 ? 'only-B-current' : 'whole-A-B-current', members: selected.map(member => ({ member, completed: false, canRebook: true, reason: null })) }))
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false); await flushPromises()
expect((wrapper.get('[aria-label="确认当前范围"]').element as HTMLInputElement).checked).toBe(false)
expect(api.getReceivingExecutionScope).toHaveBeenLastCalledWith([{ batchKey: b.batchKey, customerId: b.customerId }])
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'B-ONLY', appointmentAt: '2026-10-24T10:00:00' })
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅乙当前批确认'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.bookReceiving).toHaveBeenCalledWith(expect.objectContaining({ executionVersion: 'only-B-current', cancelledAgreementVersion: 4, bookings: [expect.objectContaining({ members: [{ batchKey: b.batchKey, batchVersion: 7, customerId: b.customerId }] })] }))
wrapper.unmount()
})
it('blocks submission after a selected execution-scope read failure and retries only that read', async () => {
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
vi.mocked(api.getReceivingExecutionScope).mockRejectedValueOnce(new Error('执行资格暂不可用'))
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false); await flushPromises()
expect(wrapper.get('[aria-label="确认当前范围"]').attributes('disabled')).toBeDefined(); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.bookReceiving).not.toHaveBeenCalled(); expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined()
await button(wrapper, '重试读取').trigger('click'); await flushPromises()
expect(api.getReceivingExecutionScope).toHaveBeenLastCalledWith([{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-b' }])
expect((wrapper.get('[aria-label="确认当前范围"]').element as HTMLInputElement).checked).toBe(false)
expect(api.bookReceiving).not.toHaveBeenCalled(); wrapper.unmount()
})
it('shows authoritative subset blockers and ignores a late response for a previous selection', async () => {
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
let resolveOld!: (scope: api.ReceivingExecutionScope) => void
vi.mocked(api.getReceivingExecutionScope).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false)
vi.mocked(api.getReceivingExecutionScope).mockImplementationOnce(async selected => ({ version: 'new-blocked', members: selected.map(member => ({ member, completed: true, canRebook: false, reason: '本客户已执行,不能重约' })) }))
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(true); await flushPromises()
resolveOld({ version: 'stale-allowed', members: [{ member: { batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-b' }, completed: false, canRebook: true, reason: null }] }); await flushPromises()
expect(wrapper.text()).toContain('本客户已执行,不能重约')
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.bookReceiving).not.toHaveBeenCalled(); expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined(); wrapper.unmount()
})
function verifiedCargo(): CargoVerificationContext {
return { source: { sourceKey: 'BOOKING:2', customerId: 'customer-b', orderId: null, inboundCode: 'IN-B', bookingNo: null, containerNo: null, originWarehouseId: 'origin-1', originWarehouseName: '本仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 40, sourceVersion: 'source-latest', canFollowup: true, blockReasons: [] }, verification: { version: 2, sourceVersion: 'source-latest', destinationKey: 'FBA:ONT8', sourcePackages: 40, packageCount: 40, shipmentReferences: ['FBA-NEW-B'], remark: null, operatorName: '核实人员', verifiedAt: '2026-10-16T10:00:00Z' }, allocatedPackages: 30, unknownAllocationCount: 0, effectivePackages: 40, availablePackages: 10, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
}
async function enterRefresh(wrapper: ReturnType<typeof render>) {
await flushPromises(); await wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料')[1]!.trigger('click'); await flushPromises()
}
it('explicitly adopts only B latest source and verification snapshots beside independently confirmed A without changing quantities or saving verification', async () => {
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()); vi.mocked(getCargoVerification).mockResolvedValue(verifiedCargo())
const wrapper = render(); await enterRefresh(wrapper)
expect(getCargoVerification).toHaveBeenCalledExactlyOnceWith({ sourceKey: 'BOOKING:2', customerId: 'customer-b', destinationKey: 'FBA:ONT8' })
expect(wrapper.text()).toContain('本批 30 箱'); expect(wrapper.text()).toContain('最新核实 40 箱'); expect(wrapper.text()).toContain('FBA-NEW-B')
expect(api.refreshReceivingCargo).not.toHaveBeenCalled()
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.refreshReceivingCargo).toHaveBeenCalledWith(expect.objectContaining({ member: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-b' }, executionVersion: 'execution-v1', sources: [{ sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', expectedSourceVersion: 'source-latest', expectedVerificationVersion: 2 }] }))
expect(saveCargoVerification).not.toHaveBeenCalled(); expect(api.proposeReceivingChange).not.toHaveBeenCalled(); expect(api.bookReceiving).not.toHaveBeenCalled()
await button(wrapper, '继续本客户目标 / 未约成').trigger('click'); await flushPromises()
expect(wrapper.find('[aria-label="客户计划日期"]').exists()).toBe(true)
wrapper.unmount()
})
it('fails closed for unknown, changed, overallocated, mismatched or unavailable latest cargo', async () => {
const cases: Array<Partial<CargoVerificationContext>> = [
{ sourceChanged: true }, { effectivePackages: null, availablePackages: null }, { unknownAllocationCount: 1 }, { allocatedPackages: 41 },
{ source: { ...verifiedCargo().source, customerId: 'customer-other' } }, { source: { ...verifiedCargo().source, destinationKey: 'FBA:OTHER' } },
{ source: { ...verifiedCargo().source, sourceVersion: null } }, { verification: { ...verifiedCargo().verification!, sourceVersion: 'stale' } }, { blockReasons: ['来源暂不可用'] },
]
for (const change of cases) {
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()); vi.mocked(getCargoVerification).mockResolvedValue({ ...verifiedCargo(), ...change })
const wrapper = render(); await enterRefresh(wrapper)
expect(button(wrapper, '确认采用本客户最新资料').attributes('disabled')).toBeDefined()
await wrapper.get('form').trigger('submit'); await flushPromises(); expect(api.refreshReceivingCargo).not.toHaveBeenCalled(); wrapper.unmount()
}
vi.mocked(getCargoVerification).mockRejectedValueOnce(new Error('核实来源读取失败'))
const wrapper = render(); await enterRefresh(wrapper)
expect(button(wrapper, '确认采用本客户最新资料').attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('最新货物核实资料读取失败'); wrapper.unmount()
})
it('refreshes rejected adoption versions for explicit reconfirmation and retries unknown adoption with the identical key', async () => {
const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c); vi.mocked(getCargoVerification).mockResolvedValue(verifiedCargo())
vi.mocked(api.refreshReceivingCargo).mockRejectedValueOnce(new ApiResponseError('资料版本变化', 'A0409', 409)).mockRejectedValueOnce(new Error('timeout'))
const wrapper = render(); await enterRefresh(wrapper)
const fresh = structuredClone(c); fresh.batch.version = 4; fresh.executionScope.version = 'exec-new'
vi.mocked(api.getReceivingContext).mockResolvedValue(fresh); vi.mocked(getCargoVerification).mockResolvedValue({ ...verifiedCargo(), verification: { ...verifiedCargo().verification!, version: 3 } })
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
expect(button(wrapper, '确认采用本客户最新资料').attributes('disabled')).toBeDefined()
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
const second = vi.mocked(api.refreshReceivingCargo).mock.calls[1]![0]
expect(second.member.batchVersion).toBe(4); expect(second.sources[0]!.expectedVerificationVersion).toBe(3); expect(second.executionVersion).toBe('exec-new'); expect(await guard()).toBe(false)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(api.refreshReceivingCargo).toHaveBeenNthCalledWith(3, second); expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
})
it('requires original source read permissions and blocks adoption for confirmed or waiting-change customers', async () => {
const c = mixedPlans(); c.pendingChanges = [{ ...pendingChange(), intent: { ...pendingChange().intent, customerId: 'customer-b' } }]
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises()
const buttons = wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料')
expect(buttons[0]!.attributes('disabled')).toBeDefined(); expect(buttons[1]!.attributes('disabled')).toBeDefined(); wrapper.unmount()
access.denied = ['platform:overseas-warehouse:last-mile-order:list']; vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
const denied = render(); await enterRefresh(denied)
expect(getCargoVerification).not.toHaveBeenCalled(); expect(api.refreshReceivingCargo).not.toHaveBeenCalled(); denied.unmount()
})
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createAppointmentForm, appointmentPayload, appointmentFormError, FbaAppointmentFields } from '@/modules/overseas-warehouse'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
import ReceivingScope from './ReceivingScope.vue'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
import { refreshReceivingCargo, type ReceivingCargoRefresh, getReceivingExecutionScope, type ReceivingExecutionScope, saveReceivingPlan, type ReceivingPlan, getReceivingContext, getReceivingGroup, proposeReceivingChange, resolveReceivingChange, bookReceiving, changeReceivingGroup, type ReceivingContext, type ReceivingGroup, type ReceivingMember, type ReceivingPending, type ReceivingResult, type ReceivingPropose, type ReceivingResolve, type ReceivingBook, type ReceivingGroupChange } from './receivingApi'
const props = defineProps<{ batchKey: string; canWrite: boolean; initialCustomerId?: string; owners?: Array<{ id: string; name: string }> }>()
const emit = defineEmits<{ updated: []; appointment: [number: string]; batch: [key: string]; external: [path: string] }>()
const auth = useAuthStore()
const owners = computed(() => [...new Map([...(props.owners || []), ...(auth.user ? [{ id: auth.user.id, name: auth.user.name }] : [])].filter(person => person.id).map(person => [person.id, person])).values()])
const access = useAccessPreviewStore(), context = ref<ReceivingContext | null>(null), group = ref<ReceivingGroup | null>(null)
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), result = ref<ReceivingResult | null>(null)
const confirming = ref(false)
const mode = ref<'refresh' | 'plan' | 'overview' | 'customer' | 'resolve' | 'book' | 'reschedule' | 'cancel'>('overview')
const customer = ref(''), change = ref<ReceivingPending | null>(null), groupKey = ref<string | null>(null)
const adjustment = reactive({ retained: {} as Record<string, number | string>, laterDate: '', ownerId: '', followup: '', reason: '' })
const evidence = ref(''), separate = ref(false), confirmedScope = ref(false)
const bookingForms = ref<Array<ReturnType<typeof createAppointmentForm>>>([]), bookingEvidence = ref<string[]>([])
const bookingMemberKeys = ref<string[]>([]), selectedMemberKeys = ref<string[]>([])
const selectedExecution = ref<ReceivingExecutionScope | null>(null), scopeLoading = ref(false), scopeError = ref<ApiErrorDetails | null>(null)
const cargoContexts = ref<CargoVerificationContext[]>([]), cargoLoading = ref(false), cargoError = ref<ApiErrorDetails | null>(null)
let cargoGeneration = 0
let scopeGeneration = 0
let initializedGroup: string | null = null
const planForm = reactive(createAppointmentForm())
const groupForm = reactive(createAppointmentForm())
const state = () => JSON.stringify({ selectedMemberKeys: selectedMemberKeys.value, planForm, adjustment, evidence: evidence.value, separate: separate.value, bookingForms: bookingForms.value, bookingEvidence: bookingEvidence.value, groupForm })
const baseline = ref(state())
type Command = { kind: 'refresh'; request: ReceivingCargoRefresh } | { kind: 'plan'; request: ReceivingPlan } | { kind: 'propose'; request: ReceivingPropose } | { kind: 'resolve'; key: string; request: ReceivingResolve } | { kind: 'book'; request: ReceivingBook } | { kind: 'group'; key: string; request: ReceivingGroupChange }
const pending = ref<Command | null>(null)
const canLeave = useWorkDraft(computed(() => state() !== baseline.value), computed(() => saving.value || confirming.value || !!pending.value))
const readable = computed(() => access.can('platform:appointment:list'))
const edit = computed(() => props.canWrite && access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
const remove = computed(() => props.canWrite && access.can('platform:appointment:delete'))
const formWritable = computed(() => mode.value === 'cancel' ? remove.value : ['customer', 'resolve'].includes(mode.value) ? edit.value && remove.value : edit.value)
const locked = computed(() => loading.value || saving.value || confirming.value || !!error.value || !!pending.value)
const sourceKey = (source: { sourceKey: string; destinationKey: string }) => JSON.stringify([source.sourceKey, source.destinationKey])
const customerItems = computed(() => context.value?.batch.items.filter(item => item.customerId === customer.value) || [])
const customers = computed(() => [...new Set(context.value?.batch.items.map(item => item.customerId) || [])])
function customerName(id: string) { return context.value?.agreements.flatMap(row => row.members).find(member => member.customerId === id)?.customerName || context.value?.pendingChanges.flatMap(row => row.originalMembers).find(member => member.customerId === id)?.customerName || null }
const eligibleCustomers = computed(() => customers.value.filter(id => context.value?.executionScope.members.some(row => row.member.batchKey === context.value?.batch.batchKey && row.member.customerId === id && row.canRebook && !row.completed)))
const uncoveredCustomers = computed(() => eligibleCustomers.value.filter(id => !context.value!.agreements.some(a => a.status === 'ACTIVE' && a.members.some(m => m.batchKey === props.batchKey && m.customerId === id)) && !context.value!.pendingChanges.some(c => c.status === 'WAITING' && c.intent.batchKey === props.batchKey && c.intent.customerId === id)))
const refreshReadable = computed(() => readable.value && access.can('platform:overseas-warehouse:last-mile-order:list'))
const refreshEligible = (id: string) => refreshReadable.value && !!context.value?.allowedActions.includes('REFRESH_CARGO') && uncoveredCustomers.value.includes(id)
const planEligible = (id: string) => !!context.value?.allowedActions.includes('PLAN_RECEIVING') && uncoveredCustomers.value.includes(id)
const customerPlan = (id: string) => context.value?.plans.find(plan => plan.batchKey === props.batchKey && plan.customerId === id)
const planTimezone = computed(() => { const saved = customerPlan(customer.value); return saved ? saved.timezone : context.value?.batch.timezone })
const cargoIncomplete = computed(() => !!context.value?.batch.items.filter(item => eligibleCustomers.value.includes(item.customerId)).some(item => item.packageCount == null || !item.destinationKey || !item.sourceVersion))
const bookMembers = computed<ReceivingMember[]>(() => {
if (group.value) return group.value.eligibleMembers
const batch = context.value?.batch
if (!batch?.batchKey || cargoIncomplete.value) return []
return eligibleCustomers.value.filter(id => !context.value!.agreements.some(a => a.status === 'ACTIVE' && a.members.some(m => m.batchKey === batch.batchKey && m.customerId === id)) && !context.value!.pendingChanges.some(c => c.status === 'WAITING' && c.intent.customerId === id)).map(id => ({ batchKey: batch.batchKey!, appointmentNo: batch.appointmentNo, batchVersion: batch.version, customerId: id, customerName: customerName(id), sources: batch.items.filter(item => item.customerId === id).map(item => ({ sourceKey: item.sourceKey, inboundCode: item.inboundCode || null, destinationKey: item.destinationKey!, packageCount: item.packageCount!, sourceVersion: item.sourceVersion!, verificationVersion: item.cargoVerificationVersion ?? null })) }))
})
const cargoProblems = computed(() => {
const problems: string[] = []
if (!customerItems.value.length || customerItems.value.length > 100 || cargoContexts.value.length !== customerItems.value.length) return ['本客户来源范围未完整读取']
if (new Set(customerItems.value.map(item => JSON.stringify([item.sourceKey, item.destinationKey]))).size !== customerItems.value.length) problems.push('本客户存在重复来源,请核对原批次')
customerItems.value.forEach((item, index) => {
const row = cargoContexts.value[index]!, source = row.source, record = row.verification
if (source.sourceKey !== item.sourceKey || source.customerId !== customer.value || source.destinationKey !== item.destinationKey || source.originWarehouseId !== context.value?.batch.originWarehouseId || record && record.destinationKey !== item.destinationKey) problems.push('货物身份、起运仓或目的地与本批不一致')
if (row.sourceChanged || record && record.sourceVersion !== source.sourceVersion) problems.push('原货源已变化,请先重新核实资料')
if (!source.sourceVersion || record && (!Number.isSafeInteger(record.version) || record.version < 1)) problems.push('最新货物资料版本不完整')
if (item.packageCount == null || item.packageCount <= 0 || row.effectivePackages == null || !Number.isSafeInteger(row.effectivePackages) || row.effectivePackages <= 0 || row.availablePackages == null || row.availablePackages < 0 || row.unknownAllocationCount !== 0 || !Number.isSafeInteger(row.allocatedPackages) || row.allocatedPackages < 0) problems.push('本批箱数、最新核实量或分配量待核实')
if (row.effectivePackages != null && (row.allocatedPackages > row.effectivePackages || (item.packageCount ?? 0) > row.effectivePackages)) problems.push('已分配量或本批箱数超过最新核实量')
problems.push(...row.blockReasons, ...source.blockReasons)
})
return [...new Set(problems)]
})
const memberKey = (member: Pick<ReceivingMember, 'batchKey' | 'customerId'>) => JSON.stringify([member.batchKey, member.customerId])
const selectedBookMembers = computed(() => bookMembers.value.filter(member => selectedMemberKeys.value.includes(memberKey(member))))
const bookingRows = computed(() => !selectedBookMembers.value.length ? [] : separate.value ? selectedBookMembers.value.map(member => ({ member, index: bookMembers.value.findIndex(row => memberKey(row) === memberKey(member)) })) : [{ member: null, index: 0 }])
const selectedExecutionAllowed = computed(() => !!selectedExecution.value?.version && selectedBookMembers.value.every(member => selectedExecution.value!.members.some(row => memberKey(row.member) === memberKey(member) && row.canRebook && !row.completed)))
const bookingScopeChanged = computed(() => JSON.stringify(bookingMemberKeys.value) !== JSON.stringify(bookMembers.value.map(memberKey)))
const currentTrips = computed(() => [...new Set((group.value?.transport || context.value?.transport)?.vehicles.map(vehicle => vehicle.tripNo).filter((number): number is string => !!number) || [])])
const laterBatch = computed(() => result.value?.batches.find(batch => result.value?.steps.some(step => step.step === 'ALLOCATE_CARGO' && step.status === 'SUCCESS' && step.targetKey === batch.batchKey)))
const canSubmit = computed(() => {
if (loading.value || error.value || saving.value || confirming.value || result.value || !context.value) return false
if (pending.value) return pending.value.kind === 'group' && pending.value.request.action === 'CANCEL' ? remove.value : pending.value.kind === 'propose' || pending.value.kind === 'resolve' ? edit.value && remove.value : edit.value
if (!confirmedScope.value) return false
if (mode.value === 'refresh') return edit.value && refreshEligible(customer.value) && !cargoLoading.value && !cargoError.value && !cargoProblems.value.length
if (mode.value === 'plan') return edit.value && planEligible(customer.value)
if (mode.value === 'customer') return edit.value && remove.value && context.value.allowedActions.includes('PROPOSE_CUSTOMER_CHANGE')
if (mode.value === 'resolve') return edit.value && remove.value && change.value?.status === 'WAITING'
if (mode.value === 'cancel') return remove.value && group.value?.agreement.status === 'ACTIVE' && !group.value.transport.blockReasons.length
if (mode.value === 'reschedule') return edit.value && group.value?.agreement.status === 'ACTIVE' && !group.value.transport.blockReasons.length
return mode.value === 'book' && edit.value && !!selectedBookMembers.value.length && !bookingScopeChanged.value && !scopeLoading.value && !scopeError.value && selectedExecutionAllowed.value && (group.value ? group.value.agreement.status === 'CANCELLED' : context.value.allowedActions.includes('BOOK_RECEIVING'))
})
async function load() {
if (!readable.value) return
loading.value = true; error.value = null
try {
const next = await getReceivingContext(props.batchKey)
const nextGroup = groupKey.value ? await getReceivingGroup(groupKey.value) : null
context.value = next; group.value = nextGroup
if (nextGroup && initializedGroup !== nextGroup.agreement.agreementKey) {
Object.assign(groupForm, createAppointmentForm(), Object.fromEntries(Object.entries(nextGroup.agreement.details).map(([key, value]) => [key, value ?? ''])))
if (mode.value === 'book') initializeBookings()
initializedGroup = nextGroup.agreement.agreementKey; baseline.value = state()
}
if (change.value) change.value = next.pendingChanges.find(row => row.changeKey === change.value?.changeKey) || null
if (mode.value === 'book' && !result.value) await refreshSelectedScope()
if (mode.value === 'refresh' && !result.value) await loadCargo()
} catch (cause) { error.value = getApiErrorDetails(cause, '预约覆盖范围读取失败') }
finally { loading.value = false }
}
function resetForm() { ++cargoGeneration; cargoLoading.value = false; cargoContexts.value = []; cargoError.value = null; ++scopeGeneration; selectedExecution.value = null; scopeLoading.value = false; scopeError.value = null; evidence.value = ''; confirmedScope.value = false; result.value = null; saveError.value = null; separate.value = false }
async function overview() { if (!await canLeave()) return; mode.value = 'overview'; groupKey.value = null; group.value = null; change.value = null; resetForm(); baseline.value = state(); await load() }
async function adjust(id: string) {
if (locked.value || !await canLeave()) return
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'customer'
adjustment.retained = Object.fromEntries(customerItems.value.filter(item => item.destinationKey).map(item => [sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey! }), item.packageCount == null ? '' : item.packageCount]))
adjustment.laterDate = ''; adjustment.ownerId = owners.value[0]?.id || ''; adjustment.followup = ''; adjustment.reason = ''; baseline.value = state()
}
async function openCargoRefresh(id: string) {
if (locked.value || !refreshEligible(id) || !await canLeave()) return
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'refresh'; baseline.value = state(); await loadCargo()
}
async function loadCargo() {
const generation = ++cargoGeneration; confirmedScope.value = false; cargoContexts.value = []; cargoError.value = null; cargoLoading.value = false
if (!refreshReadable.value || pending.value) return
if (!customerItems.value.length || customerItems.value.length > 100 || customerItems.value.some(item => !item.destinationKey)) { cargoError.value = { message: '本客户来源或目的地不完整,不能采用资料' }; return }
cargoLoading.value = true
try {
const rows = await Promise.all(customerItems.value.map(item => getCargoVerification({ sourceKey: item.sourceKey, customerId: customer.value, destinationKey: item.destinationKey! })))
if (generation === cargoGeneration) cargoContexts.value = rows
} catch (cause) { if (generation === cargoGeneration) cargoError.value = getApiErrorDetails(cause, '最新货物核实资料读取失败') }
finally { if (generation === cargoGeneration) cargoLoading.value = false }
}
async function openPlan(id: string) {
if (locked.value || !planEligible(id) || !await canLeave()) return
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'plan'
const saved = customerPlan(id)
const header = context.value!.batch
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : createAppointmentForm()
Object.assign(planForm, createAppointmentForm(), saved ? Object.fromEntries(Object.entries(saved.details).map(([key, value]) => [key, value ?? ''])) : { ...inherited, confirmationNo: '', appointmentAt: '', appointmentEndAt: '', startOffset: '', endOffset: '' })
baseline.value = state()
}
async function resume(pendingChange: ReceivingPending) { if (locked.value || !await canLeave()) return; resetForm(); change.value = pendingChange; mode.value = 'resolve'; baseline.value = state() }
function initializeBookings() { bookingMemberKeys.value = bookMembers.value.map(memberKey); selectedMemberKeys.value = [...bookingMemberKeys.value]; bookingForms.value = bookMembers.value.map(() => ({ ...createAppointmentForm(), appointmentStatus: 'SUCCESS' })); bookingEvidence.value = bookMembers.value.map(() => ''); confirmedScope.value = false; baseline.value = state() }
async function resetBookingScope() { if (await canLeave()) { initializeBookings(); await refreshSelectedScope() } }
async function openGroup(key: string, next: 'book' | 'reschedule' | 'cancel') {
if (locked.value || !await canLeave()) return
resetForm(); groupKey.value = key; group.value = null; initializedGroup = null; mode.value = next; await load()
baseline.value = state()
}
async function newBooking() { if (locked.value || !await canLeave()) return; resetForm(); groupKey.value = null; group.value = null; mode.value = 'book'; initializeBookings(); await refreshSelectedScope() }
async function refreshSelectedScope() {
const generation = ++scopeGeneration
confirmedScope.value = false; selectedExecution.value = null; scopeError.value = null; scopeLoading.value = false
if (mode.value !== 'book' || !readable.value || !selectedBookMembers.value.length || bookingScopeChanged.value) return
const members = selectedBookMembers.value.map(({ batchKey, customerId }) => ({ batchKey, customerId }))
scopeLoading.value = true
try {
const scope = await getReceivingExecutionScope(members)
if (generation === scopeGeneration) selectedExecution.value = scope
} catch (cause) { if (generation === scopeGeneration) scopeError.value = getApiErrorDetails(cause, '所选客户执行资格读取失败') }
finally { if (generation === scopeGeneration) scopeLoading.value = false }
}
onBeforeUnmount(() => { ++scopeGeneration; ++cargoGeneration })
function validation(message: string): never { throw new Error(message) }
function confirmedDetails(form: ReturnType<typeof createAppointmentForm>) { const invalid = appointmentFormError(form); if (invalid) validation(invalid); if (form.appointmentStatus !== 'SUCCESS') validation('此处登记仓库正式确认;尚未确认请使用本客户的“保存目标 / 未约成”入口'); return appointmentPayload(form) }
function buildCommand(decision: 'CONFIRM' | 'WITHDRAW'): Command {
const current = context.value!, idempotencyKey = crypto.randomUUID()
if (mode.value === 'refresh') {
return { kind: 'refresh', request: { idempotencyKey, member: { batchKey: props.batchKey, batchVersion: current.batch.version, customerId: customer.value }, executionVersion: current.executionScope.version, sources: cargoContexts.value.map(row => ({ sourceKey: row.source.sourceKey, destinationKey: row.source.destinationKey, expectedSourceVersion: row.source.sourceVersion!, expectedVerificationVersion: row.verification?.version ?? null })) } }
}
if (mode.value === 'plan') {
if (!['PENDING', 'FAILED'].includes(planForm.appointmentStatus)) validation('客户计划只能保存等待确认或未约成')
if (planForm.confirmationNo || planForm.appointmentAt || planForm.appointmentEndAt) validation('客户计划不能写入正式确认')
if ((planForm.plannedDate || planForm.plannedAt || planForm.plannedEndAt) && !planTimezone.value) validation('目的仓时区待核实,请先补齐;日期未定时可保存联系结果')
if (planForm.plannedEndAt && !planForm.plannedAt) validation('填写结束时段前请填写计划开始时段')
if (planForm.startOffset && !planForm.plannedAt || planForm.endOffset && !planForm.plannedEndAt) validation('仅填写对应计划时段时才可填写时差;仅日期或未定日期请清空时差')
for (const offset of [planForm.startOffset, planForm.endOffset]) if (offset.trim() && !/^[+-](?:0\d|1[0-7]):[0-5]\d$|^[+-]18:00$/.test(offset.trim())) validation('计划时差请按 +08:00 或 -07:00 格式填写')
const invalid = appointmentFormError(planForm); if (invalid) validation(invalid)
return { kind: 'plan', request: { idempotencyKey, member: { batchKey: props.batchKey, batchVersion: current.batch.version, customerId: customer.value }, details: appointmentPayload(planForm), executionVersion: current.executionScope.version } }
}
if (mode.value === 'customer') {
if (!owners.value.some(person => person.id === adjustment.ownerId) || !adjustment.reason.trim()) validation('请选择有效负责人并填写调整原因')
const quantities = customerItems.value.map(item => {
if (!item.destinationKey || !item.sourceVersion || item.packageCount == null) validation('本票数量或来源版本待核实,请先核实货物')
const raw = adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey! })]
if (raw === '' || raw == null) validation('请填写每票本次先送箱数,全部移出可填 0')
const retainedPackages = Number(raw)
if (!Number.isSafeInteger(retainedPackages) || retainedPackages < 0 || retainedPackages > item.packageCount!) validation('先送箱数须为 0 至本票当前箱数之间的整数')
return { sourceKey: item.sourceKey, destinationKey: item.destinationKey!, retainedPackages, expectedSourceVersion: item.sourceVersion!, expectedVerificationVersion: item.cargoVerificationVersion ?? null }
})
if (quantities.every((q, index) => q.retainedPackages === customerItems.value[index]!.packageCount)) validation('本次先送量未改变,无需提交调整')
return { kind: 'propose', request: { idempotencyKey, intent: { batchKey: props.batchKey, batchVersion: current.batch.version, customerId: customer.value, quantities, laterDate: adjustment.laterDate || null, ownerId: adjustment.ownerId, nextFollowupDate: adjustment.followup || null, reason: adjustment.reason.trim(), transportVersion: current.transport.version } } }
}
if (mode.value === 'resolve') {
if (!change.value) validation('待确认记录已变化,请刷新后核对')
if (decision === 'CONFIRM' && !evidence.value.trim()) validation('请填写仓库同意本次保留量和移出量的回复依据')
return { kind: 'resolve', key: change.value!.changeKey, request: { idempotencyKey, version: change.value!.version, decision, warehouseEvidence: evidence.value.trim() || null, transportVersion: current.transport.version } }
}
if (mode.value === 'book') {
const selected = selectedBookMembers.value
const groups = separate.value ? selected.map(member => [member]) : [selected]
return { kind: 'book', request: { idempotencyKey, cancelledAgreementKey: group.value?.agreement.agreementKey || null, cancelledAgreementVersion: group.value?.agreement.version ?? null, executionVersion: selectedExecution.value!.version, bookings: groups.map((members, index) => {
const formIndex = bookingRows.value[index]!.index
const note = bookingEvidence.value[formIndex]?.trim(); if (!note) validation('请逐份填写仓库确认覆盖客户和货量的依据')
return { members: members.map(({ batchKey, batchVersion, customerId }) => ({ batchKey, batchVersion, customerId })), details: confirmedDetails(bookingForms.value[formIndex]!), warehouseEvidence: note! }
}) } }
}
if (!group.value || !evidence.value.trim()) validation('请核对共同预约范围并填写仓库回复依据')
return { kind: 'group', key: group.value!.agreement.agreementKey, request: { idempotencyKey, version: group.value!.agreement.version, action: mode.value === 'cancel' ? 'CANCEL' : 'RESCHEDULE', details: mode.value === 'cancel' ? null : confirmedDetails(groupForm), warehouseEvidence: evidence.value.trim(), transportVersion: group.value!.transport.version } }
}
async function save(decision: 'CONFIRM' | 'WITHDRAW' = 'CONFIRM') {
if (!canSubmit.value) return
if (!pending.value) {
try {
const command = buildCommand(decision)
if (command.kind === 'group' && command.request.action === 'CANCEL') {
confirming.value = true
try { await ElMessageBox.confirm('确认取消当前展示的整份预约?货物需求与批次保留,车辆需重新核对。', '取消整份预约', { customClass: 'admin-density-confirm', confirmButtonText: '确认取消', cancelButtonText: '返回核对' }) } catch { return } finally { confirming.value = false }
}
pending.value = command
} catch (cause) { saveError.value = { message: cause instanceof Error ? cause.message : '请核对本次范围' }; return }
}
saving.value = true; saveError.value = null
try {
const command = pending.value
result.value = command.kind === 'refresh' ? await refreshReceivingCargo(command.request) : command.kind === 'plan' ? await saveReceivingPlan(command.request) : command.kind === 'propose' ? await proposeReceivingChange(command.request) : command.kind === 'resolve' ? await resolveReceivingChange(command.key, command.request) : command.kind === 'book' ? await bookReceiving(command.request) : await changeReceivingGroup(command.key, command.request)
pending.value = null; baseline.value = state(); confirmedScope.value = false; emit('updated'); await load()
} catch (cause) { saveError.value = getApiErrorDetails(cause, '结果待确认,请重试本次提交'); if (rejectedCommand(cause)) { pending.value = null; confirmedScope.value = false; await load() } }
finally { saving.value = false }
}
async function openBatch(key: string) { if (await canLeave()) emit('batch', key) }
onMounted(async () => { await load(); if (props.initialCustomerId && customers.value.includes(props.initialCustomerId)) await adjust(props.initialCustomerId) })
</script>
<template>
<section class="work-form" aria-label="客户调整与共同预约">
<h3>{{ context?.batch.appointmentNo || '预约批次' }} · 客户调整与共同预约</h3>
<p v-if="!readable">当前账号没有预约列表读取权限。</p>
<LoadErrorState v-if="error" title="预约覆盖范围读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在核对货物、预约覆盖与车辆…</p>
<template v-if="context">
<el-button v-if="mode !== 'overview' || result" :disabled="locked" @click="overview">返回范围概览</el-button>
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<section v-if="result" aria-label="本次处理结果">
<h4>{{ result.status === 'WAITING' ? '已保存待确认,原安排尚未改变' : result.status === 'UNCHANGED' ? '安排没有变化' : result.status === 'WITHDRAWN' ? '已撤回变化,原安排保留' : '本次处理已保存' }}</h4>
<p v-for="(step, index) in result.steps" :key="index">{{ step.message }}</p>
<p v-if="result.nextActions.includes('REVIEW_TRANSPORT')">下一步:核对当前收货时段及车辆安排;本次保存不代表可以发车。</p>
<el-button v-if="result.nextActions.includes('BOOK_LATER_BATCH') && laterBatch" :type="result.nextActions[0] === 'BOOK_LATER_BATCH' ? 'primary' : 'default'" :disabled="locked" @click="emit('appointment', laterBatch.appointmentNo)">继续给后送批次约仓</el-button>
<template v-if="result.nextActions.includes('REVIEW_TRANSPORT')"><el-button v-for="(number, index) in currentTrips" :key="number" :type="index === 0 && result.nextActions[0] === 'REVIEW_TRANSPORT' ? 'primary' : 'default'" :disabled="locked || !access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(number)}`)">核对本车发车安排 · {{ number }}</el-button></template>
<el-button v-if="result.status === 'WAITING' && context.pendingChanges.find(row => row.changeKey === result?.changeKey)" type="primary" :disabled="locked" @click="resume(context.pendingChanges.find(row => row.changeKey === result?.changeKey)!)">继续登记仓库回复</el-button>
<template v-if="mode === 'refresh'"><p>本客户资料已处理,可继续保存本客户计划或登记正式预约。</p><el-button :disabled="locked || !planEligible(customer)" @click="openPlan(customer)">继续本客户目标 / 未约成</el-button><el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">核对未确认客户并登记正式预约</el-button></template><p v-if="mode === 'plan'">本客户计划已处理。可返回范围概览核对客户计划,或在本客户事项继续记录回复与跟进。</p><div v-for="batch in (['plan', 'refresh'].includes(mode) ? [] : result.batches).filter(row => row.items.some(item => item.packageCount == null || item.packageCount > 0))" :key="batch.batchKey"><p>{{ batch.appointmentNo }} · {{ batch.plannedDate || '日期未定' }}</p><el-button v-if="batch.batchKey" :disabled="locked" @click="openBatch(batch.batchKey)">查看本批当前去向</el-button><el-button :disabled="locked" @click="emit('appointment', batch.appointmentNo)">继续本批预约</el-button></div>
<el-button v-for="(agreement, index) in result.agreements.filter(row => row.status === 'CANCELLED')" :key="agreement.agreementKey" :type="index === 0 && result.nextActions[0] === 'REBOOK_GROUP' ? 'primary' : 'default'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'book')">重新预约这组货物</el-button>
</section>
<template v-else-if="mode === 'overview'">
<h4>各客户计划与货物调整</h4><p>先保存待确认变化,收到仓库明确回复后再生效;本次先送量允许为 0。</p>
<div v-for="(id, index) in customers" :key="id"><b>{{ customerName(id) || '—' }} · 客户范围 {{ index + 1 }}</b><p v-for="item in context.batch.items.filter(row => row.customerId === id)" :key="item.id">{{ item.inboundCode || '原票业务号未提供' }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p v-if="customerPlan(id)">本客户计划:{{ customerPlan(id)!.details.plannedDate || '日期未定' }} · {{ customerPlan(id)!.details.appointmentStatus === 'FAILED' ? '未约成' : '等待确认' }} · {{ customerPlan(id)!.details.appointmentRemark || '—' }}</p><el-button :disabled="locked || !edit || !refreshEligible(id)" @click="openCargoRefresh(id)">核对并采用本客户最新货物资料</el-button><el-button :disabled="locked || !edit || !planEligible(id)" @click="openPlan(id)">保存目标 / 未约成</el-button><el-button :disabled="locked || !edit || !remove || !context.allowedActions.includes('PROPOSE_CUSTOMER_CHANGE')" @click="adjust(id)">调整本客户先送 / 后送</el-button></div>
<h4>待仓库回复</h4><div v-for="pendingChange in context.pendingChanges.filter(row => row.status === 'WAITING')" :key="pendingChange.changeKey"><p>{{ pendingChange.intent.reason }} · 负责人 {{ pendingChange.followup.ownerName || '—' }} · 跟进 {{ pendingChange.followup.nextFollowupDate || '待安排' }}</p><el-button :disabled="locked" @click="resume(pendingChange)">继续本次变化</el-button></div>
<h4>整份预约范围</h4><div v-for="agreement in context.agreements" :key="agreement.agreementKey"><p>{{ agreement.details.confirmationNo || '约号未提供' }} · {{ agreement.status === 'ACTIVE' ? '有效' : agreement.status === 'CANCELLED' ? '已取消' : '历史' }} · {{ agreement.details.appointmentAt || '时段未定' }} → {{ agreement.details.appointmentEndAt || '结束未定' }} · {{ agreement.timezone || '时区待核实' }}</p><ReceivingScope :members="agreement.members" /><el-button v-if="agreement.status === 'ACTIVE'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'reschedule')">核对整份改期范围</el-button><el-button v-if="agreement.status === 'ACTIVE'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'cancel')">核对整份取消范围</el-button><el-button v-if="agreement.status === 'CANCELLED'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'book')">重新预约这组货物</el-button></div>
<ReceivingScope :members="[]" :transport="context.transport" />
<el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">登记当前未覆盖货物的正式预约</el-button>
</template>
<form v-else @submit.prevent="save()">
<fieldset :disabled="locked || !formWritable">
<template v-if="mode === 'refresh'">
<h4>{{ customerName(customer) || '—' }} · 核对本客户最新货物资料</h4><p>采用后仅更新本客户的资料快照,本批箱数和其他客户的确认保持不变;保存核实资料不会自动执行采用。</p>
<p v-if="cargoLoading">正在读取本客户各票最新核实资料…</p>
<LoadErrorState v-if="cargoError" title="最新货物核实资料读取失败" :message="cargoError.message" :loading="cargoLoading" @retry="loadCargo" />
<div v-for="(row, index) in cargoContexts" :key="index"><b>{{ row.source.inboundCode || row.source.bookingNo || '—' }}</b><p>本批 {{ customerItems[index]?.packageCount ?? '待核实' }} 箱 · 最新核实 {{ row.verification?.packageCount ?? '待核实' }} 箱 · 当前有效 {{ row.effectivePackages ?? '待核实' }} 箱 · 已分配 {{ row.allocatedPackages }} 箱</p><p>最新货件号:{{ row.verification?.shipmentReferences.join('、') || '—' }}</p></div>
<p v-for="problem in cargoProblems" :key="problem" role="status">{{ problem }}</p>
<el-button :disabled="locked || cargoLoading" @click="loadCargo">重新读取最新资料</el-button>
</template>
<template v-if="mode === 'plan'">
<h4>{{ customerName(customer) || '—' }} · 本客户普通计划</h4>
<p v-for="item in customerItems" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount ?? '箱数待核实' }}</p>
<p>{{ planTimezone || '目的仓时区待核实' }} · 只保存本客户计划,其他客户的确认保持不变。</p>
<p v-if="!planEligible(customer)" role="status">本客户资格已变化,暂不能保存普通计划,请返回范围概览核对。</p>
<label>目标送仓日期<input v-model="planForm.plannedDate" type="date" aria-label="客户计划日期"></label>
<label>计划开始时段<input v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime-local" step="1" aria-label="客户计划开始"></label>
<label>计划结束时段<input v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime-local" step="1" aria-label="客户计划结束"></label>
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00"></label></details>
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" /></label>
<p>目标日期可留空;清空日期和时段会清除本客户目标,不取消货物需求。负责人及下次跟进请在本客户事项的“记录回复与跟进”维护。</p>
</template>
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<input v-model="adjustment.laterDate" type="date"></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<input v-model="adjustment.followup" type="date"></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" /></label></template>
<template v-if="group"><h4>{{ mode === 'book' ? '整组重约当前范围' : '整份预约当前范围' }}</h4><ReceivingScope :members="mode === 'book' ? selectedBookMembers : group.agreement.members" :transport="group.transport" /><div v-for="(excluded, index) in group.excludedMembers" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除范围 {{ index + 1 }} · {{ excluded.member.appointmentNo }}:{{ excluded.reason }}</div></template>
<template v-if="mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}:{{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}:{{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" /></label></template></template>
<template v-if="mode === 'reschedule' || mode === 'cancel'"><FbaAppointmentFields v-if="mode === 'reschedule'" :model="groupForm" :timezone="group?.agreement.timezone" :disabled="locked || !edit" /><p v-else>整份取消会清除活动确认及失效目标,保留历史、批次数量和车辆关系;后续仍须重新约仓并核对车辆。</p><label>仓库回复依据<textarea v-model="evidence" maxlength="1000" aria-label="整份回复依据" /></label></template>
<label><input v-model="confirmedScope" type="checkbox" :disabled="mode === 'book' && (scopeLoading || !!scopeError || !selectedExecutionAllowed) || mode === 'refresh' && (cargoLoading || !!cargoError || !!cargoProblems.length)" aria-label="确认当前范围">已核对上述客户货物、当前预约与车辆范围</label>
</fieldset>
<el-button type="primary" native-type="submit" :disabled="!canSubmit" :loading="saving">{{ pending ? '重试本次提交' : mode === 'refresh' ? '确认采用本客户最新资料' : mode === 'plan' ? '保存本客户计划' : mode === 'customer' ? '保存待仓库确认' : mode === 'resolve' ? '确认回复并应用本次调整' : mode === 'cancel' ? '确认整份取消' : mode === 'reschedule' ? '保存整份改期' : '保存本次正式预约' }}</el-button>
<el-button v-if="mode === 'resolve' && !pending" :disabled="!canSubmit" @click="save('WITHDRAW')">撤回变化并保留原安排</el-button>
</form>
<LoadErrorState v-if="saveError" title="本次提交尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
</template>
</section>
</template>
@@ -0,0 +1,48 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import { getDispatch } from '@/modules/overseas-warehouse'
import type { LeaveGuard } from './useWorkDraft'
const access = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() } }))
let guard: LeaveGuard
function render() { return mount(WorkSharedTripForm, { props: { dispatchNo: 'DS-60', canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
TripPreparationDialog: { name: 'TripPreparationDialog', props: ['modelValue', 'warehouseId', 'dispatchNos'], template: '<div />' },
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true,
} } }) }
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockImplementation(async number => ({ id: `internal-${number}`, dispatchNo: number, appointmentBatches: [], customerId: number === 'DS-60' ? 'customer-b' : 'customer-d', overseasWarehouseId: 'warehouse-real', overseasWarehouseName: '实际起运仓', packageCount: number === 'DS-60' ? 60 : 40, status: 'PENDING_DEPARTURE' })) })
it('checks exact 60 and 40 dispatch business numbers and passes both to the existing Fleet preparation before navigating to its real trip', async () => {
const wrapper = render()
expect(getDispatch).not.toHaveBeenCalled()
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(vi.mocked(getDispatch).mock.calls.map(call => call[0])).toEqual(['DS-60', 'DS-40'])
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
expect(prepare.props()).toMatchObject({ modelValue: true, warehouseId: 'warehouse-real', dispatchNos: ['DS-60', 'DS-40'] })
expect(wrapper.text()).toContain('60 箱'); expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).not.toContain('internal-')
expect(await guard()).toBe(false)
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-SHARED' } }); await flushPromises()
expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-SHARED']]); expect(wrapper.emitted('updated')).toHaveLength(1)
wrapper.unmount()
})
it('rejects duplicate, cross-warehouse, already-bound and mismatched dispatches without opening Fleet preparation', async () => {
const wrapper = render(); await wrapper.get('input').setValue('DS-60'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(getDispatch).not.toHaveBeenCalled()
for (const change of [{ overseasWarehouseId: 'another-warehouse' }, { workflow: { tripNo: 'TR-OTHER', dispatchReview: { status: 'PENDING' as const, reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING' as const, reason: null, reviewedAt: null } } }, { status: 'SHIPPED' }, { dispatchNo: 'WRONG' }]) {
vi.mocked(getDispatch).mockResolvedValueOnce({ id: 'a', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'warehouse-real', status: 'PENDING_DEPARTURE' }).mockResolvedValueOnce({ id: 'b', dispatchNo: 'DS-40', appointmentBatches: [], overseasWarehouseId: 'warehouse-real', status: 'PENDING_DEPARTURE', ...change })
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'TripPreparationDialog' }).exists()).toBe(false); expect(wrapper.find('[role="alert"]').exists()).toBe(true)
}
wrapper.unmount()
})
it('keeps a failed lookup retry read-only and respects original permissions', async () => {
const wrapper = render(); vi.mocked(getDispatch).mockRejectedValueOnce(new Error('读取失败'))
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'TripPreparationDialog' }).exists()).toBe(false)
await wrapper.get('button').trigger('click'); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(4); expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props('dispatchNos')).toEqual(['DS-60', 'DS-40'])
wrapper.unmount(); access.allowed = false
const readonly = render(); await readonly.get('button').trigger('click'); expect(getDispatch).toHaveBeenCalledTimes(4); readonly.unmount()
})
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getDispatch, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
import { TripPreparationDialog } from '@/modules/overseas-master-data'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useWorkDraft } from './useWorkDraft'
const props = defineProps<{ dispatchNo: string; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; busy: [value: boolean]; external: [path: string] }>()
const access = useAccessPreviewStore(), otherNo = ref(''), rows = ref<DispatchSummaryDto[]>([])
const loading = ref(false), error = ref<ApiErrorDetails | null>(null), prepareOpen = ref(false)
const allowed = computed(() => props.canWrite && ['platform:overseas-warehouse:dispatch:list', 'platform:fleet:trip:list', 'platform:fleet:trip:edit'].every(permission => access.can(permission)))
watch(prepareOpen, value => emit('busy', value))
useWorkDraft(computed(() => false), prepareOpen)
let generation = 0
function changed() { ++generation; loading.value = false; rows.value = []; error.value = null }
async function prepare() {
if (!allowed.value || loading.value || prepareOpen.value) return
rows.value = []; error.value = null
const other = otherNo.value.trim()
if (!other || other === props.dispatchNo) { error.value = { message: '请填写另一张已备好的排车单业务号,不能重复选择本单。' }; return }
const current = ++generation; loading.value = true
try {
const numbers = [props.dispatchNo, other]
const details = await Promise.all(numbers.map(getDispatch))
if (current !== generation) return
if (details.some((row, index) => row.dispatchNo !== numbers[index])) throw new Error('返回的排车单与所选业务号不一致,请重新核对。')
if (details.some(row => row.status !== 'PENDING_DEPARTURE' || row.workflow)) throw new Error('请选择尚未关联车次的待发运排车单;已关联的货物请从原车次继续处理。')
if (!details[0]!.overseasWarehouseId || details.some(row => row.overseasWarehouseId !== details[0]!.overseasWarehouseId)) throw new Error('两张排车单必须有明确且相同的起运仓。')
rows.value = details; prepareOpen.value = true
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '共车排车单核对失败') }
finally { if (current === generation) loading.value = false }
}
function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
onBeforeUnmount(() => { ++generation; emit('busy', false) })
</script>
<template>
<section class="work-form" aria-label="与同仓其他排车单共车">
<h4>与同仓其他排车单共车</h4>
<p>本批排车单:{{ dispatchNo }}。请先按各客户实际托盘分别备好排车单,再核对同仓两单并安排同一车次。</p>
<p>所需箱数无法对应完整实物时,请先在原库存作业中完成拆托,再返回核对;分批计划不会代替实物拆托。</p>
<label>另一张排车单号<input v-model="otherNo" aria-label="共车排车单号" maxlength="64" :disabled="loading || prepareOpen || !allowed" @input="changed" @keydown.enter.prevent="prepare"></label>
<el-button :disabled="!allowed || prepareOpen" :loading="loading" @click="prepare">核对两单并安排共车</el-button>
<LoadErrorState v-if="error" title="共车排车单核对失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-for="row in rows" :key="row.dispatchNo">{{ row.dispatchNo }} · {{ row.overseasWarehouseName || '起运仓名称待核实' }} · {{ row.packageCount ?? '箱数待核实' }}<span v-if="row.packageCount != null"> 箱</span></p>
<TripPreparationDialog v-if="rows[0]?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="rows[0].overseasWarehouseId" :warehouse-name="rows[0].overseasWarehouseName || '起运仓名称待核实'" :dispatch-nos="rows.map(row => row.dispatchNo)" @saved="saved" />
</section>
</template>
@@ -1,9 +1,10 @@
import type { FbaAppointmentBatchContext } from './batchApi'
export function batchContext(number = 'AP-001', key = 'FBA_APPOINTMENT:1'): FbaAppointmentBatchContext {
return { appointment: { id: '1', appointmentNo: number, batchKey: key, version: 3, appointmentStatus: 'PENDING', originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', selectedCount: 2, replacementAppointmentNos: [], replacementBatchKeys: [], items: [
{ id: 'item-1', customerId: 'customer-a', sourceKey: 'ORDER:1', inboundCode: 'IN-A', fbaWarehouseCode: 'ONT8', packageCount: 70, shipmentReferences: ['FBA-A'] },
{ id: 'item-2', customerId: 'customer-b', sourceKey: 'BOOKING:2', inboundCode: 'IN-B', fbaWarehouseCode: 'ONT8', packageCount: 30, shipmentReferences: ['FBA-B'] },
] }, sources: [
const appointment: FbaAppointmentBatchContext['appointment'] = { id: '1', appointmentNo: number, batchKey: key, version: 3, appointmentStatus: 'PENDING', originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', selectedCount: 2, replacementAppointmentNos: [], replacementBatchKeys: [], items: [
{ id: 'item-1', customerId: 'customer-a', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', inboundCode: 'IN-A', fbaWarehouseCode: 'ONT8', packageCount: 70, shipmentReferences: ['FBA-A'] },
{ id: 'item-2', customerId: 'customer-b', sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', inboundCode: 'IN-B', fbaWarehouseCode: 'ONT8', packageCount: 30, shipmentReferences: ['FBA-B'] },
] }
return { appointment, originalSourceRefs: appointment.items.map(item => ({ customerId: item.customerId, sourceKey: item.sourceKey, destinationKey: item.destinationKey! })), currentBatches: [appointment], sources: [
{ customerId: 'customer-a', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70, originalPackages: 100, sourceVersion: 'source-a-v2', cargoVerificationVersion: 4, shipmentReferences: ['FBA-A'] },
{ customerId: 'customer-b', sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 30, originalPackages: null, sourceVersion: null, cargoVerificationVersion: 2, shipmentReferences: ['FBA-B'] },
], allowedActions: ['VERIFY_CARGO', 'SPLIT', 'MERGE'], blockReasons: [] }
+1 -1
View File
@@ -4,7 +4,7 @@ export interface BatchSourceAllocation {
customerId: string; sourceKey: string; destinationKey: string; packageCount: number | null
originalPackages: number | null; sourceVersion: string | null; cargoVerificationVersion: number | null; shipmentReferences: string[]
}
export interface FbaAppointmentBatchContext { appointment: FbaAppointmentDto; sources: BatchSourceAllocation[]; allowedActions: Array<'VERIFY_CARGO' | 'SPLIT' | 'MERGE'>; blockReasons: string[] }
export interface FbaAppointmentBatchContext { appointment: FbaAppointmentDto; originalSourceRefs: Array<{ customerId: string; sourceKey: string; destinationKey: string }>; currentBatches: FbaAppointmentDto[]; sources: BatchSourceAllocation[]; allowedActions: Array<'VERIFY_CARGO' | 'SPLIT' | 'MERGE'>; blockReasons: string[] }
export interface BatchAllocation { customerId: string; sourceKey: string; packageCount: number; expectedSourceVersion: string | null; expectedVerificationVersion: number | null }
export interface RepartitionFbaAppointmentsRequest {
originals: Array<{ appointmentNo: string; version: number }>
@@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest'
import { addDays, calendarDates, calendarRange, moveCalendar } from './calendar'
describe('appointment calendar', () => {
it('shows six complete Monday-first weeks across year boundaries', () => {
const dates = calendarDates('2027-01-15', 'month')
expect(dates).toHaveLength(42)
expect(dates[0]).toBe('2026-12-28')
expect(dates.at(-1)).toBe('2027-02-07')
expect(calendarRange('2027-01-15', 'month').to).toBe('2027-02-08')
it.each([
['2026-10-19', '2026-09-28', '2026-11-02', 35],
['2027-01-15', '2026-12-28', '2027-02-01', 35],
['2027-02-15', '2027-02-01', '2027-03-01', 28],
['2026-08-15', '2026-07-27', '2026-09-07', 42],
] as const)('shows only the complete weeks needed for %s', (anchor, from, to, count) => {
const dates = calendarDates(anchor, 'month')
expect(dates).toHaveLength(count)
expect(dates[0]).toBe(from)
expect(dates.at(-1)).toBe(addDays(to, -1))
expect(calendarRange(anchor, 'month')).toEqual({ from, to })
})
it('moves by a week in week mode instead of following the prototype month-navigation bug', () => {
+7 -4
View File
@@ -29,16 +29,19 @@ export function addDays(value: string, days: number): string {
export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } {
const date = parseDate(anchor)
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1, 12)
if (mode === 'month') date.setDate(1)
date.setDate(date.getDate() - (date.getDay() + 6) % 7)
const from = dateKey(date)
// A half-open interval also works directly with database timestamps.
return { from, to: addDays(from, mode === 'month' ? 42 : 7) }
// 月视图只补齐月末所在周,不追加整周的下月日期;结束日期仍为开区间。
return { from, to: mode === 'month' ? addDays(dateKey(nextMonth), (8 - nextMonth.getDay()) % 7) : addDays(from, 7) }
}
export function calendarDates(anchor: string, mode: CalendarMode): string[] {
const { from } = calendarRange(anchor, mode)
return Array.from({ length: mode === 'month' ? 42 : 7 }, (_, index) => addDays(from, index))
const { from, to } = calendarRange(anchor, mode)
const dates: string[] = []
for (let date = from; date < to; date = addDays(date, 1)) dates.push(date)
return dates
}
export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string {
@@ -1 +1,2 @@
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
@@ -1,6 +1,6 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listPlanningCargo, listPlanningContainers, resolvePlanningOrder } from './planningApi'
import { listPlanningCargo, listPlanningDestinations, listPlanningContainers, resolvePlanningOrder } from './planningApi'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
@@ -20,6 +20,15 @@ it('keeps basic container listing on its own endpoint rather than requiring impa
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
expect(result.total).toBe(0)
})
it('uses authoritative destination options and sends the selected destination to server pagination', async () => {
vi.mocked(http.get).mockClear()
vi.mocked(http.get).mockResolvedValueOnce({data:['ABE4','ONT8']})
expect(await listPlanningDestinations()).toEqual(['ABE4','ONT8'])
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/cargo/destinations',{signal:undefined})
vi.mocked(http.get).mockResolvedValueOnce({data:{records:[],page:2,size:20,total:0,pages:0}})
await listPlanningCargo({page:2,size:20,keyword:'IN-A',destination:'ONT8'})
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-planning/cargo',{params:{page:2,size:20,keyword:'IN-A',destination:'ONT8'},signal:undefined})
})
it('resolves an order through the real canonical-source endpoint without fabricating an ORDER alias', async () => {
vi.mocked(http.get).mockClear()
vi.mocked(http.get).mockResolvedValue({data:{source:{sourceKey:'BOOKING:B-1'},coverage:{appointments:[]}}})
@@ -31,7 +31,7 @@ export interface PlanningCargo {
mixedDestinations: boolean
}
export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number }
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string; destination?: string }, signal?: AbortSignal) {
const response = await http.get<ApiEnvelope<PlanningCargoPage>, ApiEnvelope<PlanningCargoPage>>(
'/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
)
@@ -39,6 +39,12 @@ export async function listPlanningCargo(query: { page: number; size: number; key
return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
}
export async function listPlanningDestinations(signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<string[]>, ApiEnvelope<string[]>>(
'/platform/v1/last-leg/appointment-planning/cargo/destinations', { signal },
))
}
export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING'
export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string }
export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record<FollowupStage, number> }
@@ -0,0 +1,44 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { refreshReceivingCargo, getReceivingExecutionScope, saveReceivingPlan, getReceivingContext, getReceivingGroup, proposeReceivingChange, resolveReceivingChange, bookReceiving, changeReceivingGroup } from './receivingApi'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
beforeEach(() => { vi.clearAllMocks(); vi.mocked(http.get).mockResolvedValue({ data: {} }); vi.mocked(http.post).mockResolvedValue({ data: {} }) })
it('uses the producer receiving paths and encodes opaque stable identities', async () => {
await getReceivingContext('FBA_APPOINTMENT:1'); await getReceivingGroup('GROUP/1')
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/appointment-receiving/batches/FBA_APPOINTMENT%3A1')
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/appointment-receiving/agreements/GROUP%2F1')
const propose = { idempotencyKey: 'unchanged-key', intent: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'v1', expectedVerificationVersion: 1 }], laterDate: null, ownerId: 'account-owner', nextFollowupDate: null, reason: '全部后送', transportVersion: 'scope-v1' } }
await proposeReceivingChange(propose)
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/changes', propose)
const resolve = { idempotencyKey: 'resolve-key', version: 0, decision: 'CONFIRM' as const, warehouseEvidence: '明确回复', transportVersion: 'scope-v2' }
await resolveReceivingChange('CH/1', resolve)
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/changes/CH%2F1/resolve', resolve)
const book = { idempotencyKey: 'book-key', bookings: [], cancelledAgreementKey: 'GROUP/1', cancelledAgreementVersion: 2, executionVersion: 'scope-v2' }
await bookReceiving(book)
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/bookings', book)
const cancel = { idempotencyKey: 'cancel-key', version: 3, action: 'CANCEL' as const, details: null, warehouseEvidence: '取消依据', transportVersion: 'scope-v3' }
await changeReceivingGroup('GROUP/1', cancel)
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/agreements/GROUP%2F1/cancel', cancel)
await changeReceivingGroup('GROUP/1', { ...cancel, action: 'RESCHEDULE' })
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/agreements/GROUP%2F1/reschedule', expect.objectContaining({ action: 'RESCHEDULE' }))
})
it('posts a customer-scoped ordinary plan with execution version and no formal confirmation', async () => {
const request = { idempotencyKey: 'plan-1', member: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 4, customerId: 'customer-b' }, executionVersion: 'exec-2', details: { plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentStatus: 'FAILED' as const, appointmentRemark: '没有时段', confirmationNo: null, appointmentAt: null, appointmentEndAt: null, startOffset: null, endOffset: null } }
await saveReceivingPlan(request)
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-receiving/plans', request)
})
it('reads execution eligibility for the explicitly selected members without including guessed versions', async () => {
const members = [{ batchKey: 'FBA_APPOINTMENT:2', customerId: 'customer-b' }]
const scope = { version: 'only-B', members: [] }
vi.mocked(http.post).mockResolvedValueOnce({ data: scope })
expect(await getReceivingExecutionScope(members)).toEqual(scope)
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-receiving/execution-scope', { members })
})
it('adopts latest customer cargo versions without including quantity mutations or confirmation fields', async () => {
const request = { idempotencyKey: 'adopt-b', member: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 4, customerId: 'customer-b' }, executionVersion: 'exec-current', sources: [{ sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', expectedSourceVersion: 'latest', expectedVerificationVersion: 2 }] }
await refreshReceivingCargo(request)
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-receiving/cargo-refresh', request)
})
@@ -0,0 +1,36 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { FbaAppointmentDetailsPayload, FbaAppointmentDto } from '@/modules/overseas-warehouse'
export interface ReceivingSource { sourceKey: string; destinationKey: string; packageCount: number; sourceVersion: string; verificationVersion: number | null; inboundCode: string | null }
export interface ReceivingMember { batchKey: string; appointmentNo: string; batchVersion: number; customerId: string; sources: ReceivingSource[]; customerName: string | null }
export interface ReceivingAgreement { agreementKey: string; version: number; status: string; destinationKey: string; details: FbaAppointmentDetailsPayload; timezone: string | null; members: ReceivingMember[]; warehouseEvidence: string }
export interface ReceivingTransport { version: string; vehicles: Array<{ tripNo: string | null; version: number | null; dispatchNo: string | null; customerId: string; batchKey: string; status: string; packageCount: number }>; blockReasons: string[] }
export interface ReceivingQuantity { sourceKey: string; destinationKey: string; retainedPackages: number; expectedSourceVersion: string; expectedVerificationVersion: number | null }
export interface ReceivingIntent { batchKey: string; batchVersion: number; customerId: string; quantities: ReceivingQuantity[]; laterDate: string | null; ownerId: string; nextFollowupDate: string | null; reason: string; transportVersion: string }
export interface ReceivingExecutionScope { version: string; members: Array<{ member: { batchKey: string; customerId: string }; completed: boolean; canRebook: boolean; reason: string | null }> }
export interface ReceivingPending { changeKey: string; version: number; status: string; intent: ReceivingIntent; originalMembers: ReceivingMember[]; originalAgreements: ReceivingAgreement[]; followup: { ownerId: string; ownerName: string | null; nextFollowupDate: string | null } }
export interface ReceivingCustomerPlan { batchKey: string; customerId: string; details: FbaAppointmentDetailsPayload; timezone: string | null }
export interface ReceivingCargoRefresh { idempotencyKey: string; member: Pick<ReceivingMember, 'batchKey' | 'batchVersion' | 'customerId'>; sources: Array<{ sourceKey: string; destinationKey: string; expectedSourceVersion: string; expectedVerificationVersion: number | null }>; executionVersion: string }
export interface ReceivingPlan { idempotencyKey: string; member: Pick<ReceivingMember, 'batchKey' | 'batchVersion' | 'customerId'>; details: FbaAppointmentDetailsPayload; executionVersion: string }
export interface ReceivingContext { batch: FbaAppointmentDto; plans: ReceivingCustomerPlan[]; agreements: ReceivingAgreement[]; pendingChanges: ReceivingPending[]; transport: ReceivingTransport; executionScope: ReceivingExecutionScope; allowedActions: string[]; blockReasons: string[] }
export interface ReceivingGroup { agreement: ReceivingAgreement; eligibleMembers: ReceivingMember[]; excludedMembers: Array<{ member: ReceivingMember; reason: string; currentAgreementKey: string | null }>; transport: ReceivingTransport; executionScope: ReceivingExecutionScope }
export interface ReceivingResult { changeKey: string | null; status: string; batches: FbaAppointmentDto[]; agreements: ReceivingAgreement[]; steps: Array<{ step: string; status: string; targetKey: string; message: string }>; nextActions: string[] }
export interface ReceivingPropose { idempotencyKey: string; intent: ReceivingIntent }
export interface ReceivingResolve { idempotencyKey: string; version: number; decision: 'CONFIRM' | 'WITHDRAW'; warehouseEvidence: string | null; transportVersion: string }
export interface ReceivingBooking { members: Array<Pick<ReceivingMember, 'batchKey' | 'batchVersion' | 'customerId'>>; details: FbaAppointmentDetailsPayload; warehouseEvidence: string }
export interface ReceivingBook { idempotencyKey: string; bookings: ReceivingBooking[]; cancelledAgreementKey: string | null; cancelledAgreementVersion: number | null; executionVersion: string }
export interface ReceivingGroupChange { idempotencyKey: string; version: number; action: 'RESCHEDULE' | 'CANCEL'; details: FbaAppointmentDetailsPayload | null; warehouseEvidence: string; transportVersion: string }
const base = '/platform/v1/last-leg/appointment-receiving'
export async function getReceivingContext(batchKey: string) { return unwrapApiResponse(await http.get<ApiEnvelope<ReceivingContext>, ApiEnvelope<ReceivingContext>>(`${base}/batches/${encodeURIComponent(batchKey)}`)) }
export async function getReceivingGroup(agreementKey: string) { return unwrapApiResponse(await http.get<ApiEnvelope<ReceivingGroup>, ApiEnvelope<ReceivingGroup>>(`${base}/agreements/${encodeURIComponent(agreementKey)}`)) }
async function post(path: string, request: ReceivingCargoRefresh | ReceivingPlan | ReceivingPropose | ReceivingResolve | ReceivingBook | ReceivingGroupChange) { return unwrapApiResponse(await http.post<ApiEnvelope<ReceivingResult>, ApiEnvelope<ReceivingResult>>(`${base}${path}`, request)) }
export const proposeReceivingChange = (request: ReceivingPropose) => post('/changes', request)
export const resolveReceivingChange = (changeKey: string, request: ReceivingResolve) => post(`/changes/${encodeURIComponent(changeKey)}/resolve`, request)
export const bookReceiving = (request: ReceivingBook) => post('/bookings', request)
export const changeReceivingGroup = (agreementKey: string, request: ReceivingGroupChange) => post(`/agreements/${encodeURIComponent(agreementKey)}/${request.action === 'CANCEL' ? 'cancel' : 'reschedule'}`, request)
export const saveReceivingPlan = (request: ReceivingPlan) => post('/plans', request)
export async function getReceivingExecutionScope(members: Array<{ batchKey: string; customerId: string }>) { return unwrapApiResponse(await http.post<ApiEnvelope<ReceivingExecutionScope>, ApiEnvelope<ReceivingExecutionScope>>(`${base}/execution-scope`, { members })) }
export const refreshReceivingCargo = (request: ReceivingCargoRefresh) => post('/cargo-refresh', request)
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { projectContainerSchedule } from './schedule'
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
const range = { from: '2026-11-01', to: '2026-12-01' }
const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
describe('four-type schedule projection', () => {
it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true)
expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
})
it('adds two local calendar days across months without creating a time', () => {
const events = projectContainerSchedule(sources, [warehouse], range, true)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' })
})
it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
})
it('keeps manually moved containers visible when their arrival is outside this month', () => {
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
expect(events).toHaveLength(1)
expect(events[0]?.date).toBe('2026-11-08')
})
it('does not replace the planned date with PDA actual arrival', () => {
const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
expect(event.date).toBe('2026-11-05')
expect(event.canEdit).toBe(false)
expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
})
it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
expect(events.map(row => row.date)).toEqual([null, null])
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
})
it('does not invent default plans when the authoritative warehouse query failed', () => {
expect(projectContainerSchedule(sources, null, range, true)).toEqual([])
})
it('uses the actual return date after completion while preserving the deadline in details', () => {
const events = projectContainerSchedule({ ...sources, containers: [], emptyReturns: [{ containerBookingId: '1', containerNo: 'TEST-CONT', fleetSupplierName: null, actualEmptyReturnAt: '2026-11-04T10:00:00', emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-11-02T10:00:00' } }] }, [], range, true)
expect(events[0]).toMatchObject({ type: 'return', date: '2026-11-04', subtitle: '实际还柜' })
})
})
@@ -0,0 +1,70 @@
import { addDays } from './calendar'
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
export type ScheduleType = typeof scheduleTypes[number]
export const scheduleLabels: Record<ScheduleType, string> = { arrival: '到港', dispatch: '排车', return: '还柜', warehouse: '计划到仓' }
export interface ScheduleEvent {
key: string
type: ScheduleType
date: string | null
time: string
title: string
subtitle?: string
fields: Array<{ label: string; value?: string | null }>
sourcePath?: string
warehouse?: WarehouseArrival
canEdit?: boolean
}
const inRange = (date: string | null, range: ScheduleRange) => !date || date >= range.from && date < range.to
const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '—'
export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
const events: ScheduleEvent[] = []
const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
for (const row of sources.containers) {
const label = row.containerNo || row.cabinetNo || '—'
const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
const date = row.arrivalAt?.slice(0, 10) || null
if (inRange(date, range)) events.push({ key: `arrival-${row.containerBookingId}`, type: 'arrival', date,
time: row.arrivalAt?.slice(11, 16) || '', title: `${label} 到港`, sourcePath,
subtitle: row.arrivalAt ? row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' : '到港日期未定',
fields: [...fields, { label: row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港', value: timestamp(row.arrivalAt) }] })
// 读取失败时不能用默认值遮掉无法读取的人工安排。
if (warehouses === null) continue
const warehouse = warehouseMap.get(row.containerBookingId)
warehouseMap.delete(row.containerBookingId)
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
canEdit: warehouse?.canEdit === true,
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
}
// 人工改期可能跨月,与当前月份的到港记录无关。
for (const row of warehouseMap.values()) {
const date = row.plannedAt?.slice(0, 10) || null
if (!date || !inRange(date, range)) continue
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
{ label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
}
for (const row of sources.emptyReturns) {
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
if (!at || !inRange(at.slice(0, 10), range)) continue
events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
{ label: '还柜截止', value: timestamp(row.emptyReturnWarning.deadlineAt) }, { label: '实际还柜', value: timestamp(row.actualEmptyReturnAt) }] })
}
return events
}
@@ -0,0 +1,50 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface CalendarContainer {
containerBookingId: string
cabinetNo: string | null
containerNo: string | null
vesselName: string | null
voyageNo: string | null
destinationPortName: string | null
destinationWarehouseCode: string | null
arrivalAt: string | null
arrivalType: 'ACTUAL' | 'ESTIMATED'
}
export interface CalendarReturn {
containerBookingId: string
containerNo: string | null
fleetSupplierName: string | null
actualEmptyReturnAt: string | null
emptyReturnWarning: { status: string; deadlineAt: string | null }
}
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
export interface WarehouseArrival {
sourceContainerBookingId: string
containerNo: string | null
warehouseName: string | null
warehouseCode: string | null
plannedAt: string | null
actualAt: string | null
version: number
planCount: number
canEdit: boolean
}
export interface ScheduleRange { from: string; to: string }
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
))
}
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
))
}
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
return unwrapApiResponse(await http.put<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(containerId)}/expected-arrival`, body,
{ headers: { 'Idempotency-Key': key } },
))
}
@@ -7,6 +7,13 @@ function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'Amer
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
}
describe('location-based trip calendar', () => {
it('counts a shared trip once and excludes cancelled arrangements', () => {
const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z')
const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
expect(result.events).toHaveLength(1)
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'TR-SHARED 排车' })
})
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
const localRange = {from:'2026-09-28',to:'2026-11-09'}
expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'})
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CalendarEvent } from './AppointmentCalendarPanel.vue'
import type { ScheduleEvent } from './schedule'
export interface TripCalendarEntry {
sourceType: 'TRIP_PLAN'
@@ -54,9 +54,10 @@ function localStamp(value: string | null, zone: string | null) {
} catch { return null }
}
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
for (const row of result.records) {
for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) {
if (row.status === 'CANCELLED') continue
// Executed work stays on its actual local date even if the plan changes later.
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
if (row.originCountryCode !== 'US' || !occurrence) {
@@ -65,8 +66,8 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
if (occurrence.date < range.from || occurrence.date >= range.to) continue
const actual = localStamp(row.departedAt, row.originTimezone)
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: occurrence.date, time: occurrence.time,
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '用车计划'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '运输计划,不代表收货方预约已确认',
events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
fields: [
{ label: '车次状态', value: labels[row.status] || '待核实' },
@@ -0,0 +1,45 @@
import { afterEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { useWorkSyncRead } from './useWorkSyncRead'
const pending = () => new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409, undefined, 'request-sync', 'trace-sync')
afterEach(() => vi.useRealTimers())
it('waits only for the full-work synchronization guard and returns the fresh result', async () => {
vi.useFakeTimers()
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValueOnce(pending()).mockResolvedValue('fresh')
const result = reader.read(load, new AbortController().signal)
await vi.advanceTimersByTimeAsync(0)
expect(reader.syncing.value).toBe(true)
expect(load).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(2000)
expect(await result).toBe('fresh')
expect(reader.syncing.value).toBe(false)
})
it.each([new ApiResponseError('事项版本已变化', 'A0409', 409), new ApiResponseError('无权查看', 'A0403', 403), new Error('network')])('does not retry other failures: %s', async error => {
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
await expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
expect(load).toHaveBeenCalledTimes(1)
expect(reader.syncing.value).toBe(false)
})
it('stops waiting at the limit and preserves diagnostic IDs on the original error', async () => {
vi.useFakeTimers()
const error = pending(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
const result = expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
await vi.advanceTimersByTimeAsync(30000)
await result
expect(load).toHaveBeenCalledTimes(16)
expect(reader.syncing.value).toBe(false)
expect(error.traceId).toBe('trace-sync')
expect(vi.getTimerCount()).toBe(0)
})
it('cancels a waiting read without sending another request', async () => {
vi.useFakeTimers()
const controller = new AbortController(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(pending())
const result = expect(reader.read(load, controller.signal)).rejects.toHaveProperty('name', 'AbortError')
await vi.advanceTimersByTimeAsync(0)
controller.abort()
await result
await vi.advanceTimersByTimeAsync(30000)
expect(load).toHaveBeenCalledTimes(1)
expect(reader.syncing.value).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
@@ -0,0 +1,30 @@
import { ref } from 'vue'
import { getApiErrorDetails } from '@/shared/api/http'
// 只等待工作清单同步守卫;版本冲突、权限失败和写请求均不重试。
export function useWorkSyncRead() {
const syncing = ref(false)
let generation = 0
async function read<T>(load: () => Promise<T>, signal: AbortSignal): Promise<T> {
const current = ++generation
syncing.value = false
try {
for (let attempt = 0; ; attempt++) {
signal.throwIfAborted()
try { return await load() }
catch (cause) {
const error = getApiErrorDetails(cause)
if (error.code !== 'A0409' || error.message !== '预约批次或运输关联已变化,服务端正在协调完整工作清单' || attempt >= 15) throw cause
if (current === generation) syncing.value = true
await new Promise<void>((resolve, reject) => {
const abort = () => { clearTimeout(timer); reject(signal.reason) }
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve() }, 2000)
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) { signal.removeEventListener('abort', abort); abort() }
})
}
}
} finally { if (current === generation) syncing.value = false }
}
return { syncing, read }
}
@@ -1,10 +1,16 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getWorkCalendar, getWorkSummary, getWorkItems, getWorkDetail, submitWorkCommand } from './workApi'
import { getWorkCalendar, getWorkSummary, getWorkItems, getWorkDetail, submitWorkCommand, resolveBatchWork } from './workApi'
import { getCargoVerification, saveCargoVerification } from './cargoVerificationApi'
import { workCalendar, workDetail, workItem, workSummary } from './work.test-support'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
beforeEach(() => vi.clearAllMocks())
it('resolves a real batch and optional customer without manufacturing a work key', async () => {
const resolved = { batchKey: 'FBA_APPOINTMENT:40', customerId: '9007199254740999', detail: workDetail(), candidates: [] }
vi.mocked(http.get).mockResolvedValue({ data: resolved })
expect(await resolveBatchWork(resolved.batchKey, resolved.customerId)).toEqual(resolved)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-work/items/resolve', { params: { batchKey: resolved.batchKey, customerId: resolved.customerId }, signal: undefined })
})
it('uses inclusive calendar dates and repeated type parameters with no duplicate API prefix', async () => {
vi.mocked(http.get).mockResolvedValue({ data: workCalendar })
const signal = new AbortController().signal
@@ -58,6 +58,7 @@ export type WorkCommandPayload =
| { command: 'ACCEPT_RETURN'; payload: { handoffVersion: number; note: string | null } }
export type WorkCommand = WorkCommandPayload & { expectedVersion: number; idempotencyKey: string }
export interface WorkCommandResult { item: WorkItem; affectedWorkItemKeys: string[] }
export interface BatchWorkResolution { batchKey: string; customerId: string | null; detail: WorkDetail | null; candidates: Array<{ customerId: string; workItemKey: string; batchNo: string | null }> }
const base = '/platform/v1/last-leg/appointment-work'
// Repeated types parameters are part of v1; Axios' default types[] syntax is not.
@@ -83,6 +84,9 @@ export async function getWorkItems(query: WorkItemsQuery, signal?: AbortSignal)
export async function getWorkDetail(key: string, signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<WorkDetail>, ApiEnvelope<WorkDetail>>(`${base}/items/${encodeURIComponent(key)}`, { signal }))
}
export async function resolveBatchWork(batchKey: string, customerId?: string, signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<BatchWorkResolution>, ApiEnvelope<BatchWorkResolution>>(`${base}/items/resolve`, { params: { batchKey, customerId }, signal }))
}
export async function submitWorkCommand(key: string, command: WorkCommand) {
return unwrapApiResponse(await http.post<ApiEnvelope<WorkCommandResult>, ApiEnvelope<WorkCommandResult>>(`${base}/items/${encodeURIComponent(key)}/commands`, command))
}
@@ -32,7 +32,7 @@ type FilterModel = QueryModel & {
expectedArrivalRange: string[]
}
const props = defineProps<{ mode: InboundTaskMode }>()
const props = defineProps<{ mode: InboundTaskMode; title?: string }>()
const router = useRouter()
const access = useAccessPreviewStore()
const page = ref(1)
@@ -356,8 +356,9 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
:aria-label="`${taskLabel}待办列表`"
@selection-change="handleReviewSelectionChange"
>
<template #toolbar-start><h1>{{ isReview ? '测量数据审核' : '订单测量' }}</h1></template>
<template #toolbar-start><h1>{{ title || (isReview ? '测量数据审核' : '订单测量') }}</h1></template>
<template #filters>
<slot name="status-tabs" />
<QueryToolbar
v-model="filters"
:ariaLabel="`${taskLabel}待办筛选`"
+1
View File
@@ -3,6 +3,7 @@ export { createInboundHoldIdempotencyKey, downloadInboundCartonLabelsBatch, down
export { default as DropshipPalletPrintPanel } from './core/DropshipPalletPrintPanel.vue'
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
export { default as InboundTaskList } from './components/InboundTaskList.vue'
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
export {
@@ -36,3 +36,40 @@
| 本土散板 | 详情 | 列表渲染函数 13602 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
删除和文件预览在三类正式页面中均未定义,不自行补充承载方式;海外采购审核按当前对话确认的承载方式实现。
## 本土卡车需求字段调整
依据本轮客户端截图及用户确认,同步平台订单字段;规则唯一见 `docs/海外仓业务/README.md`“美国本土卡独立下单”。
| 页面 | 操作 | 原型/确认依据 | 承载及布局 | 调整与验收 |
|---|---|---|---|---|
| 本土卡车 | 新增、编辑 | 上表 `page-overseas-truck-add`、`page-overseas-truck-edit`;本轮用户确认同步后台 | 保持独立页面及现有分区;起运地和目的地两列对应联系人 | 删除旧需求字段,增加车型及两端联系、取送时间;待人工截图确认 |
| 本土卡车 | 列表 | 上表 `page-overseas-truck`;本轮确认 | 保持列表、操作第一列及现有承运安排 | 对应展示车型、两端联系及取送时间;待人工截图确认 |
后台不新增详情路由;池来源仍只读展示承运安排,散板最新范围见下节。
## 本土散板简化下单
字段规则唯一见[海外仓业务 README](../../../../../docs/海外仓业务/README.md#本土散板简化下单)。依据用户截图 `codex-clipboard-71e44fad-9790-48c0-bf94-fa80ddce1445.png` 和本轮确认,覆盖此前散板新增/编辑字段结论。
| 页面 | 操作 | 原型证据及布局 | 当前实现与结论 |
|---|---|---|---|
| 本土散板 | 新增 | 用户截图:独立页面;自送/我司提货;地址与联系两列;货物四列;取送时间两列;底部取消/提交 | 保持独立路由,顶部补客户;货物为商品名、预计托数、重量、体积;显示币种;件数/地址补充/备注收起;移除运输方式;两端四项联系信息和取送时间必填。待人工 1920×1080、100% 截图确认 |
| 本土散板 | 编辑 | 现有独立编辑路由;本轮确认的新需求合同 | 同新增字段并回显保存值;只允许待接单非池来源;版本冲突保留内容。待人工截图确认 |
| 本土散板 | 列表 | 原 `page-overseas-looseboard` 独立列表与左侧操作 | 展示两端联系信息、商品名、预计托数及取送时间;内部未知安排显示“—”;池来源保持承运摘要。待人工截图确认 |
本次不增加详情、审核、删除或文件预览操作;不更改现有承运安排只读弹框。
## Admin / Client 统一运输下单表单(2026-10-19)
依据:当前对话明确要求“最多 Admin 添加客户选项,Client 从登录客户下单”,覆盖此前两端独立排版结论。保留应用壳层、正式路由及权限;业务表单统一为同一字段清单、排列、控件、默认值、校验及提交转换。
| 页面 | 操作 | 承载与现有依据 | 本轮统一结论 | 验收 |
|---|---|---|---|---|
| 本土卡车 | Client 新增、Admin 新增/编辑 | 既有独立页面;原客户端截图及当前统一确认 | 运输信息双列,车辆必选;两端联系人/电话及详细地址左右对应;货物件重体;费用币种与两个分钟时间;备注;底部取消/提交。Admin 新增前置客户选择 | 自动合同及行为测试;待人工 1920×1080、100% 截图 |
| 本土散板 | Client 新增、Admin 新增/编辑 | 既有独立页面;当前统一确认 | 与卡车共用表单;保留商品名、预计托数和件重体,移除车型;其余相同。Admin 新增前置客户选择 | 同上 |
| 两类业务 | 列表、详情、承运安排 | 既有独立路由及只读承运摘要 | 本轮不改变承载;Client 不新增编辑路由 | 既有行为回归 |
统一表单组件和转换逻辑各自位于应用内部,运行时不跨应用引用。两端业务组件源码一致性另作定向检查;下单数据不允许混入客户标识,Admin 在 API 参数显式选择客户,Client 由认证会话确定客户。
@@ -20,15 +20,15 @@ const purchaseSummary = {
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
}
const truckSummary = {
id: '9007199254740997', customerId: '10002', orderNo: 'TR-1', transportType: 'PORT_PICKUP', origin: 'LA', destination: 'ONT',
containerType: '40HQ', palletCount: 2, packageCount: 10, receivedPalletCount: 1, allocatedPalletCount: 0, shippedPalletCount: 0,
totalWeightKg: 123.45, totalVolumeCbm: 6.7, budgetAmount: 88.5, currency: 'USD', contactName: 'A', contactInfo: '1',
requestedDate: '2026-08-20', status: 'PENDING_REVIEW', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
id: '9007199254740997', customerId: '10002', orderNo: 'TR-1', vehicleType: 'PALLET_15', origin: 'LA', destination: 'ONT',
palletCount: 2, packageCount: 10, receivedPalletCount: 1, allocatedPalletCount: 0, shippedPalletCount: 0,
totalWeightKg: 123.45, totalVolumeCbm: 6.7, budgetAmount: 88.5, currency: 'USD', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2',
pickupAt: '2026-08-20T09:30:00', deliveryAt: '2026-08-20T15:45:00', status: 'PENDING_REVIEW', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
}
const looseboardSummary = {
id: '9007199254741005', customerId: '10003', orderNo: 'LB-1', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2,
budgetAmount: 50, currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0,
id: '9007199254741005', customerId: '10003', orderNo: 'LB-1', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 2, packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2,
budgetAmount: 50, currency: 'USD', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', status: 'PENDING_ACCEPTANCE', version: 0,
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-01T00:00:00',
}
@@ -97,7 +97,7 @@ describe('platform overseas business API adapter', () => {
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' })).mockResolvedValueOnce(envelope(detail))
expect((await listUsTruckOrders({ routeKeyword: ' LA ' })).records[0]?.totalWeightKg).toBe('123.45')
expect((await getUsTruckOrder('TR-1', '10002')).pallets[0]?.warehousePalletId).toBe('9007199254741003')
const create = { transportType: 'PORT_PICKUP' as const, origin: 'LA', destination: 'ONT', palletCount: 2, packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1', requestedDate: '2026-08-20', idempotencyKey: 'HTTP-20260824-MARKER-truck' }
const create = { vehicleType: 'PALLET_15' as const, origin: 'LA', destination: 'ONT', packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-08-20T09:30:00', deliveryAt: '2026-08-20T15:45:00', idempotencyKey: 'HTTP-20260824-MARKER-truck' }
overseas.post.mockResolvedValue(envelope(detail)); await createUsTruckOrder('9007199254741999', create)
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', create, { params: { customerId: '9007199254741999' } })
const { idempotencyKey: _truckKey, ...truckFields } = create
@@ -111,7 +111,7 @@ describe('platform overseas business API adapter', () => {
overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail))
expect((await listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005')
expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE')
const create = { transportMode: 'SHARED_TRUCK' as const, palletRequired: 'YES' as const, transportType: 'WAREHOUSE_TO_WAREHOUSE' as const, origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' }
const create = { productName: '家居用品', estimatedPalletCount: 2, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' }
overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('9007199254742001', create)
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '9007199254742001' } })
const { idempotencyKey: _looseKey, ...looseboardFields } = create
@@ -122,9 +122,9 @@ describe('platform overseas business API adapter', () => {
it('propagates a 409 envelope without issuing any fallback write', async () => {
const payload: UpdateLocalTransportOrderRequest = {
version: 2, transportType: 'PORT_PICKUP', origin: 'LA', destination: 'ONT-EDIT', palletCount: 2,
packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1',
requestedDate: '2026-08-20',
version: 2, vehicleType: 'PALLET_15', origin: 'LA', destination: 'ONT-EDIT',
packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2',
pickupAt: '2026-08-20T09:30:00', deliveryAt: '2026-08-20T15:45:00',
}
overseas.patch.mockResolvedValue({ code: 'B0409', message: 'Order was changed', data: null, requestId: 'REQ-409' })
await expect(updateUsTruckOrder('TR-1', '9007199254741999', payload)).rejects.toMatchObject({ code: 'B0409', requestId: 'REQ-409' })
@@ -133,11 +133,11 @@ describe('platform overseas business API adapter', () => {
expect(overseas.put).not.toHaveBeenCalled()
})
it('preserves unknown pool pallet service and container type in list and detail responses', async () => {
const summary = { ...looseboardSummary, palletRequired: null, containerType: null }
it('preserves unknown pool pallet service and product name in list and detail responses', async () => {
const summary = { ...looseboardSummary, palletRequired: null, productName: null }
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [] }))
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, containerType: null })
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, containerType: null })
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, productName: null })
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, productName: null })
expect(overseas.get).toHaveBeenCalledTimes(2)
})
})
@@ -115,7 +115,7 @@ export async function updateOverseasPurchase(orderNo: string, customerId: string
return procurementSummary(unwrapApiResponse(response).summary)
}
export async function listUsTruckOrders(query: LocalTransportQuery = {}) {
const response = await http.get<ApiEnvelope<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } })
const response = await http.get<ApiEnvelope<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, vehicleType: query.vehicleType || undefined, customerId: query.customerId || undefined } })
return normalizePage(unwrapApiResponse(response), truckSummary)
}
export async function getUsTruckOrder(orderNo: string, customerId: string) {
@@ -131,7 +131,7 @@ export async function updateUsTruckOrder(orderNo: string, customerId: string, pa
return truckDetail(unwrapApiResponse(response))
}
export async function listLocalLooseboardOrders(query: LocalLooseboardQuery = {}) {
const response = await http.get<ApiEnvelope<PagePayload<LooseboardSummaryPayload>>, ApiEnvelope<PagePayload<LooseboardSummaryPayload>>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportMode: query.transportMode || undefined } })
const response = await http.get<ApiEnvelope<PagePayload<LooseboardSummaryPayload>>, ApiEnvelope<PagePayload<LooseboardSummaryPayload>>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined } })
return normalizePage(unwrapApiResponse(response), looseboardSummary)
}
export async function getLocalLooseboardOrder(orderNo: string, customerId: string) {
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FormInstance } from 'element-plus'
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
const props = withDefaults(defineProps<{
model: TransportDemandForm
kind: TransportDemandKind
disabled?: boolean
submitting?: boolean
canSubmit?: boolean
editing?: boolean
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
const emit = defineEmits<{ submit: []; cancel: [] }>()
const formRef = ref<FormInstance>()
const rules = computed(() => transportDemandRules(props.kind, props.model))
defineExpose({
validate: () => formRef.value?.validate() ?? Promise.resolve(false),
clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields),
})
</script>
<template>
<el-form ref="formRef" class="transport-demand-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
<slot name="customer" />
<section class="transport-demand-section">
<h3>运输信息</h3>
<div class="transport-demand-grid">
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
</el-form-item>
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" placeholder="请输入起运地联系人" /></el-form-item>
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" placeholder="请输入目的地联系人" /></el-form-item>
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" placeholder="请输入起运地联系电话" /></el-form-item>
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" placeholder="请输入目的地联系电话" /></el-form-item>
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
</div>
</section>
<section class="transport-demand-section">
<h3>货物信息</h3>
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" placeholder="请输入商品名称(选填)" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" placeholder="请输入预计托数" /></el-form-item>
<el-form-item v-if="kind === 'truck'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入总重量" /></el-form-item>
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" placeholder="请输入总体积" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
</div>
</section>
<section class="transport-demand-section">
<h3>费用与时效</h3>
<div class="transport-demand-grid">
<el-form-item label="预算总价" prop="budgetAmount"><div class="transport-demand-budget">
<el-input-number v-model="model.budgetAmount" :min="0" :precision="2" controls-position="right" placeholder="请输入预算总价(选填)" />
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
</div></el-form-item>
<div />
<el-form-item label="取货时间" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入其他要求(选填)" /></el-form-item>
</div>
</section>
<footer class="transport-demand-actions">
<el-button :disabled="submitting" @click="emit('cancel')">取消</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="disabled" @click="emit('submit')">{{ editing ? '保存修改' : '提交订单' }}</el-button>
</footer>
</el-form>
</template>
<style scoped>
.transport-demand-form { --el-component-size: 36px; width: 100%; max-width: 1180px; margin: 0; background: #fff; }
.transport-demand-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
.transport-demand-section h3 { margin: 0 0 16px; color: #27364d; font-size: 14px; font-weight: 600; }
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
.transport-demand-cargo-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.transport-demand-cargo-four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.transport-demand-wide { grid-column: 1 / -1; }
.transport-demand-form :deep(.el-form-item) { min-width: 0; margin-bottom: 18px; }
.transport-demand-form :deep(.el-form-item__label) { height: auto; padding-bottom: 6px; font-size: 13px; line-height: 20px; }
.transport-demand-form :deep(.el-input), .transport-demand-form :deep(.el-input-number), .transport-demand-form :deep(.el-select), .transport-demand-form :deep(.el-date-editor) { width: 100%; }
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: 10px; }
.transport-demand-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; }
.transport-demand-actions :deep(.el-button + .el-button) { margin-left: 0; }
</style>
@@ -55,9 +55,9 @@ const procurementDetail = (): PlatformProcurement => ({
customerView: { order: { ...purchase, status: 'WAIT_PURCHASE', plannedWarehouseName: '洛杉矶采购仓', actualWarehouseCode: null, actualWarehouseName: null, receivingAddress: '深圳收件地址', contactName: 'Alice', contactPhone: '13800000000' }, budget: { totalAmount: '250.00', currency: 'USD', authorizedAt: null }, purchased: false, purchaseConfirmed: false, purchaseNeedsAttention: false, progress: [{ key: 'CREATED', title: '客户下单', completedAt: purchase.createdAt }], boxes: [], shipments: [], fees: [], exceptionSummaries: [] },
purchaseFacts: {}, physical: { dispositions: [], receipts: [], packingVersion: null, packingStatus: null, cartons: [] }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: purchase.histories, operations: [], actions: [], internalRemark: '采购备注',
})
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', vehicleType: 'PALLET_15', origin: 'Long Beach', destination: 'Ontario', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', originContactName: 'Bob', originContactPhone: '10010', destinationContactName: 'Alice', destinationContactPhone: '10020', pickupAt: '2026-08-28T09:30:00', deliveryAt: '2026-08-28T15:45:00', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 2, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
@@ -133,28 +133,28 @@ describe('overseas business mounted pages', () => {
expect(wrapper.find('form').exists()).toBe(false); expect(wrapper.findAll('button').some(b => b.text() === '保存修改')).toBe(false)
expect(updateUsTruckOrder).not.toHaveBeenCalled(); expect(getUsTruckOrder).toHaveBeenCalledOnce()
})
it('distinguishes unknown pool pallet service from explicit no and yes in actual list cells', async () => {
const records = ([null, 'NO', 'YES'] as const).map((palletRequired, index) => ({ ...looseboard, id: String(100 + index), orderNo: `LB-NULL-${index}`, palletRequired, containerType: null, fleetArrangement: { ...arrangement, businessType: 'LOCAL_LOOSEBOARD' as const } }))
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records, total: 3, page: 1, size: 20, pages: 1 })
it('shows declared product and forecast pallets without exposing obsolete container selection', async () => {
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises()
const rows = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')
expect(rows.map(row => row.findAll('td')[3]!.text())).toEqual(['—', '否', '是'])
expect(wrapper.text()).toContain('家居用品')
expect(wrapper.text()).toContain('预计托数')
expect(wrapper.text()).not.toContain('柜型')
expect(listLocalLooseboardOrders).toHaveBeenCalledOnce()
})
it('shows an unknown pool container type as a dash without inventing 40HQ', async () => {
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, containerType: null, fleetArrangement: arrangement }], total: 1, page: 1, size: 20, pages: 1 })
it('shows unknown pool vehicle type as a dash without inventing a capacity', async () => {
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, vehicleType: null, fleetArrangement: arrangement }], total: 1, page: 1, size: 20, pages: 1 })
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
expect(wrapper.find('.el-table__body-wrapper tbody .el-table__row').findAll('td')[7]!.text()).toBe('—')
expect(wrapper.find('.el-table__body-wrapper tbody .el-table__row').findAll('td')[2]!.text()).toBe('—')
expect(wrapper.find('.el-table__body-wrapper').text()).not.toContain('40HQ')
})
it('still rejects an ordinary looseboard edit when its required pallet service is absent', async () => {
it('allows standalone demand editing without inventing pallet service or transport arrangements', async () => {
routing.route.params = { orderId: looseboard.orderNo }; routing.route.query = { customerId: looseboard.customerId }
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, fleetArrangement: null })
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, transportType: null })
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
expect(wrapper.findComponent({ name: 'ElForm' }).props('model').palletRequired).toBeNull()
expect(wrapper.text()).not.toContain('是否打盘')
expect(wrapper.text()).not.toContain('运输类型')
await wrapper.findAll('button').find(button => button.text().includes('保存修改'))!.trigger('click'); await flushPromises()
expect(updateLocalLooseboardOrder).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('请选择是否打盘')
expect(updateLocalLooseboardOrder).toHaveBeenCalledWith(looseboard.orderNo, looseboard.customerId, expect.objectContaining({ productName: '家居用品', estimatedPalletCount: 2, pickupAt: '2026-10-20T09:30:00' }))
expect(updateLocalLooseboardOrder).toHaveBeenCalledOnce()
})
it('keeps all procurement facts in dense groups and business references in the heading', async () => {
routing.route.params = { purchaseId: 'internal-purchase-route' }; routing.route.query = { customerId: '10001' }
@@ -270,17 +270,60 @@ describe('overseas business mounted pages', () => {
expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: null })
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchase-detail', params: { purchaseId: 'OP-100' }, query: { customerId: '10001' } })
})
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it.each(['truck', 'looseboard'] as const)('requires an explicit customer and sends only the unified business payload for %s', async domain => {
const wrapper = mountPage(TransportOrderFormView, { domain }); await flushPromises()
const model = wrapper.findComponent({ name: 'ElForm' }).props('model')
expect(model.totalWeightKg).toBe(0); expect(model.totalVolumeCbm).toBe(0)
expect(model.budgetAmount).toBeUndefined(); expect(model.currency).toBe('USD')
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual([
'客户', ...(domain === 'truck' ? ['车辆类型'] : []), '起运地', '目的地',
'起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址',
])
Object.assign(model, { vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: 0, currency: 'CNY' })
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
await submit.trigger('click'); await flushPromises()
expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
model.customerId = '10001'
await submit.trigger('click'); await flushPromises()
const command = domain === 'truck' ? vi.mocked(createUsTruckOrder) : vi.mocked(createLocalLooseboardOrder)
expect(command).toHaveBeenCalledOnce()
const [customer, payload] = command.mock.calls[0]!
expect(customer).toBe('10001')
expect(payload).toEqual(expect.objectContaining({ origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', totalWeightKg: 0, totalVolumeCbm: 0, budgetAmount: 0, currency: 'CNY', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' }))
for (const field of ['customerId', 'tenantId', 'orderNo', 'status', 'transportMode', 'containerType']) expect(payload).not.toHaveProperty(field)
})
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, transportType: 'PORT_PICKUP', palletCount: 2 })) })
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); wrapper.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'PORT_PICKUP'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系人'))!.find('input').setValue('联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系方式'))!.find('input').setValue('13800000000'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-31'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
it('requires split contacts and both local times, rejecting reversed times', async () => {
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
const model = wrapper.findComponent({ name: 'ElForm' }).props('model')
Object.assign(model, { customerId: '10001', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, totalWeightKg: 80, totalVolumeCbm: 1.8, deliveryAt: '2026-10-20T11:30:00' })
await wrapper.vm.$nextTick()
const save = wrapper.findAll('button').find(button => button.text().includes('提交订单'))!
await save.trigger('click'); await flushPromises()
expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
model.pickupAt = '2026-10-20T12:30:00'
await save.trigger('click'); await flushPromises()
expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
model.deliveryAt = '2026-10-20T15:45:00'
await save.trigger('click'); await flushPromises()
expect(createLocalLooseboardOrder).toHaveBeenCalledWith('10001', expect.objectContaining({ productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, pickupAt: '2026-10-20T12:30:00', deliveryAt: '2026-10-20T15:45:00' }))
const payload = vi.mocked(createLocalLooseboardOrder).mock.calls[0]![1]
expect(payload).not.toHaveProperty('containerType')
expect(payload).not.toHaveProperty('palletRequired')
expect(payload).not.toHaveProperty('transportType')
})
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '1' : '联系人'); const dates = wrapper.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-20T09:30:00'); dates[1].vm.$emit('update:modelValue', '2026-10-20T15:45:00'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
it.each([true, false])('opens only the matching pool order read-only arrangement: %s', matching => {
routing.route.query = { orderNo: truck.orderNo, fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: matching ? arrangement.bookingNo : 'FB-OTHER' }
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement }], page: 1, size: 20, total: 1, pages: 1 })
@@ -4,8 +4,8 @@ import { TransportRouteDialog, routeLocation } from '@/modules/overseas-master-d
import '../overseasBusinessDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft } from '@lucide/vue'
import { ElMessage, type FormInstance } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -14,13 +14,14 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '@/shared/domain/containerSpecifications'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import TransportDemandForm from '../components/TransportDemandForm.vue'
import { createTransportDemandForm, transportDemandError, buildTransportDemand, type TransportDemandForm as DemandFormModel } from '../transportDemandForm'
import type { FleetArrangement } from '../fleetArrangement'
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const routeOpen = ref(false)
@@ -31,40 +32,17 @@ const editing = computed(() => Boolean(orderId.value)); const isLooseboard = com
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车'); const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
const requiredPermission = computed(() => isLooseboard.value ? (editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate) : (editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate))
type FormStatus = LocalTransportStatus | LocalLooseboardStatus
interface TransportFormModel {
customerId: string; orderNo: string; transportMode: LooseboardTransportMode | ''; palletRequired: PalletRequired | ''
transportType: LocalTransportType | ''; origin: string; destination: string; originAddress: string | null; destinationAddress: string | null
contactName: string | null; contactInfo: string | null; containerType: string | null; palletCount: number; packageCount: number
totalWeightKg: string | null; totalVolumeCbm: string | null; budgetAmount: string | null; currency: string
requestedDate: string | null; remark: string | null; status: FormStatus; version: number; idempotencyKey: string
interface TransportFormModel extends DemandFormModel {
customerId: string; orderNo: string; status: FormStatus; version: number; idempotencyKey: string
}
function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` }
const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
const initial = (): TransportFormModel => ({ ...createTransportDemandForm(), customerId: '', orderNo: '', status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const saving = ref(false)
const fleetArrangement = ref<FleetArrangement | null>(null)
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref('')
const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' })
const rules: FormRules<TransportFormModel> = {
transportMode: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
palletRequired: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
transportType: [{ required: true, message: '请选择运输类型', trigger: 'change' }], origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
palletCount: [{ validator: (_rule, value, callback) => (isLooseboard.value || (Number.isInteger(value) && value >= 1)) ? callback() : callback(new Error('托数至少为 1')), trigger: 'change' }],
packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }], totalWeightKg: [requiredDecimal('总重量')], totalVolumeCbm: [requiredDecimal('总体积')],
contactName: [{ validator: (_rule, value, callback) => (isLooseboard.value || String(value || '').trim()) ? callback() : callback(new Error('请输入联系人')), trigger: 'blur' }],
contactInfo: [{ validator: (_rule, value, callback) => (isLooseboard.value || String(value || '').trim()) ? callback() : callback(new Error('请输入联系方式')), trigger: 'blur' }],
requestedDate: [{ validator: (_rule, value, callback) => (isLooseboard.value || value) ? callback() : callback(new Error('请选择要求时间')), trigger: 'change' }],
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
}
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus])
const editableStatus = computed(() => !fleetArrangement.value && (!editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW')))
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
function optional(value: string | null) { return value?.trim() || null }
function amount(value: string | null) { return value === null || value === '' ? null : Number(value) }
async function loadCustomers() {
if (editing.value) return
customersLoading.value = true; customersError.value = ''
@@ -81,11 +59,11 @@ async function load() {
if (isLooseboard.value) {
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportMode: detail.transportMode, palletRequired: detail.palletRequired, transportType: detail.transportType, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version })
} else {
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', vehicleType: detail.vehicleType || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, remark: detail.remark || '', status: detail.status, version: detail.version })
}
captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单详情加载失败`) } finally { loading.value = false }
@@ -93,17 +71,17 @@ async function load() {
async function save() {
if (!editableStatus.value) { submitError.value = { message: `当前${domainLabel.value}订单状态不可编辑` }; return }
if (!editing.value && !form.customerId) { submitError.value = { message: '请选择客户' }; return }
if (isLooseboard.value && form.palletRequired !== 'YES' && form.palletRequired !== 'NO') { submitError.value = { message: '请选择是否打盘' }; return }
const failure = transportDemandError(isLooseboard.value ? 'looseboard' : 'truck', form)
if (failure) { submitError.value = { message: failure }; return }
if (!await formRef.value?.validate().catch(() => false)) return
const common = { transportType: form.transportType as LocalTransportType, origin: form.origin.trim(), originAddress: optional(form.originAddress), destination: form.destination.trim(), destinationAddress: optional(form.destinationAddress), containerType: optional(form.containerType), packageCount: form.packageCount, totalWeightKg: Number(form.totalWeightKg), totalVolumeCbm: Number(form.totalVolumeCbm), budgetAmount: amount(form.budgetAmount), currency: form.currency, contactName: optional(form.contactName), contactInfo: optional(form.contactInfo), requestedDate: form.requestedDate, remark: optional(form.remark) }
saving.value = true; submitError.value = null
try {
if (isLooseboard.value) {
const loose = { ...common, transportMode: form.transportMode as LooseboardTransportMode, palletRequired: form.palletRequired as PalletRequired }
const loose = buildTransportDemand('looseboard', form)
if (editing.value) await updateLocalLooseboardOrder(orderId.value, routeCustomerId.value, { ...loose, version: form.version } satisfies UpdateLocalLooseboardOrderRequest)
else await createLocalLooseboardOrder(form.customerId, { ...loose, idempotencyKey: form.idempotencyKey } satisfies CreateLocalLooseboardOrderRequest)
} else {
const truck = { ...common, contactName: form.contactName!.trim(), contactInfo: form.contactInfo!.trim(), requestedDate: form.requestedDate!, palletCount: form.palletCount }
const truck = buildTransportDemand('truck', form)
if (editing.value) await updateUsTruckOrder(orderId.value, routeCustomerId.value, { ...truck, version: form.version } satisfies UpdateLocalTransportOrderRequest)
else await createUsTruckOrder(form.customerId, { ...truck, idempotencyKey: form.idempotencyKey } satisfies CreateLocalTransportOrderRequest)
}
@@ -119,21 +97,18 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<FleetArrangementSummary v-else-if="fleetArrangement" :arrangement="fleetArrangement" />
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
<FormSection class="density-form-section" v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
<section v-else v-loading="loading" class="form-panel">
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
<FormSection class="density-form-section" v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" placeholder="请输入联系人姓名" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" placeholder="请输入联系方式" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" placeholder="请输入托盘数" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" placeholder="请输入预算金额" ><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
</el-form><FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
<TransportDemandForm ref="formRef" :model="form" :kind="isLooseboard ? 'looseboard' : 'truck'" :submitting="saving" :disabled="!editableStatus" :editing="editing" @submit="save" @cancel="back">
<template v-if="!editing" #customer>
<FormSection class="transport-customer-section" title="客户信息"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
</template>
</TransportDemandForm>
<FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection>
</section>
</DetailPageLayout><TransportRouteDialog v-model="routeOpen" :origin="routeLocation(form.origin, form.originAddress)" :destinations="[routeLocation(form.destination, form.destinationAddress)]" /></AdminPage>
</template>
<style scoped>
.form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.form-grid.five { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.choice-block { display: grid; grid-template-columns: 1fr 1fr; gap: var(--admin-space-5); }
.span-2 { grid-column: 1 / -1; }
.transport-customer-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
</style>
@@ -17,7 +17,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types'
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const routeOpen = ref(false)
@@ -40,7 +40,6 @@ const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), st
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '商业单号', placeholder: '请输入商业单号', width: 230 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
@@ -48,23 +47,23 @@ const fields = computed<QueryField[]>(() => [
])
const truckColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalTransportOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' },
{ key: 'vehicleType', label: '车辆类型', minWidth: 110, formatter: (row: LocalTransportOrder) => row.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[row.vehicleType] : '—' },
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 }, { key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90, formatter: (row: LocalTransportOrder) => row.containerType || '—' }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
{ key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 },
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
{ key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.pickupAt) },
{ key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.createdAt) },
]
const looseboardColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : row.palletRequired === 'NO' ? '否' : '—' },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportTypeLabels[row.transportType] }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' }, { key: 'budgetAmount', label: '预算总价', width: 115, align: 'right', formatter: (row: LocalLooseboardOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 150 }, { key: 'estimatedPalletCount', prop: 'estimatedPalletCount', label: '预计托数', width: 100 }, { key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.pickupAt) }, { key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.createdAt) },
]
const columns = computed(() => {
@@ -0,0 +1,106 @@
import type { FormRules } from 'element-plus'
export type TransportDemandKind = 'truck' | 'looseboard'
export interface TransportDemandForm {
vehicleType: '' | 'PALLET_15' | 'PALLET_30'
origin: string
destination: string
originAddress: string
destinationAddress: string
originContactName: string
originContactPhone: string
destinationContactName: string
destinationContactPhone: string
productName: string
estimatedPalletCount: number
packageCount: number
totalWeightKg: number
totalVolumeCbm: number
budgetAmount: number | undefined
currency: 'USD' | 'CNY'
pickupAt: string
deliveryAt: string
remark: string
}
export function createTransportDemandForm(): TransportDemandForm {
return {
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
budgetAmount: undefined, currency: 'USD', pickupAt: '', deliveryAt: '', remark: '',
}
}
const requiredText = {
origin: '起运地', destination: '目的地', originContactName: '起运地联系人',
originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话',
} as const
export function localMinuteInput(value: unknown) {
return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : ''
}
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
const rules: FormRules<TransportDemandForm> = {}
for (const [field, label] of Object.entries(requiredText)) {
rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
}
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
}
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }]
rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, {
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
}]
return rules
}
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
for (const [field, label] of Object.entries(requiredText)) {
if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}`
}
for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) {
if (!form[field]) return `请选择${label}`
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
}
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间'
for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) {
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
}
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
return undefined
}
function commonPayload(form: TransportDemandForm) {
return {
origin: form.origin.trim(), destination: form.destination.trim(),
originAddress: form.originAddress.trim() || undefined, destinationAddress: form.destinationAddress.trim() || undefined,
originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(),
destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(),
packageCount: form.packageCount, totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
budgetAmount: form.budgetAmount ?? undefined, currency: form.currency,
pickupAt: localMinuteInput(form.pickupAt), deliveryAt: localMinuteInput(form.deliveryAt), remark: form.remark.trim() || undefined,
}
}
type CommonPayload = ReturnType<typeof commonPayload>
type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' }
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) {
const failure = transportDemandError(kind, form)
if (failure) throw new Error(failure)
const common = commonPayload(form)
return kind === 'truck'
? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
}
+23 -22
View File
@@ -28,17 +28,20 @@ export interface OverseasPurchaseEditorDetail {
}
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
export type LocalTransportStatus = 'PENDING_REVIEW' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PALLET_OVERAGE_REVIEW' | 'PARTIALLY_ALLOCATED' | 'ALLOCATED' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
export type TruckVehicleType = 'PALLET_15' | 'PALLET_30'
export interface LocalTransportQuery {
page?: number; size?: number; orderNo?: string; routeKeyword?: string; status?: LocalTransportStatus | ''
transportType?: LocalTransportType | ''; customerId?: string; requestedFrom?: string; requestedTo?: string
vehicleType?: TruckVehicleType | ''; customerId?: string; pickupFrom?: string; pickupTo?: string
}
export interface LocalTransportOrder {
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
id: string; customerId: string; orderNo: string; transportType: LocalTransportType | null
origin: string; destination: string; containerType: string | null; palletCount: number; packageCount: number
id: string; customerId: string; orderNo: string; vehicleType: TruckVehicleType | null
origin: string; destination: string; palletCount: number; packageCount: number
receivedPalletCount: number; allocatedPalletCount: number; shippedPalletCount: number
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
contactName: string; contactInfo: string; requestedDate: string; status: LocalTransportStatus
originContactName: string | null; originContactPhone: string | null
destinationContactName: string | null; destinationContactPhone: string | null
pickupAt: string | null; deliveryAt: string | null; status: LocalTransportStatus
version: number; createdAt: string; updatedAt: string
}
export interface LocalTransportPallet {
@@ -50,31 +53,28 @@ export interface LocalTransportDetail extends LocalTransportOrder {
pallets: LocalTransportPallet[]; histories: OverseasBusinessHistory[]
}
export interface CreateLocalTransportOrderRequest {
transportType?: LocalTransportType; origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
containerType?: string | null; palletCount: number; packageCount?: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string; contactName: string; contactInfo: string; requestedDate: string
remark?: string | null; idempotencyKey: string
vehicleType: TruckVehicleType; origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
packageCount?: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string
originContactName: string; originContactPhone: string; destinationContactName: string; destinationContactPhone: string
pickupAt: string; deliveryAt: string; remark?: string | null; idempotencyKey: string
}
export interface UpdateLocalTransportOrderRequest {
version: number; transportType?: LocalTransportType; origin?: string; originAddress?: string | null
destination?: string; destinationAddress?: string | null; containerType?: string | null; palletCount?: number
packageCount?: number; totalWeightKg?: number; totalVolumeCbm?: number; budgetAmount?: number | null
currency?: string; contactName?: string; contactInfo?: string; requestedDate?: string; remark?: string | null
export interface UpdateLocalTransportOrderRequest extends Omit<CreateLocalTransportOrderRequest, 'idempotencyKey' | 'origin' | 'destination' | 'totalWeightKg' | 'totalVolumeCbm'> {
version: number; origin?: string; destination?: string; totalWeightKg?: number; totalVolumeCbm?: number
}
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
export type PalletRequired = 'YES' | 'NO'
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
export interface LocalLooseboardQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string
status?: LocalLooseboardStatus | ''; transportMode?: LooseboardTransportMode | ''; requestedFrom?: string; requestedTo?: string
status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string
}
export interface LocalLooseboardOrder {
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired | null
transportType: LocalTransportType; origin: string; destination: string; containerType: string | null; packageCount: number
id: string; customerId: string; orderNo: string; palletRequired: PalletRequired | null
transportType: LocalTransportType | null; origin: string; destination: string; productName: string | null; estimatedPalletCount: number | null; packageCount: number
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
contactName: string | null; contactInfo: string | null; requestedDate: string | null; status: LocalLooseboardStatus
originContactName: string | null; originContactPhone: string | null; destinationContactName: string | null; destinationContactPhone: string | null; pickupAt: string | null; deliveryAt: string | null; status: LocalLooseboardStatus
version: number; createdAt: string; updatedAt: string
}
export interface LocalLooseboardDetail extends LocalLooseboardOrder {
@@ -82,10 +82,11 @@ export interface LocalLooseboardDetail extends LocalLooseboardOrder {
histories: OverseasBusinessHistory[]
}
export interface CreateLocalLooseboardOrderRequest {
transportMode: LooseboardTransportMode; palletRequired: PalletRequired; transportType: LocalTransportType
productName?: string | null; estimatedPalletCount: number
origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
containerType?: string | null; packageCount: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string; contactName?: string | null; contactInfo?: string | null
requestedDate?: string | null; remark?: string | null; idempotencyKey: string
packageCount: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string
originContactName: string; originContactPhone: string; destinationContactName: string; destinationContactPhone: string
pickupAt: string; deliveryAt: string; remark?: string | null; idempotencyKey: string
}
export interface UpdateLocalLooseboardOrderRequest extends Omit<CreateLocalLooseboardOrderRequest, 'idempotencyKey'> { version: number }
@@ -14,3 +14,7 @@
- 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。
- 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。
- CSV 下载只接受真实 Blob;JSON 错误 Blob 转为带 `requestId/traceId` 的业务错误,不保存为文件。
2026-10-17:仓储转运上门取货使用 `fleet-transfer-pickup-v1.yaml` 新接口,需部署Lastleg及 `baiyue_lastleg` 结构增量后生效。本轮只操作本机,不据本机验收推定远端已部署;人工页面视觉待验收。
2026-10-17 资源占用辅助查询:`GET /fleet/trips/{tripNo}/resource-occupancy`,按VEHICLE/DRIVER和本次最多100个ID批量读取;本车资源可保留、其他车次禁选。沿trip:list平台授权;只读、不预留,分配写入口再次锁定复验。前后端需一起更新,无新增数据库结构。
@@ -89,6 +89,21 @@
以上仅为代码接入依据,真实界面验收仍待执行。
## 2026-10-19 出车记录统计与列表优化
依据:当前对话图1(现状)、图2(原型);用户确认优先在前端优化。沿当前实际发车口径与接口,不恢复原型手填发车事实。
| 页面 | 操作 | 原型承载/布局 | 当前实现 | 本轮结论 |
|---|---|---|---|---|
| 出车记录 | 统计与查询 | 图2上方独立四栏饼图,下方记录与筛选 | 图1统计位于列表筛选和表格之间,条形明细内部滚动 | 独立四栏饼图及彩色图例;用户追加确认只改饼图、保留现有日期/目的地统计分组;列表一套日期/关键词/货源/状态查询,同一响应更新统计与记录;币种切换独立展示,顶部均价明确为最近统计日值,不相加或简单平均每日均价;长图例与完整统计按需展开 |
| 出车记录 | 列表、导出 | 图2列表独立卡片,操作第一列,导出在右上 | integrated列表、字段设置、XLSX下载任务 | 保留公共连续列表,整理默认列;完整字段仍可通过字段设置显示;导出沿原筛选及权限 |
| 出车记录 | 详情、实测编辑、凭证预览 | 图2仅能确认列表编辑入口;当前操作以既有确认矩阵为准 | 详情独立路由;实测820px居中及按需凭证预览 | 不改变承载、读写权限或请求方式 |
| 出车记录 | 新增、审核、删除 | 图2有新增记录,未定义审核/删除 | 当前列表无新增/审核/删除 | 不新增业务动作,实际发车事实仍由既有作业链产生 |
用户追加截图确认统计区文字过多,并要求改善图表质感:使用项目已有 ECharts / vue-echarts 替换手写SVG,统一原生饼图、圆点图例和悬停数值;默认仅保留指标标题、主数值、四个饼图及短图例,日期图例使用月日,完整值在统计详情读取;默认不显示各图说明、重复系列标题、整行成本和额外明细入口。页头“统计详情”统一按需展开统计口径、服务端汇总、成本及完整图例明细,原有数据与权限保留。
本轮只调整前端;自动检查与人工视觉验收分别记录,修改后1920×1080、100%截图待用户确认。
## 新版报价与出车记录接入
| 入口 | 原型依据 | 当前承载 | 核对边界 |
@@ -148,3 +163,17 @@
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
2026-10-10 拼车简化收口:调度默认待我处理,销售默认可拼车次;登记仍为1080px居中弹框,默认只展开客户和卸货站,已知货量与确认前资料分区展开;只读跟进仅突出当前有权限的主要动作,其他操作与关联单据收起。沿原请求、权限与真实作业节点;本次视觉变更待1920×1080、100%缩放人工截图确认。
## 仓储转运上门取货(2026-10-17)
依据本轮用户确认的仓储转运与车辆排车结合方案。
| 页面 | 承载 | 业务与验收边界 |
|---|---|---|
| 车辆排车 | 原列表增加待取货页签,服务端分页 | 同仓选择原转运单;780px居中安排弹框,不新增业务单 |
| 车次详情 | 原独立详情内展示逐票取货进度 | 共用车辆司机分配;发车、送仓、运输结案使用确认框;680px居中取货弹框填写实提和凭证 |
| 平台尾程详情、客户端转运详情 | 原单详情内嵌取货进度 | 只显示本票车辆司机、实提与到仓及凭证;不替代仓内测量状态 |
取货用途隐藏出库关联、外接预订、装载和余位发布。请求结果未知保留原键与载荷重试,阻止刷新和改派丢失请求。本轮类型检查、构建、单测及本机HTTP验收分别记录,尚未进行人工页面截图视觉验收。
2026-10-17 车次资源选择修正:沿原详情分配表单及远程下拉框,每次目录搜索后批量读取当前候选的实际占用;显示空闲、当前车次或占用车次号,别车占用不可选。资格与占用分别显示,保存由原锁定分配复验,不在浏览器从当前分页车次推断空闲。占用查询失败不展示未知候选为可用。单测与本机HTTP分别验证,未做截图视觉验收。
@@ -0,0 +1,51 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeTripDetail } from './trips'
import type { TripDetail } from '../tripTypes'
export interface PickupSource { customerId: string; transferNo: string }
export interface PickupCandidate extends PickupSource { lastlegOrderNo: string; warehouseId: string; warehouseCode?: string | null; warehouseName?: string | null; pickupAddress: string; contactName: string; contactPhone: string; packageCount: number; expectedArrivalAt: string | null }
export interface PickupEvidence { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface PickupTask extends Omit<PickupCandidate, 'expectedArrivalAt'> {
status: string; tripNo: string | null; vehiclePlate: string | null; driverName: string | null; driverPhone: string | null
plannedDepartureAt: string | null; actualPackages: number | null; pickedAt: string | null; arrivedAt: string | null; remark: string | null; evidence: PickupEvidence[]
}
export interface PickupPlan { sources: PickupSource[]; plannedDepartureAt: string; expectedDriverEndAt: string; remark?: string; idempotencyKey: string }
export interface PickupCommand { recordVersion: number; idempotencyKey: string; reason: string }
export interface PickupConfirmation { recordVersion: number; idempotencyKey: string; actualPackages: number; evidenceFileIds: string[]; remark?: string }
export const pickupStatusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', WAITING: '待安排取货', ASSIGNED: '已安排取货', PICKED: '已取货', ARRIVED: '已送仓' }
const base = '/platform/v1/last-leg/fleet/pickups'
const tripPath = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}`
const taskPath = (tripNo: string, row: PickupSource) => `${tripPath(tripNo)}/tasks/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}`
type Page = { records: PickupCandidate[]; total: string | number }
type WireDetail = Parameters<typeof normalizeTripDetail>[0]
/** 待取货列表始终以服务端资格和总数为准。 */
export async function listPickupCandidates(page: number, size: number, keyword?: string) {
const value = unwrapApiResponse(await http.get<ApiEnvelope<Page>, ApiEnvelope<Page>>(base, { params: { page, size, keyword } }))
return { records: value.records, total: Number(value.total) }
}
/** 安排成功返回原有车次详情。 */
export async function createPickupTrip(body: PickupPlan): Promise<TripDetail> {
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(base, body)))
}
/** 车次成员一次读取,避免逐行加载。 */
export async function getPickupTasks(tripNo: string): Promise<PickupTask[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${tripPath(tripNo)}/tasks`))
}
/** 运输发车与结案沿取货专用规则执行。 */
export async function commandPickupTrip(tripNo: string, action: 'depart' | 'complete', body: PickupCommand): Promise<TripDetail> {
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${tripPath(tripNo)}/${action}`, body)))
}
/** 取货和送仓逐票确认,保留请求键以支持未知结果重试。 */
export async function commandPickupTask(tripNo: string, row: PickupSource, action: 'picked' | 'arrive', body: PickupCommand | PickupConfirmation): Promise<PickupTask[]> {
return unwrapApiResponse(await http.post<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${taskPath(tripNo, row)}/${action}`, body))
}
/** 文件上传绑定本票与当前车次。 */
export async function uploadPickupEvidence(tripNo: string, row: PickupSource, file: File): Promise<PickupEvidence> {
const body = new FormData(); body.append('file', file)
return unwrapApiResponse(await http.post<ApiEnvelope<PickupEvidence>, ApiEnvelope<PickupEvidence>>(`${taskPath(tripNo, row)}/files`, body))
}
/** 本票凭证通过授权入口读取,永不使用公共文件直链。 */
export async function downloadPickupEvidence(row: PickupSource, fileId: string): Promise<Blob> {
return http.get<Blob, Blob>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(row.transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}`, { params: { customerId: row.customerId }, responseType: 'blob' })
}
@@ -19,4 +19,9 @@ it('normalizes quote source without overwriting negotiated price and handles man
expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25)
expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull()
expect(normalizeBooking({ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } })).toMatchObject({ offerNo: 'CO-1', expectedPalletCount: null, cargo: { packageCount: null } })
const received = normalizeBooking({ ...row, status: 'RECEIVED', receipt: {
source: { incomingNo: 'WT-1', inboundCode: 'IN-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'READY_FOR_DISPATCH', receiptHash: 'merged', receivedPackages: '1', pallets: [{ warehousePalletId: '9007199254740997', palletNo: 'PALLET-MERGED', packages: '1', weightKg: '12.345', volumeCbm: '0.123' }] },
pallets: [{ warehousePalletId: '9007199254740997', palletSpaces: '2', weightKg: '12.345', volumeCbm: '0.123' }], operatorId: 'operator', confirmedAt: '',
} })
expect(received.receipt?.pallets[0]).toEqual({ warehousePalletId: '9007199254740997', palletSpaces: 2, weightKg: 12.345, volumeCbm: 0.123 })
})
@@ -7,13 +7,15 @@ type N = number | string
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource' | 'expectedPalletCount'> & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
export type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] }
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N; weightKg?: N | null; volumeCbm?: N | null }[] }
export type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result }
const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result }
const nullable = (value: N | null) => value == null ? null : number(value)
const normalizeReceiptPallet = ({ weightKg, volumeCbm, ...row }: WireReceipt['pallets'][number]) => ({ ...row, palletSpaces: integer(row.palletSpaces),
...(weightKg === undefined ? {} : { weightKg: nullable(weightKg) }), ...(volumeCbm === undefined ? {} : { volumeCbm: nullable(volumeCbm) }) })
export const normalizeIncoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(normalizeReceiptPallet) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
export const normalizeBookingResult = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number }
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip,
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, getTripResourceOccupancy, bindTripDispatch, unbindTripDispatch, cancelTrip,
getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan,
} from './trips'
@@ -14,7 +14,7 @@ const trip = { tripNo: 'TR/TEST', status: 'PLANNED', warehouseId: longId, wareho
plannedDepartureAt: '2026-10-02T10:00:00Z', expectedDriverEndAt: '2026-10-03T10:00:00Z', departedAt: null, completedAt: null,
stops: [{ name: '起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, updatedAt: '2026-10-02T08:00:00Z', vehicleRelease: null, driverRelease: null }
const reviewState = { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
const detail = { trip, driverQualification: null, loadingReview: reviewState, dispatches: [{ dispatchNo: 'DP-1', customerId: longId, packageCount: '12', boundBy: longId, boundAt: trip.updatedAt, review: reviewState }] }
const detail = { trip, driverQualification: null, previousDispatches: [], appointmentHistoryUnavailableReason: null, loadingReview: reviewState, dispatches: [{ appointmentBatches: [], dispatchNo: 'DP-1', customerId: longId, packageCount: '12', boundBy: longId, boundAt: trip.updatedAt, review: reviewState }] }
const cargo = { dispatchNo: 'DP-1', customerId: longId, dispatchItemId: longId, warehousePalletId: longId, palletNo: 'PALLET-1', packages: '12', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
const load = { palletSpaces: '1', weightKg: '100.125', volumeCbm: '1.005' }
const confirmed = { manifestHash: 'a'.repeat(64), cargo: [cargo], pallets: [{ ...load, warehousePalletId: longId, deliveryIndex: '1' }],
@@ -26,6 +26,15 @@ const envelope = <T>(data: T) => ({ code: '200', message: '成功', data })
describe('fleet trip API contract', () => {
beforeEach(() => vi.clearAllMocks())
it('batches occupancy queries without rounding long resource identifiers', async () => {
const occupancy = [{ resourceId: longId, occupiedTripNo: 'TR-OTHER', available: false }]
vi.mocked(http.get).mockResolvedValueOnce(envelope(occupancy))
expect(await getTripResourceOccupancy('TR/TEST', 'DRIVER', [longId, '9007199254740997'])).toEqual(occupancy)
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/resource-occupancy`, { params: { kind: 'DRIVER', resourceIds: `${longId},9007199254740997` } })
expect(await getTripResourceOccupancy('TR/TEST', 'VEHICLE', [])).toEqual([])
expect(http.get).toHaveBeenCalledTimes(1)
})
it('uses one paged request and keeps long identifiers as exact strings', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' }))
const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId }
@@ -113,3 +122,28 @@ describe('fleet trip API contract', () => {
await expect(cancelTrip(trip.tripNo, { recordVersion: 0, reason: '取消' })).rejects.toBe(conflict)
})
})
it('prepares the same trip from two real customer dispatches without issuing cargo creation or separate bind commands', async () => {
vi.mocked(http.post).mockResolvedValueOnce(envelope({ ...detail, dispatches: [{ ...detail.dispatches[0]!, dispatchNo: 'DS-60', customerId: 'customer-b', packageCount: '60' }, { ...detail.dispatches[0]!, dispatchNo: 'DS-40', customerId: 'customer-d', packageCount: '40' }] }))
const request = { tripNo: trip.tripNo, recordVersion: 4, plan: null, vehicleId: null, driverId: null, dispatchNos: ['DS-60', 'DS-40'], idempotencyKey: 'same-share-key' }
const value = await prepareTrip(request)
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/prepare`, request)
expect(value.dispatches.map(row => [row.dispatchNo, row.customerId, row.packageCount])).toEqual([['DS-60', 'customer-b', 60], ['DS-40', 'customer-d', 40]])
expect(http.get).not.toHaveBeenCalled()
})
it('preserves current and previous stable batch identities and normalizes only their numeric fields', async () => {
const reference = { batchKey: 'FBA_APPOINTMENT:9223372036854775801', batchNo: null, customerId: longId, sources: [{ sourceKey: 'ORDER:9223372036854775801', destinationKey: 'FBA:ONT8', packageCount: '40' }] }
vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...detail, dispatches: [{ ...detail.dispatches[0]!, appointmentBatches: [reference] }], previousDispatches: [{ dispatchNo: 'DS-REMOVED', customerId: longId, recordVersion: '7', recordedAt: trip.updatedAt, appointmentBatches: [reference] }], appointmentHistoryUnavailableReason: '缺少部分历史来源' }))
const result = await getTrip(trip.tripNo)
expect(result.dispatches[0]!.appointmentBatches).toEqual([{ ...reference, sources: [{ ...reference.sources[0], packageCount: 40 }] }])
expect(result.previousDispatches[0]).toMatchObject({ dispatchNo: 'DS-REMOVED', customerId: longId, recordVersion: 7, appointmentBatches: result.dispatches[0]!.appointmentBatches })
expect(result.appointmentHistoryUnavailableReason).toBe('缺少部分历史来源')
})
it('rejects missing stable-reference arrays instead of silently falling back to legacy appointment numbers', async () => {
for (const invalid of [{ ...detail, previousDispatches: undefined }, { ...detail, dispatches: [{ ...detail.dispatches[0]!, appointmentBatches: undefined, fbaAppointmentNo: 'AP-OLD' }] }]) {
vi.mocked(http.get).mockResolvedValueOnce(envelope(invalid))
await expect(getTrip(trip.tripNo)).rejects.toThrow()
}
})
+16 -8
View File
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeBooking, type WireBooking } from './tripBookings'
import type { PageResult } from './overseasMasterData'
import type {
FleetTrip, TripStatus, TripDetail, TripDraft, TripPlan, TripAssignment, TripBindingCommand, TripBinding,
TripPreviousDispatch, FleetTrip, TripStatus, TripDetail, TripDraft, TripPlan, TripAssignment, TripBindingCommand, TripBinding,
TripCargo, TripLoad, TripMeasurement, TripConfirmedLoad, TripLoading, TripLoadConfirmation,
TripOutcome, TripCompletionInspection, TripComplete, TripResourceRelease, TripHistory, TripReviewState, TripReviewCommand, TripCapacityPlanning,
} from '../tripTypes'
@@ -15,7 +15,8 @@ type WireTrip = Omit<FleetTrip, 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg
}
type WireReview = Omit<TripReviewState, 'recordVersion'> & { recordVersion: Numeric | null }
type WireBinding = Omit<TripBinding, 'packageCount' | 'review'> & { packageCount: Numeric; review: WireReview }
type WireDetail = Omit<TripDetail, 'trip' | 'dispatches' | 'loadingReview'> & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview }
type WirePreviousDispatch = Omit<TripPreviousDispatch, 'recordVersion'> & { recordVersion: Numeric }
type WireDetail = Omit<TripDetail, 'trip' | 'dispatches' | 'loadingReview' | 'previousDispatches'> & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview; previousDispatches: WirePreviousDispatch[] }
type WirePage<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
type WireCargo = Omit<TripCargo, 'packages'> & { packages: Numeric }
type WireLoad = { [K in keyof TripLoad]: Numeric }
@@ -49,8 +50,9 @@ const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer
maxPalletSpaces: value.maxPalletSpaces == null ? null : integer(value.maxPalletSpaces),
maxLoadKg: value.maxLoadKg == null ? null : number(value.maxLoadKg), maxVolumeCbm: value.maxVolumeCbm == null ? null : number(value.maxVolumeCbm) })
const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) })
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), review: review(value.review) })
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), loadingReview: review(value.loadingReview) })
const batchReferences = (values: TripBinding['appointmentBatches']) => values.map(reference => ({ ...reference, sources: reference.sources.map(source => ({ ...source, packageCount: integer(source.packageCount) })) }))
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), appointmentBatches: batchReferences(value.appointmentBatches), review: review(value.review) })
export const normalizeTripDetail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages),
signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) })
@@ -69,13 +71,19 @@ export async function listTrips(query: TripQuery): Promise<PageResult<FleetTrip>
return page(unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireTrip>>, ApiEnvelope<WirePage<WireTrip>>>(base, { params: query })), trip)
}
export async function getTrip(tripNo: string): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
return normalizeTripDetail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
}
async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
}
export const createTrip = (body: TripDraft) => postDetail(base, body)
export const assignTrip = (tripNo: string, body: TripAssignment) => postDetail(`${path(tripNo)}/assignment`, body)
export interface TripResourceOccupancy { resourceId: string; occupiedTripNo: string | null; available: boolean }
/** 当前下拉候选批量查询实际占用,长 ID 始终按字符串传递。 */
export async function getTripResourceOccupancy(tripNo: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]): Promise<TripResourceOccupancy[]> {
if (!resourceIds.length) return []
return unwrapApiResponse(await http.get<ApiEnvelope<TripResourceOccupancy[]>, ApiEnvelope<TripResourceOccupancy[]>>(`${path(tripNo)}/resource-occupancy`, { params: { kind, resourceIds: resourceIds.join(',') } }))
}
export const reviewTripDispatch = (tripNo: string, dispatchNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/dispatches/${encodeURIComponent(dispatchNo)}/review`, body)
export const reviewTripLoading = (tripNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/loading-review`, body)
export const bindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/bind`, body)
@@ -84,7 +92,7 @@ export const cancelTrip = (tripNo: string, body: { recordVersion: number; reason
export const completeTrip = (tripNo: string, body: TripComplete) => postDetail(`${path(tripNo)}/complete`, body)
export const releaseTripResource = (tripNo: string, body: TripResourceRelease) => postDetail(`${path(tripNo)}/resources/release`, body)
export async function updateTripPlan(tripNo: string, body: TripPlan): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
return normalizeTripDetail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
}
const planning = (value: WirePlanning): TripCapacityPlanning => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), capacityPlan: confirmed(value.capacityPlan), planValid: value.planValid === true, availableSegments: value.availableSegments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) })
export async function getTripCapacityPlan(tripNo: string): Promise<TripCapacityPlanning> {
@@ -119,5 +127,5 @@ export async function listTripDispatchCandidates(tripNo: string, keyword?: strin
}
export interface TripPreparation { tripNo: string | null; recordVersion: number | null; plan: TripDraft | null; vehicleId: string | null; driverId: string | null; dispatchNos: string[]; idempotencyKey: string }
export async function prepareTrip(body: TripPreparation): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
}
@@ -16,7 +16,7 @@ export const bookingStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认
export interface TripIncomingPallet { warehousePalletId: string; palletNo: string; packages: number; weightKg: number | null; volumeCbm: number | null }
export interface TripIncomingEvidence { incomingNo: string; inboundCode: string; warehouseId: string; destinationKey: string; status: string; receiptHash: string; pallets: TripIncomingPallet[]; receivedPackages: number }
export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number }
export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number; weightKg?: number | null; volumeCbm?: number | null }
export interface TripAcceptedReceipt { source: TripIncomingEvidence; pallets: TripPalletSpaces[]; operatorId: string; confirmedAt: string }
export interface TripIncomingPreview { tripRecordVersion: number; recordVersion: number; incoming: TripIncomingEvidence | null }
export interface TripIncomingStart extends Omit<TripBookingCommand, 'reason'> { estimatedArrivalAt?: string | null; deliveryInstructions?: string | null; privateAddress?: string | null; postalCode?: string | null; recipientName?: string | null; recipientPhone?: string | null }
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import PickupDemandPanel from './PickupDemandPanel.vue'
import { listPickupCandidates, createPickupTrip, type PickupCandidate } from '../api/pickups'
import type { TripDetail } from '../tripTypes'
const state = vi.hoisted(() => ({ push: vi.fn(), write: true }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }), onBeforeRouteLeave: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.write }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() } }))
vi.mock('../api/pickups', () => ({ listPickupCandidates: vi.fn(), createPickupTrip: vi.fn() }))
const rows: PickupCandidate[] = [1, 2].map(i => ({ customerId: `C${i}`, transferNo: `WT-${i}`, lastlegOrderNo: `LL-${i}`, warehouseId: '9007199254740999', pickupAddress: `TEST Ontario ${i}`, contactName: '取货验收员', contactPhone: '000', packageCount: 5, expectedArrivalAt: null }))
const wrappers: VueWrapper[] = []
function mount() {
const wrapper = shallowMount(PickupDemandPanel, { global: { stubs: {
DataTable: { name: 'DataTable', props: ['data', 'total'], emits: ['selection-change'], template: '<div><slot name="toolbar-actions"/><slot name="notices"/></div>' },
AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/></div>' },
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
ElDatePicker: { name: 'ElDatePicker', props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' },
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => { vi.clearAllMocks(); state.write = true; vi.mocked(listPickupCandidates).mockResolvedValue({ records: rows, total: 42 }) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('uses server total and rejects combining different destination warehouses', async () => {
const wrapper = mount(); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(42)
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('selection-change', [rows[0], { ...rows[1], warehouseId: 'OTHER' }]); await flushPromises()
await wrapper.find('button').trigger('click')
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(false)
expect(createPickupTrip).not.toHaveBeenCalled()
})
it('keeps exact source identities and the same create request after an unknown network result', async () => {
const wrapper = mount(); await flushPromises()
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('selection-change', rows); await flushPromises(); await wrapper.find('button').trigger('click')
const dates = wrapper.findAllComponents({ name: 'ElDatePicker' })
dates[0]!.vm.$emit('update:modelValue', '2026-10-17T08:00:00Z'); dates[1]!.vm.$emit('update:modelValue', '2026-10-18T08:00:00Z'); await flushPromises()
vi.mocked(createPickupTrip).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ trip: { tripNo: 'TR-TEST' } } as TripDetail)
const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises()
const request = vi.mocked(createPickupTrip).mock.calls[0]![0]
expect(request.sources).toEqual(rows.map(({ customerId, transferNo }) => ({ customerId, transferNo })))
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(createPickupTrip).mock.calls[1]![0]).toBe(request)
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' } })
})
it('does not show scheduling to read-only users', async () => {
state.write = false; const wrapper = mount(); await flushPromises()
expect(wrapper.find('button').exists()).toBe(false)
})
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessage } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPickupCandidates, createPickupTrip, type PickupCandidate, type PickupPlan } from '../api/pickups'
import { overseasMasterDataPermissions as permission } from '../permissions'
const router = useRouter(), access = useAccessPreviewStore()
const rows = ref<PickupCandidate[]>([]), selected = ref<PickupCandidate[]>([]), page = ref(1), total = ref(0), loading = ref(false)
const filters = ref<QueryModel>({ keyword: '' }), applied = ref(''), error = ref(''), open = ref(false), busy = ref(false)
const departure = ref(''), end = ref(''), remark = ref(''), snapshot = ref<PickupCandidate[]>([]), pending = ref<PickupPlan | null>(null)
let sequence = 0
const columns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', width: 45 }, { key: 'transferNo', prop: 'transferNo', label: '仓储转运单', minWidth: 220 },
{ key: 'lastlegOrderNo', prop: 'lastlegOrderNo', label: '尾程单号', minWidth: 200 },
{ key: 'warehouseId', prop: 'warehouseId', label: '入仓仓库', minWidth: 165, slotName: 'warehouse' },
{ key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 280 },
{ key: 'contactName', prop: 'contactName', label: '联系人', width: 110 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 150 },
{ key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }, { key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '期望入仓时间', minWidth: 175 },
]
/** 分页或条件变化时清除旧选择,迟到响应不能覆盖新页。 */
async function load() {
const current = ++sequence; loading.value = true; error.value = ''; selected.value = []
try { const value = await listPickupCandidates(page.value, 20, applied.value || undefined); if (current === sequence) { rows.value = value.records; total.value = value.total } }
catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '待取货需求加载失败').message } }
finally { if (current === sequence) loading.value = false }
}
/** 查询只采用明确提交的条件。 */
function search() { applied.value = String(filters.value.keyword || '').trim(); if (page.value !== 1) page.value = 1; else void load() }
/** 安排弹框固定本次选单快照。 */
function arrange() {
if (!selected.value.length) { ElMessage.warning('请勾选待取货订单'); return }
if (new Set(selected.value.map(row => row.warehouseId)).size !== 1) { ElMessage.warning('同车取货必须送往同一海外仓'); return }
snapshot.value = [...selected.value]; pending.value = null; departure.value = ''; end.value = ''; remark.value = ''; error.value = ''; open.value = true
}
/** 网络结果未知时沿原键原内容重试,业务错误后允许修正输入。 */
async function save() {
if (busy.value) return
if (!pending.value && (!departure.value || !end.value)) { ElMessage.warning('请填写取货发车时间和预计任务结束时间'); return }
pending.value ||= { sources: snapshot.value.map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date(departure.value).toISOString(), expectedDriverEndAt: new Date(end.value).toISOString(), remark: remark.value.trim(), idempotencyKey: crypto.randomUUID() }
busy.value = true
try { const value = await createPickupTrip(pending.value); open.value = false; pending.value = null; await router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: value.trip.tripNo } }) }
catch (cause) { error.value = getApiErrorDetails(cause, '安排取货未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
finally { busy.value = false }
}
/** 未知提交结果必须保留原请求,避免关闭后重复建车次。 */
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先重试原安排请求,确认结果后再关闭'); return }; done() }
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先确认本次安排结果'); return false }; return true })
watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
<template>
<DataTable v-model:page="page" :page-size="20" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="transferNo" empty-text="暂无已审核的待取货订单" @selection-change="selected = $event">
<template #filters><QueryToolbar ariaLabel="待取货需求查询" v-model="filters" :fields="[{ key: 'keyword', type: 'input', label: '关键词', placeholder: '转运单号 / 取货地址', width: 300 }]" @search="search" @reset="search" /></template>
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" :disabled="!selected.length" @click="arrange">安排取货({{ selected.length }})</el-button></template>
<template #notices><LoadErrorState v-if="error && !open" title="待取货需求加载失败" :message="error" @retry="load" /></template>
<template #warehouse="{ row }">{{ row.warehouseName || '海外仓' }} · {{ row.warehouseCode || row.warehouseId }}</template>
</DataTable>
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="pending ? '重试原安排请求' : '创建取货车次'" :confirm-loading="busy" @confirm="save">
<p>已选 {{ snapshot.length }} 单:{{ snapshot.map(row => row.transferNo).join('、') }}</p>
<el-form label-position="top" class="pickup-plan-form" :disabled="busy || !!pending">
<el-form-item label="计划取货发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="司机预计任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="调度备注"><el-input v-model="remark" maxlength="1000" placeholder="请输入调度备注(选填)" /></el-form-item>
</el-form>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
</AppDialog>
</template>
<style scoped>.pickup-plan-form{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}.pickup-plan-form :deep(.el-date-editor){width:100%}.pickup-plan-form>.el-form-item:last-child{grid-column:1/-1}</style>
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, shallowMount } from '@vue/test-utils'
import TripAssignmentPanel from './TripAssignmentPanel.vue'
import { listVehicles } from '../api/overseasMasterData'
import { listDrivers } from '../api/drivers'
import { assignTrip, getTripResourceOccupancy } from '../api/trips'
import type { TripDetail } from '../tripTypes'
import type { VehicleDetail } from '../types'
import type { DriverDetail } from '../driverTypes'
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn() }))
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn() }))
vi.mock('../api/trips', () => ({ assignTrip: vi.fn(), getTripResourceOccupancy: vi.fn() }))
const vehicle: VehicleDetail = { id: '9007199254740999', plateNumber: 'TEST-FREE', model: '26ft', maxPalletSpaces: 12, maxLoadKg: 8000, maxVolumeCbm: 45, fuelConsumptionPer100Km: 20, mileageKm: 0, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: '1', updatedAt: '' }
const driver: DriverDetail = { id: '9007199254740998', driverNo: 'DR-TEST', details: { name: '验收司机', phone: '000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-12-31', licenseZone: 'UTC', remark: null, profile: null }, active: true, reviewStatus: 'APPROVED', licenseFileId: '1', recordVersion: 0, updatedBy: '1', updatedAt: '', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-12-31' }, licenseState: 'VALID', warehouseCode: null, warehouseName: null }
const trip: TripDetail = { trip: { tripNo: 'TR-CURRENT', status: 'PLANNED', warehouseId: '1', warehouseCode: 'WH', warehouseName: '仓库', vehicleId: null, vehiclePlate: null, driverId: null, driverName: null, driverPhone: null, maxPalletSpaces: null, maxLoadKg: null, maxVolumeCbm: null, plannedDepartureAt: '2026-10-18T01:00:00Z', expectedDriverEndAt: '2026-10-18T09:00:00Z', departedAt: null, completedAt: null, stops: [], remark: null, recordVersion: 1, updatedAt: '', vehicleRelease: null, driverRelease: null }, driverQualification: null, dispatches: [], loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }, previousDispatches: [], appointmentHistoryUnavailableReason: null }
function mount() {
return shallowMount(TripAssignmentPanel, { props: { detail: trip }, global: { stubs: {
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<div><slot/></div>' },
ElSelect: { name: 'ElSelect', props: ['modelValue'], emits: ['focus', 'update:modelValue'], template: '<div><slot/></div>' },
ElOption: { name: 'ElOption', props: ['value', 'label', 'disabled'], template: '<span>{{ label }}</span>' },
ElButton: { template: '<button><slot/></button>' },
} } })
}
beforeEach(() => {
vi.useFakeTimers(); vi.clearAllMocks()
vi.mocked(listVehicles).mockResolvedValue({ records: [vehicle], page: 1, size: 30, total: 1, pages: 1 })
vi.mocked(listDrivers).mockResolvedValue({ records: [driver], page: 1, size: 30, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
})
afterEach(() => vi.useRealTimers())
it('disables resources held by another trip and shows the occupying trip before saving', async () => {
vi.mocked(getTripResourceOccupancy).mockImplementation(async (_, kind, ids) => ids.map(resourceId => ({ resourceId, occupiedTripNo: kind === 'VEHICLE' ? 'TR-OTHER' : 'TR-CURRENT', available: kind === 'DRIVER' })))
const wrapper = mount()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects.forEach(select => select.vm.$emit('focus')); await vi.advanceTimersByTimeAsync(250); await flushPromises()
const options = wrapper.findAllComponents({ name: 'ElOption' })
expect(options[0]!.props()).toMatchObject({ value: vehicle.id, disabled: true })
expect(options[0]!.props('label')).toContain('被车次 TR-OTHER 占用')
expect(options[1]!.props('disabled')).toBe(false)
expect(options[1]!.props('label')).toContain('当前车次')
expect(getTripResourceOccupancy).toHaveBeenCalledTimes(2)
selects[0]!.vm.$emit('update:modelValue', vehicle.id); selects[1]!.vm.$emit('update:modelValue', driver.id); await flushPromises()
await wrapper.find('button').trigger('click'); expect(assignTrip).not.toHaveBeenCalled()
wrapper.unmount()
})
it('does not expose directory candidates as free when occupancy lookup fails', async () => {
vi.mocked(getTripResourceOccupancy).mockRejectedValue(new Error('查询占用失败'))
const wrapper = mount(); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('focus')
await vi.advanceTimersByTimeAsync(250); await flushPromises()
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
wrapper.unmount()
})
@@ -6,7 +6,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { listVehicles } from '../api/overseasMasterData'
import { listDrivers } from '../api/drivers'
import { assignTrip } from '../api/trips'
import { assignTrip, getTripResourceOccupancy, type TripResourceOccupancy } from '../api/trips'
import { driverQualificationLabels, type DriverDetail } from '../driverTypes'
import type { VehicleDetail } from '../types'
import type { TripDetail } from '../tripTypes'
@@ -14,41 +14,64 @@ import type { TripDetail } from '../tripTypes'
const props = defineProps<{ detail: TripDetail }>()
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
const vehicleOccupancy = ref<Record<string, TripResourceOccupancy>>({}), driverOccupancy = ref<Record<string, TripResourceOccupancy>>({})
const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false)
const vehicleLoading = ref(false), driverLoading = ref(false), error = ref<ApiErrorDetails | null>(null)
let vehicleRequest = 0, driverRequest = 0, generation = 0
let vehicleTimer: ReturnType<typeof setTimeout> | undefined, driverTimer: ReturnType<typeof setTimeout> | undefined
/** 本车占用可保留,其他车次占用显示具体业务号并禁止选择。 */
function occupancyLabel(row: TripResourceOccupancy | undefined): string {
if (!row) return ' · 占用待查询'
if (!row.available) return ` · 被车次 ${row.occupiedTripNo} 占用`
return row.occupiedTripNo ? ' · 当前车次' : ' · 空闲'
}
watch(() => props.detail.trip, trip => {
generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer)
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
vehicleOccupancy.value = {}; driverOccupancy.value = {}
vehicleId.value = trip.vehicleId || ''; driverId.value = trip.driverId || ''; busy.value = false
}, { immediate: true })
/** 下拉搜索先批量核对占用,失败不把未知状态的资源当成空闲。 */
function searchVehicles(keyword = '') {
clearTimeout(vehicleTimer); const request = ++vehicleRequest
vehicles.value = []; vehicleOccupancy.value = {}; vehicleLoading.value = true
vehicleTimer = setTimeout(async () => {
vehicleLoading.value = true
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (request === vehicleRequest) vehicles.value = result.records }
try {
const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' })
if (request !== vehicleRequest) return
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'VEHICLE', result.records.map(row => row.id))
if (request === vehicleRequest) { vehicleOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); vehicles.value = result.records }
}
catch (cause) { if (request === vehicleRequest) error.value = getApiErrorDetails(cause, '车辆候选加载失败') }
finally { if (request === vehicleRequest) vehicleLoading.value = false }
}, 250)
}
/** 证照合格与资源空闲分别展示,不能用可派车资格代替未占用。 */
function searchDrivers(keyword = '') {
clearTimeout(driverTimer); const request = ++driverRequest
drivers.value = []; driverOccupancy.value = {}; driverLoading.value = true
driverTimer = setTimeout(async () => {
driverLoading.value = true
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt }); if (request === driverRequest) drivers.value = result.records }
try {
const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt })
if (request !== driverRequest) return
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'DRIVER', result.records.map(row => row.id))
if (request === driverRequest) { driverOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); drivers.value = result.records }
}
catch (cause) { if (request === driverRequest) error.value = getApiErrorDetails(cause, '司机候选加载失败') }
finally { if (request === driverRequest) driverLoading.value = false }
}, 250)
}
/** 候选阶段挡住已知占用,保存仍由后端锁定复验以防同时被其他人分配。 */
async function save() {
if (busy.value) return
if (!vehicleId.value || !driverId.value) { ElMessage.warning('请选择车辆和司机'); return }
if (vehicleLoading.value || driverLoading.value) { ElMessage.warning('请等待车辆和司机占用查询完成'); return }
if (vehicleOccupancy.value[vehicleId.value]?.available === false || driverOccupancy.value[driverId.value]?.available === false) { ElMessage.warning('所选车辆或司机已被其他车次占用,请重新选择'); return }
const current = generation; busy.value = true; error.value = null
try {
const result = await assignTrip(props.detail.trip.tripNo, { recordVersion: props.detail.trip.recordVersion, vehicleId: vehicleId.value,
driverId: driverId.value, reason: reason.value.trim() || '分配或调整车辆司机' })
if (current === generation) { emit('saved', result); ElMessage.success('车辆和司机已分配,请重新核对装载') }
if (current === generation) { emit('saved', result); ElMessage.success(props.detail.trip.tripPurpose === 'PICKUP' ? '车辆和司机已分配,可安排取货发车' : '车辆和司机已分配,请重新核对装载') }
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '分配失败') }
finally { if (current === generation) busy.value = false }
}
@@ -60,11 +83,11 @@ onBeforeUnmount(() => { generation++; vehicleRequest++; driverRequest++; clearTi
<el-form label-position="top" class="density-form assignment-form" :disabled="busy" @submit.prevent="save">
<el-form-item label="车辆" required><el-select v-model="vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" placeholder="按车牌搜索" @focus="searchVehicles()">
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位`" />
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :disabled="vehicleOccupancy[vehicle.id]?.available !== true" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位${occupancyLabel(vehicleOccupancy[vehicle.id])}`" />
</el-select></el-form-item>
<el-form-item label="司机" required><el-select v-model="driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" placeholder="按姓名、电话搜索" @focus="searchDrivers()">
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}`" />
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :disabled="driverOccupancy[driver.id]?.available !== true || !driver.qualification.canDispatch" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}${occupancyLabel(driverOccupancy[driver.id])}`" />
</el-select></el-form-item>
<el-form-item label="调整说明(选填)"><el-input v-model="reason" placeholder="如车辆维修,安排替班" /></el-form-item>
<div><el-button type="primary" :loading="busy" @click="save">保存分配</el-button></div>
@@ -1,4 +1,4 @@
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index'
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
import { ElInput } from 'element-plus/es/components/input/index'
@@ -9,9 +9,12 @@ import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripIncomingPreview } from '../bookingTypes'
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
import { ApiResponseError } from '@/shared/api/http'
const writable = vi.hoisted(() => ({ value: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => writable.value }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() }))
const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null,
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
@@ -21,12 +24,12 @@ function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/><slot name="weightKg" :row="row"/><slot name="volumeCbm" :row="row"/></div></div>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
ElButton: { template: '<button><slot/></button>' }, LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
beforeEach(() => { vi.resetAllMocks(); writable.value = true; vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
@@ -115,3 +118,82 @@ it('compares real receipt with the forecast, preserves unknown values, and does
expect(wrapper.find('[aria-label="预报与实收对照"]').exists()).toBe(false)
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
})
it('requires explicit acceptance of a changed received snapshot and submits the current complete pallets and versions', async () => {
const previous = { ...booking, status: 'RECEIVED' as const, receipt: { source: { ...preview.incoming!, receiptHash: 'previous-hash' }, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }], operatorId: 'operator', confirmedAt: '2026-10-01T00:00:00Z' } }
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 11, recordVersion: 8, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-hash', pallets: [
{ warehousePalletId: '903', palletNo: 'PLT-MERGED', packages: 22, weightKg: 150, volumeCbm: 1.5 },
] } })
const wrapper = mount('receipt'); await wrapper.setProps({ booking: previous }); await flushPromises()
expect(wrapper.text()).toContain('上次核实与当前实收对照')
expect(wrapper.text()).not.toContain('无法还原原始预报')
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 12, booking: { ...previous, recordVersion: 9 }, replayed: false })
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenLastCalledWith(expect.stringContaining('50.00 USD'), '确认实收差异', expect.any(Object))
expect(api.acceptTripIncoming).toHaveBeenCalledExactlyOnceWith('TR-1', 'FB-1', { tripRecordVersion: 11, recordVersion: 8, idempotencyKey: expect.any(String), receiptHash: 'merged-hash', unitPrice: 50, pallets: [{ warehousePalletId: '903', palletSpaces: 1 }] })
})
it('uses a new key after a completed confirmation while preserving the original key for unknown retries', async () => {
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
const wrapper = mount('receipt'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
await wrapper.setProps({ modelValue: false })
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 6, recordVersion: 3, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'new-hash' } })
await wrapper.setProps({ modelValue: true, booking: { ...booking, status: 'RECEIVED', recordVersion: 3 } }); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const next = vi.mocked(api.acceptTripIncoming).mock.calls[1]![2]
expect(next.idempotencyKey).not.toBe(first.idempotencyKey)
expect(next).toMatchObject({ tripRecordVersion: 6, recordVersion: 3, receiptHash: 'new-hash' })
})
it('does not report success when the server rejects the updated receipt capacity', async () => {
vi.mocked(api.acceptTripIncoming).mockRejectedValue(new ApiResponseError('当前车次容量不足,请重新核对', 'A0409', 409, null, 'capacity-request'))
const wrapper = mount('receipt'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(ElMessage.success).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toContain('当前车次容量不足')
expect((wrapper.vm as unknown as { error: { requestId: string } }).error).toMatchObject({ requestId: 'capacity-request' })
})
it.each(['bound', 'departed', 'permission'] as const)('rejects a receipt write if the context changes to %s after opening', async context => {
const wrapper = mount('receipt'); await flushPromises()
if (context === 'bound') await wrapper.setProps({ booking: { ...booking, status: 'BOUND', dispatchNo: 'DS-1' } })
if (context === 'departed') await wrapper.setProps({ trip: { ...trip, status: 'IN_TRANSIT' } })
if (context === 'permission') {
writable.value = false
await wrapper.setProps({ booking: { ...booking, recordVersion: 3 } })
}
await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
})
it('requires new measurements for changed pallet composition and keeps them in the exact unknown-result retry', async () => {
const merged = { ...preview, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletNo: 'MERGED-1', packages: 22, weightKg: null, volumeCbm: null }] } }
vi.mocked(api.getTripIncoming).mockResolvedValue(merged)
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
const wrapper = mount('receipt'); await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } }); await flushPromises()
const confirm = () => wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm')
confirm(); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('重新填写'))
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测重量')!.vm.$emit('update:modelValue', 123.456)
fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测体积')!.vm.$emit('update:modelValue', 1.234)
await flushPromises(); confirm(); await flushPromises()
const original = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
expect(original).toMatchObject({ receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletSpaces: 1, weightKg: 123.456, volumeCbm: 1.234 }] })
confirm(); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
})
it('does not offer measurement overrides for unchanged source pallets', async () => {
const wrapper = mount('receipt'); await flushPromises()
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).some(field => /实测重量|实测体积/.test(field.attributes('aria-label') || ''))).toBe(false)
})
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -12,36 +14,46 @@ import { useFleetDraftGuard } from '../useFleetDraftGuard'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'> & Partial<Pick<FleetTrip, 'status'>>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
const access = useAccessPreviewStore()
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
const measurements = reactive({ weightKg: {} as Record<string, number | undefined>, volumeCbm: {} as Record<string, number | undefined> })
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, measurements, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
defineExpose({ hasPending: computed(() => !!pending.value) })
let generation = 0
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '确认排入本车', view: '外接来货详情' }
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
const receiptAllowed = computed(() => props.pool || props.trip.status === 'PLANNED' && !!props.booking
&& ['CONFIRMED', 'RECEIVED'].includes(props.booking.status) && !!props.booking.incomingNo && !props.booking.dispatchNo && access.can(permission.tripBookingConfirm))
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
const receiptComparison = computed(() => {
const incoming = preview.value?.incoming
const pallets = incoming?.pallets || []
const forecast = props.booking?.status === 'CONFIRMED' ? props.booking.cargo : null
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => row[key] != null && Number.isFinite(row[key]))
? pallets.reduce((total, row) => total + row[key]!, 0) : null
const previous = props.booking?.status === 'RECEIVED' ? props.booking.receipt : null
const forecast = props.booking?.status === 'CONFIRMED' || previous ? props.booking?.cargo : null
const measured = (row: typeof pallets[number], key: 'weightKg' | 'volumeCbm') => row[key] ?? measurements[key][row.warehousePalletId]
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => measured(row, key) != null && Number.isFinite(measured(row, key)))
? pallets.reduce((total, row) => total + measured(row, key)!, 0) : null
const values = [
{ label: '箱数', expected: forecast?.packageCount, actual: incoming?.receivedPackages },
{ label: '实托数', expected: forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
{ label: '标准托位(待核对)', expected: forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
{ label: '箱数', expected: previous?.source.receivedPackages ?? forecast?.packageCount, actual: incoming?.receivedPackages },
{ label: '实托数', expected: previous ? previous.source.pallets.length : forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
{ label: '标准托位(待核对)', expected: previous ? previous.pallets.reduce((total, row) => total + row.palletSpaces, 0) : forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
{ label: '重量(kg)', expected: forecast?.weightKg, actual: sum('weightKg') },
{ label: '体积(m³)', expected: forecast?.volumeCbm, actual: sum('volumeCbm') },
]
return values.map(row => ({ ...row, difference: row.expected == null || row.actual == null ? null : Number((row.actual - row.expected).toFixed(3)) }))
})
const receiptChanged = computed(() => receiptComparison.value.some(row => row.difference != null && row.difference !== 0)
|| unitPrice.value !== props.booking?.cargo.unitPrice
|| !!props.booking?.receipt && (props.booking.receipt.source.receiptHash !== preview.value?.incoming?.receiptHash
|| props.booking.receipt.pallets.some(row => spaces[row.warehousePalletId] !== row.palletSpaces)))
const quantityText = (value: number | null | undefined) => value == null ? '—' : String(Number(value.toFixed(3)))
const incomingStatusLabel = computed(() => {
const status = preview.value?.incoming?.status
@@ -49,15 +61,18 @@ const incomingStatusLabel = computed(() => {
return statuses[status || ''] || '等待读取'
})
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150, ...(props.mode === 'receipt' ? { slotName: 'weightKg', singleLine: false, showOverflowTooltip: false } : {}) }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150, ...(props.mode === 'receipt' ? { slotName: 'volumeCbm', singleLine: false, showOverflowTooltip: false } : {}) },
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 180, singleLine: false, showOverflowTooltip: false }] : [])])
function resetMeasurements() {
for (const field of ['weightKg', 'volumeCbm'] as const) Object.keys(measurements[field]).forEach(key => delete measurements[field][key])
}
async function load() {
if (!props.booking) return
const current = generation; loading.value = true; error.value = null
try {
const value = props.pool ? (await getInquiryFulfillment(props.booking.bookingNo)).preview : await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
if (current !== generation) return
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
preview.value = value; resetMeasurements(); Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
await nextTick(); guard.capture()
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
@@ -70,7 +85,7 @@ watch(() => [open.value, target.value], () => {
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
Object.assign(form, { estimatedArrivalAt: props.arrivalAt || '', deliveryInstructions: props.instructions || props.booking.cargo.remark || '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
if (props.initialPreview && (props.mode === 'receipt' || props.mode === 'view')) {
preview.value = props.initialPreview; Object.keys(spaces).forEach(key => delete spaces[key])
preview.value = props.initialPreview; resetMeasurements(); Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of props.initialPreview.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
void nextTick(() => guard.capture())
} else if (props.mode === 'receipt' || props.mode === 'view') void load()
@@ -78,6 +93,7 @@ watch(() => [open.value, target.value], () => {
}, { immediate: true })
async function save() {
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
if (props.mode === 'receipt' && !receiptAllowed.value) return
const current = generation, booking = props.booking, mode = props.mode
if (!pending.value) {
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
@@ -88,8 +104,19 @@ async function save() {
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
} else {
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
if (preview.value.incoming.pallets.some(row => ['weightKg', 'volumeCbm'].some(key => {
const field = key as 'weightKg' | 'volumeCbm', value = row[field] ?? measurements[field][row.warehousePalletId]
return value == null || !Number.isFinite(value) || value <= 0
}))) { ElMessage.warning('拆合托后的来源须重新填写该票货物在每托的实测重量和体积,不沿用旧托测量'); return }
if (receiptChanged.value) {
busy.value = true
try { await ElMessageBox.confirm(`当前实托、货量、托位或约定价格存在变化,确认按当前完整实收和 ${amount.value?.toFixed(2)} ${booking.cargo.currency || ''} 核实?容量仍由服务端复核,超容量不会保存。`, '确认实收差异', { customClass: 'admin-density-confirm', confirmButtonText: '确认当前实收与运费', cancelButtonText: '返回核对', type: 'warning' }) }
catch { return }
finally { busy.value = false }
if (current !== generation || !receiptAllowed.value) return
}
pending.value = { target: target.value, body: { ...versions, receiptHash: preview.value.incoming.receiptHash, unitPrice: unitPrice.value,
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]! })) } }
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]!, ...(row.weightKg == null ? { weightKg: measurements.weightKg[row.warehousePalletId]! } : {}), ...(row.volumeCbm == null ? { volumeCbm: measurements.volumeCbm[row.warehousePalletId]! } : {}) })) } }
}
}
busy.value = true; error.value = null
@@ -105,7 +132,7 @@ async function save() {
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (!pending && mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && (!receiptAllowed || (!pending && !ready)))" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="booking" v-loading="loading">
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p><p v-if="pending" role="status">本票上次提交结果尚未确定,保留原内容与请求键重试;不会改为处理另一票货物。</p>
@@ -115,12 +142,13 @@ onBeforeUnmount(() => { generation++ })
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
</el-form>
<template v-if="mode === 'receipt' || mode === 'view'">
<p v-if="mode === 'receipt' && !receiptAllowed" role="alert">当前车次或预订不能核对实收,请刷新并确认车次仍在计划中、预订未绑定排车且具有核对实收权限。</p>
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ incomingStatusLabel }}</p>
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template></DataTable>
<section v-if="mode === 'receipt'" aria-label="预报与实收对照">
<p>预报与实收对照;差异仅供核对,容量与费用仍需确认。</p>
<p v-if="booking.status !== 'CONFIRMED'">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
<table class="density-table"><thead><tr><th>项目</th><th>承运预报</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template><template v-for="field in (['weightKg', 'volumeCbm'] as const)" #[field]="{ row }"><div v-if="row[field] == null" class="receipt-spaces-control"><el-input-number v-model="measurements[field][row.warehousePalletId]" :min="0.001" :max="999999999.999" :precision="3" :disabled="busy || !!pending" :aria-label="`${row.palletNo} ${field === 'weightKg' ? '实测重量' : '实测体积'}`" placeholder="重新实测该票货物" /></div><span v-else>{{ row[field] }}</span></template></DataTable>
<p v-if="mode === 'receipt'">托盘组成变化后须填写该票外接货的当前实测重量、体积;未变化托盘只读沿用原收货证据,不填写整托混合货的总量。</p><section v-if="mode === 'receipt'" aria-label="预报与实收对照">
<p>{{ booking.status === 'RECEIVED' && booking.receipt ? '上次核实与当前实收对照' : '预报与实收对照' }};差异须明确确认,容量与费用仍由服务端核对。</p>
<p v-if="booking.status !== 'CONFIRMED' && !booking.receipt">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
<table class="density-table"><thead><tr><th>项目</th><th>{{ booking.status === 'RECEIVED' && booking.receipt ? '上次核实' : '承运预报' }}</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
<tbody><tr v-for="item in receiptComparison" :key="item.label"><td>{{ item.label }}</td><td>{{ quantityText(item.expected) }}</td><td>{{ quantityText(item.actual) }}</td><td>{{ item.difference == null ? '—' : item.difference > 0 ? `+${quantityText(item.difference)}` : quantityText(item.difference) }}</td></tr></tbody>
</table>
</section>
@@ -3,6 +3,7 @@ import { ElInput } from 'element-plus/es/components/input/index'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingPanel from './TripBookingPanel.vue'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking } from '../bookingTypes'
@@ -147,3 +148,30 @@ it('keeps manual entry available when candidate lookup fails and hides lookup wi
const denied = mount(); await flushPromises(); await button(denied, '编辑').trigger('click'); await flushPromises()
expect(denied.findAll('button').some(node => node.text() === '查询报价库')).toBe(false)
})
it.each(['CONFIRMED', 'RECEIVED'] as const)('opens the existing receipt confirmation for an unbound %s booking', async status => {
const current = { ...row, status, incomingNo: 'WT-1', recordVersion: 7 }
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 9, records: [current] })
const wrapper = mount(); await flushPromises()
await button(wrapper, '核对实收').trigger('click'); await flushPromises()
expect(wrapper.findComponent(TripBookingFulfillmentDialog).props()).toMatchObject({ modelValue: true, mode: 'receipt', booking: current })
})
it.each([
{ status: 'BOUND' as const, dispatchNo: 'DS-1', planned: true, permitted: true },
{ status: 'RECEIVED' as const, dispatchNo: 'DS-1', planned: true, permitted: true },
{ status: 'RECEIVED' as const, dispatchNo: null, planned: false, permitted: true },
{ status: 'RECEIVED' as const, dispatchNo: null, planned: true, permitted: false },
{ status: 'CANCELLED' as const, dispatchNo: null, planned: true, permitted: true },
])('blocks receipt rechecking for $status / bound=$dispatchNo / planned=$planned / permitted=$permitted', async scenario => {
access.writable = scenario.permitted
const current = { ...row, status: scenario.status, incomingNo: 'WT-1', dispatchNo: scenario.dispatchNo }
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [current] })
const wrapper = mount()
if (!scenario.planned) await wrapper.setProps({ trip: { ...trip, status: 'IN_TRANSIT' } })
await flushPromises()
expect(wrapper.findAll('button').some(node => node.text() === '核对实收')).toBe(false)
const state = wrapper.vm as unknown as { fulfill: (booking: TripBooking, mode: 'receipt') => void }
state.fulfill(current, 'receipt'); await flushPromises()
expect(wrapper.findComponent(TripBookingFulfillmentDialog).props('modelValue')).toBe(false)
})
@@ -18,7 +18,13 @@ const emit = defineEmits<{ changed: [version: number]; linked: [] }>()
const access = useAccessPreviewStore()
const records = ref<TripBooking[]>([]), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const fulfillmentOpen = ref(false), fulfillmentBooking = ref<TripBooking | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') { fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true }
function canConfirmReceipt(row: TripBooking) {
return planned.value && ['CONFIRMED', 'RECEIVED'].includes(row.status) && !!row.incomingNo && !row.dispatchNo && access.can(permission.tripBookingConfirm)
}
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') {
if (busy.value || mode === 'receipt' && !canConfirmReceipt(row)) return
fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true
}
function fulfilled(value: TripBookingResult) { accept(value); if (fulfillmentMode.value === 'bind') { emit('linked'); void load() } }
const readOnly = ref(false)
const open = ref(false), editing = ref<TripBooking | null>(null), customers = ref<FleetCustomerOption[]>([]), customersLoading = ref(false)
@@ -145,7 +151,7 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="canConfirmReceipt(row as TripBooking)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
@@ -11,7 +11,7 @@ vi.mock('../api/tripHandovers', async original => ({ ...(await original<typeof a
vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn() }))
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn() }))
const date = '2026-10-03T01:00:00Z'
const detail: TripDetail = { trip: { tripNo: 'TR-ONE', status: 'IN_TRANSIT', warehouseId: '1', warehouseCode: 'WH', warehouseName: '海外仓', vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone', maxPalletSpaces: 2, maxLoadKg: 200, maxVolumeCbm: 4, plannedDepartureAt: date, expectedDriverEndAt: '2026-10-04T01:00:00Z', departedAt: date, completedAt: null, stops: [], remark: null, recordVersion: 7, updatedAt: date, vehicleRelease: null, driverRelease: null }, driverQualification: null, dispatches: [], loadingReview: { status: 'APPROVED', reason: null, reviewerId: null, reviewedAt: date, recordVersion: 7 } }
const detail: TripDetail = { trip: { tripNo: 'TR-ONE', status: 'IN_TRANSIT', warehouseId: '1', warehouseCode: 'WH', warehouseName: '海外仓', vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone', maxPalletSpaces: 2, maxLoadKg: 200, maxVolumeCbm: 4, plannedDepartureAt: date, expectedDriverEndAt: '2026-10-04T01:00:00Z', departedAt: date, completedAt: null, stops: [], remark: null, recordVersion: 7, updatedAt: date, vehicleRelease: null, driverRelease: null }, driverQualification: null, previousDispatches: [], appointmentHistoryUnavailableReason: null, dispatches: [], loadingReview: { status: 'APPROVED', reason: null, reviewerId: null, reviewedAt: date, recordVersion: 7 } }
const previous = { vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone' }
const replacement = { vehicleId: '12', vehiclePlate: 'NEW-PLATE', driverId: '22', driverName: '接替司机', driverPhone: 'phone' }
const load = { palletSpaces: 1, weightKg: 100, volumeCbm: 2 }
@@ -0,0 +1,101 @@
import { afterEach, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import type { EChartsOption, LegendComponentOption, PieSeriesOption } from 'echarts'
import TripOperationsCharts from './TripOperationsCharts.vue'
import type { TripOperationsCharts as Charts, TripOperationsSummary } from '../api/tripOperations'
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option', 'initOptions', 'updateOptions', 'autoresize'], template: '<div class="echarts-pie-stub"/>' } }))
const wrappers: VueWrapper[] = []
const emptyCharts = (): Charts => ({ physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] })
const summary = (): TripOperationsSummary => ({ trips: 20, inTransit: 5, completed: 15, physicalPallets: 90, palletSpaces: 100, ownPackages: 50, externalPackages: 20, externalAgreedAmounts: [{ currency: 'CNY', amount: 390 }, { currency: 'USD', amount: 100 }], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } })
function render(charts: Charts, totals = summary()) {
const wrapper = mount(TripOperationsCharts, { props: { charts, summary: totals }, global: { stubs: {
ElButton: { template: '<button><slot/></button>' },
} } })
wrappers.push(wrapper)
return wrapper
}
const option = (wrapper: VueWrapper, selector: string) => wrapper.find(selector).findComponent({ name: 'VChart' }).props('option') as EChartsOption
const series = (options: EChartsOption) => (options.series as PieSeriesOption[])[0]!
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('uses four ECharts pies with compact native legends, accurate server totals and daily rates', () => {
const charts = emptyCharts()
charts.physicalPallets = [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-02', value: 10 }] }]
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: [{ label: 'ONT8', value: 3 }, { label: 'ABE2', value: 8 }] }]
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }]
charts.externalPalletRates = [{ label: 'CNY', unit: 'CNY/标准托位', points: [{ label: '2026-10-07', value: 30 }, { label: '2026-10-02', value: 90 }] }]
const original = structuredClone(charts)
const wrapper = render(charts)
expect(wrapper.findAllComponents({ name: 'VChart' })).toHaveLength(4)
for (const chart of wrapper.findAllComponents({ name: 'VChart' })) {
expect(series(chart.props('option'))).toMatchObject({ type: 'pie', label: { show: false } })
expect(chart.props('initOptions')).toEqual({ renderer: 'canvas' })
expect(chart.props('updateOptions')).toEqual({ notMerge: true })
}
expect(wrapper.find('svg').exists()).toBe(false)
expect(wrapper.find('.series-caption').exists()).toBe(false)
expect(wrapper.find('.chart > .chart-note').exists()).toBe(false)
expect(wrapper.find('.chart--pallets .chart-metric').text()).toBe('90 托')
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('CNY/标准托位 30.00')
expect(wrapper.find('.chart--rates .chart-metric').attributes('title')).toContain('最近统计日:2026-10-07')
const legend = option(wrapper, '.chart--pallets').legend as LegendComponentOption
expect(legend.icon).toBe('circle')
expect(typeof legend.formatter === 'function' && legend.formatter('2026-10-02')).toBe('10-02')
expect(option(wrapper, '.chart--pallets').tooltip).toMatchObject({ trigger: 'item', confine: true, renderMode: 'richText' })
expect(series(option(wrapper, '.chart--rates')).data).toEqual([
{ name: '2026-10-02', value: 90, itemStyle: { color: '#3b82f6' } },
{ name: '2026-10-07', value: 30, itemStyle: { color: '#14b8a6' } },
])
expect(charts).toEqual(original)
})
it('switches currency without combining monetary amounts or leaving stale ECharts slices', async () => {
const charts = emptyCharts()
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }, { label: 'USD', unit: 'USD', points: [{ label: '2026-10-03', value: 100 }] }]
const wrapper = render(charts)
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
expect(wrapper.find('.chart--amounts').findAllComponents({ name: 'VChart' })).toHaveLength(1)
const usd = wrapper.findAll('.chart--amounts .series-selector button').find(button => button.text() === 'USD')!
await usd.trigger('click')
expect(usd.attributes('aria-pressed')).toBe('true')
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('USD 100.00')
expect(series(option(wrapper, '.chart--amounts')).data).toEqual([{ name: '2026-10-03', value: 100, itemStyle: { color: '#3b82f6' } }])
await wrapper.setProps({ charts: { ...charts, externalAgreedAmounts: [charts.externalAgreedAmounts[0]!] } })
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
})
it('keeps every destination in its pie and exposes full legend data only on demand', async () => {
const charts = emptyCharts()
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: Array.from({ length: 7 }, (_, index) => ({ label: `FBA-${index}`, value: index + 1 })) }]
const wrapper = render(charts)
expect(wrapper.find('.chart--destinations .chart-metric').text()).toBe('7 个目的地')
const options = option(wrapper, '.chart--destinations')
expect(series(options).data).toHaveLength(7)
expect((options.legend as LegendComponentOption).data).toHaveLength(5)
expect(wrapper.find('.chart-details').exists()).toBe(false)
await wrapper.find('.chart-more').trigger('click')
expect(wrapper.emitted('show-details')).toHaveLength(1)
await wrapper.setProps({ detailsVisible: true })
expect(wrapper.find('[aria-label="派送目的地明细"]').findAll('tbody tr')).toHaveLength(7)
expect(wrapper.find('[aria-label="派送目的地明细"]').text()).toContain('FBA-0')
})
it('preserves known zeros and does not draw equal positive shares for an empty sum', () => {
const charts = emptyCharts()
charts.physicalPallets = [{ label: '托盘', unit: '托', points: [{ label: '2026-10-01', value: 0 }, { label: '2026-10-02', value: 2 }] }]
charts.externalPalletRates = [{ label: 'USD', unit: 'USD/标准托位', points: [{ label: '2026-10-01', value: 0 }] }]
const wrapper = render(charts)
expect(series(option(wrapper, '.chart--rates'))).toMatchObject({ stillShowZeroSum: false, showEmptyCircle: true, data: [{ name: '2026-10-01', value: 0 }] })
expect(option(wrapper, '.chart--rates').graphic).toMatchObject([{ style: { text: '暂无正值数据' } }])
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('0.00')
expect(series(option(wrapper, '.chart--pallets')).data).toHaveLength(2)
})
it('shows missing amounts and rates as unknown and omits empty chart widgets', () => {
const wrapper = render(emptyCharts(), { ...summary(), physicalPallets: 0, externalAgreedAmounts: [] })
expect(wrapper.findAll('.chart-empty')).toHaveLength(4)
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('—')
expect(wrapper.find('.chart--rates .chart-metric').text()).toBe('—')
expect(wrapper.findComponent({ name: 'VChart' }).exists()).toBe(false)
})
@@ -1,28 +1,140 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TripOperationsCharts, TripOperationsSeries } from '../api/tripOperations'
const props = defineProps<{ charts: TripOperationsCharts }>()
import { computed, ref } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import type { EChartsOption } from 'echarts'
import { PieChart } from 'echarts/charts'
import { AriaComponent, GraphicComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import type { TripOperationsCharts, TripOperationsSeries, TripOperationsSummary } from '../api/tripOperations'
use([PieChart, LegendComponent, TooltipComponent, GraphicComponent, AriaComponent, CanvasRenderer])
const props = withDefaults(defineProps<{ charts: TripOperationsCharts; summary: TripOperationsSummary; detailsVisible?: boolean }>(), { detailsVisible: false })
const emit = defineEmits<{ 'show-details': [] }>()
const selectedSeries = ref<Record<string, string>>({})
const palette = ['#3b82f6', '#14b8a6', '#f59e0b', '#8b5cf6', '#f46565', '#06b6d4', '#ec4899', '#84cc16', '#6366f1', '#f97316']
const sections = computed(() => [
{ title: '实运托盘', note: '按实际发车日(UTC)汇总,物理托盘去重', series: props.charts.physicalPallets },
{ title: '派送目的地', note: '各目的地涉及的车次,同车多站可分别出现', series: props.charts.destinationTrips },
{ title: '外接约定金额', note: '按币种分别展示,不代表财务应收或实收', series: props.charts.externalAgreedAmounts },
{ title: '外接按托报价均价', note: '按确认计价托位加权,排除整单报价', series: props.charts.externalPalletRates },
])
function width(value: number, series: TripOperationsSeries) { const max = Math.max(...series.points.map(p => p.value), 0); return max ? value / max * 100 : 0 }
{ key: 'pallets', title: '实运托盘', note: '按实际发车日(UTC)分布,物理托盘去重', series: props.charts.physicalPallets, money: false },
{ key: 'destinations', title: '派送目的地', note: '按目的地涉及车次分布,同车多站可分别计入', series: props.charts.destinationTrips, money: false },
{ key: 'amounts', title: '外接约定金额', note: '按日期分布,各币种独立展示,不代表应收或实收', series: props.charts.externalAgreedAmounts, money: true },
{ key: 'rates', title: '外接按托报价均价', note: '按日均价数值对比,顶部为最近统计日均价', series: props.charts.externalPalletRates, money: true },
].map(section => {
const series = section.series.filter(series => series.points.length).map(series => ({
...series,
points: [...series.points].sort((a, b) => section.key === 'destinations' ? b.value - a.value || a.label.localeCompare(b.label) : a.label.localeCompare(b.label)),
}))
return { ...section, series, active: series.find(series => series.label === selectedSeries.value[section.key]) ?? series[0] ?? null }
}))
const destinationCount = computed(() => new Set(props.charts.destinationTrips.flatMap(series => series.points.map(point => point.label))).size)
const hasPoints = computed(() => sections.value.some(section => section.series.length))
const number = (value: number, money = false) => value.toLocaleString('zh-CN', { minimumFractionDigits: money ? 2 : 0, maximumFractionDigits: 2 })
const color = (index: number) => palette[index % palette.length]!
const seriesTitle = (series: TripOperationsSeries) => series.label === series.unit ? series.label : `${series.label}(${series.unit})`
const amount = (currency: string) => props.summary.externalAgreedAmounts.find(amount => amount.currency === currency)
const legendLabel = (label: string) => /^\d{4}-\d{2}-\d{2}$/.test(label) ? label.slice(5) : label
function pieOption(series: TripOperationsSeries, money: boolean): EChartsOption {
const hasPositive = series.points.some(point => point.value > 0)
return {
animation: false,
aria: { enabled: true, description: `${seriesTitle(series)},完整日期、图例和数值可在统计详情读取` },
tooltip: {
trigger: 'item', confine: true, renderMode: 'richText',
formatter: params => {
if (Array.isArray(params)) return ''
return `${params.name}\n${typeof params.value === 'number' ? number(params.value, money) : '—'} ${series.unit}`
},
},
legend: {
type: 'plain', left: 'center', bottom: 4, width: series.points.length > 5 ? '82%' : '96%',
icon: 'circle', itemWidth: 9, itemHeight: 9, itemGap: 10, selectedMode: false,
textStyle: { color: '#8492a6', fontSize: 12 },
data: series.points.slice(0, 5).map(point => point.label), formatter: legendLabel,
tooltip: { show: true, confine: true },
},
series: [{
name: seriesTitle(series), type: 'pie', radius: 64, center: ['50%', 72],
label: { show: false }, labelLine: { show: false },
itemStyle: { borderColor: '#fff', borderWidth: 2 },
emphasis: { scale: false, focus: 'self' },
stillShowZeroSum: false, showEmptyCircle: true,
emptyCircleStyle: { color: '#f3f4f6', borderWidth: 0 },
data: series.points.map((point, index) => ({ name: point.label, value: point.value, itemStyle: { color: color(index) } })),
}],
graphic: hasPositive ? [] : [{ type: 'text', left: 'center', top: 66, style: { text: '暂无正值数据', fill: '#8492a6', fontSize: 12 } }],
}
}
</script>
<template><section class="operations-charts" aria-label="出车数据统计"><article v-for="section in sections" :key="section.title" class="chart">
<strong>{{ section.title }}</strong><p>{{ section.note }}</p>
<p v-if="!section.series.some(s => s.points.length)">暂无对应统计</p>
<div class="chart-values"><template v-for="(series, si) in section.series" :key="`${series.label}-${si}`"><div v-if="series.points.length"><b>{{ series.label }}({{ series.unit }})</b>
<div v-for="(point, pi) in series.points" :key="pi" class="point"><span>{{ point.label }}</span><div class="bar-track" aria-hidden="true"><div class="bar" :style="{ width: width(point.value, series) + '%' }" /></div><span>{{ point.value }}</span></div>
</div></template></div>
</article></section></template>
<template>
<div class="operations-visualizations">
<section class="operations-charts" aria-label="出车统计饼图">
<article v-for="section in sections" :key="section.key" class="chart" :class="`chart--${section.key}`" :aria-label="section.title">
<h3 :title="section.note">{{ section.title }}</h3>
<div class="chart-metric" :title="section.key === 'rates' && section.active ? `最近统计日:${section.active.points[section.active.points.length - 1]!.label};${section.note}` : section.note">
<strong v-if="section.key === 'pallets'">{{ number(summary.physicalPallets) }} <small>托</small></strong>
<strong v-else-if="section.key === 'destinations'">{{ number(destinationCount) }} <small>个目的地</small></strong>
<template v-else-if="section.key === 'amounts'">
<strong v-if="section.active && amount(section.active.label)"><small>{{ section.active.label }}</small> {{ number(amount(section.active.label)!.amount, true) }}</strong>
<strong v-else>—</strong>
</template>
<template v-else>
<strong v-if="section.active"><small>{{ section.active.unit }}</small> {{ number(section.active.points[section.active.points.length - 1]!.value, true) }}</strong>
<strong v-else>—</strong>
</template>
</div>
<div v-if="section.series.length > 1" class="series-selector" role="group" :aria-label="`${section.title}币种或系列`">
<el-button v-for="series in section.series" :key="series.label" size="small" :type="section.active?.label === series.label ? 'primary' : 'default'" :aria-pressed="section.active?.label === series.label" @click="selectedSeries[section.key] = series.label">{{ series.label }}</el-button>
</div>
<template v-if="section.active">
<div class="chart-visualization">
<VChart class="pie-chart" :option="pieOption(section.active, section.money)" :init-options="{ renderer: 'canvas' }" :update-options="{ notMerge: true }" autoresize :aria-label="`${section.title},完整图例和数值见统计详情`" />
<el-button v-if="section.active.points.length > 5" class="chart-more" link :aria-label="`查看其余 ${section.active.points.length - 5} 项图例及数值`" @click="emit('show-details')">+{{ section.active.points.length - 5 }}</el-button>
</div>
</template>
<p v-else class="chart-empty">暂无对应统计</p>
</article>
</section>
<section v-if="detailsVisible && hasPoints" class="chart-details" aria-label="完整统计明细">
<div class="chart-details-grid">
<section v-for="section in sections" :key="section.key" :aria-label="`${section.title}明细`">
<h3>{{ section.title }}</h3>
<p class="chart-note">{{ section.note }}</p>
<p v-if="section.key === 'rates'" class="chart-note">各日均价按确认计价托位加权,排除整单报价;不将每日均价相加或简单平均为期间均价。</p>
<p v-if="!section.series.length" class="chart-note">暂无对应统计</p>
<table v-for="(series, seriesIndex) in section.series" :key="`${series.label}-${seriesIndex}`" class="density-table">
<caption>{{ seriesTitle(series) }}</caption>
<thead><tr><th scope="col">{{ section.key === 'destinations' ? '目的地' : '日期(UTC)' }}</th><th scope="col">数值</th></tr></thead>
<tbody><tr v-for="(point, pointIndex) in series.points" :key="point.label"><td><span class="legend-dot" :style="{ background: color(pointIndex) }" aria-hidden="true" /> {{ point.label }}</td><td>{{ number(point.value, section.money) }}</td></tr></tbody>
</table>
</section>
</div>
</section>
</div>
</template>
<style scoped>
.operations-charts { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--admin-space-3); }
.chart { display:grid; align-content:start; gap:var(--admin-space-2); min-width:0; padding:var(--admin-space-3); border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:var(--admin-surface); }
.chart > strong { font-size:var(--admin-font-section); }
.chart p { margin:0; line-height:1.6; color:var(--cc-muted); font-size:var(--admin-font-caption); }
.chart-values { max-height:220px; overflow:auto; }
.point { display:grid; grid-template-columns:minmax(95px,1fr) minmax(45px,1fr) minmax(40px,auto); gap:var(--admin-space-2); align-items:center; margin:var(--admin-space-2) 0; }
.bar-track { height:10px; background:var(--el-fill-color-light); }.bar { height:100%; background:var(--el-color-primary); }
.operations-visualizations { display: grid; gap: var(--admin-space-3); min-width: 0; }
.operations-charts, .chart-details-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-6); }
.chart { min-width: 0; padding-right: var(--admin-space-6); border-right: 1px solid var(--cc-border); color: var(--cc-text); text-align: center; }
.chart:last-child { padding-right: 0; border-right: 0; }
.chart h3, .chart-details h3 { margin: 0 0 var(--admin-space-2); font-size: var(--admin-font-section); }
.chart-metric { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: var(--admin-space-1) var(--admin-space-5); margin-bottom: var(--admin-space-1); font-variant-numeric: tabular-nums; }
.chart-metric > strong { font-size: calc(var(--admin-font-title) * 1.25); overflow-wrap: anywhere; }
.chart-metric small { font-size: var(--admin-font-caption); font-weight: normal; color: var(--cc-muted); }
.chart-note, .chart-empty { margin: 0; font-size: var(--admin-font-caption); color: var(--cc-muted); line-height: 1.6; }
.chart-empty { display: grid; min-height: 136px; place-content: center; }
.series-selector { display: flex; justify-content: center; flex-wrap: wrap; gap: var(--admin-space-2); margin-top: var(--admin-space-2); }
.series-selector :deep(.el-button + .el-button) { margin-left: 0; }
.chart-visualization { position: relative; min-width: 0; }
.pie-chart { display: block; width: 100%; height: 190px; margin: var(--admin-space-2) auto 0; }
.chart-more { position: absolute; right: 0; bottom: var(--admin-space-1); height: auto; padding: 0; font-size: var(--admin-font-caption); }
.legend-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }
.chart-details-grid { margin-top: var(--admin-space-5); }
.chart-details-grid > section { min-width: 0; }
.chart-details table { width: 100%; table-layout: fixed; }
.chart-details caption { text-align: left; padding: var(--admin-space-2) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.chart-details td { overflow-wrap: anywhere; }
@media (max-width: 1100px) { .operations-charts, .chart-details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .chart:nth-child(2n) { padding-right: 0; border-right: 0; } }
@media (max-width: 720px) { .operations-charts, .chart-details-grid { grid-template-columns: minmax(0, 1fr); } .chart { padding-right: 0; padding-bottom: var(--admin-space-5); border-right: 0; border-bottom: 1px solid var(--cc-border); } .chart:last-child { padding-bottom: 0; border-bottom: 0; } }
</style>
@@ -0,0 +1,47 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises } from '@vue/test-utils'
import TripPickupPanel from './TripPickupPanel.vue'
import { commandPickupTask, commandPickupTrip, getPickupTasks, type PickupTask } from '../api/pickups'
import { getTrip } from '../api/trips'
import type { TripDetail } from '../tripTypes'
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
vi.mock('../api/trips', () => ({ getTrip: vi.fn() }))
vi.mock('../api/pickups', () => ({ getPickupTasks: vi.fn(), commandPickupTask: vi.fn(), commandPickupTrip: vi.fn(), uploadPickupEvidence: vi.fn(), downloadPickupEvidence: vi.fn(), pickupStatusLabels: {} }))
const row: PickupTask = { customerId: 'C1', transferNo: 'WT-1', status: 'PICKED', evidence: [], lastlegOrderNo: 'LL-1', warehouseId: 'W1', pickupAddress: 'TEST Pickup Ave', contactName: '验收联系人', contactPhone: '0000000000', packageCount: 3, tripNo: 'TR-1', vehiclePlate: 'TEST-V1', driverName: '验收司机', driverPhone: '0000000000', plannedDepartureAt: null, actualPackages: 3, pickedAt: null, arrivedAt: null, remark: null }
const detail = (status: string) => ({ trip: { tripNo: 'TR-1', status, recordVersion: 2, vehicleId: 'V1', driverId: 'D1' } }) as TripDetail
function mount(status: string) {
return shallowMount(TripPickupPanel, { props: { detail: detail(status) }, global: { stubs: {
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot v-for="row in data" name="actions" :row="row"/></div>' },
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
LoadErrorState: { name: 'LoadErrorState', emits: ['retry'], template: '<button @click="$emit(\'retry\')">重试读取</button>' },
} } })
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getPickupTasks).mockResolvedValue([row]) })
it('locks parent actions and retries the exact departure command after an unknown result', async () => {
vi.mocked(commandPickupTrip).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce(detail('IN_TRANSIT'))
const wrapper = mount('PLANNED'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '确认取货发车')!.trigger('click'); await flushPromises()
expect(wrapper.emitted('locked')?.at(-1)).toEqual([true])
const body = vi.mocked(commandPickupTrip).mock.calls[0]![2]
await wrapper.findAll('button').find(button => button.text() === '重试原操作')!.trigger('click'); await flushPromises()
expect(vi.mocked(commandPickupTrip).mock.calls[1]![2]).toBe(body)
expect(wrapper.emitted('locked')?.at(-1)).toEqual([false])
wrapper.unmount()
})
it('retries only reading after arrival committed but the trip refresh failed', async () => {
vi.mocked(commandPickupTask).mockResolvedValue([{ ...row, status: 'ARRIVED' }])
vi.mocked(getTrip).mockRejectedValueOnce(new Error('refresh lost')).mockResolvedValueOnce(detail('IN_TRANSIT'))
const wrapper = mount('IN_TRANSIT'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '确认送仓')!.trigger('click'); await flushPromises()
expect(commandPickupTask).toHaveBeenCalledTimes(1)
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
expect(commandPickupTask).toHaveBeenCalledTimes(1)
expect(getTrip).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
import { getTrip } from '../api/trips'
import { getPickupTasks, commandPickupTrip, commandPickupTask, uploadPickupEvidence, downloadPickupEvidence, pickupStatusLabels,
type PickupTask, type PickupCommand, type PickupConfirmation, type PickupEvidence } from '../api/pickups'
import { overseasMasterDataPermissions as permission } from '../permissions'
import type { TripDetail } from '../tripTypes'
const props = defineProps<{ detail: TripDetail }>(), emit = defineEmits<{ saved: [detail: TripDetail]; locked: [value: boolean] }>()
const access = useAccessPreviewStore(), rows = ref<PickupTask[]>([]), loading = ref(false), busy = ref(false), error = ref('')
const selected = ref<PickupTask | null>(null), open = ref(false), actual = ref(1), remark = ref(''), evidence = ref<PickupEvidence[]>([])
const needsTripRefresh = ref(false)
const pending = ref<{ action: 'depart' | 'complete' | 'picked' | 'arrive'; row: PickupTask | null; body: PickupCommand | PickupConfirmation } | null>(null)
// 命令结果未知时阻止父页面刷新和改派,保留原请求用于幂等重试。
watch(() => busy.value || !!pending.value, value => emit('locked', value), { flush: 'sync' })
let sequence = 0
const canComplete = computed(() => rows.value.length > 0 && rows.value.every(row => row.status === 'ARRIVED'))
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 175, fixed: 'left', slotName: 'actions' },
{ key: 'transferNo', prop: 'transferNo', label: '仓储转运单', minWidth: 220 },
{ key: 'status', label: '取货进度', slotName: 'status', width: 110 }, { key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 270 },
{ key: 'contactName', prop: 'contactName', label: '联系人', width: 110 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 145 },
{ key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }, { key: 'actualPackages', label: '实提箱数', slotName: 'actual', width: 100 },
{ key: 'evidence', label: '取货凭证', slotName: 'evidence', minWidth: 180 }, { key: 'remark', prop: 'remark', label: '取货备注', minWidth: 180 },
]
/** 车次变化时丢弃旧请求;同车版本变化保留未确定结果的命令。 */
async function load() {
const current = ++sequence; loading.value = true; error.value = ''
try {
if (needsTripRefresh.value) {
const detail = await getTrip(props.detail.trip.tripNo)
if (current !== sequence) return
emit('saved', detail); needsTripRefresh.value = false
}
const value = await getPickupTasks(props.detail.trip.tripNo); if (current === sequence) rows.value = value
}
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货任务加载失败').message }
finally { if (current === sequence) loading.value = false }
}
/** 逐票录入实提,默认箱数仅供编辑,需操作员确认。 */
function edit(row: PickupTask) {
if (pending.value) return
selected.value = row; actual.value = row.packageCount; remark.value = ''; evidence.value = []; open.value = true
}
/** 凭证上传与确认分开,重试确认不重新上传文件。 */
async function upload(event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]
if (!file || !selected.value || busy.value) return
busy.value = true; error.value = ''; const current = sequence
try { const value = await uploadPickupEvidence(props.detail.trip.tripNo, selected.value, file); if (current === sequence) evidence.value.push(value) }
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货凭证上传失败').message }
finally { if (current === sequence) busy.value = false; input.value = '' }
}
/** 失败结果未知时固定原请求,成功后只刷新车次版本和成员。 */
async function execute() {
if (!pending.value || busy.value) return
const request = pending.value, current = sequence, number = props.detail.trip.tripNo
busy.value = true; error.value = ''
try {
if (request.action === 'depart' || request.action === 'complete') {
const detail = await commandPickupTrip(number, request.action, request.body as PickupCommand)
if (current === sequence) { emit('saved', detail); pending.value = null }
} else if (request.row) {
const tasks = await commandPickupTask(number, request.row, request.action, request.body)
if (current !== sequence) return
// 业务命令已经成功,后续刷新失败只重试读取,不能再次生成执行命令。
rows.value = tasks; pending.value = null; open.value = false; needsTripRefresh.value = true
const detail = await getTrip(number); if (current === sequence) { emit('saved', detail); needsTripRefresh.value = false }
}
} catch (cause) {
if (current === sequence) { error.value = getApiErrorDetails(cause, '取货操作未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
} finally { if (current === sequence) busy.value = false }
}
/** 发车、送仓和结案提交前说明本次事实。 */
async function command(action: 'depart' | 'arrive' | 'complete', row: PickupTask | null = null) {
if (pending.value || busy.value || needsTripRefresh.value) return
const messages = { depart: '确认车辆和司机已出发执行本车取货任务?', arrive: '确认该单货物已送达选定海外仓?确认后仓库可开始 PDA 清点。', complete: '确认本车全部取货订单已送仓,结束本次取货运输?' }
try { await ElMessageBox.confirm(messages[action], '确认取货运输', { customClass: 'admin-density-confirm' }) } catch { return }
pending.value = { action, row, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), reason: messages[action] } }
await execute()
}
/** 照片和实提箱数为取货确认的必要信息。 */
async function confirmPickup() {
if (pending.value) { await execute(); return }
if (needsTripRefresh.value) { ElMessage.warning('请先刷新车次后继续'); return }
if (!selected.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length) { ElMessage.warning('请填写实提箱数并上传至少一份取货凭证'); return }
pending.value = { action: 'picked', row: selected.value, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: remark.value.trim() } }
await execute()
}
/** 只下载本票确认的文件,使用完即释放临时地址。 */
async function download(row: PickupTask, file: PickupEvidence) {
try { const blob = await downloadPickupEvidence(row, file.fileId); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败').message }
}
/** 原命令结果未知时保持弹框与请求快照。 */
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先确认原取货操作的结果'); return }; done() }
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先重试并确认原取货操作'); return false }; return true })
watch(() => props.detail.trip.tripNo, () => { pending.value = null; open.value = false; rows.value = []; void load() }, { immediate: true })
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<section aria-label="本车仓储转运取货任务">
<div class="density-actions pickup-actions">
<el-button v-if="detail.trip.status === 'PLANNED' && access.can(permission.tripAssign)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !detail.trip.vehicleId || !detail.trip.driverId" @click="command('depart')">确认取货发车</el-button>
<el-button v-if="detail.trip.status === 'IN_TRANSIT' && access.can(permission.tripComplete)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !canComplete" @click="command('complete')">取货运输结案</el-button>
<el-button :disabled="busy || !!pending" @click="load">刷新取货任务</el-button>
<el-button v-if="pending && !open" type="primary" :loading="busy" @click="execute">重试原操作</el-button>
</div>
<LoadErrorState v-if="error && !open" title="取货操作未完成" :message="error" @retry="pending ? execute() : load()" />
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="transferNo" empty-text="暂无取货成员">
<template #actions="{ row }"><div class="density-actions"><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'ASSIGNED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="edit(row as PickupTask)">确认取货</el-button><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'PICKED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="command('arrive', row as PickupTask)">确认送仓</el-button></div></template>
<template #status="{ row }">{{ pickupStatusLabels[row.status] || row.status }}</template><template #actual="{ row }">{{ row.actualPackages ?? '待取货' }}</template>
<template #evidence="{ row }"><div v-for="file in row.evidence" :key="file.fileId"><el-button link @click="download(row as PickupTask, file)">{{ file.fileName }}</el-button></div></template>
</DataTable>
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
<p>{{ selected?.transferNo }} · {{ selected?.pickupAddress }}</p>
<el-form label-position="top" :disabled="busy || !!pending">
<el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :max="5000" :precision="0" placeholder="请输入实提箱数" /></el-form-item>
<el-form-item label="取货凭证" required><div><input type="file" accept="image/png,image/jpeg,application/pdf" :disabled="busy || !!pending || evidence.length >= 10" @change="upload"><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></div></el-form-item>
<el-form-item label="取货备注"><el-input v-model="remark" type="textarea" maxlength="1000" placeholder="请输入取货备注(选填)" /></el-form-item>
</el-form><el-alert v-if="error" :title="error" type="error" :closable="false" />
</AppDialog>
</section>
</template>
<style scoped>.pickup-actions{margin-bottom:16px}</style>
@@ -26,7 +26,7 @@ it('loads same-warehouse trips on demand and retries one immutable atomic prepar
select.vm.$emit('focus'); await flushPromises()
expect(listTrips).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '9007199254740993', status: 'PLANNED' }))
select.vm.$emit('update:modelValue', trip.tripNo); await flushPromises()
vi.mocked(prepareTrip).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ trip, dispatches: [], driverQualification: null, loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null } } as TripDetail)
vi.mocked(prepareTrip).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ trip, dispatches: [], driverQualification: null, previousDispatches: [], appointmentHistoryUnavailableReason: null, loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null } } as TripDetail)
const dialog=wrapper.findComponent({ name: 'AppDialog' })
dialog.vm.$emit('confirm'); await flushPromises()
const original=vi.mocked(prepareTrip).mock.calls[0]![0]
@@ -8,3 +8,5 @@ export { routeLocation, type RouteLocation } from './routeLocations'
export { default as TripPreparationDialog } from './components/TripPreparationDialog.vue'
export { default as TripDispatchSelector } from './components/TripDispatchSelector.vue'
export { listTripDispatchCandidates, prepareTrip, type TripPreparation, type DispatchCandidate } from './api/trips'
export type { TripPreviousDispatch } from './tripTypes'
@@ -2,6 +2,8 @@ import { afterEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import FleetQuotesView from './FleetQuotesView.vue'
import TripOperationsView from './TripOperationsView.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
import * as quotes from '../api/fleetQuotes'
import * as reports from '../api/tripOperations'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p.endsWith(':list') }) }))
@@ -9,10 +11,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/fleetQuotes', () => ({ listFleetQuotes: vi.fn(), setFleetQuoteStatus: vi.fn(), deleteFleetQuote: vi.fn(), exportFleetQuotes: vi.fn(), saveFleetCsv: vi.fn() }))
vi.mock('../api/tripOperations', () => ({ listTripOperations: vi.fn(), exportTripOperations: vi.fn() }))
const wrappers: VueWrapper[] = []
const layout = { template: '<div><slot/></div>' }
const layout = { template: '<div><slot name="metrics"/><slot name="state"/><slot/></div>' }
function mount(component: object) { const wrapper = shallowMount(component, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, QueryToolbar: true,
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><slot name="toolbar-start"/><slot name="toolbar-actions"/><slot name="filters"/><slot name="notices"/></div>' }, ElButton: { template: '<button><slot/></button>' },
LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
ElTooltip: true, LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.clearAllMocks() })
it('quotes read-only view fetches a page once and hides all write/export tools', async () => {
vi.mocked(quotes.listFleetQuotes).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
@@ -23,5 +25,42 @@ it('quotes read-only view fetches a page once and hides all write/export tools',
it('operations uses server totals rather than displayed page size', async () => {
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 21, page: 1, size: 20, pages: 2 }, summary: { trips: 21, inTransit: 12, completed: 9, physicalPallets: 40, palletSpaces: 40, ownPackages: 70, externalPackages: 10, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
const wrapper = mount(TripOperationsView); await flushPromises()
expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('实际出车 21 次'); expect(wrapper.text()).not.toContain('导出CSV')
expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).not.toContain('导出CSV')
expect(wrapper.find('#operations-statistics-details').exists()).toBe(false)
expect(wrapper.findComponent(TripOperationsCharts).props('detailsVisible')).toBe(false)
await wrapper.find('[aria-controls="operations-statistics-details"]').trigger('click')
expect(wrapper.text()).toContain('实际出车 21 次')
expect(wrapper.findComponent(TripOperationsCharts).props('detailsVisible')).toBe(true)
expect(reports.listTripOperations).toHaveBeenCalledTimes(1)
await wrapper.find('[aria-controls="operations-statistics-details"]').trigger('click')
expect(wrapper.find('#operations-statistics-details').exists()).toBe(false)
wrapper.findComponent(TripOperationsCharts).vm.$emit('show-details'); await flushPromises()
expect(wrapper.find('#operations-statistics-details').exists()).toBe(true)
expect(reports.listTripOperations).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(TripOperationsCharts).props('summary').physicalPallets).toBe(40)
expect(wrapper.find('.operations-overview').element.compareDocumentPosition(wrapper.findComponent({ name: 'DataTable' }).element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns').find((column: { key: string }) => column.key === 'finance')).toMatchObject({ defaultVisible: false })
})
it('operations queries charts and records together and resets with only one request', async () => {
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 0, page: 1, size: 20, pages: 0 }, summary: { trips: 0, inTransit: 0, completed: 0, physicalPallets: 0, palletSpaces: 0, ownPackages: 0, externalPackages: 0, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
const wrapper = mount(TripOperationsView); await flushPromises()
const initial = vi.mocked(reports.listTripOperations).mock.calls[0]![0]
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { from: '2026-10-01', to: '2026-10-07', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' })
toolbar.vm.$emit('search'); await flushPromises()
expect(reports.listTripOperations).toHaveBeenLastCalledWith(expect.objectContaining({ from: '2026-10-01T00:00:00Z', to: '2026-10-08T00:00:00.000Z', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' }))
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
toolbar.vm.$emit('update:modelValue', { from: '2026-09-01', to: '2026-09-07' }); await flushPromises()
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
toolbar.vm.$emit('reset'); toolbar.vm.$emit('search'); await flushPromises()
expect(reports.listTripOperations).toHaveBeenCalledTimes(3)
expect(reports.listTripOperations).toHaveBeenLastCalledWith(initial)
expect(toolbar.props('modelValue')).toMatchObject({ keyword: '', sourceType: '', status: '' })
})
it('operations shows query failures without leaving stale statistics', async () => {
vi.mocked(reports.listTripOperations).mockRejectedValue(new Error('统计暂不可用'))
const wrapper = mount(TripOperationsView); await flushPromises()
expect(wrapper.findComponent(TripOperationsCharts).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toBe('运营记录加载失败')
expect(wrapper.text()).not.toContain('实际出车 0 次')
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { appointmentReturnTarget, TripAppointmentPanel } from '@/modules/appointment-calendar'
import { inquiryReturnTarget } from '../inquiryNavigation'
import TripDispatchSelector from '../components/TripDispatchSelector.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
@@ -15,6 +15,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import TripPlanDialog from '../components/TripPlanDialog.vue'
import TripPickupPanel from '../components/TripPickupPanel.vue'
import TripAssignmentPanel from '../components/TripAssignmentPanel.vue'
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
import TripBookingPanel from '../components/TripBookingPanel.vue'
@@ -32,12 +33,16 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
const activeTab = ref('overview'), editorOpen = ref(false), busy = ref(''), dispatchNo = ref('')
const offerSettingsOpen = ref(false)
const pickupLocked = ref(false)
const appointmentsOpen = ref(false)
const appointmentsPanel = ref<InstanceType<typeof TripAppointmentPanel> | null>(null)
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
const history = ref<TripHistory[]>([]), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new Date().toISOString()), endLocation = ref('')
const vehicleStatus = ref<ReleasedVehicleStatus>('IN_SERVICE')
const completionRequest = ref<TripComplete | null>(null), releaseRequest = ref<TripResourceRelease | null>(null)
let generation = 0, inspectionSequence = 0, historySequence = 0
const isPickup = computed(() => detail.value?.trip.tripPurpose === 'PICKUP')
const planned = computed(() => detail.value?.trip.status === 'PLANNED')
const editable = computed(() => planned.value && access.can(permission.tripEdit))
const time = (value: string | null | undefined) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
@@ -56,7 +61,7 @@ const outcomeLabels: Record<string, string> = { DELIVERED: '已签收', RETURNED
const historyColumns: DataTableColumn[] = [{ key: 'occurredAt', label: '操作时间', slotName: 'time', minWidth: 175 },
{ key: 'action', label: '操作', slotName: 'action', minWidth: 145 }, { key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
{ key: 'vehicle', prop: 'snapshot.trip.vehiclePlate', label: '当时车辆', minWidth: 145 }, { key: 'driver', prop: 'snapshot.trip.driverName', label: '当时司机', minWidth: 125 }]
const actionLabels: Record<string, string> = { CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
const actionLabels: Record<string, string> = { PICKUP_PLAN: '安排上门取货', PICKUP_DEPART: '取货发车', PICKUP_PICKED: '确认取货', PICKUP_ARRIVED: '确认送仓', PICKUP_COMPLETE: '取货运输结案', CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', HANDOVER: '在途交接', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
function accept(value: TripDetail) {
@@ -65,16 +70,19 @@ function accept(value: TripDetail) {
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
}
async function load() {
if (pickupLocked.value) { ElMessage.warning('请先重试并确认原取货操作'); return }
if (appointmentsPanel.value && !await appointmentsPanel.value.canLeave()) return
const current = ++generation; inspectionSequence++; historySequence++
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1
appointmentsOpen.value = false
completionRequest.value = null; releaseRequest.value = null; endLocation.value = ''
try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '车次详情加载失败') }
finally { if (current === generation) loading.value = false }
}
async function run(action: string, command: () => Promise<TripDetail>): Promise<boolean> {
if (busy.value) return false
if (busy.value || pickupLocked.value) return false
const current = generation; busy.value = action; operationError.value = null
try { const result = await command(); if (current !== generation) return false; accept(result); return true }
catch (cause) {
@@ -181,34 +189,39 @@ async function loadHistory() {
}
watch(() => route.params.tripNo, load, { immediate: true })
watch(() => route.query.tab, tab => { activeTab.value = tab === 'loading' ? 'loading' : 'overview' })
watch(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
watch(activeTab, tab => { if (tab === 'completion' && !isPickup.value) void inspect(); if (tab === 'history') void loadHistory() })
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
</script>
<template>
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy || pickupLocked" @click="load">刷新车次</el-button><el-button v-if="!isPickup && access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy || pickupLocked" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || pickupLocked || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
<div v-loading="loading"><template v-if="detail">
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [['起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [[isPickup ? '入仓仓库' : '起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
<p>{{ detail.trip.stops.map(stop => stop.name).join(' → ') }}</p><p v-if="detail.driverQualification">{{ driverQualificationLabels[detail.driverQualification.reason] }}<span v-if="detail.driverQualification.expiringSoon"> · 证照将在 30 天内到期,请留意任务延期</span></p>
</FormSection>
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
<template v-if="activeTab === 'overview'">
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
<div v-if="activeTab === 'overview' || isPickup" v-show="activeTab === 'overview'">
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel v-if="!pickupLocked" :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
<TripPickupPanel v-if="isPickup" :detail="detail" @saved="accept" @locked="pickupLocked = $event" />
<FormSection v-else class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
<p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
</FormSection>
</template>
</div>
<FormSection v-show="activeTab === 'overview'" v-if="!isPickup && access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
<el-button v-if="!appointmentsOpen" @click="appointmentsOpen = true">核对客户预约与后送去向</el-button>
<TripAppointmentPanel v-if="appointmentsOpen" ref="appointmentsPanel" :key="detail.trip.tripNo" :dispatches="detail.dispatches" :previous-dispatches="detail.previousDispatches" :history-unavailable-reason="detail.appointmentHistoryUnavailableReason" :can-write="planned && !busy" @updated="refreshLinked" @work="key => router.push({ path: '/appointment-calendar', query: { workItem: key } })" @external="path => router.push(path)" />
</FormSection>
<FormSection v-if="activeTab === 'bookings'" class="density-form-section" title="本车外接预订"><TripBookingPanel :key="detail.trip.tripNo" :trip="detail.trip" @changed="detail.trip.recordVersion = $event" @linked="refreshLinked" /></FormSection>
<FormSection v-if="activeTab === 'handovers'" class="density-form-section" title="在途车辆与司机交接"><TripHandoverPanel :key="detail.trip.tripNo" :detail="detail" @saved="refreshLinked" /></FormSection>
<FormSection v-if="activeTab === 'costs'" class="density-form-section" title="本趟直接成本"><TripCostPanel :key="detail.trip.tripNo" :trip-no="detail.trip.tripNo" /></FormSection>
<FormSection v-if="activeTab === 'capacity'" class="density-form-section" title="核对本车计划载荷"><TripLoadingPanel :key="`${detail.trip.tripNo}-capacity`" :trip="detail.trip" :editable="editable" planning @planned="capacityPlanned" /></FormSection>
<FormSection v-if="activeTab === 'loading'" class="density-form-section" title="按实物托盘确认装载"><TripLoadingPanel :key="detail.trip.tripNo" :trip="detail.trip" :editable="editable" @saved="loaded" /><div class="trip-review"><p>理货审核:{{ tripReviewLabels[detail.loadingReview.status] }}<span v-if="detail.loadingReview.reason"> · {{ detail.loadingReview.reason }}</span><span v-if="detail.loadingReview.reviewedAt"> · {{ time(detail.loadingReview.reviewedAt) }}</span></p><div v-if="planned && access.can(permission.tripLoadingReview)" class="density-actions"><el-button type="primary" :disabled="!!busy || detail.loadingReview.status === 'APPROVED'" @click="review('APPROVED')">理货审核通过</el-button><el-button type="danger" plain :disabled="!!busy" @click="review('REJECTED')">理货打回</el-button></div></div></FormSection>
<template v-if="activeTab === 'completion'">
<FormSection v-if="detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
<FormSection v-if="!isPickup && detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
<DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable>
</FormSection>
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
@@ -19,49 +19,82 @@ const submitExport = useExportTask()
const router = useRouter(), access = useAccessPreviewStore()
const result = ref<TripOperationsResult | null>(null), page = ref(1), size = ref(20), loading = ref(false), exporting = ref(false), error = ref<ApiErrorDetails | null>(null)
const recordOpen = ref(false), selected = ref<TripOperationsRow | null>(null)
const statisticsOpen = ref(false)
const today = new Date().toISOString().slice(0, 10), monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
const filters = ref<QueryModel>({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
const defaultFilters = () => ({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
const filters = ref<QueryModel>(defaultFilters())
const displayedRange = ref('')
let applied: TripOperationsQuery = range(), sequence = 0
const fields: QueryField[] = [
{ key: 'from', label: '实际发车起日(UTC)', type: 'date', width: 185 }, { key: 'to', label: '实际发车止日(UTC)', type: 'date', width: 185 },
{ key: 'keyword', label: '关键词', type: 'input', width: 235, placeholder: '车次 / 发车车牌 / 司机 / 目的仓' },
{ key: 'sourceType', label: '货源', type: 'select', width: 145, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
{ key: 'status', label: '状态', type: 'select', width: 130, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
{ key: 'from', label: '实际发车起日(UTC)', placeholder: '发车起日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车起日(UTC)' } },
{ key: 'to', label: '实际发车止日(UTC)', placeholder: '发车止日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车止日(UTC)' } },
{ key: 'keyword', label: '关键词', type: 'input', width: 270, placeholder: '车次 / 车牌 / 司机 / 目的仓', props: { 'aria-label': '车次、车牌、司机或目的仓' } },
{ key: 'sourceType', label: '货源', placeholder: '全部货源', type: 'select', width: 140, props: { 'aria-label': '货源' }, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
{ key: 'status', label: '状态', placeholder: '全部状态', type: 'select', width: 140, props: { 'aria-label': '状态' }, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 185, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'vehiclePlate', label: '发车车牌', minWidth: 125 }, { key: 'driverName', label: '发车司机', minWidth: 100 },
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'completedAt', label: '货物结案(UTC)', minWidth: 190 },
{ key: 'physicalPallets', label: '实物托盘', width: 100 }, { key: 'palletSpaces', label: '标准托位', width: 100 }, { key: 'ownPackages', label: '自有箱数', width: 100 }, { key: 'externalPackages', label: '外接箱数', width: 100 },
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' }, { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance' }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel' }, { key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance' }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost' }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated' }]
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100 },
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' },
{ key: 'completedAt', label: '货物结案(UTC)', minWidth: 190, defaultVisible: false },
{ key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
{ key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance', defaultVisible: false }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel', defaultVisible: false },
{ key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance', defaultVisible: false }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost', defaultVisible: false }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated', defaultVisible: false }]
function range(): TripOperationsQuery {
const from = String(filters.value.from || monthAgo), to = String(filters.value.to || today)
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('请输入有效日期 YYYY-MM-DD')
return { from: `${from}T00:00:00Z`, to: new Date(Date.parse(`${to}T00:00:00Z`) + 86400000).toISOString(), keyword: String(filters.value.keyword || '') || undefined, sourceType: (filters.value.sourceType || undefined) as TripOperationsQuery['sourceType'], status: String(filters.value.status || '') || undefined }
}
async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const data = await listTripOperations({ ...applied, page: page.value, size: size.value }); if (current === sequence) result.value = data } catch (e) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') } } finally { if (current === sequence) loading.value = false } }
async function load() {
const current = ++sequence, query = { ...applied, page: page.value, size: size.value }
loading.value = true; error.value = null
try {
const data = await listTripOperations(query)
if (current === sequence) {
result.value = data
displayedRange.value = `${query.from.slice(0, 10)} 至 ${new Date(Date.parse(query.to) - 86400000).toISOString().slice(0, 10)}(UTC)`
}
} catch (e) {
if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') }
} finally { if (current === sequence) loading.value = false }
}
function search() { try { applied = range(); if (page.value !== 1) page.value = 1; else void load() } catch (e) { error.value = getApiErrorDetails(e, '日期范围无效') } }
function resetFilters() { filters.value = defaultFilters() }
function openRecord(tripNo: unknown) { const row = result.value?.page.records.find(item => item.tripNo === tripNo); if (row) { selected.value = row; recordOpen.value = true } }
async function download() { exporting.value = true; try { await submitExport('TRIP_OPERATIONS_LIST', '出车记录', applied) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="出车记录">
<template #metrics>
<section class="operations-overview" aria-labelledby="operations-statistics-title" :aria-busy="loading">
<header class="operations-heading">
<h2 id="operations-statistics-title">出车数据统计</h2>
<div class="operations-heading-tools">
<span class="operations-note">{{ loading ? '正在更新…' : displayedRange }}</span>
<el-button v-if="result" link :aria-expanded="statisticsOpen" aria-controls="operations-statistics-details" @click="statisticsOpen = !statisticsOpen">{{ statisticsOpen ? '收起详情' : '统计详情' }}</el-button>
</div>
</header>
<template v-if="result">
<TripOperationsCharts :charts="result.charts" :summary="result.summary" :details-visible="statisticsOpen" @show-details="statisticsOpen = true" />
<section v-if="statisticsOpen" id="operations-statistics-details" class="operations-detail-body" aria-label="统计汇总与口径">
<div class="operations-summary" aria-live="polite">
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
</div>
<p v-if="result.summary.costs.visible" class="operations-note">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips ?? '—' }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips ?? '—' }} 趟。</p>
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。物理托盘去重;同车多站可分别计入目的地。外接约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
</section>
</template>
<p v-else class="operations-placeholder operations-note" role="status">{{ loading ? '正在加载出车统计…' : '暂无统计数据' }}</p>
</section>
</template>
<template #state>
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</template>
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-operations-table" v-model:page="page" v-model:page-size="size" :data="result?.page.records || []" :columns="columns" :total="result?.page.total || 0" :loading="loading" row-key="tripNo" empty-text="该范围暂无实际出车记录">
<template #toolbar-start><h1>出车记录</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="search" /></template>
<template #notices>
<div class="operations-overview">
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<TripOperationsCharts v-if="result" :charts="result.charts" />
<div v-if="result" class="operations-summary" aria-live="polite">
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
<span>实物托盘 <strong>{{ result.summary.physicalPallets }}</strong> 托</span><span>外接约定 {{ money(result.summary.externalAgreedAmounts) }}</span>
</div>
<p v-if="result?.summary.costs.visible" class="operations-note">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips }} 趟。</p>
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
</div>
</template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="resetFilters" /></template>
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export') && access.can('platform:download:list')" :loading="exporting" @click="download">导出</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="access.can('platform:fleet:trip:list')" class="density-action" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })">详情</el-button><el-button class="density-action" @click="openRecord(row.tripNo)">实测记录</el-button></div></template>
<template #status="{ row }">{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}</template><template #destinations="{ row }">{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}</template>
@@ -73,7 +106,11 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
<TripOperationsRecordDialog v-model="recordOpen" :row="selected" :readonly="!access.can('platform:fleet:operations:edit')" @saved="load" />
</ListPageLayout></AdminPage></template>
<style scoped>
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; }
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; padding: var(--admin-space-5); background: var(--admin-surface); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
.operations-heading, .operations-heading-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--admin-space-3) var(--admin-space-6); }
.operations-heading h2 { margin: 0; font-size: var(--admin-font-title); }
.operations-detail-body { display: grid; gap: var(--admin-space-3); border-top: 1px solid var(--cc-border); padding-top: var(--admin-space-3); }
.operations-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-6); font-variant-numeric: tabular-nums; }
.operations-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
.operations-placeholder { min-height: var(--admin-empty-height); display: grid; place-content: center; }
</style>
@@ -22,7 +22,7 @@ const trip: FleetTrip = { tripNo: 'TR-TEST', status: 'PLANNED', warehouseId: '92
plannedDepartureAt: '2026-10-03T00:00:00Z', expectedDriverEndAt: '2026-10-04T00:00:00Z', departedAt: null, completedAt: null,
stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, recordVersion: 3, updatedAt: '2026-10-02T00:00:00Z', vehicleRelease: null, driverRelease: null }
const reviewState = { status: 'PENDING' as const, reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
const detail: TripDetail = { trip, driverQualification: null, dispatches: [], loadingReview: reviewState }
const detail: TripDetail = { trip, driverQualification: null, previousDispatches: [], appointmentHistoryUnavailableReason: null, dispatches: [], loadingReview: reviewState }
const layout = { template: '<div><slot name="header"/><slot/></div>' }
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',$event.target.value)" />' }
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存计划</button></div>' }
@@ -53,6 +53,18 @@ it('returns from a trip to the exact appointment work context and preserves it w
const page = mount(TripDetailView); await flushPromises(); await button(page, '返回预约工作').trigger('click')
expect(state.push).toHaveBeenLastCalledWith(tripQuery.appointmentReturn)
})
it('loads customer appointment cards only on request and navigates using a resolved work key', async () => {
const previousDispatches = [{ dispatchNo: 'DS-REMOVED', customerId: 'A', recordVersion: 2, recordedAt: trip.updatedAt, appointmentBatches: [] }]
vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), previousDispatches, appointmentHistoryUnavailableReason: '历史证据不完整' })
const page = mount(TripDetailView); await flushPromises()
expect(page.findComponent({ name: 'TripAppointmentPanel' }).exists()).toBe(false)
await button(page, '核对客户预约与后送去向').trigger('click'); await flushPromises()
const panel = page.getComponent({ name: 'TripAppointmentPanel' })
expect(panel.props()).toMatchObject({ previousDispatches, historyUnavailableReason: '历史证据不完整' })
expect(panel.props('dispatches')).toEqual([])
panel.vm.$emit('work', 'real-child-work'); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ path: '/appointment-calendar', query: { workItem: 'real-child-work' } })
})
it('shows mixed signature and return quantities from one lazy completion read', async () => {
vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), trip: { ...trip, status: 'IN_TRANSIT' } })
@@ -129,7 +141,7 @@ it('counts a shared physical pallet once and submits only operator-entered measu
})
it('submits a specific dispatch review with optional approval notes and a reason when rejecting', async () => {
const pending: TripDetail = { ...structuredClone(detail), dispatches: [{ dispatchNo: 'DP-A', customerId: 'A', packageCount: 10, boundBy: '1', boundAt: trip.updatedAt, review: reviewState }] }
const pending: TripDetail = { ...structuredClone(detail), dispatches: [{ appointmentBatches: [], dispatchNo: 'DP-A', customerId: 'A', packageCount: 10, boundBy: '1', boundAt: trip.updatedAt, review: reviewState }] }
vi.mocked(api.getTrip).mockResolvedValue(pending)
vi.mocked(api.reviewTripDispatch).mockResolvedValue({ ...pending, trip: { ...trip, recordVersion: 4 } })
const wrapper = mount(TripDetailView); await flushPromises()
@@ -10,6 +10,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import TripPlanDialog from '../components/TripPlanDialog.vue'
import PickupDemandPanel from '../components/PickupDemandPanel.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listTrips, type TripQuery } from '../api/trips'
@@ -17,6 +18,8 @@ import { overseasMasterDataPermissions as permission } from '../permissions'
import { tripStatusLabels, type FleetTrip, type TripDetail } from '../tripTypes'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const workTab = ref('trips'), pickupVisited = ref(false)
watch(workTab, value => { if (value === 'pickups') pickupVisited.value = true })
const rows = ref<FleetTrip[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), editorOpen = ref(false)
const error = ref<ApiErrorDetails | null>(null), filters = ref<QueryModel>({ keyword: '', status: '' })
let applied: Omit<TripQuery, 'page' | 'size'> = {}, sequence = 0
@@ -24,7 +27,7 @@ const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: Object.entries(tripStatusLabels).map(([value, label]) => ({ value, label })) }]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 215 },
{ key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
{ key: 'purpose', label: '任务类型', slotName: 'purpose', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
{ key: 'vehiclePlate', label: '车牌', minWidth: 130 }, { key: 'driverName', label: '司机', minWidth: 110 },
{ key: 'departure', label: '计划发车', slotName: 'departure', minWidth: 175 }, { key: 'end', label: '司机预计结束', slotName: 'end', minWidth: 175 },
{ key: 'route', label: '路线', slotName: 'route', minWidth: 240 }, { key: 'remark', label: '备注', minWidth: 180 },
@@ -46,12 +49,15 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="车次管理">
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
<el-tabs v-model="workTab" class="density-element-tabs"><el-tab-pane label="车次管理" name="trips" /><el-tab-pane label="待取货" name="pickups" /></el-tabs>
<PickupDemandPanel v-if="pickupVisited" v-show="workTab === 'pickups'" />
<DataTable v-show="workTab === 'trips'" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>车次管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车次查询" preference-key="fleet-trips-query" :loading="loading" @search="search" @reset="search" /></template>
<template #notices><LoadErrorState v-if="error" title="车次加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" @click="editorOpen = true">新建车次</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" @click="view(row.tripNo)">详情</el-button></div></template>
<template #purpose="{ row }">{{ row.tripPurpose === 'PICKUP' ? '入仓取货' : '出库派送' }}</template>
<template #status="{ row }">{{ tripStatusLabels[row.status as keyof typeof tripStatusLabels] }}</template>
<template #departure="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #end="{ row }">{{ time(row.expectedDriverEndAt) }}</template>
<template #route="{ row }">{{ row.stops.map((stop: FleetTrip['stops'][number]) => stop.name).join(' → ') }}</template>
@@ -1,3 +1,4 @@
import type { DispatchBatchReference } from '@/modules/overseas-warehouse'
import type { TripBooking } from './bookingTypes'
import type { DriverQualification } from './driverTypes'
@@ -7,6 +8,7 @@ export interface TripStop { name: string; address?: string | null; destinationKe
export type ReleasedVehicleStatus = 'IN_SERVICE' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
export interface TripReleaseInfo { actualEndAt: string; endLocation: string; vehicleStatus: ReleasedVehicleStatus | null }
export interface FleetTrip {
tripPurpose?: 'DELIVERY' | 'PICKUP'
tripNo: string
status: TripStatus
warehouseId: string
@@ -34,8 +36,9 @@ export interface FleetTrip {
export interface TripReviewState { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewerId: string | null; reviewedAt: string | null; recordVersion: number | null }
export interface TripReviewCommand { recordVersion: number; decision: 'APPROVED' | 'REJECTED'; reason?: string | null }
export const tripReviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' } as const
export interface TripBinding { dispatchNo: string; customerId: string; packageCount: number; boundBy: string; boundAt: string; review: TripReviewState }
export interface TripDetail { trip: FleetTrip; driverQualification: DriverQualification | null; dispatches: TripBinding[]; loadingReview: TripReviewState }
export interface TripPreviousDispatch { dispatchNo: string; customerId: string; recordVersion: number; recordedAt: string; appointmentBatches: DispatchBatchReference[] }
export interface TripBinding { appointmentBatches: DispatchBatchReference[]; dispatchNo: string; customerId: string; packageCount: number; boundBy: string; boundAt: string; review: TripReviewState }
export interface TripDetail { previousDispatches: TripPreviousDispatch[]; appointmentHistoryUnavailableReason: string | null; trip: FleetTrip; driverQualification: DriverQualification | null; dispatches: TripBinding[]; loadingReview: TripReviewState }
export interface TripDraft { warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; stops: TripStop[]; remark?: string | null; idempotencyKey: string }
export interface TripPlan { recordVersion: number; plannedDepartureAt: string; expectedDriverEndAt: string; stops: TripStop[]; remark?: string | null; reason: string }
export interface TripAssignment { recordVersion: number; vehicleId: string; driverId: string; reason: string }
+1 -1
View File
@@ -38,7 +38,7 @@
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘;有 FBA 仓码的托盘与国内统一使用 Print `FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm,FBA 仓码、完整托盘号 Code 128、托盘号文字),其他托盘使用 `INBOUND_MARK_V1`。不在前端生成标签、改码或维护第二套模板。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成一张待出库单及按目的 FBA 仓拆分的派送单
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库经头程排车转出库;换标只有平台卡车进入预排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务;普通库存仍按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成一张待出库单及按目的 FBA 仓拆分的派送单
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
## 验证
@@ -45,13 +45,33 @@ describe('delivery order API', () => {
})
it('loads return options and marks a return in the explicit customer scope', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] })
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ lastMileOrderId: '109', lastMileOrderNo: 'LM-1', inboundOrderId: '9', inboundCode: 'IN-1', returnSources: [] }] })
await listDeliveryReturnInboundOptions('DEL/1', '1001')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } })
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' }
const payload = { lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' }
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } })
await markDeliveryReturn('DEL/1', '1001', payload)
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
})
it('preserves original return carton identity and submits it without inventing an inventory position', async () => {
const source = { outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-CARTON-1', maxReturnPackages: 1 }
const relation = { lastMileOrderId: '9007199254741299', lastMileOrderNo: 'LM-RETURN-1', inboundOrderId: null, inboundCode: null, customerId: '1001', returnSources: [{ ...source, maxReturnPackages: '1' }] }
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [relation] })
const options = await listDeliveryReturnInboundOptions('DEL/1', '1001')
expect(options[0]?.returnSources[0]).toEqual(source)
expect(options[0]).toMatchObject({ lastMileOrderId: relation.lastMileOrderId, inboundOrderId: null, inboundCode: null })
const payload = { lastMileOrderId: relation.lastMileOrderId, reason: '原箱拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: source.outboundPalletItemId, returnCartonId: source.returnCartonId, packageCount: 1 }], idempotencyKey: 'carton-return' }
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX-1' } })
await markDeliveryReturn('DEL/1', '1001', payload)
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
expect(vi.mocked(http.post).mock.calls[0]?.[1]).not.toHaveProperty('inboundOrderId')
})
it.each([undefined, null, '', '0', 'invalid'])('rejects a candidate with invalid master identity %s even if it has an inbound association', async lastMileOrderId => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ lastMileOrderId, inboundOrderId: '9', inboundCode: 'IN-1', returnSources: [] }] })
await expect(listDeliveryReturnInboundOptions('DEL/1', '1001')).rejects.toThrow('尾程主单标识')
expect(http.post).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeReturnSource, type ReturnSourceIdentity } from '../returnSource'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
import type { DeliveryDocumentSummary } from './deliveryDocuments'
@@ -70,26 +71,27 @@ export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: str
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[]; feeCoverage?: import('../feeCoverageTypes').FeeCoverageEvidence | null }
export interface DeliveryReturnSource {
export interface DeliveryReturnSource extends ReturnSourceIdentity {
outboundPalletItemId: string
inventoryPositionId: string
cartonMark?: string | null
palletNo?: string | null
skuCode?: string | null
maxReturnPackages: number
}
export interface DeliveryReturnRelation {
inboundOrderId: string
lastMileOrderId: string
inboundOrderId?: string | null
inboundCode?: string | null
lastMileOrderNo?: string | null
customerId: string
returnSources: DeliveryReturnSource[]
}
export interface MarkDeliveryReturnPayload {
inboundOrderId: string
lastMileOrderId: string
reason: string
returnDirection: 'OVERSEAS_WAREHOUSE' | 'CUSTOMER' | 'OTHER'
returnLogisticsNo?: string
items: Array<{ outboundPalletItemId: string; inventoryPositionId: string; packageCount: number }>
items: Array<ReturnSourceIdentity & { outboundPalletItemId: string; packageCount: number }>
idempotencyKey: string
}
@@ -120,7 +122,14 @@ export async function listDeliveryReturnInboundOptions(deliveryNo: string, custo
const response = await http.get<ApiEnvelope<DeliveryReturnRelation[]>, ApiEnvelope<DeliveryReturnRelation[]>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { params: { customerId } },
)
return unwrapApiResponse(response)
return unwrapApiResponse(response).map(relation => {
if (typeof relation.lastMileOrderId !== 'string' || !/^[1-9]\d*$/.test(relation.lastMileOrderId)) throw new Error('退回候选缺少有效尾程主单标识,请重新加载')
return { ...relation, returnSources: relation.returnSources.map(source => {
const quantity: unknown = source.maxReturnPackages
if ((typeof quantity !== 'number' && typeof quantity !== 'string') || !/^\d+$/.test(String(quantity)) || !Number.isSafeInteger(Number(quantity))) throw new Error('可退箱数无效,请重新加载')
return { ...normalizeReturnSource(source), maxReturnPackages: Number(quantity) }
}) }
})
}
export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: MarkDeliveryReturnPayload) {
@@ -14,6 +14,18 @@ export interface DispatchQuery {
export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null }
export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview }
export interface DispatchActions {
canEdit: boolean
editBlockReason: string | null
canCancel: boolean
cancelBlockReason: string | null
canRemovePallet: boolean
removePalletBlockReason: string | null
canTransfer: boolean
transferBlockReason: string | null
outboundNo: string | null
outboundStatus: string | null
}
export interface DispatchPalletCandidateDto {
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
@@ -97,7 +109,10 @@ export async function getBatchDispatchCandidates(batchKey: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<BatchDispatchCandidates>, ApiEnvelope<BatchDispatchCandidates>>(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(batchKey)}/dispatch-candidates`))
}
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }> }
export interface DispatchSummaryDto {
actions?: DispatchActions
appointmentBatches: DispatchBatchReference[]
workflow?: DispatchWorkflow | null
id: string
dispatchNo: string
@@ -5,6 +5,13 @@ import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, ge
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
describe('exception order API', () => {
it('normalizes native carton sources and rejects missing, ambiguous or unsafe source identities', () => {
const raw = { order: { version: '1' }, returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: '1' }], returnReceipts: [] } as unknown as ExceptionDetail
expect(normalizeExceptionDetail(raw).returnSources[0]).toMatchObject({ inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: 1 })
for (const identity of [{ inventoryPositionId: null, returnCartonId: null }, { inventoryPositionId: '20', returnCartonId: '21' }, { inventoryPositionId: 'null', returnCartonId: null }, { inventoryPositionId: null, returnCartonId: Number.MAX_SAFE_INTEGER + 2 }]) {
expect(() => normalizeExceptionDetail({ ...raw, returnSources: [{ ...raw.returnSources[0]!, ...identity }] } as ExceptionDetail)).toThrow()
}
})
it('preserves receipt and dispatch business numbers while normalizing all actual history quantities', () => {
const raw = { order: { version: '2' }, returnSources: [], returnReceipts: [{ receiptNo: 'RSR-9007199254740993', receivedPackages: '2', receivedAt: '2026-10-03T23:24:13',
redispatches: [{ dispatchNo: 'DS/1', status: 'DEPARTED', selectedPackages: '2', pickedPackages: '2', deliveries: [{ deliveryNo: 'DL/1', status: 'DELIVERED' }] }] }] } as unknown as ExceptionDetail
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeReturnSource, type ReturnSourceIdentity } from '../returnSource'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
@@ -13,12 +14,12 @@ export interface ExceptionOrderRow {
version?: number; allowedActions?: string[]; evidenceFileIds?: string[]; handlingResult?: string | null; completedAt?: string | null
}
export interface ExceptionReturnSource { outboundPalletItemId: string; inventoryPositionId: string; palletNo?: string | null; skuCode?: string | null; maxReturnPackages: number }
export interface ExceptionReturnSource extends ReturnSourceIdentity { outboundPalletItemId: string; palletNo?: string | null; skuCode?: string | null; cartonMark?: string | null; maxReturnPackages: number }
export interface ExceptionRelatedDelivery { deliveryNo: string; status: string }
export interface ExceptionRedispatch { dispatchNo: string; status: string; selectedPackages: number; pickedPackages: number; deliveries: ExceptionRelatedDelivery[] }
export interface ExceptionReturnReceipt { receiptNo: string; receivedPackages: number; receivedAt: string; redispatches: ExceptionRedispatch[] }
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[] }
export interface ExceptionReceipt { outboundPalletItemId: string; inventoryPositionId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
export interface ExceptionReceipt extends ReturnSourceIdentity { outboundPalletItemId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] }
export type ExceptionAction = 'start-processing' | 'complete' | 'cancel'
export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean }
@@ -29,7 +30,7 @@ function safeInteger(value: unknown, label: string) {
}
export function normalizeExceptionDetail(value: ExceptionDetail): ExceptionDetail {
return { ...value, order: { ...value.order, version: safeInteger(value.order.version, '异常版本'), evidenceFileIds: value.order.evidenceFileIds?.map(String) },
returnSources: value.returnSources.map(row => ({ ...row, outboundPalletItemId: String(row.outboundPalletItemId), inventoryPositionId: String(row.inventoryPositionId), maxReturnPackages: safeInteger(row.maxReturnPackages, '剩余可收数量') })),
returnSources: value.returnSources.map(row => ({ ...normalizeReturnSource(row), outboundPalletItemId: String(row.outboundPalletItemId), maxReturnPackages: safeInteger(row.maxReturnPackages, '剩余可收数量') })),
returnReceipts: value.returnReceipts.map(receipt => ({ ...receipt, receivedPackages: safeInteger(receipt.receivedPackages, '历史实收数量'),
redispatches: receipt.redispatches.map(dispatch => ({ ...dispatch, selectedPackages: safeInteger(dispatch.selectedPackages, '重派选定数量'),
pickedPackages: safeInteger(dispatch.pickedPackages, '重派实拣数量') })) })) }
@@ -70,6 +70,10 @@ export interface RelabelSummaryDto {
labelRequirement?: string | null
totalFileId?: string | null
totalFileName?: string | null
cartonTotalFileId?: string | null
cartonTotalFileName?: string | null
skuTotalFileId?: string | null
skuTotalFileName?: string | null
totalFileUrl?: string | null
chargeAmount?: number | string | null
chargeCurrency?: string | null
@@ -270,3 +274,9 @@ export async function downloadRelabelCompletionPhoto(relabelNo: string, fileId:
{ responseType: 'blob' },
)
}
/** 通过平台授权入口读取贴标资料或面单。 */
export async function downloadRelabelDocument(relabelNo: string, fileId: string, scope: string, itemId?: string) {
const base = '/platform/v1/last-leg/relabel-applications/' + encodeURIComponent(relabelNo)
return http.get<Blob, Blob>(scope === 'SHIPPING' ? base + '/shipping-label/' + encodeURIComponent(fileId) + '/download' : base + '/files/' + encodeURIComponent(fileId) + '/download', { params: scope === 'SHIPPING' ? undefined : { scope, itemId }, responseType: 'blob' })
}
@@ -0,0 +1,15 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface PickupProgress {
status: string; tripNo: string | null; vehiclePlate: string | null; driverName: string | null; driverPhone: string | null
plannedDepartureAt: string | null; actualPackages: number | null; pickedAt: string | null; arrivedAt: string | null; remark: string | null
evidence: { fileId: string; fileName: string }[]
}
/** 原单详情只查询本票取货进度。 */
export async function getTransferPickupProgress(transferNo: string, customerId: string): Promise<PickupProgress | null> {
return unwrapApiResponse(await http.get<ApiEnvelope<PickupProgress | null>, ApiEnvelope<PickupProgress | null>>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/pickup-progress`, { params: { customerId } }))
}
/** 下载当前票确认的取货凭证。 */
export async function downloadTransferPickupEvidence(transferNo: string, customerId: string, fileId: string): Promise<Blob> {
return http.get<Blob, Blob>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}`, { params: { customerId }, responseType: 'blob' })
}
@@ -41,6 +41,7 @@ export interface WarehouseTransferItemDto {
}
export interface WarehouseTransferCartonDto {
items?: Array<{ transferItemId: string; skuCode: string; quantity: number | string }>
transferItemId?: string | null
transferPalletId?: string | null
length?: number | string | null
@@ -226,3 +227,11 @@ export async function reviewWarehouseTransfer(
)
return normalizeTransferDetail(unwrapApiResponse(response))
}
/** 平台在指定客户范围内追加整单预报箱数。 */
export async function increaseWarehouseTransferForecast(customerId: string, transferNo: string, payload: { expectedPackageCount: number; packageCount: number }) {
const response = await http.post<ApiEnvelope<WarehouseTransferDetailDto>, ApiEnvelope<WarehouseTransferDetailDto>>(
'/platform/v1/last-leg/warehouse-transfers/' + encodeURIComponent(transferNo) + '/increase-forecast-cartons', payload, { params: { customerId } },
)
return unwrapApiResponse(response)
}
@@ -125,6 +125,22 @@ it('submits two actual batches using refreshed remaining sources and independent
expect(second[3].idempotencyKey).not.toBe(first[3].idempotencyKey); expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
wrapper.unmount()
})
it('receives the native original carton whole into a verified target without inventing inventory', async () => {
const carton = snapshot('EX-1', 1, 1)
carton.returnSources = [{ outboundPalletItemId: '10', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RETURN-CARTON-1', maxReturnPackages: 1 }]
vi.mocked(getExceptionDetail).mockResolvedValueOnce(carton).mockResolvedValue(snapshot('EX-1', 2, 0))
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 2, status: 'COMPLETED', restoredPackages: 1, replayed: false })
const wrapper = open(); await flushPromises()
expect(wrapper.text()).toContain('原退货箱 RETURN-CARTON-1(须整箱实收)')
expect(wrapper.text()).not.toContain('9007199254740997')
await fill(wrapper, 0.5); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).not.toHaveBeenCalled()
await fill(wrapper, 1); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 'complete', expect.objectContaining({ receipts: [{ outboundPalletItemId: '10', returnCartonId: '9007199254740997', receivedPackages: 1, targetPalletId: '30', targetPalletNo: 'TARGET', targetLocationId: '40', targetLocationCode: 'LOC' }] }))
expect(getOverseasLocation).toHaveBeenCalledWith('92', '40')
wrapper.unmount()
})
it('retains the exact command after unknown failure even if the visible form is changed', async () => {
vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
const wrapper = open(); await flushPromises(); await fill(wrapper)

Some files were not shown because too many files have changed in this diff Show More