完善预约确认与排车表单并统一计费和筛选展示

This commit is contained in:
2026-10-28 12:01:43 +08:00
parent 0c3f4ece2a
commit 39b9a6a1a0
27 changed files with 277 additions and 77 deletions
@@ -11,6 +11,7 @@ import { batchContext } from './batch.test-support'
import type { ScheduleEvent } from './schedule'
import { getWorkDetail, type WorkAction } from './workApi'
import { workDetail, workItem } from './work.test-support'
import { getCargoVerification } from './cargoVerificationApi'
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
@@ -19,6 +20,7 @@ vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArriv
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => { vi.unstubAllGlobals() })
const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true }
@@ -35,6 +37,7 @@ beforeEach(() => {
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
vi.mocked(getCargoVerification).mockResolvedValue({ source, verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: 20, effectiveShipmentReferences: ['FBA-TEST'], availablePackages: 20, sourceChanged: false, allowedActions: [], blockReasons: [] })
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
@@ -52,6 +55,7 @@ async function create(wrapper: Awaited<ReturnType<typeof open>>) {
const content = creationBody()
await content.get('input[type="checkbox"]').setValue(true)
await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises()
await content.get('input[placeholder="填写收货方提供的确认号"]').setValue('CONFIRM-NEW'); await flushPromises()
return dialog
}
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
@@ -72,17 +76,19 @@ it('keeps the arrival context and typed local time across native fullscreen and
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
})
it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => {
it('keeps the real arrival input editable after a failed attempt and submits the edited time with a fresh key', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(dialog.get('input').attributes('disabled')).toBeDefined()
expect(dialog.text()).toContain('上次提交结果尚未确认')
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果')
expect(dialog.get('input').attributes('disabled')).toBeUndefined()
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('保存')
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-23T09:30'); await flushPromises()
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
const calls = vi.mocked(saveWarehouseArrival).mock.calls
expect(calls[1]![2]).not.toBe(calls[0]![2])
expect(calls[1]![1]).toEqual({ estimatedWarehouseArrivalAt: '2026-10-23T09:30:00', expectedVersion: 3 })
})
it('clears the Element arrival picker without saving an empty required time', async () => {
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
@@ -114,7 +120,7 @@ it('preserves real FBA fields and entered values when expanding the create dialo
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(date.props('modelValue')).toBe('2026-10-25')
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) }))
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }), idempotencyKey: expect.any(String) }))
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
expect(creationBody().text()).toContain('AP-001')
})
@@ -141,9 +147,10 @@ it('uses the same real continuous form through the original work detail deep lin
const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises()
const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!)
await content.get('.primary-next-action').trigger('click'); await flushPromises()
await content.get('input[placeholder="填写收货方提供的确认号"]').setValue('CONFIRM-NEW'); await flushPromises()
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true)
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) }))
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }) }))
expect(content.text()).toContain('本次保存已完成')
})
@@ -12,7 +12,7 @@ vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanning
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] }
const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } }
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, effectiveShipmentReferences: [], availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
function render(active = true, disabled = false) {
return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
@@ -52,8 +52,10 @@ it('leaves pending arrangements lazy and reads only when formal confirmation is
})
it('does not ask for known materials to be entered again', async () => {
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectiveShipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.text()).toContain('FBA-ORIGINAL、TRACK-ORIGINAL')
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
})
@@ -68,11 +68,12 @@ onBeforeUnmount(() => { ++generation; unregister?.() })
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>需要补充的资料</th></tr></thead><tbody>
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>FBA 货件号 / 追踪号</th><th>需要补充的资料</th></tr></thead><tbody>
<tr v-for="row in rows" :key="key(row)">
<td><el-button v-if="issues(row).length && row.resolved.source.orderId" :disabled="disabled || loading || !!editing || !row.cargo.allowedActions.includes('VERIFY_CARGO')" @click="editing = key(row)">在此补齐资料</el-button><span v-else>—</span></td>
<td>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td>
<td>{{ row.cargo.effectivePackages ?? '待补充' }}</td>
<td>{{ row.cargo.effectiveShipmentReferences.join('、') || '—' }}</td>
<td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td>
</tr>
</tbody></table>
@@ -131,8 +131,8 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
<tr><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
<tr v-if="createdForm.appointmentAt || createdForm.appointmentEndAt"><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
<tr v-if="createdForm.startOffset || createdForm.endOffset"><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
</tbody></table>
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
@@ -10,7 +10,7 @@ vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCa
const source = { sourceKey: 'ORDER:9007199254740993', customerId: 'customer-1', destinationKey: 'FBA:ONT8' }
const context: CargoVerificationContext = {
source: { ...source, orderId: '9007199254740993', inboundCode: 'IN-TEST-001', bookingNo: null, containerNo: 'TEST-CN-001', originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null, canFollowup: true, blockReasons: [] },
verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, availablePackages: null, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [],
verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, effectiveShipmentReferences: [], availablePackages: null, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [],
}
function render() { return mount(CargoVerificationForm, { props: { source }, global: { stubs: { ElButton: { props: ['nativeType'], template: '<button :type="nativeType"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true } } }) }
beforeEach(() => {
@@ -26,6 +26,15 @@ it('saves independent cargo verification before any appointment exists, preservi
expect(vi.mocked(saveCargoVerification).mock.calls[0]![1]).not.toHaveProperty('appointmentStatus')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('prefills existing order shipment and tracking numbers without creating a verification record', async () => {
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: 20, effectiveShipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] })
const wrapper = render(); await flushPromises()
expect((wrapper.get('textarea[aria-label="本票货件号"]').element as HTMLTextAreaElement).value).toBe('FBA-ORIGINAL\nTRACK-ORIGINAL')
expect(saveCargoVerification).not.toHaveBeenCalled()
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ shipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] }))
wrapper.unmount()
})
it('uses the same key and payload after an unknown save outcome', async () => {
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('network lost'))
const wrapper = render(); await flushPromises()
@@ -27,7 +27,7 @@ async function load(preserve = false) {
context.value = await getCargoVerification(props.source)
if (!preserve) {
const record = context.value.verification
Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: context.value.effectiveShipmentReferences.join('\n'), remark: record?.remark || '' })
baseline.value = JSON.stringify(form)
}
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
@@ -26,7 +26,7 @@ it('shows readable preview requirements without exposing missing-field machine c
})
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' })
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED' })
fields.vm.$emit('confirmationReady', true)
await flushPromises()
}
@@ -35,7 +35,7 @@ it('previews and creates the exact canonical external source and customer with r
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: 'SUCCESS', confirmationNo: 'EXT-CONFIRMED' }) }))
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentAt: null, appointmentEndAt: null, startOffset: null, endOffset: 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 () => {
@@ -63,7 +63,7 @@ onMounted(load)
</tbody></table>
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
</section>
<FbaAppointmentFields continuous ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<FbaAppointmentFields continuous hide-confirmation-times ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
</template>
@@ -452,7 +452,7 @@ it('shows authoritative subset blockers and ignores a late response for a previo
})
function verifiedCargo(): CargoVerificationContext {
return { source: { sourceKey: 'BOOKING:2', customerId: 'customer-b', orderId: null, inboundCode: 'IN-B', bookingNo: null, containerNo: null, originWarehouseId: 'origin-1', originWarehouseName: '本仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 40, sourceVersion: 'source-latest', canFollowup: true, blockReasons: [] }, verification: { version: 2, sourceVersion: 'source-latest', destinationKey: 'FBA:ONT8', sourcePackages: 40, packageCount: 40, shipmentReferences: ['FBA-NEW-B'], remark: null, operatorName: '核实人员', verifiedAt: '2026-10-16T10:00:00Z' }, allocatedPackages: 30, unknownAllocationCount: 0, effectivePackages: 40, availablePackages: 10, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
return { source: { sourceKey: 'BOOKING:2', customerId: 'customer-b', orderId: null, inboundCode: 'IN-B', bookingNo: null, containerNo: null, originWarehouseId: 'origin-1', originWarehouseName: '本仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 40, sourceVersion: 'source-latest', canFollowup: true, blockReasons: [] }, verification: { version: 2, sourceVersion: 'source-latest', destinationKey: 'FBA:ONT8', sourcePackages: 40, packageCount: 40, shipmentReferences: ['FBA-NEW-B'], remark: null, operatorName: '核实人员', verifiedAt: '2026-10-16T10:00:00Z' }, allocatedPackages: 30, unknownAllocationCount: 0, effectivePackages: 40, effectiveShipmentReferences: ['FBA-NEW-B'], availablePackages: 10, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
}
async function enterRefresh(wrapper: ReturnType<typeof render>) {
await flushPromises(); await wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料')[1]!.trigger('click'); await flushPromises()
@@ -1,7 +1,7 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { PlanningSource } from './planningApi'
export interface CargoVerification { version: number; sourceVersion: string | null; destinationKey: string; sourcePackages: number | null; packageCount: number | null; shipmentReferences: string[]; remark: string | null; operatorName: string | null; verifiedAt: string }
export interface CargoVerificationContext { source: PlanningSource; verification: CargoVerification | null; allocatedPackages: number; unknownAllocationCount: number; effectivePackages: number | null; availablePackages: number | null; sourceChanged: boolean; allowedActions: string[]; blockReasons: string[] }
export interface CargoVerificationContext { source: PlanningSource; verification: CargoVerification | null; allocatedPackages: number; unknownAllocationCount: number; effectivePackages: number | null; effectiveShipmentReferences: string[]; availablePackages: number | null; sourceChanged: boolean; allowedActions: string[]; blockReasons: string[] }
export interface CargoVerificationSource { sourceKey: string; customerId: string; destinationKey?: string }
export interface SaveCargoVerification { destinationKey: string; expectedSourceVersion: string | null; expectedVersion: number; packageCount: number | null; shipmentReferences: string[]; remark: string | null; idempotencyKey: string }
const path = (key: string) => `/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(key)}/cargo-verification`
@@ -13,7 +13,7 @@ import {
submitInboundMeasurement,
updateConfirmedPackageCount,
} from './api'
import type { InboundMeasurementDetail } from './types'
import type { InboundMeasurementDetail, SaveInboundMeasurementItemRequest } from './types'
import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
vi.mock('./api', () => ({
@@ -152,6 +152,10 @@ async function mountPanel(permissions = [
return wrapper
}
function measurementDraft(wrapper: Awaited<ReturnType<typeof mountPanel>>) {
return (wrapper.vm.$ as unknown as { setupState: { form: { items: SaveInboundMeasurementItemRequest[] } } }).setupState.form
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
@@ -317,6 +321,50 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.find('.measurement-declaration').element.parentElement).toBe(wrapper.find('.measurement-summary-section').element.parentElement)
})
it('shows raw measured volume rather than customer weighted billing volume in the rows and comparison', async () => {
const value = measurement({ originalVolumeCbm: 3.3, originalPackageCount: 22, actualPackageCount: 22, actualVolumeCbm: 7.205, volumeWeightKg: 1203.235 })
Object.assign(value.items[0]!, {
lengthCm: 62, widthCm: 52, heightCm: 52, packageCount: 22,
coefficientMode: 'MULTIPLY', chargeRuleId: '6001',
calculatedOuterLengthCm: 77.5, calculatedOuterWidthCm: 65, calculatedOuterHeightCm: 65,
unitVolumeCbm: 0.3275, totalVolumeCbm: 7.205,
})
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(value)
const wrapper = await mountPanel()
const comparisonVolume = () => wrapper.get('.measurement-comparison tbody tr:last-child').findAll('td').map(cell => cell.text())
expect(comparisonVolume()).toEqual(['3.300', '3.688', '差异 +0.388 m³'])
expect(wrapper.get('.measurement-table').text()).toContain('3.688')
expect(wrapper.get('.measurement-table').text()).not.toContain('7.205')
expect(wrapper.get('.measurement-extra-facts').text()).toContain('计费材积重')
await wrapper.get('input[aria-label="仓库修改外箱长"]').setValue('80')
expect(comparisonVolume()[1]).toBe('3.688')
await wrapper.get('.measurement-count-editor input').setValue('11')
expect(comparisonVolume()).toEqual(['3.300', '1.844', '差异 -1.456 m³'])
expect(wrapper.get('.measurement-table').text()).toContain('1.844')
expect(value.actualVolumeCbm).toBe(7.205)
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
expect(updateConfirmedPackageCount).not.toHaveBeenCalled()
})
it('sums measured volumes across rows and does not substitute billing totals for incomplete dimensions', async () => {
const value = measurement({ actualVolumeCbm: 9.999 })
value.items.push({ ...value.items[0]!, id: '9102', itemNo: '002', boxCode: 'RK202608040001#10-2', lengthCm: 10, widthCm: 20, heightCm: 30, packageCount: 2, totalVolumeCbm: 8.888 })
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(value)
const wrapper = await mountPanel()
expect(wrapper.get('.measurement-comparison tbody tr:last-child').findAll('td')[1]!.text()).toBe('0.612')
value.items[1]!.lengthCm = 0
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(value)
await wrapper.setProps({ inboundId: '7002' })
await flushPromises()
const cells = wrapper.get('.measurement-comparison tbody tr:last-child').findAll('td')
expect(cells[1]!.text()).toBe('未测量')
expect(cells[2]!.text()).toBe('待测量')
expect(wrapper.get('.measurement-table').text()).not.toContain('8.888')
})
it('shows pending comparison rather than a negative count when no boxes have been measured', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({
items: [], actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
@@ -424,7 +472,7 @@ describe('InboundMeasurementPanel', () => {
it('retains server-calculated facts in the draft without the removed expandable row', async () => {
const wrapper = await mountPanel()
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
expect(wrapper.vm.form.items[0]).toMatchObject({
expect(measurementDraft(wrapper).items[0]).toMatchObject({
boxSpecification: '50×40×30cm', coefficientMode: 'ACTUAL',
calculatedOuterLengthCm: 50, calculatedOuterWidthCm: 40, calculatedOuterHeightCm: 30,
calculationRuleVersion: 'DIMENSION_ACTUAL_V1',
@@ -621,7 +669,7 @@ describe('InboundMeasurementPanel', () => {
it('retains the formal deviceMeasuredAt response field in the measurement draft', async () => {
const wrapper = await mountPanel()
expect(wrapper.vm.form.items[0].measuredAt).toBe('2026-08-04T10:30:00+08:00')
expect(measurementDraft(wrapper).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')
@@ -120,11 +120,28 @@ const mayMaintain = computed(() => canEdit.value && countEditableProcess.value &
const manualEntryEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const mayCreateManualEntry = computed(() => canManualEntry.value && manualEntryEditableProcess.value && !loading.value && !saving.value && !submitting.value && !isDirty.value)
const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => sum + (Number.isFinite(item.packageCount) ? item.packageCount : 0), 0))
// No scan rows means no measured count yet, rather than a measured zero.
// 实测展示只使用原始尺寸与确认箱数,不套用计费规则或仓库外箱修正,也不回写计费快照。
function measuredVolumeCbm(item: Pick<EditableMeasurementItem, 'lengthCm' | 'widthCm' | 'heightCm' | 'packageCount'>) {
if (![item.lengthCm, item.widthCm, item.heightCm].every(value => Number.isFinite(value) && value > 0)
|| !Number.isInteger(item.packageCount) || item.packageCount < 1) return null
const volume = item.lengthCm * item.widthCm * item.heightCm * item.packageCount / 1_000_000
return Number.isFinite(volume) ? volume : null
}
const measuredTotalVolumeCbm = computed(() => {
if (!form.items.length) return null
let total = 0
for (const item of form.items) {
const volume = measuredVolumeCbm(item)
if (volume == null) return null
total += volume
}
return total
})
// 尚无扫描行时保持未测量,不把缺失的实测事实显示为零。
const comparisonMetrics = computed(() => [
{ label: '箱数', declared: detail.value?.originalPackageCount, actual: form.items.length ? confirmedPackageCount.value : null, digits: 0, unit: '' },
{ label: '毛重(kg)', declared: detail.value?.originalGrossWeightKg, actual: detail.value?.actualGrossWeightKg, digits: 3, unit: ' kg' },
{ label: '总体积(m³)', declared: detail.value?.originalVolumeCbm, actual: detail.value?.actualVolumeCbm, digits: 3, unit: ' m³' },
{ label: '总体积(m³)', declared: detail.value?.originalVolumeCbm, actual: measuredTotalVolumeCbm.value, digits: 3, unit: ' m³' },
])
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
@@ -734,8 +751,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
</table></div>
<div class="measurement-extra-facts">
<span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
<span>材积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span>
<el-tooltip content="净重、材积重为平台数据,不与客户申报毛重比较"><span tabindex="0">平台补充数据</span></el-tooltip>
<span>计费材积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span>
<el-tooltip content="实测体积按原始尺寸和确认箱数展示;计费材积重沿用服务端按客户计费规则计算的结果,不与客户申报毛重比较"><span tabindex="0">平台补充数据</span></el-tooltip>
</div>
</section>
@@ -827,7 +844,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
</template>
</el-table-column>
<el-table-column label="总体积(m³)" width="108" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 3, 3) }}</template>
<template #default="{ row }">{{ formatNumber(measuredVolumeCbm(row as EditableMeasurementItem), 3, 3) }}</template>
</el-table-column>
<el-table-column label="图片" width="82" align="center">
<template #default="{ row }">
@@ -3,11 +3,11 @@ import { appointmentFormError, appointmentPayload, createAppointmentForm } from
it('requires an existing external confirmation for a new platform registration', () => {
const form = createAppointmentForm()
expect(appointmentFormError(form)).toContain('外部约号与确认时间')
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({ appointmentAt: null, appointmentEndAt: null, startOffset: null, endOffset: null })
expect(appointmentPayload(form)).not.toHaveProperty('expectedLoadingAt')
})
it('retains an already confirmed repeated local time using its stored UTC instant', () => {
@@ -23,7 +23,7 @@ export function appointmentPayload(form: AppointmentForm): FbaAppointmentDetails
}
export function appointmentFormError(form: AppointmentForm): string | null {
if (form.appointmentStatus === 'CANCELLED') return '请在预约列表使用取消预约操作,并填写实际取消原因'
if (form.appointmentStatus === 'SUCCESS' && (!form.confirmationNo.trim() || !form.appointmentAt)) return '登记对方确认时,请填写外部约号与确认时间'
if (form.appointmentStatus === 'SUCCESS' && !form.confirmationNo.trim()) return '登记对方确认时,请填写外部约号'
if (form.appointmentStatus === 'FAILED' && !form.appointmentRemark.trim()) return '请填写未约成的原因'
return null
}
@@ -38,13 +38,16 @@ describe('delivery document drafts', () => {
it('loads once and stages replacement and deletion until a full versioned save', async () => {
const wrapper = mountDialog(); await flushPromises()
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.find('[aria-label="派送资料摘要"]').exists()).toBe(false)
expect(wrapper.findAll('.document-file')).toHaveLength(1)
expect(wrapper.findAll('.document-files th').map(cell => cell.text())).toEqual(['操作', '文件名称'])
await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['next'], 'new.pdf'))
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('original.pdf')
expect(wrapper.text()).toContain('new.pdf')
expect(wrapper.get('.document-file-pending').text()).toBe('待保存')
await wrapper.find('[data-category="BOL"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click')
expect(wrapper.text()).toContain('0/3 已上传')
expect(wrapper.findAll('.document-file')).toHaveLength(0)
await wrapper.find('.save').trigger('click'); await flushPromises()
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) })
expect(wrapper.emitted('saved')).toHaveLength(1)
@@ -64,7 +67,7 @@ describe('delivery document drafts', () => {
expect(dialog.findAll('[data-category]')).toHaveLength(3)
await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['pod'], 'pod.pdf'))
await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['courier'], 'courier.pdf'))
expect(dialog.text()).toContain('2/3 已上传')
expect(dialog.findAll('[data-category="RECEIPT"] .document-file')).toHaveLength(2)
await choose(dialog.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['signed'], 'signed-bol.pdf'))
await dialog.find('[data-category="RECEIPT"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click')
expect(dialog.text()).not.toContain('pod.pdf')
@@ -80,7 +83,8 @@ describe('delivery document drafts', () => {
vi.mocked(getDeliveryDocuments).mockResolvedValue(persisted)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
const reopened = body.find('[role="dialog"]')
expect(reopened.text()).toContain('2/3 已上传')
expect(reopened.findAll('.document-file')).toHaveLength(2)
expect(reopened.find('.document-file-pending').exists()).toBe(false)
expect(reopened.text()).toContain('signed-bol.pdf')
expect(reopened.text()).toContain('courier.pdf')
expect(reopened.text()).not.toContain('original.pdf')
@@ -90,12 +94,13 @@ describe('delivery document drafts', () => {
it('discards unsaved uploads on cancel and reloads persisted categories on reopening', async () => {
const wrapper = mountDialog(); await flushPromises()
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['proof'], 'new.pdf'))
expect(wrapper.text()).toContain('2/3 已上传')
expect(wrapper.findAll('.document-file')).toHaveLength(2)
await wrapper.find('.close').trigger('click')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.findAll('.document-file')).toHaveLength(1)
expect(wrapper.find('.document-file-pending').exists()).toBe(false)
expect(wrapper.text()).not.toContain('new.pdf')
wrapper.unmount()
})
@@ -184,7 +189,7 @@ describe('delivery document drafts', () => {
await wrapper.setProps({ modelValue: true, deliveryNo: 'DL/2', customerId: '200' }); await flushPromises()
fail(new Error('old upload failed')); await flushPromises()
expect(wrapper.text()).not.toContain('old upload failed')
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.findAll('.document-file')).toHaveLength(1)
expect(wrapper.find('[data-category="RECEIPT"] input[type="file"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
@@ -28,7 +28,6 @@ const error = ref<ApiErrorDetails | null>(null)
const conflict = ref(false)
const canEdit = computed(() => access.can(permission.deliveryDocuments) && Boolean(snapshot.value?.allowedActions.includes('MANAGE_DOCUMENTS')))
const busy = computed(() => saving.value || uploading.value)
const uploadedCount = computed(() => visibleGroups.value.filter(item => item.files.length > 0).length)
const visibleGroups = computed(() => draft.value.filter(item => !props.categories || props.categories.includes(item.category)))
const dirty = computed(() => Boolean(snapshot.value) && (bolNo.value !== (snapshot.value?.bolNo || '') || draft.value.some(group => JSON.stringify(group.files.map(file => file.fileId)) !== JSON.stringify(snapshot.value?.categories.find(item => item.category === group.category)?.files.map(file => file.fileId) || []))))
defineExpose({ busy, dirty, canEdit, loading, conflict, save })
@@ -118,16 +117,21 @@ async function save() {
<component :is="embedded ? 'div' : AppDialog" class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="embedded ? undefined : '派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section v-if="!embedded" 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>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/' + visibleGroups.length + ' 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
<el-skeleton v-if="loading && !snapshot" :rows="6" animated />
<template v-if="snapshot">
<section v-if="!categories || categories.includes('BOL')" class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
<section v-for="group in visibleGroups" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
<header><h3 class="density-dialog-section__title">{{ group.label }}</h3><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<section v-for="group in visibleGroups" :key="group.category" class="document-group density-dialog-section" :data-category="group.category" :aria-label="group.label">
<header>
<h3 class="density-dialog-section__title">{{ group.label }}<small>{{ group.files.length }} 个文件</small></h3>
<el-upload v-if="canEdit" :key="uploadKey" class="document-group-upload" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload>
</header>
<el-form v-if="group.category === 'BOL'" class="density-form document-bol-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading">
<el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item>
</el-form>
<p v-if="!group.files.length" class="density-dialog-note">尚未上传文件</p>
<table v-else class="density-table document-files"><thead><tr><th class="document-file-actions">操作</th><th>文件名称</th><th class="document-file-state">保存状态</th><th class="document-file-time">上传时间</th></tr></thead><tbody>
<table v-else class="density-table document-files"><thead><tr><th scope="col" class="document-file-actions">操作</th><th scope="col">文件名称</th></tr></thead><tbody>
<tr v-for="file in group.files" :key="file.fileId" class="document-file">
<td><div class="density-actions"><el-button link :aria-label="'下载 ' + file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">下载</el-button><template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict">替换</el-button></el-upload><el-button class="density-action" data-tone="danger" :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template></div></td>
<td><LineClampText :content="file.fileName" /></td><td>{{ isSaved(group.category, file.fileId) ? '已保存' : '待保存' }}</td><td>{{ file.uploadedAt?.replace('T', ' ') || '—' }}</td>
<td><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :aria-label="'下载 ' + file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">下载</el-button><template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict">替换</el-button></el-upload><el-button class="density-action" data-tone="danger" :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template></div></td>
<td><div class="document-file-name"><LineClampText :content="file.fileName" :lines="2" class="document-file-label" /><el-tag v-if="!isSaved(group.category, file.fileId)" class="document-file-pending" type="warning" size="small">待保存</el-tag></div></td>
</tr>
</tbody></table>
</section>
@@ -138,10 +142,12 @@ async function save() {
<style scoped>
.document-group header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); margin-bottom: var(--admin-space-3); }
.document-group header h3 { margin: 0; }
.document-group header > :last-child { margin-left: auto; }
.document-group header h3 { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-3); margin: 0; }
.document-group-upload { margin-left: auto; }
.document-bol-form { max-width: 460px; margin-bottom: var(--admin-space-4); }
.document-files { table-layout: fixed; }
.document-file-actions { width: 220px; }
.document-file-time { width: 170px; }
.document-file-state { width: 90px; }
.document-file-actions { width: 180px; }
.document-file-name { display: flex; align-items: center; gap: var(--admin-space-3); }
.document-file-label { flex: 1; min-width: 0; }
.document-file-pending { flex-shrink: 0; }
</style>
@@ -39,6 +39,19 @@ it('links both pallets to the confirmed appointment on the original dispatch wit
batchAllocations: [expect.objectContaining({ planPalletId: '21', packageCount: 10, recordVersion: '0' }), expect.objectContaining({ planPalletId: '22', packageCount: 10, recordVersion: '0' })] }))
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('links a formal confirmation without inventing a missing receiving time', async () => {
const value = facts()
value.items.forEach(item => { item.sources[0]!.options[0]!.appointmentAt = null })
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
const wrapper = await open()
expect(wrapper.text()).toContain('CONF-9'); expect(wrapper.text()).toContain('确认时间未填写')
const dialog = wrapper.findComponent(AppDialog)
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledTimes(1)
expect(vi.mocked(api.saveDispatchAppointmentAllocations).mock.calls[0]![1].batchAllocations).toHaveLength(2)
wrapper.unmount()
})
it('counts a shared source quota once across pallets and rejects incomplete allocation', async () => {
const value = facts(); value.items.forEach(item => { item.sources[0]!.options[0]!.availablePackages = 15 })
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
@@ -97,8 +97,8 @@ onBeforeUnmount(() => { generation++ })
<p class="density-dialog-note">按本车每托、每票货物核对已确认的正式预约及箱数。保存后保留原排车,重新提交两级审核。</p>
<div class="appointment-table"><table class="density-table density-table--editable"><thead><tr><th>客户/托盘</th><th>订单号/入仓码</th><th>目的仓</th><th>本票箱数</th><th>正式预约/当地送达时间</th><th>本票可排箱数</th><th>分配箱数</th></tr></thead><tbody>
<template v-for="row in rows" :key="row.key">
<tr v-for="choice in row.choices" :key="choice.batchKey"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td>{{ choice.batchNo || '—' }}<span v-if="choice.unavailable">(已失效)</span><br>{{ choice.confirmationNo || '—' }}<br>{{ choice.appointmentAt?.replace('T', ' ') }}<template v-if="choice.appointmentEndAt"> ~ {{ choice.appointmentEndAt.replace('T', ' ') }}</template><br>{{ choice.timezone }}</td><td>{{ choice.availablePackages }}<span v-if="!choice.unavailable">(同票共用)</span></td><td><el-input-number v-model="choice.packages" :min="0" :precision="0" :controls="false" :disabled="loading || saving || !permitted" :aria-label="`${row.item.palletNo} ${choice.batchNo} 分配箱数`" /></td></tr>
<tr v-if="!row.choices.length"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td colspan="3">{{ row.source.blockReasons.join(';') || '暂无已确认的正式预约,请先在预约列表确认送仓时间' }}</td></tr>
<tr v-for="choice in row.choices" :key="choice.batchKey"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td>{{ choice.batchNo || '—' }}<span v-if="choice.unavailable">(已失效)</span><br>{{ choice.confirmationNo || '—' }}<br>{{ choice.appointmentAt?.replace('T', ' ') || '确认时间未填写' }}<template v-if="choice.appointmentEndAt"> ~ {{ choice.appointmentEndAt.replace('T', ' ') }}</template><br>{{ choice.timezone }}</td><td>{{ choice.availablePackages }}<span v-if="!choice.unavailable">(同票共用)</span></td><td><el-input-number v-model="choice.packages" :min="0" :precision="0" :controls="false" :disabled="loading || saving || !permitted" :aria-label="`${row.item.palletNo} ${choice.batchNo} 分配箱数`" /></td></tr>
<tr v-if="!row.choices.length"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td colspan="3">{{ row.source.blockReasons.join(';') || '暂无已确认的正式预约,请先在预约列表登记外部约号及覆盖货物' }}</td></tr>
</template><tr v-if="!loading && !rows.length"><td colspan="7">本车暂无可关联正式预约的FBA货物</td></tr>
</tbody></table></div>
<el-alert v-if="factsReady && permitted && validation" :title="validation" type="warning" :closable="false" show-icon />
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import { createAppointmentForm } from '../appointmentForm'
import FbaAppointmentFields from './FbaAppointmentFields.vue'
it('removes confirmation time and offset inputs only for the new appointment flow while retaining planned dates', async () => {
const wrapper = mount(FbaAppointmentFields, {
props: { model: createAppointmentForm(), continuous: true, hideConfirmationTimes: true, timezone: 'America/New_York' },
global: { stubs: {
ElForm: { template: '<form><slot /></form>' },
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
ElInput: { template: '<input />' },
ElDatePicker: { template: '<input data-picker />' },
InlineHelp: true,
} },
})
expect(wrapper.text()).toContain('外部约号')
expect(wrapper.text()).toContain('计划送仓日期')
expect(wrapper.text()).toContain('补充计划时段')
expect(wrapper.findAll('[data-picker]')).toHaveLength(3)
expect(wrapper.text()).not.toMatch(/对方确认开始时间|对方确认结束时间|确认开始时差|确认结束时差|夏令时/)
await wrapper.setProps({ hideConfirmationTimes: false })
expect(wrapper.text()).toContain('对方确认开始时间')
expect(wrapper.findAll('[data-picker]')).toHaveLength(5)
wrapper.unmount()
})
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
import { ref, watchEffect } from 'vue'
import type { AppointmentForm } from '../appointmentForm'
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; sources?: PlanningSourceLookup[] }>()
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; hideConfirmationTimes?: boolean; sources?: PlanningSourceLookup[] }>()
const emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
watchEffect(() => {
@@ -13,7 +13,7 @@ watchEffect(() => {
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
</script>
<template>
<p v-if="model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
<p v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
<AppointmentConfirmationCargo v-if="sources?.length" ref="cargo" :sources="sources" :active="model.appointmentStatus === 'SUCCESS'" :disabled="disabled" @ready="emit('confirmationReady', $event)" />
<div v-if="continuous" class="density-dialog-sections">
<section class="density-dialog-section" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
@@ -22,13 +22,13 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item></td></tr>
<template v-if="model.appointmentStatus === 'SUCCESS'">
<tr><td colspan="2"><el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item></td></tr>
<tr><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>
<tr v-if="!hideConfirmationTimes"><td><el-form-item label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
</template>
<tr><td colspan="2"><el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" aria-label="预约备注" maxlength="1000" /></el-form-item></td></tr>
</tbody></table>
<details><summary>补充计划时段与夏令时信息</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
<details><summary>{{ hideConfirmationTimes ? '补充计划时段' : '补充计划时段与夏令时信息' }}</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
<tr><td><el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item></td><td><el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
<template v-if="model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
<template v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
</tbody></table></details></el-form>
</section>
</div>
@@ -39,15 +39,15 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
<el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'">
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
<el-form-item 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>
<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>
<el-form-item v-if="!hideConfirmationTimes" label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
<el-form-item v-if="!hideConfirmationTimes" label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
</template>
<el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" aria-label="预约备注" maxlength="1000" /></el-form-item>
<details class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
<details class="appointment-fields__extra"><summary>{{ hideConfirmationTimes ? '补充计划时段' : '补充计划时段与夏令时信息' }}</summary>
<div class="appointment-fields__extra-grid">
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
<template v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
<el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item>
<el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item>
</template>
@@ -58,7 +58,7 @@ export interface DispatchItems { dispatchNo: string; recordVersion: Version; sta
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
export interface AppointmentAllocationOption { batchKey: string; batchNo: string; recordVersion: Version; availablePackages: number; confirmationNo: string | null; appointmentAt: string; appointmentEndAt: string | null; timezone: string }
export interface AppointmentAllocationOption { batchKey: string; batchNo: string; recordVersion: Version; availablePackages: number; confirmationNo: string | null; appointmentAt: string | null; appointmentEndAt: string | null; timezone: string }
export interface AppointmentAllocationSource { sourceKey: string; orderNo: string | null; inboundCode: string | null; destinationKey: string; packageCount: number | null; options: AppointmentAllocationOption[]; blockReasons: string[] }
export interface AppointmentAllocationItem { dispatchItemId: string; planPalletId: string; customerId: string; customerName: string | null; palletNo: string; sources: AppointmentAllocationSource[]; allocations: DispatchBatchAllocation[] }
export interface AppointmentAllocations { dispatchNo: string; recordVersion: Version; items: AppointmentAllocationItem[]; edit: Gate }
@@ -139,19 +139,19 @@ describe('native delivery operation dialogs', () => {
it('keeps readonly document facts plain and only exposes saved downloads', async () => {
api.documents.mockResolvedValueOnce({ ...documents(), allowedActions: [] })
const { dialog } = await openDocuments()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('当前账号仅可查看已保存资料')
expect(dialog.props('showConfirm')).toBe(false)
expect(dialog.findAll('input')).toHaveLength(0); expect(dialog.text()).toContain('BOL-1')
expect(dialog.findAll('[data-category]')).toHaveLength(3)
expect(dialog.findAll('button').some(button => button.text() === '上传')).toBe(false)
await dialog.get('[aria-label="下载 完整文件名称.pdf"]').trigger('click'); await flushPromises(); expect(api.download).toHaveBeenCalledOnce()
})
it('shows an unavailable summary after a document read failure and retries within scope', async () => {
it('shows the document read failure and retries within scope', async () => {
api.documents.mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0503', 503, null, 'document-read-request'))
const { dialog } = await openDocuments()
expect(dialog.text()).toContain('document-read-request'); expect(dialog.text()).toContain('资料未返回')
expect(dialog.text()).not.toContain('正在读取…'); expect(dialog.props('showConfirm')).toBe(false)
expect(dialog.text()).toContain('document-read-request'); expect(dialog.findComponent(LoadErrorState).props('message')).toBe('读取失败')
expect(dialog.findAll('[data-category]')).toHaveLength(0); expect(dialog.props('showConfirm')).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.documents).toHaveBeenLastCalledWith('DEL/1', 'C1'); expect(dialog.text()).toContain('1/3 已上传')
expect(api.documents).toHaveBeenLastCalledWith('DEL/1', 'C1'); expect(dialog.findAll('.document-file')).toHaveLength(1)
})
it('retains conflicting document drafts until confirmed reload', async () => {
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
@@ -65,7 +65,7 @@ function mountView() {
AdminPage: { template: '<main><slot /></main>' },
InboundTaskList: false,
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields', 'maxVisible'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar"><slot name="actions" /></div>' },
DataTable: { name: 'DataTable', props: ['page', 'pageSize', 'data'], emits: ['update:page', 'update:pageSize', 'selection-change'], template: '<div data-testid="data-table"><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="filters" /><slot name="notices" /><div v-for="row in data" :key="row.orderId" class="test-row"><slot name="estimatedVolume" :row="row" /><slot name="expectedArrival" :row="row" /></div></div>' },
'el-button': { template: '<button><slot /></button>' },
'el-input-number': { name: 'ElInputNumber', props: ['modelValue', 'disabled'], emits: ['update:modelValue', 'change'], template: '<input class="number-input" :value="modelValue" :disabled="disabled" @change="$emit(\'change\')" />' },
@@ -101,7 +101,8 @@ describe('UnifiedOrdersView list request coordination', () => {
const reviewToolbar = wrapper.getComponent({ name: 'QueryToolbar' })
expect(reviewToolbar.props('modelValue')).toMatchObject(model)
expect(reviewToolbar.attributes('preference-key')).toBe('unified-orders-query-v2')
expect(reviewToolbar.attributes('available-fields')).toBeDefined()
expect(reviewToolbar.props('maxVisible')).toBe(3)
expect(reviewToolbar.props('fields').map((field: { key: string }) => field.key)).toEqual(expect.arrayContaining(['createdRange', 'tenantId', 'inboundCode']))
reviewToolbar.vm.$emit('reset')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ mainStatus: 'MEASUREMENT_REVIEW', receivingWarehouseId: undefined, inboundCode: undefined, channelName: undefined, tenantId: undefined }))
@@ -116,6 +117,32 @@ describe('UnifiedOrdersView list request coordination', () => {
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ mainStatus: 'PENDING_REVIEW', inboundCode: 'IN-REVIEW' }))
})
it.each(['全部', '测量数据审核'])('limits fields after saved ordering without changing filters or querying when %s is expanded and collapsed', async (tabLabel) => {
const wrapper = mountView()
await flushPromises()
if (tabLabel !== '全部') {
await wrapper.findAll('[role="tab"]').find(tab => tab.text().startsWith(tabLabel))!.trigger('click')
await flushPromises()
}
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
const fieldKeys = toolbar.props('fields').map((field: { key: string }) => field.key)
expect(fieldKeys).toHaveLength(15)
expect(fieldKeys).toEqual(expect.arrayContaining(['createdRange', 'tenantId', 'inboundCode']))
expect(toolbar.props('maxVisible')).toBe(3)
const model = { ...toolbar.props('modelValue'), tenantId: 'customer-1', inboundCode: 'IN-KEEP', createdRange: ['2026-10-01', '2026-10-28'] }
toolbar.vm.$emit('update:modelValue', model)
await flushPromises()
const requestCount = vi.mocked(listPlatformUnifiedOrders).mock.calls.length
await wrapper.get('[aria-controls="unified-orders-advanced-filters"]').trigger('click')
expect(toolbar.props('maxVisible')).toBeUndefined()
await wrapper.get('[aria-controls="unified-orders-advanced-filters"]').trigger('click')
expect(toolbar.props('maxVisible')).toBe(3)
expect(toolbar.props('fields').map((field: { key: string }) => field.key)).toEqual(fieldKeys)
expect(toolbar.props('modelValue')).toEqual(model)
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(requestCount)
wrapper.unmount()
})
it('shows only supported counts, keeps all text-only and never scopes counts by status or pagination', async () => {
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockResolvedValue({ counts: { PENDING_REVIEW: 12, WAIT_MEASURE: 0, SIGNED: 3 } })
const wrapper = mountView()
@@ -672,9 +672,6 @@ const advancedQueryFields = computed<QueryField[]>(() => [
])
const allQueryFields = computed(() => [...primaryQueryFields.value, ...advancedQueryFields.value])
const queryFields = computed(() => showAdvancedFilters.value
? allQueryFields.value
: primaryQueryFields.value)
const columns = computed<DataTableColumn[]>(() => [
// 业务表格保持操作列第一;批量选择列仅在有对应权限时紧随其后。
@@ -2878,7 +2875,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
</template>
<template #filters="{ loading: reviewLoading }">
<div class="orders-query-panel integrated-query-group">
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :loading="reviewLoading" :search-on-reset="false" @search="search" @reset="reset">
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="allQueryFields" :max-visible="showAdvancedFilters ? undefined : 3" preference-key="unified-orders-query-v2" :loading="reviewLoading" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
@@ -2921,7 +2918,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
</template>
<template #filters>
<div class="orders-query-panel integrated-query-group">
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="allQueryFields" :max-visible="showAdvancedFilters ? undefined : 3" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
@@ -77,6 +77,44 @@ describe('QueryToolbar layout preferences', () => {
expect(source).not.toContain('.query-toolbar__filters { display: contents; }')
})
it('takes collapsed fields from the saved order and preserves that choice after expanding, reordering and remounting', async () => {
const orderFields: QueryField[] = [
{ key: 'receivingWarehouseId', type: 'select', label: '定位仓库', placeholder: '定位仓库' },
{ key: 'destinationWarehouseId', type: 'select', label: '交货仓', placeholder: '交货仓' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '入仓码' },
{ key: 'tenantId', type: 'select', label: '公司', placeholder: '公司' },
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '创建日期' },
]
localStorage.setItem(preferenceStorageKey('query', 'user-1', 'orders-query'), JSON.stringify({
version: 1, order: ['createdRange', 'tenantId', 'inboundCode', 'destinationWarehouseId', 'receivingWarehouseId'], hidden: [],
}))
const modelValue = { receivingWarehouseId: 'warehouse-1', destinationWarehouseId: '', inboundCode: 'IN-1', tenantId: 'customer-1', createdRange: ['2026-10-01', '2026-10-28'] }
const wrapper = mountToolbar({ fields: orderFields, modelValue, maxVisible: 3 })
const labels = () => wrapper.findAll('input').map(input => input.attributes('data-placeholder'))
expect(labels()).toEqual(['创建日期', '公司', '入仓码'])
expect(wrapper.findComponent(LayoutPreferenceList).props('items')).toHaveLength(5)
expect(wrapper.find('.query-toolbar__tail input').attributes('data-placeholder')).toBe('入仓码')
await wrapper.setProps({ maxVisible: undefined })
expect(labels()).toEqual(['创建日期', '公司', '入仓码', '交货仓', '定位仓库'])
wrapper.findComponent(LayoutPreferenceList).vm.$emit('move', 'receivingWarehouseId', 0)
await wrapper.vm.$nextTick()
await wrapper.setProps({ maxVisible: 3 })
expect(labels()).toEqual(['定位仓库', '创建日期', '公司'])
expect(wrapper.props('modelValue')).toEqual(modelValue)
expect(wrapper.emitted('search')).toBeUndefined()
wrapper.unmount()
const restored = mountToolbar({ fields: orderFields, maxVisible: 3 })
expect(restored.findAll('input').map(input => input.attributes('data-placeholder'))).toEqual(['定位仓库', '创建日期', '公司'])
restored.unmount()
})
it('keeps an explicit visible subset unchanged when no field count limit is requested', async () => {
const wrapper = mountToolbar({ fields: [fields[0]!], availableFields: fields })
wrapper.findComponent(LayoutPreferenceList).vm.$emit('move', 'createdRange', 0)
await wrapper.vm.$nextTick()
expect(wrapper.findAll('input').map(input => input.attributes('data-placeholder'))).toEqual(['关键词'])
})
it('derives compact widths by field type while explicit business widths win', () => {
const compactFields: QueryField[] = [
{ key: 'status', type: 'select', label: '短状态' },
@@ -151,6 +189,7 @@ describe('QueryToolbar layout preferences', () => {
it('resets all filters once and blocks search and reset while loading', async () => {
const wrapper = mountToolbar({
modelValue: { keyword: 'AKR1', status: 'ACTIVE', createdRange: ['2026-08-01', '2026-08-14'] },
maxVisible: 1,
})
;(wrapper.vm as unknown as { reset: () => void }).reset()
+3 -1
View File
@@ -36,6 +36,7 @@ const props = withDefaults(defineProps<{
ariaLabel: string
fields: QueryField[]
availableFields?: QueryField[]
maxVisible?: number
preferenceKey?: string
searchText?: string
resetText?: string
@@ -90,7 +91,8 @@ const orderedPreferenceFields = computed(() => {
})
const orderedFields = computed(() => {
const visibleKeys = new Set(props.fields.map((field) => field.key))
return orderedPreferenceFields.value.filter((field) => visibleKeys.has(field.key))
const fields = orderedPreferenceFields.value.filter((field) => visibleKeys.has(field.key))
return props.maxVisible == null ? fields : fields.slice(0, Math.max(0, props.maxVisible))
})
const preferenceItems = computed<LayoutPreferenceItem[]>(() => orderedPreferenceFields.value.map((field) => ({
key: field.key,
+4 -3
View File
@@ -1530,9 +1530,10 @@ describe('approved Admin density ownership', () => {
expect(source).toContain(':confirm-loading="returnSaving || returnLoading"')
expect(source).toContain('width="min(720px, calc(100vw - 28px))" fullscreenable')
expect(documents).toContain('width="min(1040px, calc(100vw - 40px))" fullscreenable')
expect(documents).toContain('data-tone="summary"')
expect(documents).toContain('data-tone="source"')
expect(documents).toContain('data-tone="work"')
expect(documents).toContain('class="document-group density-dialog-section"')
expect(documents).toContain('class="density-actions row-actions admin-text-actions"')
expect(documents).not.toMatch(/data-tone="(?:summary|source|work)"/)
expect(documents).not.toContain('派送资料摘要')
expect(documents).toContain('<LineClampText :content="file.fileName"')
expect(documents).not.toContain(':model-value="deliveryNo" readonly')
expect(documents).not.toContain('max-height=')