修复订单货柜预约与排车维护界面
This commit is contained in:
@@ -63,7 +63,7 @@ watch(() => [props.active, JSON.stringify(props.sources)] as const, ([active]) =
|
||||
onBeforeUnmount(() => { ++generation; unregister?.() })
|
||||
</script>
|
||||
<template>
|
||||
<section v-show="active || editing" class="density-dialog-section" data-tone="source" aria-label="正式确认前的货物资料" :aria-busy="loading">
|
||||
<section v-show="active || editing" class="density-dialog-section" aria-label="正式确认前的货物资料" :aria-busy="loading">
|
||||
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
|
||||
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
|
||||
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
|
||||
@@ -80,6 +80,6 @@ onBeforeUnmount(() => { ++generation; unregister?.() })
|
||||
<CargoVerificationForm ref="editor" :source="editingRow.resolved.source" :disabled="disabled" confirmation @saved="saved" />
|
||||
<el-button :disabled="disabled" @click="closeEditor">收起补充资料</el-button>
|
||||
</div>
|
||||
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。暂时拿不到资料,可先选择“先保存安排,等待对方确认”。</p>
|
||||
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -30,6 +30,12 @@ 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()
|
||||
const fields = wrapper.findComponent({ name: 'FbaAppointmentFields' })
|
||||
if (fields.exists()) {
|
||||
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-25T09:30:00' })
|
||||
fields.vm.$emit('confirmationReady', true)
|
||||
}
|
||||
await flushPromises()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); guards.clear()
|
||||
|
||||
@@ -49,14 +49,16 @@ it('does not edit or cancel replaced snapshots and offers the stable batch conte
|
||||
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' })
|
||||
it('rebooks the original cargo using a new confirmed appointment without reallocating cargo', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...detail, version: 5 })
|
||||
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('')
|
||||
expect(model.appointmentStatus).toBe('SUCCESS'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
|
||||
Object.assign(model, { confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00' })
|
||||
wrapper.getComponent({ name: 'FbaAppointmentFields' }).vm.$emit('confirmationReady', true)
|
||||
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 }) }))
|
||||
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'SUCCESS', plannedDate: null, confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00', 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)
|
||||
|
||||
@@ -30,7 +30,7 @@ async function load(preserve = false) {
|
||||
try {
|
||||
detail.value = await getFbaAppointment(props.appointmentNo)
|
||||
if (!preserve || !initialized) {
|
||||
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'PENDING'
|
||||
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'SUCCESS'
|
||||
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,16 +24,23 @@ it('shows readable preview requirements without exposing missing-field machine c
|
||||
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('previews and creates the exact canonical external source and customer without inventing times', async () => {
|
||||
async function confirmRegistration(wrapper: ReturnType<typeof render>) {
|
||||
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
|
||||
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-16T09:30:00' })
|
||||
fields.vm.$emit('confirmationReady', true)
|
||||
await flushPromises()
|
||||
}
|
||||
it('previews and creates the exact canonical external source and customer with real confirmation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await confirmRegistration(wrapper)
|
||||
expect(previewPlanningBatch).toHaveBeenCalledWith(sources)
|
||||
await wrapper.get('input').setValue('2026-10-16'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentStatus: 'PENDING' }) }))
|
||||
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-CONFIRMED' }) }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).not.toContain('external-1'); wrapper.unmount()
|
||||
})
|
||||
it('retains the same immutable request after an unknown outcome and blocks leaving', async () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
const wrapper = render(); await flushPromises(); await confirmRegistration(wrapper); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
const request = vi.mocked(createSourceAppointment).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false)
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
@@ -42,6 +49,7 @@ it('retains the same immutable request after an unknown outcome and blocks leavi
|
||||
it('refreshes qualification after conflict while keeping user-entered dates', async () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new ApiResponseError('来源变化', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input').setValue('2026-10-16')
|
||||
await confirmRegistration(wrapper)
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledTimes(2)
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-16')
|
||||
|
||||
@@ -236,7 +236,7 @@ function mixedPlans(): api.ReceivingContext {
|
||||
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: '等待仓库' }) }]
|
||||
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING', plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
|
||||
return c
|
||||
}
|
||||
async function enterBPlan(wrapper: ReturnType<typeof render>) {
|
||||
@@ -373,7 +373,7 @@ it('inherits unconfirmed legacy header plans only without active independent cov
|
||||
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()) }]
|
||||
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING' }) }]
|
||||
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('')
|
||||
|
||||
@@ -143,7 +143,7 @@ async function openPlan(id: string) {
|
||||
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()
|
||||
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : { ...createAppointmentForm(), appointmentStatus: 'PENDING' as const }
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -3,27 +3,39 @@ 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 }
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true, plannedDate: '2026-11-01', isDefault: 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)
|
||||
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, planCount: 1, isDefault: false, 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: '' })
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '', title: 'D0001 计划到仓' })
|
||||
})
|
||||
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: '人工维护' })
|
||||
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedDate: '2026-11-04' }], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
|
||||
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1, isDefault: false }], 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('shows a date-only frozen plan as frozen without manufacturing midnight', () => {
|
||||
const events = projectContainerSchedule(sources, [{ ...warehouse, planCount: 1, isDefault: false, canEdit: false }], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ date: '2026-11-01', time: '', subtitle: '已有入库计划,日期已冻结', canEdit: false })
|
||||
expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '2026-11-01' })
|
||||
})
|
||||
it('keeps cross-month frozen date-only cards and their details readable without a port-arrival row', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedDate: '2026-11-08', planCount: 1, isDefault: false, canEdit: false }], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ date: '2026-11-08', time: '', title: 'D0001 计划到仓', subtitle: '已有入库计划,日期已冻结' })
|
||||
expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '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')
|
||||
@@ -31,7 +43,7 @@ describe('four-type schedule projection', () => {
|
||||
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)
|
||||
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, cabinetNo: null, arrivalAt: null }] }, [{ ...warehouse, plannedDate: null }], 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(['— 到港', '— 计划到仓'])
|
||||
|
||||
@@ -22,8 +22,9 @@ 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]) || [])
|
||||
const containerMap = new Map(sources.containers.map(row => [row.containerBookingId, row]))
|
||||
for (const row of sources.containers) {
|
||||
const label = row.containerNo || row.cabinetNo || '—'
|
||||
const label = 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
|
||||
@@ -36,31 +37,32 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
|
||||
if (warehouses === null) continue
|
||||
const warehouse = warehouseMap.get(row.containerBookingId)
|
||||
warehouseMap.delete(row.containerBookingId)
|
||||
const projected = projectWarehouseArrival(row.arrivalAt, warehouse)
|
||||
const projected = projectWarehouseArrival(warehouse)
|
||||
const planned = projected.date
|
||||
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
|
||||
time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse,
|
||||
canEdit: warehouse?.canEdit === true,
|
||||
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : projected.isDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
||||
subtitle: !planned ? '柜内计划时间不一致,待核实' : warehouse && warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : projected.isDefault ? '到港日期+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
|
||||
const projected = projectWarehouseArrival(row)
|
||||
const date = projected.date
|
||||
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,
|
||||
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: projected.time,
|
||||
title: `${row.cabinetNo || '—'} 计划到仓`, 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) }] })
|
||||
{ label: '计划到仓', value: row.plannedAt ? timestamp(row.plannedAt) : date }, { 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 ? '已还柜' : '还柜'}`,
|
||||
title: `${containerMap.get(row.containerBookingId)?.cabinetNo || '—'} ${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 },
|
||||
|
||||
@@ -35,7 +35,7 @@ async function readSchedule(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
describe('warehouse calendar response to schedule', () => {
|
||||
it.each(['0', 0])('uses zero plan count %j to show arrival plus two days without inventing a time', async planCount => {
|
||||
const { warehouses, event } = await readSchedule({ planCount })
|
||||
const { warehouses, event } = await readSchedule({ planCount, plannedDate: '2026-10-22', isDefault: true })
|
||||
expect(warehouses[0]).toMatchObject({ sourceContainerBookingId: bookingId, planCount: 0, plannedAt: null, actualAt: null })
|
||||
expect(event).toMatchObject({ date: '2026-10-22', time: '', canEdit: true, subtitle: '到港日期+2个自然日(默认)' })
|
||||
expect(event?.fields).toContainEqual({ label: '计划到仓', value: '2026-10-22' })
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface CalendarReturn {
|
||||
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
|
||||
export interface WarehouseArrival {
|
||||
sourceContainerBookingId: string
|
||||
cabinetNo?: string | null
|
||||
containerNo: string | null
|
||||
warehouseName: string | null
|
||||
warehouseCode: string | null
|
||||
@@ -29,6 +30,8 @@ export interface WarehouseArrival {
|
||||
version: number
|
||||
planCount: number
|
||||
canEdit: boolean
|
||||
plannedDate?: string | null
|
||||
isDefault?: boolean
|
||||
}
|
||||
export interface ScheduleRange { from: string; to: string }
|
||||
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface ContainerArrivalTime {
|
||||
actualWarehouseArrivalAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
plannedDate?: string | null
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
|
||||
|
||||
@@ -164,8 +164,7 @@ describe('ContainerCustomsPanel', () => {
|
||||
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
|
||||
const wrapper = await render('READY_TO_START')
|
||||
expect(wrapper.text()).toContain('开始报关')
|
||||
expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
|
||||
expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
|
||||
expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||
expect(wrapper.text()).toContain('客户退税资料')
|
||||
expect(wrapper.text()).toContain('报关行回执')
|
||||
|
||||
@@ -67,7 +67,6 @@ function clearanceResultLabel(value: ClearanceResultType) {
|
||||
}
|
||||
|
||||
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
|
||||
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
|
||||
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
|
||||
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
|
||||
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
|
||||
@@ -315,12 +314,9 @@ onMounted(load)
|
||||
<template v-else-if="customs">
|
||||
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
|
||||
<template v-else>
|
||||
<dl class="density-fields customs-summary" aria-label="报关概况">
|
||||
<div><dt>实际箱数</dt><dd>{{ customs.actualBoxCount }}</dd></div><div><dt>退税总票</dt><dd>{{ customs.taxRefundCount }}</dd></div><div><dt>已覆盖</dt><dd>{{ customs.coveredTaxRefundCount }}</dd></div><div><dt>缺失</dt><dd :class="{ 'coverage-missing': missingCoverageCount }">{{ missingCoverageCount }}</dd></div>
|
||||
<template v-if="brokerSubmission">
|
||||
<dl v-if="brokerSubmission" class="density-fields customs-summary" aria-label="报关概况">
|
||||
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div><div><dt>资料版本</dt><dd>v{{ brokerSubmission.sourceVersion }}</dd></div>
|
||||
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
|
||||
</template>
|
||||
</dl>
|
||||
<div v-if="missingInbounds.length" class="compact-table-wrap">
|
||||
<h3>缺退税资料清单</h3>
|
||||
@@ -370,5 +366,5 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--workbench-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--workbench-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
|
||||
</style>
|
||||
|
||||
@@ -186,9 +186,9 @@ onMounted(load)
|
||||
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="containerDialogTitle(editing ? '调整尾程费用' : '新增尾程费用', props)" width="min(920px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form class="density-form" label-position="left">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table fee-editor-table" aria-label="尾程费用填写"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td></tr>
|
||||
|
||||
@@ -153,7 +153,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
|
||||
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
|
||||
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
|
||||
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div><div><dt>任务明细</dt><dd>{{ overview.pda.itemCount }}</dd></div><div><dt>已处理</dt><dd>{{ overview.pda.completedItemCount }}</dd></div><div><dt>未解决</dt><dd>{{ overview.pda.unresolvedItemCount }}</dd></div><div><dt>有效照片</dt><dd>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</dd></div></dl><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div></dl><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -170,9 +170,9 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
.overview-section p { margin: 5px 0; color: var(--workbench-muted); font-size: var(--admin-font-body); }
|
||||
.exception-management{padding-top:var(--admin-space-4);padding-bottom:var(--admin-space-4)}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{max-width:360px;white-space: normal;overflow-wrap: anywhere}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:var(--admin-font-caption)}.exception-management__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.exception-history{overflow-x:auto;border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:var(--admin-space-3);align-items:center;min-width:680px;padding:var(--admin-space-2) var(--admin-space-1)}.exception-history__head{color:var(--workbench-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--workbench-border)}.exception-history li time,.exception-history li span{color:var(--workbench-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)!important}
|
||||
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--workbench-border)}.document-table{width:100%;min-width:940px;table-layout:fixed}.document-table tbody tr:last-child td{border-bottom:0}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--workbench-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:var(--admin-font-body)}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--workbench-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--workbench-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
|
||||
.overview-panel .pda-summary { grid-template-columns: minmax(280px, 2fr) repeat(4, minmax(0, 1fr)); margin-bottom: var(--admin-space-4); }
|
||||
.overview-panel .pda-summary { grid-template-columns: minmax(0, 1fr); margin-bottom: var(--admin-space-4); }
|
||||
.pda-progress dd { width: 100%; }
|
||||
.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,220px));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--workbench-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;height:112px;object-fit:contain;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:grid;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--workbench-muted);text-overflow:ellipsis;white-space:nowrap}
|
||||
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr 1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
||||
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
||||
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
|
||||
</style>
|
||||
|
||||
@@ -101,9 +101,9 @@ defineExpose({ candidates, candidateKey, containerNo, save })
|
||||
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="modelValue" :title="containerDialogTitle('关联已放舱订舱', container)" fullscreenable width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form class="density-form" label-position="left" label-width="160px">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable" aria-label="关联订舱资料"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table" aria-label="关联订舱资料"><tbody>
|
||||
<tr><td><el-form-item label="柜次号"><span>{{ container?.cabinetNo || '—' }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item></td></tr>
|
||||
|
||||
@@ -93,12 +93,10 @@ async function rememberWorkbenchTab(tabName: string | number) {
|
||||
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
||||
const arrivalTime = ref<ContainerArrivalTime | null>(null)
|
||||
const expectedArrivalText = computed(() => {
|
||||
const row = workbenchDetail.value
|
||||
const arrival = arrivalTime.value
|
||||
const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
|
||||
: projected.date ? `${projected.date}(默认推算)` : '—'
|
||||
: projected.date ? `${projected.date}${projected.isDefault ? '(默认推算)' : ''}` : '—'
|
||||
})
|
||||
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
|
||||
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
|
||||
@@ -322,9 +320,6 @@ const overseasStageText = computed(() => {
|
||||
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
|
||||
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
|
||||
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
|
||||
const loadingListDescription = computed(() => loadingListMode.value === 'confirmed'
|
||||
? '核对作业证据、报关资料与装柜货物。'
|
||||
: '核对预装货物与 PDA 作业进度。')
|
||||
const overseasDestinationText = computed(() => {
|
||||
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
|
||||
.filter(Boolean).join(' / ')
|
||||
@@ -739,7 +734,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
|
||||
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2><p>{{ loadingListDescription }}</p></div></header>
|
||||
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></div></header>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
|
||||
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
|
||||
@@ -757,24 +752,24 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
|
||||
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
|
||||
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
||||
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><div class="workbench-evidence"><table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table></div></section>
|
||||
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
|
||||
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><div class="workbench-evidence"><table class="density-table density-table--editable container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><template #label><span>实际卸货当地时间<InlineHelp title="直送卸货登记规则"><p>按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p></InlineHelp></span></template><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></div></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
|
||||
<section v-if="directSelected" class="density-dialog-section"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><template #label><span>实际卸货当地时间<InlineHelp title="直送卸货登记规则"><p>按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p></InlineHelp></span></template><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></div></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
|
||||
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
|
||||
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
|
||||
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
||||
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
|
||||
<section class="density-dialog-section" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table density-table--editable container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
<section class="density-dialog-section"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
|
||||
|
||||
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||||
@@ -784,6 +779,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-detail-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
|
||||
.container-workflow-table { table-layout: fixed; }
|
||||
.direct-remark { white-space: pre-wrap; }
|
||||
.loading-list-heading { margin-bottom: var(--admin-space-5); }
|
||||
|
||||
@@ -589,7 +589,7 @@ describe('ContainersView', () => {
|
||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row,
|
||||
estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' },
|
||||
})) })
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }])
|
||||
const wrapper = await mountOverseasRoute()
|
||||
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')!
|
||||
expect(picker).toBeDefined()
|
||||
|
||||
@@ -286,12 +286,11 @@ function optional(value: string | null) { return value?.trim() || null }
|
||||
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
||||
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
|
||||
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||
function defaultArrivalDate(source: object) {
|
||||
function warehouseArrivalProjection(source: object) {
|
||||
const row = source as ContainerWorkbenchRow
|
||||
const arrival = arrivalTimes.value[row.id]
|
||||
const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return projected.isDefault ? projected.date : null
|
||||
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return projected
|
||||
}
|
||||
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
|
||||
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
|
||||
@@ -764,11 +763,11 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template>
|
||||
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
|
||||
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
|
||||
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="defaultArrivalDate(row as ContainerWorkbenchRow) ? '到港日期+2个自然日(默认推算);可选择日期时间人工调整' : undefined"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" :placeholder="defaultArrivalDate(row as ContainerWorkbenchRow) ? `${defaultArrivalDate(row as ContainerWorkbenchRow)}(默认)` : '选择预计到仓'" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalTimes[row.id].planCount > 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
|
||||
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="warehouseArrivalProjection(row as ContainerWorkbenchRow).isDefault ? '到港日期+2个自然日(默认推算);可选择日期时间人工调整' : undefined"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" :placeholder="warehouseArrivalProjection(row as ContainerWorkbenchRow).date ? `${warehouseArrivalProjection(row as ContainerWorkbenchRow).date}${warehouseArrivalProjection(row as ContainerWorkbenchRow).isDefault ? '(默认)' : ''}` : '选择预计到仓'" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalTimes[row.id].planCount > 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
|
||||
<template #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
|
||||
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
||||
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
|
||||
|
||||
@@ -615,7 +615,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
||||
expect(document.body.textContent).not.toContain('不能部分输入、单独分配或单独移除')
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
|
||||
const contextRows = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr')
|
||||
|
||||
@@ -852,7 +852,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form class="density-form">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
|
||||
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
|
||||
@@ -867,13 +867,11 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</section>
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<section class="density-dialog-section" data-tone="source"><div class="workbench-evidence"><table class="density-table" aria-label="全局装柜同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></div></section>
|
||||
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :closable="false" />
|
||||
</template>
|
||||
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
||||
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
|
||||
</template>
|
||||
<p v-else class="density-dialog-note">本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</p>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -882,6 +880,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.smart-loading-assignment-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
|
||||
.assignment-context-table { table-layout: fixed; }
|
||||
.assignment-context-table th { width: 120px; }
|
||||
.assignment-required { margin-right: var(--admin-space-1); color: var(--el-color-danger); }
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.text()).toContain('DP-260908-0001')
|
||||
await wrapper.find('.el-table__body-wrapper button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不可拆分单元整托加入')
|
||||
expect(document.body.textContent).not.toContain('不可拆分单元整托加入')
|
||||
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
|
||||
|
||||
@@ -180,14 +180,6 @@ function startQuantityEdit(source: object) {
|
||||
quantityDrafts[item.id] = item.packageCount
|
||||
editingQuantityId.value = item.id
|
||||
}
|
||||
function explainCandidate(source: object) {
|
||||
const candidate = candidateRow(source)
|
||||
const volume = Number(candidate.averageUnitVolumeCbm || 0)
|
||||
const weight = Number(candidate.unitGrossWeightKg || 0)
|
||||
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
|
||||
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
|
||||
return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
|
||||
}
|
||||
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
|
||||
if (!value) return '待生成'
|
||||
const cabinetNo = String(value.cabinetNo || '').trim()
|
||||
@@ -419,7 +411,7 @@ onMounted(load)
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(780px, calc(100vw - 28px))" :fullscreenable="Boolean(selectedCandidate?.coLoadGroupId || selectedCandidate?.unitType === 'SEALED_PALLET')" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign">
|
||||
<div v-if="selectedCandidate" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form class="density-form">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-table" aria-label="加入当前柜次"><tbody>
|
||||
<tr><th>来源编号</th><td>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</td><th>当前柜次</th><td>{{ container?.cabinetNo || displayContainerNo(container) }}</td></tr>
|
||||
@@ -435,15 +427,10 @@ onMounted(load)
|
||||
</section>
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<section class="density-dialog-section" data-tone="source"><div class="workbench-evidence"><table class="density-table" aria-label="同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></div></section>
|
||||
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :closable="false" />
|
||||
</template>
|
||||
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
||||
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" />
|
||||
<p class="density-dialog-note">只记录数量,具体上柜箱号未核验。</p>
|
||||
</template>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -7,18 +7,22 @@ describe('shared warehouse arrival projection', () => {
|
||||
['2026-10-30T23:30:00-07:00', '2026-11-01'],
|
||||
['2026-12-31T23:30:00', '2027-01-02'],
|
||||
['2026-03-07T23:30:00-08:00', '2026-03-09'],
|
||||
])('adds two local calendar days to %s without manufacturing a time', (arrivalAt, date) => {
|
||||
expect(projectWarehouseArrival(arrivalAt, { plannedAt: null, version: 0, planCount: 0 })).toEqual({ date, time: '', isDefault: true })
|
||||
])('displays the authoritative local date for %s without manufacturing a time', (arrivalAt, date) => {
|
||||
expect(projectWarehouseArrival({ plannedAt: null, version: 0, planCount: 0, plannedDate: date, isDefault: true })).toEqual({ date, time: '', isDefault: true })
|
||||
})
|
||||
it('keeps manually maintained and frozen dates instead of following port arrival', () => {
|
||||
for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) {
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
|
||||
expect(projectWarehouseArrival(plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
|
||||
}
|
||||
})
|
||||
it('does not replace inconsistent or frozen missing dates with defaults', () => {
|
||||
for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) {
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull()
|
||||
expect(projectWarehouseArrival(plan).date).toBeNull()
|
||||
}
|
||||
for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).date).toBeNull()
|
||||
expect(projectWarehouseArrival(undefined).date).toBeNull()
|
||||
})
|
||||
it('keeps the frozen server date without recalculating after a port date changes', () => {
|
||||
expect(projectWarehouseArrival({ plannedAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02', isDefault: false }))
|
||||
.toEqual({ date: '2026-11-02', time: '', isDefault: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,19 +2,12 @@ export interface WarehouseArrivalPlan {
|
||||
plannedAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
plannedDate?: string | null
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */
|
||||
export function projectWarehouseArrival(arrivalAt: string | null | undefined, plan: WarehouseArrivalPlan | undefined) {
|
||||
const canDefault = !plan || plan.version === 0 && plan.planCount === 0
|
||||
let date = plan?.plannedAt?.slice(0, 10) || null
|
||||
if (!date && canDefault && arrivalAt) {
|
||||
const day = arrivalAt.slice(0, 10)
|
||||
const calendarDate = new Date(`${day}T12:00:00Z`)
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !Number.isNaN(calendarDate.getTime()) && calendarDate.toISOString().slice(0, 10) === day) {
|
||||
calendarDate.setUTCDate(calendarDate.getUTCDate() + 2)
|
||||
date = calendarDate.toISOString().slice(0, 10)
|
||||
}
|
||||
}
|
||||
return { date, time: plan?.plannedAt?.slice(11, 16) || '', isDefault: !plan?.plannedAt && canDefault }
|
||||
/** 仅展示服务端统一解析的日期,不在清关列表或日历重复推算。 */
|
||||
export function projectWarehouseArrival(plan: WarehouseArrivalPlan | undefined) {
|
||||
const date = plan && plan.version >= 0 ? plan.plannedAt?.slice(0, 10) || plan.plannedDate || null : null
|
||||
return { date, time: date ? plan?.plannedAt?.slice(11, 16) || '' : '', isDefault: !!date && plan?.isDefault === true }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ describe('finance status presentation', () => {
|
||||
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
|
||||
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
|
||||
expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('审核通过')
|
||||
expect(financeOperationActionLabel('FEE_SOURCE_PUBLISHED')).toBe('费用同步')
|
||||
expect(financeOperationActionLabel('FEE_SOURCE_VERSION_ADJUST')).toBe('费用调整同步')
|
||||
expect(financeOperationActionLabel('RECONCILIATION_ADJUST')).toBe('调整对账单')
|
||||
expect(financeOperationActionLabel('SETTLEMENT_PAYMENT_CREATE')).toBe('登记收付款')
|
||||
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
|
||||
|
||||
@@ -82,6 +82,7 @@ const financeOperationActionLabels: Record<string, string> = {
|
||||
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', UPDATE: '更新',
|
||||
FEE_SOURCE_EDIT: '编辑费用', FEE_SOURCE_SUBMIT: '提交审核', FEE_SOURCE_REVIEW_APPROVE: '审核通过',
|
||||
FEE_SOURCE_REVIEW_REJECT: '审核驳回', FEE_SOURCE_ADJUST: '调整费用', FEE_SOURCE_VOID: '作废费用',
|
||||
FEE_SOURCE_PUBLISHED: '费用同步', FEE_SOURCE_VERSION_ADJUST: '费用调整同步',
|
||||
RECONCILIATION_CREATE: '生成对账单', RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_ADJUST: '调整对账单',
|
||||
BILL_CREATE: '创建账单', BILL_ISSUE: '出账', BILL_ADJUST: '调整账单', BILL_VOID: '作废账单',
|
||||
SETTLEMENT_AR_CREATE: '生成应收结算单', SETTLEMENT_AP_CREATE: '生成应付结算单', SETTLEMENT_PAYMENT_CREATE: '登记收付款',
|
||||
|
||||
@@ -187,15 +187,29 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(wrapper.find('.measurement-table').text()).toContain('FBA18SCAN5678')
|
||||
expect(wrapper.find('.measurement-table').text()).toContain('0.6')
|
||||
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
|
||||
await wrapper.find('.el-table__expand-icon').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('箱规周长')
|
||||
expect(wrapper.text()).toContain('190 cm')
|
||||
expect(wrapper.text()).toContain('MJ20210327')
|
||||
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
|
||||
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
|
||||
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps server over-limit warnings visible without declaration deviations or an expandable row', async () => {
|
||||
const detail = measurement()
|
||||
detail.items[0]!.overLimitWarning = '箱规超过渠道限制,请复核'
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(detail)
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(wrapper.find('.measurement-deviation-alert').exists()).toBe(false)
|
||||
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
|
||||
expect(wrapper.get('.measurement-table .measurement-limit-warning').text()).toBe('箱规超过渠道限制,请复核')
|
||||
expect(wrapper.get('.measurement-table .measurement-limit-warning').attributes('role')).toBe('alert')
|
||||
|
||||
detail.items[0]!.overLimitWarning = null
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(detail)
|
||||
await wrapper.setProps({ inboundId: '7002' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.measurement-limit-warning').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.measurement-table th').some(cell => cell.text() === '超限提醒')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the list customer name when a legacy measurement detail has no customer snapshot', async () => {
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ companyName: '' }))
|
||||
const wrapper = await mountPanel(undefined, { customerName: '柏悦物流' })
|
||||
@@ -407,20 +421,14 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
||||
})
|
||||
|
||||
it('shows the complete server-calculated measurement facts in the expanded row', async () => {
|
||||
it('retains server-calculated facts in the draft without the removed expandable row', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.get('.el-table__expand-icon').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('箱规')
|
||||
expect(text).toContain('50×40×30cm')
|
||||
expect(text).toContain('计算方式')
|
||||
expect(text).toContain('实测原尺寸')
|
||||
expect(text).toContain('计算后外箱')
|
||||
expect(text).toContain('50 × 40 × 30 cm')
|
||||
expect(text).toContain('计算规则版本')
|
||||
expect(text).toContain('DIMENSION_ACTUAL_V1')
|
||||
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
|
||||
expect(wrapper.vm.form.items[0]).toMatchObject({
|
||||
boxSpecification: '50×40×30cm', coefficientMode: 'ACTUAL',
|
||||
calculatedOuterLengthCm: 50, calculatedOuterWidthCm: 40, calculatedOuterHeightCm: 30,
|
||||
calculationRuleVersion: 'DIMENSION_ACTUAL_V1',
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses the established first-leg detail surface and status patterns', async () => {
|
||||
@@ -611,11 +619,9 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps the formal deviceMeasuredAt response field into the read-only measurement time', async () => {
|
||||
it('retains the formal deviceMeasuredAt response field in the measurement draft', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.get('.el-table__expand-icon').trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
|
||||
expect(wrapper.vm.form.items[0].measuredAt).toBe('2026-08-04T10:30:00+08:00')
|
||||
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
|
||||
expect(source).toContain('measuredAt: item.deviceMeasuredAt')
|
||||
expect(source).not.toContain('measuredAt: item.measuredAt')
|
||||
|
||||
@@ -614,10 +614,6 @@ function updateWeightEvidence(
|
||||
confirmedWeightAvailable.value = hasConfirmedWeight
|
||||
}
|
||||
|
||||
function calculationModeLabel(value: EditableMeasurementItem['coefficientMode']) {
|
||||
return value === 'ADD' ? '箱规尺寸' : value === 'MULTIPLY' ? '箱规系数' : '实测原尺寸'
|
||||
}
|
||||
|
||||
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
|
||||
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
|
||||
}
|
||||
@@ -789,23 +785,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
class="measurement-table"
|
||||
:row-class-name="measurementRowClassName"
|
||||
>
|
||||
<el-table-column type="expand" width="42">
|
||||
<template #default="{ row }">
|
||||
<dl class="measurement-row-details">
|
||||
<div><dt>箱规</dt><dd>{{ row.boxSpecification || '—' }}</dd></div>
|
||||
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
|
||||
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
|
||||
<div><dt>计算方式</dt><dd>{{ calculationModeLabel(row.coefficientMode) }}</dd></div>
|
||||
<div><dt>计算后外箱</dt><dd>{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm</dd></div>
|
||||
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 3, 3) }} m³</dd></div>
|
||||
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
|
||||
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
|
||||
<div><dt>计算规则版本</dt><dd>{{ row.calculationRuleVersion || '—' }}</dd></div>
|
||||
<div><dt>超限提醒</dt><dd>{{ row.overLimitWarning || '—' }}</dd></div>
|
||||
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
|
||||
</dl>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="itemNo" label="#" width="70" align="center" />
|
||||
<el-table-column label="入仓码" min-width="154" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.inboundCode || '—' }}</template>
|
||||
@@ -822,6 +801,9 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
<el-table-column label="尺寸 长×宽×高(cm)" width="166" align="center">
|
||||
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="form.items.some(item => item.overLimitWarning?.trim())" label="超限提醒" min-width="200">
|
||||
<template #default="{ row }"><span v-if="row.overLimitWarning?.trim()" class="measurement-limit-warning" role="alert">{{ row.overLimitWarning }}</span><span v-else>—</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="仓库修改外箱(长)" width="165" align="center">
|
||||
<template #default="{ row }"><el-input-number v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
|
||||
</el-table-column>
|
||||
@@ -930,7 +912,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
.measurement-comparison th, .measurement-comparison td { padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 1px solid var(--workbench-border); text-align: left; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.measurement-comparison thead th { background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); }
|
||||
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
|
||||
.measurement-comparison thead th:nth-child(3) { background: #f4fbf8; color: #18795d; }
|
||||
.measurement-comparison thead th:nth-child(3) { background: var(--admin-surface, #fff); color: #18795d; }
|
||||
.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
|
||||
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.measurement-extra-facts strong { color: var(--workbench-text); }
|
||||
@@ -990,10 +972,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
.measurement-table :deep(.measurement-table__row--error > td.el-table__cell) { background: var(--el-color-danger-light-9); }
|
||||
.warehouse-suggestion-table { display: grid; gap: var(--admin-space-4, 10px); margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); }
|
||||
.measurement-box-code { font-weight: 650; }
|
||||
.measurement-limit-warning { white-space: normal; overflow-wrap: anywhere; color: var(--el-color-danger-dark-2); }
|
||||
.measurement-count-editor { width: 104px !important; }
|
||||
.measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3, 8px) 20px; margin: 0; padding: var(--admin-space-1, 4px) 18px var(--admin-space-3, 8px) 62px; }
|
||||
.measurement-row-details dt { color: var(--workbench-muted); font-size: var(--admin-font-caption, 11px); }
|
||||
.measurement-row-details dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--workbench-text); font-size: var(--admin-font-caption, 12px); font-weight: 600; }
|
||||
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); padding: var(--admin-space-5, 12px) 20px; border-top: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
|
||||
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: var(--admin-space-2, 6px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.measurement-panel__footer > span:has(svg) { color: #b54708; font-weight: 650; }
|
||||
@@ -1001,7 +981,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.measurement-data-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.measurement-row-details { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -1015,7 +994,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
.manual-entry-table colgroup { display: none; }
|
||||
.manual-entry-table tr { display: grid; grid-template-columns: 36% minmax(0, 1fr); }
|
||||
.manual-entry-table :is(th, td) { min-width: 0; }
|
||||
.measurement-row-details { grid-template-columns: 1fr; padding-left: 54px; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Measurement workspace: keep all sections inside the panel's content bounds. */
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-summary-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-summary-bg); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header { flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock('./api', () => ({
|
||||
vi.mock('../weight/components/WeightOcrPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'WeightOcrPanel',
|
||||
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean },
|
||||
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean, tableMode: Boolean },
|
||||
emits: ['changed'],
|
||||
template: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>',
|
||||
},
|
||||
@@ -193,6 +193,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
|
||||
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
|
||||
expect(weightPanel.props('reviewMode')).toBe(true)
|
||||
expect(weightPanel.props('tableMode')).toBe(true)
|
||||
expect(weightPanel.props('correctionMode')).toBe(false)
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
|
||||
expect(weightPanel.props('correctionMode')).toBe(true)
|
||||
|
||||
@@ -205,7 +205,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
<template v-else-if="detail">
|
||||
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
|
||||
|
||||
<section class="review-data density-dialog-section" data-tone="summary" aria-labelledby="review-data-title">
|
||||
<section class="review-data density-dialog-section" aria-labelledby="review-data-title">
|
||||
<header class="review-section-heading density-dialog-section__title"><h3 id="review-data-title">审核数据总览</h3></header>
|
||||
<div class="workbench-evidence"><table class="review-comparison density-table" aria-label="客户申报与平台实测对比">
|
||||
<thead><tr><th scope="col">指标</th><th scope="col">客户申报</th><th scope="col">平台实测</th><th scope="col">差异(实测 − 申报)</th></tr></thead>
|
||||
@@ -286,6 +286,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
|
||||
<WeightOcrPanel
|
||||
review-mode
|
||||
table-mode
|
||||
:correction-mode="correctionMode"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
|
||||
@@ -496,7 +496,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
expect(wrapper.text()).toContain('1747.500 KG')
|
||||
})
|
||||
|
||||
it('atomically corrects and confirms an approved weight during measurement review', async () => {
|
||||
it('opens a table image and atomically corrects and confirms an approved weight during measurement review', async () => {
|
||||
const confirmed = {
|
||||
...pendingWeight,
|
||||
recognitionStatus: 'CONFIRMED',
|
||||
@@ -514,13 +514,14 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
weightResults: [confirmed],
|
||||
})
|
||||
vi.mocked(confirmWeightResult).mockResolvedValue({ ...confirmed, grossWeightKg: 125, netWeightKg: 113, version: 2 })
|
||||
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true })
|
||||
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true, tableMode: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('删除图片')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('修改含托盘毛重'))?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.weight-data-table').exists()).toBe(true)
|
||||
expect(wrapper.find('.weight-grid').exists()).toBe(false)
|
||||
await openWeightDialog(wrapper)
|
||||
await dialogWeightInput().setValue('125')
|
||||
const reason = document.body.querySelector<HTMLTextAreaElement>('textarea[placeholder="请填写本次毛重修正原因"]')
|
||||
expect(reason).not.toBeNull()
|
||||
|
||||
@@ -410,9 +410,9 @@ onMounted(() => void load())
|
||||
<el-alert v-else-if="suppliersError" type="warning" :closable="false" show-icon :title="suppliersError.message" />
|
||||
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
|
||||
</template>
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" class="density-form density-form--flush-end">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="柜次费用填写"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table fee-editor-table" aria-label="柜次费用填写"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="方向" :prop="target ? undefined : 'direction'"><span v-if="target">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</span><el-segmented v-else v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" :prop="target ? undefined : 'feeItemId'"><span v-if="target">{{ target.feeItemName }}</span><el-select v-else v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="target || form.direction === 'PAYABLE'"><td><el-form-item label-position="left" label-width="110px" label="供应商" :required="!target"><span v-if="target">{{ target.supplierName || '—' }}</span><el-select v-else v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="结算方式" :required="!target"><span v-if="target">{{ settlementLabel(target) }}</span><el-radio-group v-else v-model="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item></td></tr>
|
||||
<tr v-if="target"><td><el-form-item label="已付摘要" label-position="left" label-width="110px"><span>{{ paidSummary(target) }}</span></el-form-item></td><td><el-form-item label="币种" label-position="left" label-width="110px"><span>{{ target.currency }}</span></el-form-item></td></tr>
|
||||
@@ -425,7 +425,7 @@ onMounted(() => void load())
|
||||
</tbody></table></div>
|
||||
<el-form-item label-position="top" v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
|
||||
<el-alert type="info" :closable="false" show-icon title="客户必须明确;入仓码没有时按订单归属,分摊合计必须等于费用总额。" />
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable allocation-editor-table" aria-label="业务归属分摊"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th class="allocation-amount">分摊金额(CNY)</th></tr></thead><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table allocation-editor-table" aria-label="业务归属分摊"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th class="allocation-amount">分摊金额(CNY)</th></tr></thead><tbody>
|
||||
<tr v-for="(row, index) in allocationDraft" :key="`${row.customerId}-${row.orderId}-${row.inboundId || 'none'}`"><td>{{ customerLabel(row.customerId || targetGroupRows[index]?.customerId || '') }}</td><td>{{ row.orderNo }}</td><td>{{ row.inboundCode || '无入仓码' }}</td><td><el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入分摊金额" /></td></tr>
|
||||
</tbody><tfoot><tr><td colspan="4"><span class="allocation-editor__sum" :class="{ 'is-error': Math.abs(allocationSum - form.amount) > 0.001 }">已分摊 ¥{{ money(allocationSum) }} / 总额 ¥{{ money(form.amount) }}</span></td></tr></tfoot></table></div>
|
||||
</el-form-item>
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
||||
import type { LoadingDetail } from '../api/platformLoadingLists'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('shared manifest presentation', () => {
|
||||
it('names producer events and retains separate adjustments even when their visible facts match', async () => {
|
||||
const actions = ['CREATE_PLAN', 'ADD_INBOUND', 'ADJUST_ITEM', 'ADJUST_ITEM', 'REMARKS', 'UPDATE_PRELOAD_TRANSPORT']
|
||||
const wrapper = mount(LoadingListDetailDrawer, {
|
||||
props: { mode: 'confirmed', canEdit: false, loading: false, error: null,
|
||||
detail: {
|
||||
summary: { planId: '1', warehouseId: '11', loadingListNo: 'LP-TEST', status: 'COMPLETED' }, items: [],
|
||||
histories: actions.map((actionType, index) => ({
|
||||
historyId: String(index + 1), operationId: `operation-${index + 1}`, itemId: null, actionType,
|
||||
fromStatus: 'EDITING', toStatus: 'EDITING', beforeSnapshot: null, afterSnapshot: null,
|
||||
operatorId: '7', operatorName: '操作员', operationReason: null, occurredAt: '2026-10-21T10:00:00',
|
||||
})),
|
||||
} as unknown as LoadingDetail },
|
||||
global: { plugins: [ElementPlus], directives: { loading: {} }, stubs: { DataTable: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
const rows = wrapper.findAll('.history-section .el-table__body tbody tr')
|
||||
expect(rows).toHaveLength(6)
|
||||
expect(rows.map(row => row.findAll('td')[1]!.text())).toEqual([
|
||||
'创建计划', '加入入仓货物', '调整箱数', '调整箱数', '修改备注', '修改预装运输信息',
|
||||
])
|
||||
expect(wrapper.get('.history-section__toggle').text()).toContain('6 条')
|
||||
expect(wrapper.get('.history-section').text()).not.toContain('状态更新')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => {
|
||||
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||
const wrapper = shallowMount(LoadingListDetailDrawer, {
|
||||
|
||||
@@ -145,8 +145,16 @@ function actionLabel(action: string) {
|
||||
return ({
|
||||
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
|
||||
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
|
||||
CONFIRM_PRELOAD: '确认预装',
|
||||
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
|
||||
CREATE_PLAN: '创建计划', ADD_ITEM: '加入货物', ADD_INBOUND: '加入入仓货物',
|
||||
ADD_PENDING_REVIEW_ORDER: '加入待审核订单', ADD_PALLET: '加入封托托盘',
|
||||
ADD_COLOAD_GROUP: '加入同柜组', REMOVE_COLOAD_GROUP: '移除同柜组',
|
||||
SMART_RECOMMEND: '智能排柜', ADJUST_ITEM: '调整箱数', REMARKS: '修改备注',
|
||||
REMOVE_ITEM: '移除货物', REMOVE_PALLET: '移除封托托盘', REMOVE_UNPICKED: '移除未拣货物',
|
||||
UPDATE_PRELOAD_TRANSPORT: '修改预装运输信息', CONFIRM_PRELOAD: '确认预装',
|
||||
START_LOADING: '开始拣货', PDA_COMPLETE_LOADING: 'PDA完成装柜', COMPLETE: '完成装柜',
|
||||
UPLOAD_DELIVERY_ORDER: '上传DO资料',
|
||||
ATTACH_DESTINATION_CLEARANCE: '关联目的港清关资料', UPLOAD_DESTINATION_CLEARANCE: '上传目的港清关资料',
|
||||
COMPLETE_DESTINATION_CLEARANCE: '确认目的港清关完成',
|
||||
} as Record<string, string>)[action] || '状态更新'
|
||||
}
|
||||
|
||||
|
||||
@@ -117,11 +117,11 @@ describe('预装清单统一弹窗', () => {
|
||||
const wrapper = await render(initial)
|
||||
const drawer = wrapper.getComponent({ name: 'LoadingListDetailDrawer' })
|
||||
drawer.vm.$emit('confirm-preload'); await flushPromises()
|
||||
expect(dialog(wrapper, '确认预装').props('modelValue')).toBe(false)
|
||||
expect(dialog(wrapper, '推送PDA').props('modelValue')).toBe(false)
|
||||
wrapper.getComponent(ElSelect).vm.$emit('update:modelValue', '71')
|
||||
await flushPromises()
|
||||
drawer.vm.$emit('confirm-preload'); await flushPromises()
|
||||
const popup = dialog(wrapper, '确认预装')
|
||||
const popup = dialog(wrapper, '推送PDA')
|
||||
expect(popup.get('table[aria-label="确认预装资料"]').text()).toContain('操作员甲')
|
||||
vi.mocked(confirmPreloadPlan).mockResolvedValue({ planId: initial.summary.planId, containerBookingId: initial.summary.containerBookingId, confirmationStatus: 'CONFIRMED', version: 8, readiness: { ...initial.summary.readiness, confirmationStatus: 'CONFIRMED', confirmedPreloadCount: 1, pdaTaskId: '91', pdaTaskStatus: 'PENDING', canConfirm: false, readyForPda: true } })
|
||||
popup.vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
@@ -109,7 +109,8 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
||||
expect(source).toContain('summary?.readiness.canConfirm')
|
||||
expect(source).toContain('confirmSaving.value')
|
||||
expect(source).toContain('await loadDetail()')
|
||||
expect(source).toContain('本操作将确认整份预装计划')
|
||||
expect(source).toContain("preloadDialogTitle('推送PDA')")
|
||||
expect(source).not.toContain('本操作将确认整份预装计划')
|
||||
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading"')
|
||||
expect(source).not.toContain('/inbounds/')
|
||||
})
|
||||
|
||||
@@ -297,9 +297,9 @@ watch([planId, warehouseId], () => {
|
||||
</div>
|
||||
|
||||
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle(editDialogTitle)" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form ref="editFormRef" class="density-form" :model="editForm" :rules="editRules">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable preload-editor-table" aria-label="运输信息填写"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table preload-editor-table" aria-label="运输信息填写"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="120px" label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入提柜后取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="120px" label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="120px" :label="isContainerCorrection ? '纠错原因' : '操作原因(可选)'" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" :placeholder="isContainerCorrection ? '必填,将记录在操作历史中' : '可填写补录说明'" /></el-form-item></td></tr>
|
||||
@@ -310,9 +310,9 @@ watch([planId, warehouseId], () => {
|
||||
|
||||
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('编辑备注', remarkTarget?.inboundCode || remarkTarget?.orderNo)" width="min(800px, calc(100vw - 28px))" fullscreenable confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
|
||||
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form class="density-form">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable preload-editor-table" aria-label="预装明细备注"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table preload-editor-table" aria-label="预装明细备注"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="客户备注(只读)"><span class="customer-remark-text">{{ remarkTarget?.customerRemark || '—' }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
@@ -323,10 +323,9 @@ watch([planId, warehouseId], () => {
|
||||
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('确认预装并推送 PDA')" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('推送PDA')" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
|
||||
<div class="confirm-content">
|
||||
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
|
||||
<section class="density-dialog-section" data-tone="summary">
|
||||
<section class="density-dialog-section">
|
||||
<div class="workbench-evidence"><table class="density-table preload-confirm-table" aria-label="确认预装资料"><tbody>
|
||||
<template v-if="detail"><tr><th>预装单号</th><td>{{ detail.summary.preloadNo }}</td></tr><tr><th>柜次号</th><td>{{ detail.summary.cabinetNo || '—' }}</td></tr><tr><th>集装箱号</th><td>{{ detail.summary.containerNo || '待补录' }}</td></tr></template>
|
||||
<tr><th>PDA 操作员</th><td>{{ selectedAssignee?.displayName || '未选择' }}</td></tr>
|
||||
|
||||
@@ -14,6 +14,18 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
})
|
||||
|
||||
describe('ocean bookings api', () => {
|
||||
it('preserves the backend history creation timestamp when normalizing a detail response', async () => {
|
||||
const createdAt = '2026-10-20T09:12:34'
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: {
|
||||
...detailResponse(),
|
||||
histories: [{ id: '9007199254740999', fromStatus: null, toStatus: 'DRAFT', action: 'CREATE', operatorName: '创建人', createdAt }],
|
||||
} })
|
||||
const result = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, parties: [], containerRequirements: [] } })
|
||||
expect(post).toHaveBeenCalledOnce()
|
||||
expect(result.histories[0]).toMatchObject({ id: '9007199254740999', action: 'CREATE', createdAt })
|
||||
expect(result.statusHistory).toEqual(result.histories)
|
||||
})
|
||||
|
||||
it('normalizes pagination and long ids', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, bookingNo: 'OB-260825-0183', customerId: 456, shipperName: '发货企业', consigneeName: '收货企业', containerCount: '1' }], page: '2', size: '20', total: '21', pages: '2' } })
|
||||
const result = await listOceanBookings({ page: 1, size: 10 })
|
||||
|
||||
@@ -82,7 +82,7 @@ async function mountView() {
|
||||
stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
DetailPageLayout: { template: '<section><slot name="header" /><slot /></section>' },
|
||||
PageToolbar: { template: '<header><slot name="actions" /></header>' },
|
||||
PageToolbar: { props: ['description'], template: '<header><p class="toolbar-description">{{ description }}</p><slot name="actions" /></header>' },
|
||||
CarrierScheduleQueryPanel: true,
|
||||
},
|
||||
},
|
||||
@@ -135,6 +135,8 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(dialog.props('title')).toBe('确认放舱 · OB-123')
|
||||
expect(wrapper.get('.release-summary').text()).toContain('EDITED-BL')
|
||||
expect(wrapper.get('.release-summary').element.closest('section')?.hasAttribute('data-tone')).toBe(false)
|
||||
expect(wrapper.find('.el-alert--warning').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('不能修改、不能删除')
|
||||
expect(wrapper.text()).not.toContain('处理数据库')
|
||||
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
||||
@@ -149,6 +151,37 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders authoritative creation and release history timestamps from the response', async () => {
|
||||
const value = booking('RELEASED')
|
||||
value.histories = [
|
||||
{ id: '61', fromStatus: null, toStatus: 'DRAFT', action: 'CREATE', operatorName: '创建人', createdAt: '2026-10-20T09:12:34' },
|
||||
{ id: '62', fromStatus: 'DRAFT', toStatus: 'RELEASED', action: 'RELEASE', operatorName: '放舱人', createdAt: '2026-10-21T10:23:45' },
|
||||
]
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(value)
|
||||
const { wrapper } = await mountView()
|
||||
const rows = wrapper.findAll('.booking-history-table tbody tr')
|
||||
expect(rows[0]!.findAll('td')[0]!.text()).toBe('2026-10-20T09:12:34')
|
||||
expect(rows[1]!.findAll('td')[0]!.text()).toBe('2026-10-21T10:23:45')
|
||||
expect(rows[0]!.text()).toContain('创建人')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the route alone in the heading without a single customer and keeps an actual customer name', async () => {
|
||||
const value = booking()
|
||||
value.customerId = null; value.customerName = null
|
||||
value.oceanBooking.customerId = null; value.oceanBooking.customerName = null
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(value)
|
||||
const { wrapper } = await mountView()
|
||||
expect(wrapper.get('.toolbar-description').text()).toBe('上海 → 洛杉矶')
|
||||
expect(wrapper.text()).not.toContain('未关联客户')
|
||||
wrapper.unmount()
|
||||
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking())
|
||||
const { wrapper: customerWrapper } = await mountView()
|
||||
expect(customerWrapper.get('.toolbar-description').text()).toBe('测试客户 · 上海 → 洛杉矶')
|
||||
customerWrapper.unmount()
|
||||
})
|
||||
|
||||
it('saves dirty form data and releases with the returned latest version', async () => {
|
||||
vi.mocked(updateOceanBookingDraft).mockResolvedValue(booking('DRAFT', 4, 'NEW-BL'))
|
||||
vi.mocked(releaseOceanBooking).mockResolvedValue(booking('RELEASED', 5, 'NEW-BL'))
|
||||
|
||||
@@ -377,7 +377,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : ''">
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName ? `${detail.customerName} · ` : ''}${detail.pol || '—'} → ${detail.pod || '—'}` : ''">
|
||||
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
|
||||
</PageToolbar>
|
||||
</div>
|
||||
@@ -471,7 +471,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<section v-if="detail" class="workbench-panel is-compact history-panel">
|
||||
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
|
||||
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.occurredAt }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="4">暂无状态历史</td></tr></tbody></table></div>
|
||||
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.createdAt || '—' }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="4">暂无状态历史</td></tr></tbody></table></div>
|
||||
</section>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
@@ -479,8 +479,8 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。" type="warning" :closable="false" show-icon />
|
||||
<section class="density-dialog-section" data-tone="summary"><el-descriptions class="release-summary density-descriptions" :column="2" border>
|
||||
<p class="density-dialog-note">放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。</p>
|
||||
<section class="density-dialog-section"><el-descriptions class="release-summary density-descriptions" :column="2" border>
|
||||
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="船名">{{ form.data.vesselName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="航次">{{ form.data.voyageNo || '—' }}</el-descriptions-item>
|
||||
|
||||
@@ -81,7 +81,7 @@ export interface OceanBookingResource {
|
||||
amount: string | null; currency: string | null
|
||||
}
|
||||
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]; containers: OceanBookingAllocatedContainer[]; documents: OceanBookingDocument[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
|
||||
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
|
||||
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; createdAt: string }
|
||||
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
|
||||
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
|
||||
export interface OceanBookingLinkCandidate {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import * as api from './dispatches'
|
||||
import { blankVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import { candidate, command, detail, loadingPreview, page } from '../dispatchTestFixtures'
|
||||
import type { CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
|
||||
@@ -16,6 +17,19 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('native whole-vehicle dispatch API', () => {
|
||||
it('round-trips this trip maintenance data and its vehicle photo references without altering actual departure', async () => {
|
||||
const maintenance = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-1' }
|
||||
const value = detail()
|
||||
value.vehicleInfo.maintenance = maintenance
|
||||
value.vehicleInfo.vehicle!.evidenceFileIds = ['9007199254740993']
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: value })
|
||||
const saved = await api.getDispatch('PC/1')
|
||||
expect(saved.vehicleInfo.maintenance).toEqual(maintenance)
|
||||
expect(saved.vehicleInfo.vehicle!.evidenceFileIds).toEqual(['9007199254740993'])
|
||||
await api.updateDispatch('PC/1', { recordVersion: saved.recordVersion, idempotencyKey: 'maintenance-key', vehicle: saved.vehicleInfo.vehicle, driver: saved.vehicleInfo.driver, maintenance, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, publishLooseboardDemand: true })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', expect.objectContaining({ maintenance, vehicle: expect.objectContaining({ evidenceFileIds: ['9007199254740993'] }) }))
|
||||
expect(vi.mocked(http.put).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
it.each([0, '0', '9007199254740993'])('preserves authoritative batch version %s across direct create, add cargo and grouped conversion', async recordVersion => {
|
||||
const allocation: DispatchBatchAllocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }
|
||||
const create: CreateDispatch = { publishLooseboardDemand: true, warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { appointmentFormError, appointmentPayload, createAppointmentForm } from './appointmentForm'
|
||||
|
||||
it('allows an undated arrangement without inventing confirmation or loading facts', () => {
|
||||
it('requires an existing external confirmation for a new platform registration', () => {
|
||||
const form = createAppointmentForm()
|
||||
expect(appointmentFormError(form)).toContain('外部约号与确认时间')
|
||||
expect(appointmentPayload(form)).toMatchObject({ plannedDate: null, plannedAt: null, appointmentAt: null, confirmationNo: null, appointmentStatus: 'SUCCESS' })
|
||||
form.confirmationNo = 'EXT-CONFIRMED'
|
||||
form.appointmentAt = '2026-10-25T09:30:00'
|
||||
expect(appointmentFormError(form)).toBeNull()
|
||||
expect(appointmentPayload(form)).toMatchObject({ plannedDate: null, plannedAt: null, appointmentAt: null, confirmationNo: null, appointmentStatus: 'PENDING' })
|
||||
expect(appointmentPayload(form)).not.toHaveProperty('expectedLoadingAt')
|
||||
})
|
||||
it('retains an already confirmed repeated local time using its stored UTC instant', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AppointmentForm {
|
||||
}
|
||||
export function createAppointmentForm(detail?: FbaAppointmentDto): AppointmentForm {
|
||||
return { plannedDate: detail?.plannedDate || '', plannedAt: detail?.plannedAt || '', plannedEndAt: detail?.plannedEndAt || '', confirmationNo: detail?.confirmationNo || '',
|
||||
appointmentAt: detail?.appointmentAt || '', appointmentEndAt: detail?.appointmentEndAt || '', appointmentStatus: (detail?.appointmentStatus || 'PENDING') as AppointmentForm['appointmentStatus'],
|
||||
appointmentAt: detail?.appointmentAt || '', appointmentEndAt: detail?.appointmentEndAt || '', appointmentStatus: (detail?.appointmentStatus || 'SUCCESS') as AppointmentForm['appointmentStatus'],
|
||||
appointmentRemark: detail?.appointmentRemark || '', startOffset: confirmedOffset(detail?.appointmentAt, detail?.appointmentStartUtc), endOffset: confirmedOffset(detail?.appointmentEndAt, detail?.appointmentEndUtc) }
|
||||
}
|
||||
function confirmedOffset(local?: string | null, utc?: string | null): string {
|
||||
|
||||
@@ -27,6 +27,8 @@ it('renders authenticated photos above their caption and releases thumbnails on
|
||||
expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
|
||||
const figure = wrapper.get('figure')
|
||||
expect(figure.get('img').attributes('src')).toBe('blob:authenticated')
|
||||
expect(wrapper.get('.dispatch-evidence').attributes('data-tone')).toBeUndefined()
|
||||
expect(figure.get('.evidence-photo-name').text()).toBe('本车证据.png')
|
||||
expect(figure.get('figcaption').text()).toBe('车辆图片')
|
||||
expect(figure.element.lastElementChild?.tagName).toBe('FIGCAPTION')
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||
|
||||
@@ -119,13 +119,14 @@ watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value, props.ma
|
||||
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
||||
</script>
|
||||
<template>
|
||||
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 v-if="!photoCard" class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></h4>
|
||||
<section class="dispatch-evidence density-dialog-section" :data-tone="photoCard ? undefined : 'source'" :class="{ 'dispatch-evidence--readonly': readonly, 'dispatch-evidence--photos': photoCard }" :aria-label="label"><h4 v-if="!photoCard" class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></h4>
|
||||
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<figure v-if="photoCard" class="evidence-photo-card">
|
||||
<div v-if="allIds.length" class="evidence-photo-list">
|
||||
<div v-for="id in allIds" :key="id">
|
||||
<div v-for="id in allIds" :key="id" class="evidence-photo-item">
|
||||
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
|
||||
<div v-if="!readonly && ids.includes(id)" class="density-actions"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
|
||||
<p class="density-dialog-note evidence-photo-name">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
|
||||
<div v-if="!readonly && ids.includes(id)" class="density-actions evidence-photo-actions"><el-button size="small" :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button size="small" :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="evidence-photo-empty">暂无照片</div>
|
||||
@@ -135,7 +136,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
<table v-if="allIds.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in allIds" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly && ids.includes(id)"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||||
<p v-else class="density-dialog-note">暂无本趟附件。</p>
|
||||
</template>
|
||||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" :accept="uploadTypes.join(',')" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
|
||||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" :accept="uploadTypes.join(',')" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button class="evidence-upload-button" type="primary" plain :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
|
||||
<AppDialog append-to-body class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewBusy" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||||
<div class="evidence-preview" :aria-busy="previewBusy">
|
||||
<LoadErrorState v-if="imageFailure" title="图片无法预览" :message="imageFailure" compact retry-text="重新预览" @retry="preview(previewId)" />
|
||||
@@ -150,8 +151,12 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
|
||||
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
|
||||
.evidence-input { display: none; }
|
||||
.dispatch-evidence--photos { min-width: 0; }
|
||||
.evidence-photo-card { margin: 0; text-align: center; }
|
||||
.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
|
||||
.evidence-photo-name { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
|
||||
.evidence-photo-actions { justify-content: center; }
|
||||
.dispatch-evidence--photos > .evidence-upload-button { width: 100%; }
|
||||
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
|
||||
.evidence-photo-preview { padding: 0; cursor: pointer; }
|
||||
.evidence-photo-preview img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
@@ -3,11 +3,39 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import { resources } from '../dispatchTestFixtures'
|
||||
import { listDrivers, listVehicles } from '@/modules/overseas-master-data'
|
||||
import { listDrivers, listVehicles, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
|
||||
it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous master snapshot fields read-only and uses a dash for missing facts (%s)', async (carrierName, phone) => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'; value.vehicle.carrierName = carrierName; value.driver.mode = 'MASTER'; value.driver.driverId = 'driver-1'; value.driver.phone = phone
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' })
|
||||
expect(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
|
||||
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
|
||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(wrapper.emitted('update:driver')).toBeUndefined(); wrapper.unmount()
|
||||
})
|
||||
it('keeps master company and phone fields out of the existing non-continuous creation form', async () => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.driver.mode = 'MASTER'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const labels = wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))
|
||||
expect(labels).not.toContain('拖车公司名称'); expect(labels).not.toContain('联系电话'); wrapper.unmount()
|
||||
})
|
||||
it('shows the newly selected authorized vehicle company without copying it into the master command', async () => {
|
||||
const candidate: VehicleDetail = { id: 'vehicle-2', plateNumber: 'CA-200', model: '厢式货车', driverOrDispatchCompany: '新承运公司', phone: null, maxPalletSpaces: 20, maxLoadKg: 18000, maxVolumeCbm: 60, fuelConsumptionPer100Km: 0, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-21T10:00:00Z' }
|
||||
vi.mocked(listVehicles).mockResolvedValueOnce({ records: [candidate], page: 1, size: 100, total: 1, pages: 1 })
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } })
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; selectVehicle(id: string): void }
|
||||
await state.searchVehicles(); state.selectVehicle('vehicle-2')
|
||||
const selected = wrapper.emitted('update:vehicle')!.at(-1)![0] as typeof value.vehicle
|
||||
expect(selected).toMatchObject({ vehicleId: 'vehicle-2', carrierName: null })
|
||||
await wrapper.setProps({ vehicle: selected })
|
||||
const company = wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '拖车公司名称')!.getComponent({ name: 'ElInput' })
|
||||
expect(company.props('modelValue')).toBe('新承运公司'); expect(company.props('readonly')).toBe(true); wrapper.unmount()
|
||||
})
|
||||
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import type { Driver, Vehicle } from '../dispatchContracts'
|
||||
@@ -8,6 +8,9 @@ const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
defineProps<{ disabled: boolean; continuous?: boolean }>()
|
||||
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
|
||||
/** 维护弹窗只读展示本趟快照或已读取的当前主档资料,不回写主档字段。 */
|
||||
const masterCarrierName = computed(() => vehicle.value.carrierName?.trim() || vehicles.value.find(row => row.id === vehicle.value.vehicleId)?.driverOrDispatchCompany?.trim() || '—')
|
||||
const masterDriverPhone = computed(() => driver.value.phone?.trim() || drivers.value.find(row => row.id === driver.value.driverId)?.details.phone?.trim() || '—')
|
||||
let vehicleSequence = 0, driverSequence = 0
|
||||
async function searchVehicles(keyword = '') {
|
||||
if (!access.can(resourcePermission.vehicleList)) { error.value = '缺少车辆档案读取权限'; return }
|
||||
@@ -41,7 +44,9 @@ function selectDriver(id: string) {
|
||||
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
</template>
|
||||
<tr v-if="vehicle.mode === 'MASTER'"><td colspan="2"><el-form-item label="拖车公司名称"><el-input :model-value="masterCarrierName" readonly /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
|
||||
<tr v-if="driver.mode === 'MASTER'"><td colspan="2"><el-form-item label="联系电话"><el-input :model-value="masterDriverPhone" readonly /></el-form-item></td></tr>
|
||||
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
|
||||
<tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders'
|
||||
import type { DeliveryReference } from '../dispatchContracts'
|
||||
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn() }))
|
||||
const reference: DeliveryReference = { groupKey: 'group-a', customerId: 'C1', customerName: '客户甲', outboundNo: 'OUT-1', deliveryNo: 'DL-1', status: 'CREATED', packageCount: 2, palletCount: 1, destination: { key: 'WAREHOUSE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: null, address: null, postalCode: null, contactName: null, contactPhone: null } }
|
||||
function result(): DeliveryOrderDetail {
|
||||
return { order: { id: '11', version: 0, allowedActions: [], customerId: 'C1', deliveryNo: 'DL-1', outboundNo: 'OUT-1', dispatchNo: 'PC/1', status: 'CREATED', palletCount: 1, packageCount: 2, signedPackages: null, createdAt: '2026-10-21T10:00:00Z' }, skus: [], operations: [], fees: [{ id: '21', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 28, voidStatus: 'CONFIRMED' }] }
|
||||
}
|
||||
function render(deliveries = [reference]) { return mount(DispatchVehicleMaintenanceFees, { props: { dispatchNo: 'PC/1', deliveries }, global: { plugins: [ElementPlus] } }) }
|
||||
beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true); vi.mocked(getDeliveryOrder).mockResolvedValue(result()) })
|
||||
|
||||
it('reads official fees once per customer and delivery and preserves their currency and status', async () => {
|
||||
const wrapper = render([reference, reference]); await flushPromises()
|
||||
expect(getDeliveryOrder).toHaveBeenCalledExactlyOnceWith('DL-1', 'C1')
|
||||
expect(wrapper.text()).toContain('派送费用'); expect(wrapper.text()).toContain('USD'); expect(wrapper.text()).toContain('28'); expect(wrapper.text()).toContain('已作废')
|
||||
expect(wrapper.find('input').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('does not request or invent fees before a formal delivery exists', async () => {
|
||||
const wrapper = render([]); await flushPromises()
|
||||
expect(getDeliveryOrder).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('尚未生成派送单费用'); wrapper.unmount()
|
||||
})
|
||||
it('preserves the existing delivery-list read permission', async () => {
|
||||
can.mockReturnValue(false)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getDeliveryOrder).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('暂无派送单费用查看权限'); wrapper.unmount()
|
||||
})
|
||||
it('rejects a fee response belonging to another dispatch and keeps failures separate from empty records', async () => {
|
||||
const wrong = result(); wrong.order.dispatchNo = 'PC/OTHER'
|
||||
vi.mocked(getDeliveryOrder).mockResolvedValueOnce(wrong)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('费用读取失败'); expect(wrapper.text()).not.toContain('暂无正式费用记录'); expect(wrapper.text()).not.toContain('USD')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a late response from the previous dispatch', async () => {
|
||||
let resolve!: (value: DeliveryOrderDetail) => void
|
||||
vi.mocked(getDeliveryOrder).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.setProps({ dispatchNo: 'PC/2', deliveries: [] })
|
||||
resolve(result()); await flushPromises()
|
||||
expect(wrapper.text()).toContain('尚未生成派送单费用'); expect(wrapper.text()).not.toContain('USD'); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getDeliveryOrder, type DeliveryFeeRow } from '../api/deliveryOrders'
|
||||
import type { DeliveryReference } from '../dispatchContracts'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string; deliveries: DeliveryReference[] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const allowed = computed(() => access.can(permission.deliveryList))
|
||||
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const fees = ref<Array<DeliveryFeeRow & { deliveryNo: string; customerName: string }>>([])
|
||||
const labels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
|
||||
let generation = 0
|
||||
/** 只读本车正式关联派送单费用,不把未生成或读取失败展示成零费。 */
|
||||
async function load() {
|
||||
const current = ++generation, number = props.dispatchNo
|
||||
fees.value = []; error.value = null; loading.value = false
|
||||
if (!allowed.value || !props.deliveries.length) return
|
||||
loading.value = true
|
||||
try {
|
||||
const references = [...new Map(props.deliveries.map(row => [`${row.customerId}:${row.deliveryNo}`, row])).values()]
|
||||
const rows = await Promise.all(references.map(async row => {
|
||||
const value = await getDeliveryOrder(row.deliveryNo, row.customerId)
|
||||
if (value.order.deliveryNo !== row.deliveryNo || value.order.customerId !== row.customerId || value.order.dispatchNo !== number) throw new Error('费用返回的派送单与本车关联不一致')
|
||||
return value.fees.map(fee => ({ ...fee, deliveryNo: row.deliveryNo, customerName: row.customerName }))
|
||||
}))
|
||||
if (current === generation) fees.value = rows.flat()
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '正式费用读取失败,请重试') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function status(fee: DeliveryFeeRow) {
|
||||
if (fee.voidStatus === 'CONFIRMED') return '已作废'
|
||||
if (fee.voidStatus === 'PENDING_FINANCE') return '作废待财务确认'
|
||||
if (fee.voidStatus === 'FINANCE_ATTENTION') return '作废待财务处理'
|
||||
if (fee.sourceStatus === 'SUPERSEDED') return '已替代'
|
||||
return ({ APPROVED: '业务已审核', PENDING: '待审核', DRAFT: '草稿', REJECTED: '已驳回' } as Record<string, string>)[fee.reviewStatus || ''] || '—'
|
||||
}
|
||||
watch(() => JSON.stringify([props.dispatchNo, props.deliveries, allowed.value]), load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<p v-if="!deliveries.length" class="density-dialog-note">尚未生成派送单费用。</p>
|
||||
<p v-else-if="!allowed" class="density-dialog-note">暂无派送单费用查看权限。</p>
|
||||
<LoadErrorState v-else-if="error" title="费用读取失败" :message="error.message" compact @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-else class="density-table"><thead><tr><th>客户</th><th>派送单号</th><th>费用项目</th><th>币种</th><th>金额</th><th>费用状态</th></tr></thead><tbody><tr v-for="fee in fees" :key="`${fee.deliveryNo}-${fee.id}`"><td>{{ fee.customerName || '—' }}</td><td>{{ fee.deliveryNo }}</td><td>{{ labels[fee.feeType] || fee.feeType || '—' }}</td><td>{{ fee.currency || '—' }}</td><td>{{ fee.amount ?? '—' }}</td><td>{{ status(fee) }}</td></tr><tr v-if="!loading && !fees.length"><td colspan="6">暂无正式费用记录</td></tr></tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
|
||||
import type { DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
|
||||
defineProps<{ detail: DispatchDetail; disabled: boolean }>()
|
||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
const maintenance = defineModel<DispatchVehicleMaintenance>('maintenance', { required: true })
|
||||
const plannedDepartureAt = defineModel<string>('plannedDepartureAt', { required: true })
|
||||
const estimatedArrivalAt = defineModel<string>('estimatedArrivalAt', { required: true })
|
||||
const publishLooseboardDemand = defineModel<boolean>('publishLooseboardDemand', { required: true })
|
||||
const tab = ref('info')
|
||||
const deliveryMethods = ['FBA自提', '客户自提', '卡派打托', '卡派地板', '快递', '外配']
|
||||
const pickupMethods = ['一提一卸', '一提多卸']
|
||||
const loadTypes = ['托盘', '地板', '混装']
|
||||
const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs maintenance-tabs">
|
||||
<el-tab-pane label="排车信息" name="info">
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" aria-label="本车资源登记">
|
||||
<h3 class="density-dialog-section__title">车辆与司机</h3>
|
||||
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /></el-form-item></td></tr></tbody></table>
|
||||
<DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车时间登记">
|
||||
<h3 class="density-dialog-section__title">排车时间</h3>
|
||||
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="plannedDepartureAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="estimatedArrivalAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="派送补充资料">
|
||||
<h3 class="density-dialog-section__title">派送信息</h3>
|
||||
<div class="maintenance-delivery-fields">
|
||||
<el-form-item label="预派送日期"><el-date-picker v-model="maintenance.plannedDeliveryDate" type="date" value-format="YYYY-MM-DD" :disabled="disabled" placeholder="请选择预派送日期" /></el-form-item>
|
||||
<el-form-item label="派送方式"><el-select v-model="maintenance.deliveryMethod" :disabled="disabled" clearable placeholder="请选择派送方式"><el-option v-for="value in deliveryMethods" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="提货方式"><el-select v-model="maintenance.pickupMethod" :disabled="disabled" clearable placeholder="请选择提货方式"><el-option v-for="value in pickupMethods" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="装车类型"><el-select v-model="maintenance.loadType" :disabled="disabled" clearable placeholder="请选择装车类型"><el-option v-for="value in loadTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="车队邮箱"><el-input v-model="maintenance.fleetEmail" :disabled="disabled" maxlength="254" placeholder="请输入车队邮箱" /></el-form-item>
|
||||
<el-form-item label="车辆类型"><el-select v-model="maintenance.vehicleType" :disabled="disabled" clearable placeholder="请选择车辆类型"><el-option v-for="value in vehicleTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="预约账号"><el-input v-model="maintenance.appointmentAccount" :disabled="disabled" maxlength="128" placeholder="请输入预约账号" /></el-form-item>
|
||||
<el-form-item label="预约号"><el-input v-model="maintenance.appointmentNo" :disabled="disabled" maxlength="128" placeholder="请输入预约号" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="detail.taskType === 'DELIVERY'" label="散板需求"><el-checkbox v-model="publishLooseboardDemand" :disabled="disabled">发布公共散板需求</el-checkbox></el-form-item>
|
||||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>已确认预约号</th><th>确认送达时间</th></tr></thead><tbody><tr v-for="(appointment, index) in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey + '-' + index"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || appointment.batchNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td></tr></tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车费用信息"><h3 class="density-dialog-section__title">费用信息</h3><slot name="fees"><p class="density-dialog-note">{{ detail.documents.deliveries.length ? '费用以关联派送单的正式费用记录为准。' : '尚未生成派送单费用。' }}</p></slot></section>
|
||||
<section class="density-dialog-section" aria-label="车辆维护附件"><h3 class="density-dialog-section__title">附件</h3><slot name="attachments" /></section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`关联订单 (${detail.orderDetails.length})`" name="orders">
|
||||
<div class="maintenance-orders"><table class="density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th>重量(kg)</th><th>托数</th><th>目的FBA仓</th><th>体积(m³)</th><th>集装箱号</th></tr></thead><tbody><tr v-for="(order, index) in detail.orderDetails" :key="`${order.sourceKey}-${order.orderNo}-${index}`"><td>{{ order.customerName || '—' }}</td><td>{{ order.orderNo || '—' }}</td><td>{{ order.inboundCode || '—' }}</td><td>{{ order.grossWeightKg ?? '—' }}</td><td>{{ order.palletCount ?? '—' }}</td><td>{{ order.destinationFbaWarehouse || '—' }}</td><td>{{ order.volumeCbm ?? '—' }}</td><td>{{ order.containerNo || '—' }}</td></tr><tr v-if="!detail.orderDetails.length"><td colspan="8">暂无关联订单</td></tr></tbody></table></div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.maintenance-table { table-layout: fixed; }
|
||||
.maintenance-table td { width: 50%; }
|
||||
.maintenance-delivery-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-4); }
|
||||
.maintenance-delivery-fields > * { min-width: 0; }
|
||||
.maintenance-delivery-fields :deep(.el-date-editor), .maintenance-table :deep(.el-date-editor) { width: 100%; }
|
||||
.maintenance-orders { overflow-x: auto; }
|
||||
.maintenance-orders table { min-width: 1000px; }
|
||||
</style>
|
||||
@@ -4,12 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import { blankVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/dispatches'
|
||||
import { command, detail, loadingPreview, resources } from '../dispatchTestFixtures'
|
||||
import type { DispatchAction, DispatchActions, DispatchEvidenceFile, UpdateDispatch } from '../dispatchContracts'
|
||||
import type { DispatchAction, DispatchActions, DispatchDetail, DispatchEvidenceFile, UpdateDispatch } from '../dispatchContracts'
|
||||
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
@@ -43,8 +44,8 @@ beforeEach(() => {
|
||||
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(evidence('302'))
|
||||
})
|
||||
async function render(action: DispatchAction, operation?: 'ROUTE', files = false) {
|
||||
const initial = detail({ actions }); if (files) initial.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||
async function render(action: DispatchAction, operation?: 'ROUTE', files = false, overrides: Partial<DispatchDetail> = {}) {
|
||||
const initial = detail({ actions, ...overrides }); if (files) initial.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(initial)
|
||||
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action, operation }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
return { wrapper, dialog: wrapper.findComponent(AppDialog) }
|
||||
@@ -52,6 +53,20 @@ async function render(action: DispatchAction, operation?: 'ROUTE', files = false
|
||||
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
|
||||
describe('dispatch workflow native layout', () => {
|
||||
it('restores saved maintenance facts and shows associated orders without editable departure facts', async () => {
|
||||
const saved = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-SAVED' }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: { ...resources(), maintenance: saved } })
|
||||
expect((field(dialog, '车队邮箱').get('input').element as HTMLInputElement).value).toBe('fleet@example.test')
|
||||
expect((field(dialog, '预约号').get('input').element as HTMLInputElement).value).toBe('APPT-SAVED')
|
||||
expect(dialog.text()).toContain('关联订单 (1)')
|
||||
const orders = dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))!
|
||||
await orders.trigger('click')
|
||||
expect(dialog.get('.maintenance-orders').text()).toContain('LM-1')
|
||||
expect(dialog.get('.maintenance-orders').findAll('button, input')).toHaveLength(0)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved }))
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('blocks %s approval for missing appointment but keeps rejection available', async action => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { ...actions, [action]: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
@@ -71,11 +86,17 @@ describe('dispatch workflow native layout', () => {
|
||||
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
|
||||
await field(dialog, '司机姓名').get('input').setValue('新司机')
|
||||
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
|
||||
field(dialog, '预派送日期').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02')
|
||||
for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
await field(dialog, '车队邮箱').get('input').setValue(' fleet@example.test ')
|
||||
await field(dialog, '预约账号').get('input').setValue(' fleet-account ')
|
||||
await field(dialog, '预约号').get('input').setValue(' APPT-NEW ')
|
||||
expect(field(dialog, '发车时间').get('input').attributes('readonly')).toBeDefined()
|
||||
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, publishLooseboardDemand: true, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, publishLooseboardDemand: true, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', appointmentNo: 'APPT-NEW' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||
})
|
||||
it('saves all three resource photos before submitting review with the refreshed version', async () => {
|
||||
const { dialog } = await render('EDIT')
|
||||
@@ -179,6 +200,7 @@ describe('dispatch workflow native layout', () => {
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('shows saved photos and old audit evidence read-only in %s', async action => {
|
||||
const { wrapper, dialog } = await render(action)
|
||||
const value = detail({ actions })
|
||||
value.vehicleInfo.vehicle!.evidenceFileIds = ['401']
|
||||
value.vehicleInfo.driver!.evidence!.photoFileIds = ['402']
|
||||
value.reviews.dispatchReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: ['403'], licenseFileIds: [] }
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(value)
|
||||
@@ -186,6 +208,9 @@ describe('dispatch workflow native layout', () => {
|
||||
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true)
|
||||
expect(fields[0]!.props('modelValue')).toEqual(['401'])
|
||||
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '401')
|
||||
expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '401')
|
||||
expect(fields[1]!.props('modelValue')).toEqual(['402', '403'])
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
|
||||
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../
|
||||
import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue'
|
||||
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
|
||||
import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
@@ -42,7 +44,7 @@ const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(c
|
||||
const approvalBlock = computed(() => isReview.value && detail.value?.actions[currentAction.value]?.blockCode === 'APPROVAL_BLOCKED' ? detail.value.actions[currentAction.value]?.blockReason : null)
|
||||
const reviewTime = (value: string | null | undefined) => value ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short', hour12: false }).format(new Date(value)) : '未填写'
|
||||
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
|
||||
const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||
@@ -67,6 +69,7 @@ async function load() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
|
||||
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||||
const oldVehicle = value.vehicleInfo.vehicle
|
||||
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
|
||||
@@ -107,7 +110,7 @@ function payload(): object {
|
||||
case 'MARK_EXCEPTION':
|
||||
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
|
||||
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
|
||||
case 'EDIT': return { ...common, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'EDIT': return { ...common, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value, driver: driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
@@ -231,9 +234,16 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
|
||||
</template>
|
||||
<template v-else-if="vehicleEditor">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
||||
<el-form-item v-if="detail?.taskType === 'DELIVERY'" label="散板需求"><el-checkbox v-model="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">发布公共散板需求</el-checkbox></el-form-item>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
||||
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" v-model:publish-looseboard-demand="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
||||
<template #attachments>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</div>
|
||||
</template>
|
||||
</DispatchVehicleMaintenanceForm>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||||
@@ -248,12 +258,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<div v-if="vehicleEditor" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</div>
|
||||
<div v-else-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<div v-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
@@ -274,8 +279,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.workflow-editor-table { table-layout: fixed; }
|
||||
.workflow-editor-table td { width: 50%; }
|
||||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||||
.workflow-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.workflow-review-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); }
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
|
||||
import { ref } from 'vue'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import type { AppointmentForm } from '../appointmentForm'
|
||||
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; sources?: PlanningSourceLookup[] }>()
|
||||
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; sources?: PlanningSourceLookup[] }>()
|
||||
const emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
|
||||
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
|
||||
watchEffect(() => {
|
||||
if (!props.disabled && ['PENDING', 'FAILED'].includes(props.model.appointmentStatus)) props.model.appointmentStatus = 'SUCCESS'
|
||||
})
|
||||
|
||||
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
|
||||
</script>
|
||||
<template>
|
||||
<p v-if="model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
|
||||
<AppointmentConfirmationCargo v-if="sources?.length" ref="cargo" :sources="sources" :active="model.appointmentStatus === 'SUCCESS'" :disabled="disabled" @ready="emit('confirmationReady', $event)" />
|
||||
<div v-if="continuous" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="预约安排登记"><h3 class="density-dialog-section__title">预约安排</h3>
|
||||
<section class="density-dialog-section" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" aria-label="预约安排登记"><h3 class="density-dialog-section__title">预约安排</h3>
|
||||
<el-form class="density-form" label-position="left" label-width="140px" :disabled="disabled"><table class="density-table density-table--editable appointment-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item></td></tr>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
||||
<tr><td colspan="2"><el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
|
||||
@@ -32,7 +36,7 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
|
||||
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
|
||||
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
|
||||
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
|
||||
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item>
|
||||
<el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
||||
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
|
||||
<el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DispatchVehicleMaintenance } from './dispatchVehicleMaintenance'
|
||||
// Models after the dispatch API boundary: Long quantities are validated safe numbers.
|
||||
// dispatchWire.ts declares their HTTP string/number representation; IDs/versions remain lossless.
|
||||
export type Version = string | number
|
||||
@@ -35,11 +36,11 @@ export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXC
|
||||
export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER'
|
||||
export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string }
|
||||
export interface MarkDispatchException extends VersionCommand { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[] }
|
||||
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[]; publishLooseboardDemand: boolean }
|
||||
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources & { maintenance?: DispatchVehicleMaintenance | null }; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[]; publishLooseboardDemand: boolean }
|
||||
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
|
||||
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[]; publishLooseboardDemand: boolean }
|
||||
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
|
||||
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; publishLooseboardDemand: boolean }
|
||||
export interface UpdateDispatch extends VersionCommand { maintenance?: DispatchVehicleMaintenance | null; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; publishLooseboardDemand: boolean }
|
||||
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||
export interface ReasonCommand extends VersionCommand { reason: string }
|
||||
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null; attachments?: ReviewAttachments }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface DispatchVehicleMaintenance {
|
||||
plannedDeliveryDate: string | null
|
||||
deliveryMethod: string | null
|
||||
pickupMethod: string | null
|
||||
loadType: string | null
|
||||
fleetEmail: string | null
|
||||
vehicleType: string | null
|
||||
appointmentAccount: string | null
|
||||
appointmentNo: string | null
|
||||
}
|
||||
|
||||
/** 本车补充资料保持未填为空,不从原型示例或实际发车时间推定。 */
|
||||
export function blankVehicleMaintenance(): DispatchVehicleMaintenance {
|
||||
return { plannedDeliveryDate: null, deliveryMethod: null, pickupMethod: null, loadType: null, fleetEmail: null, vehicleType: null, appointmentAccount: null, appointmentNo: null }
|
||||
}
|
||||
|
||||
/** 保存前只整理已填写资料,不据补充资料触发派送、预约或费用。 */
|
||||
export function vehicleMaintenancePayload(value: DispatchVehicleMaintenance): DispatchVehicleMaintenance {
|
||||
const text = (item: string | null) => item?.trim() || null
|
||||
return { plannedDeliveryDate: text(value.plannedDeliveryDate), deliveryMethod: text(value.deliveryMethod), pickupMethod: text(value.pickupMethod), loadType: text(value.loadType), fleetEmail: text(value.fleetEmail), vehicleType: text(value.vehicleType), appointmentAccount: text(value.appointmentAccount), appointmentNo: text(value.appointmentNo) }
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: 'C-1' } }), useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null, AppointmentConfirmationCargo: { name: 'AppointmentConfirmationCargo', mounted() { this.$emit('ready', true) }, methods: { canLeave: async () => true }, template: '<div />' } }))
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null, AppointmentConfirmationCargo: { name: 'AppointmentConfirmationCargo', mounted(this: { $emit: (event: string, value: boolean) => void }) { this.$emit('ready', true) }, methods: { canLeave: async () => true }, template: '<div />' } }))
|
||||
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', template: '<div />' } }))
|
||||
vi.mock('../api/inboundOrders', () => ({ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
|
||||
@@ -88,7 +88,8 @@ describe('inbound appointment native dialogs', () => {
|
||||
vi.mocked(updateFbaAppointment).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockResolvedValueOnce({ ...appointment })
|
||||
const { dialog } = await openEditAppointment()
|
||||
const fields = dialog.getComponent(FbaAppointmentFields)
|
||||
fields.props('model').appointmentRemark = '交接备注'; await flushPromises()
|
||||
Object.assign(fields.props('model'), { appointmentRemark: '交接备注', confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-25T09:30:00' })
|
||||
fields.vm.$emit('confirmationReady', true); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenCalledTimes(1)
|
||||
const original = vi.mocked(updateFbaAppointment).mock.calls[0]!
|
||||
|
||||
@@ -256,7 +256,7 @@ onMounted(() => { void load() })
|
||||
</ListPageLayout>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" fullscreenable :loading="appointmentLoading" :confirm-disabled="appointmentLoading" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="预约范围核对"><h3 class="density-dialog-section__title">预约范围</h3><table class="density-table"><tbody><tr><th scope="row">已选择</th><td>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" aria-label="预约范围核对"><h3 class="density-dialog-section__title">预约范围</h3><table class="density-table"><tbody><tr><th scope="row">已选择</th><td>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
|
||||
<el-alert v-if="appointmentReplacementNos.length" :title="`原批次已被替代,当前只读。替代预约:${appointmentReplacementNos.join('、')}`" type="warning" :closable="false" show-icon />
|
||||
<LoadErrorState v-if="mutationError" compact title="预约保存失败" :message="mutationError.message" :loading="saving" :show-retry="!appointmentReadOnly" :retry-text="pendingAppointment ? '重试本次保存' : '重新提交'" @retry="submitAppointment"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<FbaAppointmentFields continuous v-if="appointmentOpen && !appointmentLoading" ref="appointmentFields" :sources="appointmentSources" @confirmation-ready="confirmationReady = $event" :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
|
||||
|
||||
@@ -282,13 +282,13 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
<details v-if="supersededActualNodes.length" class="technical-details duplicate-node-history"><summary>历史重复实际节点({{ supersededActualNodes.length }})</summary><ol class="technical-history"><li v-for="item in supersededActualNodes" :key="item.id"><span>{{ nodeTimeText(item) }}</span><strong>{{ nodeText(item.standardNode) }}</strong><small>{{ nodeLocation(item) }} · {{ item.providerCode }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template></small></li></ol></details>
|
||||
<details v-if="query" class="technical-details"><summary>查询任务摘要、异常与变更历史</summary><dl class="query-facts" :class="{ 'density-fields': dense }"><div><dt>查询任务</dt><dd>{{ query.queryNo || '—' }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div><div><dt>任务状态</dt><dd>{{ statusText }}</dd></div><div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div><div><dt>最新实际节点</dt><dd>{{ nodeText(query.latestActualNode) }}</dd></div><div><dt>实际发生时间</dt><dd>{{ formatTime(query.latestActualAt) }}</dd></div><div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div><div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>结果有效性</dt><dd>{{ queryView?.expired ? '结果已过期' : '结果有效' }}</dd></div><div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div></dl><h3>任务变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else class="technical-history"><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></details>
|
||||
</template>
|
||||
<AppDialog class="admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="manualDialogOpen" :title="manualDialogTitle" :width="dense ? 'min(780px, calc(100vw - 28px))' : 'min(620px, calc(100vw - 28px))'" :fullscreenable="dense" append-to-body confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode">
|
||||
<AppDialog class="container-node-dialog admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="manualDialogOpen" :title="manualDialogTitle" :width="dense ? 'min(780px, calc(100vw - 28px))' : 'min(620px, calc(100vw - 28px))'" :fullscreenable="dense" append-to-body confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode">
|
||||
<el-alert v-if="manualError" type="error" :closable="false" show-icon title="补录未成功,已保留当前填写内容"><template #default><ApiErrorMeta :details="manualError" /></template></el-alert>
|
||||
<el-alert v-if="manualValidationError" type="warning" :closable="false" show-icon :title="manualValidationError" />
|
||||
<el-alert type="info" :closable="false" title="观测时间按提交时刻记录;预计事实不推进实际进度,既有事实不可编辑或删除。" />
|
||||
<section v-if="dense" class="density-dialog-section" data-tone="work">
|
||||
<section v-if="dense" class="density-dialog-section">
|
||||
<el-form class="density-form">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable manual-editor-table" aria-label="人工补录船节点"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table manual-editor-table" aria-label="人工补录船节点"><tbody>
|
||||
<tr><td colspan="2"><el-form-item label-position="left" label-width="90px" label="标准节点" required><el-select v-model="manualForm.standardNode" placeholder="请选择标准节点"><el-option v-for="node in standardNodes" :key="node" :label="nodeText(node)" :value="node" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="90px" label="预计时间"><el-date-picker v-model="manualForm.estimatedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选" /></el-form-item></td><td><el-form-item label-position="left" label-width="90px" label="实际时间"><el-date-picker v-model="manualForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选,填写后推进实际节点" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="90px" label="地点编码"><el-input v-model="manualForm.locationCode" maxlength="32" placeholder="可选,如 USLAX" /></el-form-item></td><td><el-form-item label-position="left" label-width="90px" label="地点名称"><el-input v-model="manualForm.locationName" maxlength="128" placeholder="可选" /></el-form-item></td></tr>
|
||||
@@ -307,6 +307,7 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-node-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
|
||||
.fulfillment-table-wrap { min-width:0; overflow-x:auto; }
|
||||
.fulfillment-table { min-width:800px; table-layout:fixed; }
|
||||
.fulfillment-table__time { width:160px; }
|
||||
|
||||
@@ -117,10 +117,10 @@ onBeforeUnmount(() => { generation++ })
|
||||
<tr><th>提单号</th><td>{{ container.billOfLadingNo || '—' }}</td><th>客户</th><td>{{ container.customerName || '—' }}</td></tr>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
|
||||
<el-form v-if="canWrite" class="density-form" :disabled="busy || !!pending || loading">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable schedule-editor-table" aria-label="原柜船期填写"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table schedule-editor-table" aria-label="原柜船期填写"><tbody>
|
||||
<tr><td><el-form-item label="船名" label-position="left" label-width="120px" required><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次" label-position="left" label-width="120px" required><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||||
<tr><td v-for="key in dates" :key="key"><el-form-item :label="dateLabels[key]" label-position="left" label-width="120px" required><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></td></tr>
|
||||
<template v-if="!container.currentPortScheduleId">
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('InboundFeeDetailManager', () => {
|
||||
[['edit', 'delete'], ['编辑', '删除'], false],
|
||||
[['review', 'delete'], ['审核', '驳回', '删除'], false],
|
||||
[['edit', 'review'], ['编辑', '审核', '驳回'], false],
|
||||
[['edit', 'review', 'delete'], ['编辑', '审核', '驳回', ''], true],
|
||||
[['edit', 'review', 'delete'], ['编辑', '审核', '驳回', '更多'], true],
|
||||
])('folds pending fee actions only when more than three are available: %j', async (actions, labels, overflow) => {
|
||||
vi.mocked(listInboundFeeDetails).mockResolvedValue([pending])
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', ...actions.map(action => 'firstleg:inbound:fee-detail:' + action)])
|
||||
|
||||
@@ -317,7 +317,7 @@ onMounted(() => void load())
|
||||
<div v-loading="loading" class="fee-table-shell">
|
||||
<el-table :data="rows" border stripe empty-text="暂无费用数据" row-key="id">
|
||||
<el-table-column label="操作" fixed="left" width="250">
|
||||
<template #default="{ row }"><RowActions :actions="feeRowActions(row as InboundFeeDetail)" :more-label="`${(row as InboundFeeDetail).feeTypeNameSnapshot || '当前费用'}的更多操作`" @action="handleFeeRowAction($event, row as InboundFeeDetail)" /></template>
|
||||
<template #default="{ row }"><RowActions text-actions :actions="feeRowActions(row as InboundFeeDetail)" :more-label="`${(row as InboundFeeDetail).feeTypeNameSnapshot || '当前费用'}的更多操作`" @action="handleFeeRowAction($event, row as InboundFeeDetail)" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></template></el-table-column>
|
||||
<el-table-column label="来源" width="120"><template #default="{ row }">{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</template></el-table-column>
|
||||
@@ -346,7 +346,7 @@ onMounted(() => void load())
|
||||
<el-alert v-if="feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></el-alert>
|
||||
<el-alert v-if="suppliersError" type="error" :closable="true" @close="suppliersError = null"><template #title>{{ suppliersError.message }}</template><ApiErrorMeta :details="suppliersError" /></el-alert>
|
||||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<h3 class="density-dialog-section__title">费用资料</h3>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form density-form">
|
||||
<el-form-item label="应收/应付" prop="direction"><el-segmented v-model="form.direction" :options="directionOptions" /></el-form-item>
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('exposes desktop pallet printing only for one-click dropship orders', () => {
|
||||
expect(source).toContain("detail.value?.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP'")
|
||||
expect(source).toContain("{ key: 'dropship-print', label: '托盘打印', icon: '🖨️' }")
|
||||
expect(source).toContain("{ key: 'dropship-print', label: '托盘打印', icon: Printer }")
|
||||
expect(source).toContain("activeDetailTab === 'dropship-print'")
|
||||
expect(source).toContain('<DropshipPalletPrintPanel')
|
||||
expect(source).toContain("can('platform:firstleg:inbound:list')")
|
||||
@@ -70,6 +70,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
})
|
||||
|
||||
it('renders accessible current-status tabs with keyboard navigation', () => {
|
||||
expect(source).toContain("unifiedOrderStatusOptions.filter((tab) => tab.value !== 'WEIGHT_CONFIRMED')")
|
||||
expect(source.match(/class="orders-status-toolbar"/g)).toHaveLength(2)
|
||||
expect(source).toContain('.orders-status-toolbar :deep(.workbench-table.is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-start)')
|
||||
expect(source).toContain('flex: 0 0 100%;')
|
||||
@@ -591,7 +592,9 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('子单 · 第 ${row.relation.splitSequence} 次')
|
||||
expect(source).toContain('title="父子订单关联"')
|
||||
expect(source).toContain('@click="openOrderRelation(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain("{ key: 'fulfillment', label: '拆分履约', icon: '🧩' }")
|
||||
expect(source).toContain("{ key: 'fulfillment', label: '拆分履约', icon: GitBranch }")
|
||||
const tabs = source.slice(source.indexOf('const detailTabs ='), source.indexOf('const customerMeasurementColumns ='))
|
||||
expect(tabs.indexOf("key: 'other'")).toBeGreaterThan(tabs.indexOf("key: 'fulfillment'"))
|
||||
expect(source).toContain("activeDetailTab === 'fulfillment'")
|
||||
expect(source).toContain('<OrderFulfillmentTreePanel')
|
||||
expect(source).not.toContain('根单、已完成分片与当前余量')
|
||||
|
||||
@@ -373,8 +373,8 @@ const detailTabs = computed(() => [
|
||||
{ key: 'history', label: '状态历史', icon: History },
|
||||
{ key: 'measurement', label: '测量数据', icon: Ruler },
|
||||
...(isDropshipOrder.value ? [{ key: 'dropship-print', label: '托盘打印', icon: Printer }] : []),
|
||||
{ key: 'other', label: '其他', icon: MoreHorizontal },
|
||||
{ key: 'fulfillment', label: '拆分履约', icon: GitBranch },
|
||||
{ key: 'other', label: '其他', icon: MoreHorizontal },
|
||||
])
|
||||
|
||||
const customerMeasurementColumns = [
|
||||
@@ -609,7 +609,7 @@ async function waitForSavedMeasurementVersion(tenantId: string, inboundId: strin
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
{ value: '' as const, label: '全部' },
|
||||
...unifiedOrderStatusOptions.flatMap((tab) => tab.value === 'WAIT_MEASURE' && can('firstleg:inbound:measurement-review')
|
||||
...unifiedOrderStatusOptions.filter((tab) => tab.value !== 'WEIGHT_CONFIRMED').flatMap((tab) => tab.value === 'WAIT_MEASURE' && can('firstleg:inbound:measurement-review')
|
||||
? [tab, { value: 'MEASUREMENT_REVIEW' as const, label: '测量数据审核' }]
|
||||
: [tab]),
|
||||
])
|
||||
|
||||
@@ -1193,7 +1193,7 @@ describe('approved Admin density ownership', () => {
|
||||
sectionStyles.set(rule.selector, declarations)
|
||||
})
|
||||
for (const [section, background] of [
|
||||
['summary-section', 'var(--admin-dialog-summary-bg)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-surface, #fff)'],
|
||||
['summary-section', 'var(--admin-surface, #fff)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-surface, #fff)'],
|
||||
]) {
|
||||
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)).toMatchObject({
|
||||
padding: 'var(--admin-space-4, 10px) var(--admin-space-5, 12px)',
|
||||
@@ -1373,11 +1373,13 @@ describe('approved Admin density ownership', () => {
|
||||
})
|
||||
it('keeps dispatch workflow and owned evidence on shared compact sections', () => {
|
||||
const workflow = read('modules/overseas-warehouse/components/DispatchWorkflowDialog.vue')
|
||||
const maintenance = read('modules/overseas-warehouse/components/DispatchVehicleMaintenanceForm.vue')
|
||||
const cargo = read('modules/overseas-warehouse/components/SmartDispatchExistingCargo.vue')
|
||||
const evidence = read('modules/overseas-warehouse/components/DispatchEvidenceFiles.vue')
|
||||
expect(workflow).toContain(':fullscreenable="width !== \'560px\'"')
|
||||
expect(workflow).toContain('<DispatchResourceFields continuous')
|
||||
expect(workflow).toContain('density-table density-table--editable workflow-editor-table')
|
||||
expect(workflow).toContain('<DispatchVehicleMaintenanceForm')
|
||||
expect(maintenance).toContain('<DispatchResourceFields continuous')
|
||||
expect(maintenance).toContain('density-table density-table--editable maintenance-table')
|
||||
expect(workflow).toContain('label-position="left" label-width="130px"')
|
||||
expect(workflow).toContain(':confirm-loading="submitting || busy || uploadingEvidence"')
|
||||
expect(workflow).not.toContain('aria-label="候选货物分页"')
|
||||
|
||||
Reference in New Issue
Block a user