Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test
This commit is contained in:
@@ -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>
|
||||
|
||||
+13
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=')
|
||||
|
||||
Reference in New Issue
Block a user