Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1028b715ab |
@@ -18,10 +18,10 @@ const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
const batch = 'FBA_APPOINTMENT:1'
|
||||
function receiving(key = batch, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', key).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'transport', vehicles, blockReasons: [] }, executionScope: { version: 'execution', members: [] }, allowedActions: [], blockReasons: [] } }
|
||||
function row(id = '9007199254740993', customerId = 'customer-a', key = batch): BatchDispatchCandidate { return { planPalletId: id, palletNo: `PALLET-${id.slice(-2)}`, planId: `plan-${id}`, planNo: 'IP-001', customerId, sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: id, batchKey: key, customerId, sourceKey: customerId === 'customer-a' ? 'ORDER:1' : 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12, recordVersion: 4 }], blockReasons: [] } }
|
||||
function candidate(value = row()): Candidate { return { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] } }
|
||||
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
|
||||
function detail(no = 'DS/001', version = 1): DispatchDetail {
|
||||
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
|
||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
}
|
||||
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
|
||||
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -38,7 +38,7 @@ function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(st
|
||||
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
||||
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
||||
function reasons(row: BatchRow) { return row.blockReasons }
|
||||
function physicalCount(values: BatchRow[]) { return new Set(values.map(row => row.native?.selection.warehousePalletId || row.native?.sourceKey || row.key)).size }
|
||||
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
|
||||
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
async function load() {
|
||||
if (!readable.value || pending.value || corrupted.value) return
|
||||
@@ -78,7 +78,7 @@ async function load() {
|
||||
const sources = receiving[index]!.batch.items
|
||||
const blocks = [...context.blockReasons, ...row.blockReasons]
|
||||
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
||||
if (candidate) blocks.push(...candidate.blockReasons)
|
||||
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
|
||||
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
|
||||
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
|
||||
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
||||
@@ -132,7 +132,11 @@ async function executeStep() {
|
||||
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
||||
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
||||
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
||||
request.lockedKeys.push(source.sourceKey)
|
||||
const group = requireSelectionGroup(source)
|
||||
assertSelectionGroupVersions([expected!.native], [source])
|
||||
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
|
||||
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
|
||||
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
|
||||
}
|
||||
request.draft = draft
|
||||
}
|
||||
@@ -164,7 +168,7 @@ async function save() {
|
||||
else {
|
||||
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
||||
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: cargo.map(source => source!.selection), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
|
||||
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
|
||||
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS/ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' } } } }) }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()); vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'DISPATCH:9007199254740999', customerId: 'customer-a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS/ORIGINAL' }) }), candidates: [] }) })
|
||||
|
||||
@@ -75,9 +75,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.text()).not.toContain('LP-99')
|
||||
expect(wrapper.text()).toContain('IN-1')
|
||||
expect(wrapper.text()).not.toContain('ORD-1')
|
||||
expect(wrapper.text()).not.toContain('150% 容量上限')
|
||||
await wrapper.get('button[aria-label="查看装柜说明"]').trigger('mouseenter')
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('150% 容量上限'))
|
||||
expect(wrapper.text()).toContain('150% 容量上限')
|
||||
})
|
||||
|
||||
it('shows the full inbound composition for an assigned sealed pallet', async () => {
|
||||
|
||||
@@ -235,13 +235,11 @@ describe('InboundFeePanel', () => {
|
||||
|
||||
it('treats A0404 as a neutral not-yet-generated state', async () => {
|
||||
vi.mocked(getLatestInboundFee).mockRejectedValue(new ApiResponseError('费用快照不存在', 'A0404'))
|
||||
const wrapper = mountPanel(['firstleg:fba-pricing:view'])
|
||||
mountPanel(['firstleg:fba-pricing:view'])
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('尚未生成费用快照')
|
||||
expect(document.body.textContent).not.toContain('测量审核完成后')
|
||||
await wrapper.get('button[aria-label="查看计费说明"]').trigger('mouseenter')
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('测量审核完成后'))
|
||||
expect(document.body.textContent).toContain('测量审核完成后')
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -98,10 +98,9 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
for (const invalid of [undefined, null, -1, '', 'bad', Number.MAX_SAFE_INTEGER + 1]) await expect(Promise.resolve().then<unknown>(() => invoke(invalid))).rejects.toThrow('版本')
|
||||
expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 }))
|
||||
})
|
||||
it('updates only an existing appointment reference and its explicit version', async () => {
|
||||
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: 0 } }
|
||||
it('updates vehicle resources and timing with the explicit dispatch version', async () => {
|
||||
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
|
||||
await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
|
||||
await expect(api.updateDispatch('PC/1', { ...request, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: null as unknown as Version } })).rejects.toThrow('版本')
|
||||
await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
|
||||
})
|
||||
it('uses versioned removal and cancellation POSTs instead of unversioned deletes', async () => {
|
||||
|
||||
@@ -28,7 +28,6 @@ export const getDispatchCommand = (dispatchNo: string, key: string) => get<Comma
|
||||
export const createDispatch = (body: CreateDispatch) => { requireBatchVersions(body.batchAllocations); return postCommand(base, body) }
|
||||
export async function updateDispatch(dispatchNo: string, body: UpdateDispatch) {
|
||||
versioned(body)
|
||||
if (body.vehicleAppointment) requireVersion(body.vehicleAppointment.recordVersion)
|
||||
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(path(dispatchNo), body)))
|
||||
}
|
||||
export const addDispatchCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${path(no)}/items`, body) }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect, it } from 'vitest'
|
||||
import DispatchArrangeFields from './DispatchArrangeFields.vue'
|
||||
import type { ArrangeGroup, CargoLine, PreviewGroup } from '../dispatchContracts'
|
||||
import { destination } from '../dispatchTestFixtures'
|
||||
const cargo = (sourceKey: string, customerId = 'C1'): CargoLine => ({ sourceKey, customerId, inboundCode: `IN-${sourceKey}` }) as CargoLine
|
||||
const cargo = (sourceKey: string, customerId = 'C1'): CargoLine => ({ sourceKey, customerId, inboundCode: `IN-${sourceKey}`, sourceOrigin: null }) as CargoLine
|
||||
function values() {
|
||||
const preview: PreviewGroup = { groupKey: 'group-1', customerId: 'C1', customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo('A'), cargo('B')], externalFreightHash: 'external-hash', externalQuotes: [], feeCoverageCargoHash: 'coverage-hash', feeSources: [{ financeFeeId: 'fee-1', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 2, sourceHash: 'fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE', 'LOADING_FEE'], sourceKeys: ['A'] }], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }
|
||||
const group: ArrangeGroup = { groupKey: 'group-1', groupCargoHash: 'group-cargo', plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: 'PALLET', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: 'external-hash', feeCoverageCargoHash: 'coverage-hash', feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: feeType, direction: 'RECEIVABLE', currency: 'USD', amount: null })) }
|
||||
|
||||
@@ -26,29 +26,32 @@ function required(index: number, preview: PreviewGroup, service: string) {
|
||||
defineExpose({ covered })
|
||||
</script>
|
||||
<template>
|
||||
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group">
|
||||
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group density-dialog-sections">
|
||||
<template v-if="groups[index]">
|
||||
<h3>{{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}</h3>
|
||||
<p v-for="reason in previewGroup.blockReasons" :key="reason" role="alert">{{ reason }}</p>
|
||||
<div class="arrange-grid">
|
||||
<el-form-item label="预计派送时间" required><el-date-picker v-model="groups[index]!.plannedDeliveryAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择派送时间" /></el-form-item>
|
||||
<el-form-item label="派送方式" required><el-select v-model="groups[index]!.deliveryMethod" :disabled="disabled" placeholder="请选择"><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item>
|
||||
<el-form-item label="提货方式" required><el-select v-model="groups[index]!.pickupMethod" :disabled="disabled" placeholder="请选择"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item>
|
||||
<el-form-item label="装车类型" required><el-select v-model="groups[index]!.loadingType" :disabled="disabled" placeholder="请选择"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item>
|
||||
<el-form-item label="承运商"><el-input v-model="groups[index]!.carrierName" :disabled="disabled" placeholder="请输入承运商(选填)" /></el-form-item><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" placeholder="请输入运单号(选填)" /></el-form-item>
|
||||
<el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" placeholder="请输入本组派送车型(选填)" /></el-form-item><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" placeholder="请输入车辆尺寸(选填)" /></el-form-item>
|
||||
<el-form-item label="预约账号"><el-select v-model="groups[index]!.appointmentAccount" :disabled="disabled" clearable placeholder="请选择"><el-option label="自有账号" value="SELF" /><el-option label="买约" value="BUY_APPOINTMENT" /></el-select></el-form-item><el-form-item v-if="groups[index]!.appointmentAccount === 'BUY_APPOINTMENT'" label="卖家姓名" required><el-input v-model="groups[index]!.sellerName" :disabled="disabled" placeholder="请输入卖家姓名" /></el-form-item><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" placeholder="请输入本组派送备注(选填)" /></el-form-item>
|
||||
</div>
|
||||
<el-table :data="previewGroup.cargo" border max-height="240"><el-table-column prop="orderNo" label="来源订单" min-width="150" /><el-table-column prop="inboundCode" label="入仓码" min-width="150" /><el-table-column prop="palletNo" label="实物托盘码" min-width="150" /><el-table-column prop="cartonMark" label="来源箱唛" min-width="150" /><el-table-column prop="packages" label="本次箱数" width="100" /></el-table>
|
||||
<p v-for="quote in previewGroup.externalQuotes" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.inboundCode }}:{{ quote.currency }} {{ quote.amount }}(外接约定运费)</p>
|
||||
<h4>原费用覆盖</h4><p v-if="!previewGroup.feeSources.length">暂无可引用原费用。</p>
|
||||
<div v-for="fee in previewGroup.feeSources" :key="fee.financeFeeId"><span>{{ fee.currency }} {{ fee.amount }} · {{ fee.status }}</span><el-checkbox v-for="service in fee.services" :key="service" :disabled="disabled" :model-value="groups[index]!.feeCoverage.some(row => row.financeFeeId === fee.financeFeeId && row.services.includes(service))" @update:model-value="setCoverage(index, fee, service, $event)">已包含{{ serviceLabels[service] || '服务费用' }}</el-checkbox></div>
|
||||
<h4>本组新增费用</h4><div class="arrange-grid"><template v-for="fee in groups[index]!.fees.filter(item => required(index, previewGroup, item.feeType))" :key="fee.feeType"><el-form-item v-if="!covered(index, previewGroup, fee.feeType)" :label="`${serviceLabels[fee.feeType] || fee.feeName}(未覆盖部分)`" required><el-input-number v-model="fee.amount" :disabled="disabled" :min="0.01" :precision="2" placeholder="请输入实际新增应收" /><el-select v-model="fee.currency" :disabled="disabled" placeholder="币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item><p v-else>本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。</p></template></div>
|
||||
<section class="density-dialog-section" data-tone="source" :aria-label="'派送分组依据 ' + (index + 1)"><h3 class="density-dialog-section__title">{{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}</h3>
|
||||
<el-alert v-for="reason in previewGroup.blockReasons" :key="reason" :title="reason" type="warning" :closable="false" show-icon />
|
||||
<el-table :data="previewGroup.cargo" border><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="实物托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="cartonMark" label="来源箱唛" min-width="150" show-overflow-tooltip /><el-table-column prop="packages" label="本次箱数" width="100" /></el-table>
|
||||
<template v-if="previewGroup.externalQuotes.length"><h4 class="density-dialog-section__title">外接约定运费</h4><table class="density-table"><thead><tr><th>外接预订号</th><th>入仓码</th><th>币种</th><th>金额</th></tr></thead><tbody><tr v-for="quote in previewGroup.externalQuotes" :key="quote.bookingNo"><td>{{ quote.bookingNo }}</td><td>{{ quote.inboundCode }}</td><td>{{ quote.currency }}</td><td>{{ quote.amount }}</td></tr></tbody></table></template>
|
||||
<h4 class="density-dialog-section__title">原费用覆盖</h4><p v-if="!previewGroup.feeSources.length" class="density-dialog-note">暂无可引用原费用。</p>
|
||||
<table v-else class="density-table"><thead><tr><th>覆盖服务</th><th>币种</th><th>金额</th><th>状态</th></tr></thead><tbody><tr v-for="fee in previewGroup.feeSources" :key="fee.financeFeeId"><td><el-checkbox v-for="service in fee.services" :key="service" :disabled="disabled" :model-value="groups[index]!.feeCoverage.some(row => row.financeFeeId === fee.financeFeeId && row.services.includes(service))" @update:model-value="setCoverage(index, fee, service, $event)">已包含{{ serviceLabels[service] || '服务费用' }}</el-checkbox></td><td>{{ fee.currency }}</td><td>{{ fee.amount }}</td><td>{{ fee.status }}</td></tr></tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" :aria-label="'派送分组登记 ' + (index + 1)"><h3 class="density-dialog-section__title">派送信息</h3>
|
||||
<table class="density-table density-table--editable arrange-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="预计派送时间" required><el-date-picker v-model="groups[index]!.plannedDeliveryAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择派送时间" /></el-form-item></td><td><el-form-item label="派送方式" required><el-select v-model="groups[index]!.deliveryMethod" :disabled="disabled" placeholder="请选择"><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="提货方式" required><el-select v-model="groups[index]!.pickupMethod" :disabled="disabled" placeholder="请选择"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item></td><td><el-form-item label="装车类型" required><el-select v-model="groups[index]!.loadingType" :disabled="disabled" placeholder="请选择"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="承运商"><el-input v-model="groups[index]!.carrierName" :disabled="disabled" placeholder="请输入承运商(选填)" /></el-form-item></td><td><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" placeholder="请输入运单号(选填)" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" placeholder="请输入本组派送车型(选填)" /></el-form-item></td><td><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" placeholder="请输入车辆尺寸(选填)" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="预约账号"><el-select v-model="groups[index]!.appointmentAccount" :disabled="disabled" clearable placeholder="请选择"><el-option label="自有账号" value="SELF" /><el-option label="买约" value="BUY_APPOINTMENT" /></el-select></el-form-item></td><td><el-form-item v-if="groups[index]!.appointmentAccount === 'BUY_APPOINTMENT'" label="卖家姓名" required><el-input v-model="groups[index]!.sellerName" :disabled="disabled" placeholder="请输入卖家姓名" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" placeholder="请输入本组派送备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
<h4 class="density-dialog-section__title">本组新增费用</h4><table class="density-table density-table--editable"><tbody><tr v-for="fee in groups[index]!.fees.filter(item => required(index, previewGroup, item.feeType))" :key="fee.feeType"><td><el-form-item v-if="!covered(index, previewGroup, fee.feeType)" :label="(serviceLabels[fee.feeType] || fee.feeName) + '(未覆盖部分)'" required><div class="arrange-fee-controls"><el-input-number v-model="fee.amount" :disabled="disabled" :min="0.01" :precision="2" placeholder="请输入实际新增应收" controls-position="right" /><el-select v-model="fee.currency" :disabled="disabled" placeholder="币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></div></el-form-item><p v-else class="density-dialog-note">本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。</p></td></tr></tbody></table>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.arrange-group { padding-bottom: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
|
||||
.arrange-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||
.arrange-grid :deep(.el-select), .arrange-grid :deep(.el-date-editor), .arrange-grid :deep(.el-input-number) { width: 100%; }
|
||||
.arrange-editor-table { table-layout: fixed; }
|
||||
.arrange-editor-table td:not([colspan]) { width: 50%; }
|
||||
.arrange-fee-controls { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 120px; gap: var(--admin-space-3); }
|
||||
.arrange-group h4 { margin-top: var(--admin-space-4); }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
|
||||
@@ -70,16 +73,21 @@ watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value]), load, { i
|
||||
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
||||
</script>
|
||||
<template>
|
||||
<section class="dispatch-evidence" :aria-label="label"><h4>{{ label }}</h4>
|
||||
<p v-if="error" role="alert">{{ error.message }} <el-button link @click="load">重新读取资料</el-button></p><ApiErrorMeta v-if="error" :details="error" />
|
||||
<ul><li v-for="id in ids" :key="id"><span>{{ metadata[id]?.fileName || '附件资料待核实' }}</span> <el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></li></ul>
|
||||
<p v-if="!ids.length">暂无本趟附件。</p><template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="`上传${label}`" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button><p>PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></template>
|
||||
<AppDialog class="admin-density-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="900px" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview"><p v-if="previewLoading">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></AppDialog>
|
||||
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 class="density-dialog-section__title">{{ label }}</h4>
|
||||
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="ids.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in ids" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||||
<p v-else class="density-dialog-note">暂无本趟附件。</p>
|
||||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button><p class="density-dialog-note">PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></template>
|
||||
<AppDialog class="admin-density-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||||
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dispatch-evidence { padding: var(--admin-space-4) 0; }
|
||||
.dispatch-evidence ul { margin: 0; padding-left: 20px; }
|
||||
.dispatch-evidence-table { table-layout: fixed; }
|
||||
.dispatch-evidence__actions { width: 190px; }
|
||||
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
|
||||
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
|
||||
.evidence-input { display: none; }
|
||||
.evidence-image { display: block; max-width: 100%; max-height: 72vh; margin: auto; object-fit: contain; }
|
||||
</style>
|
||||
|
||||
@@ -8,21 +8,21 @@ const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
|
||||
it('renders known business labels for existing master resources without falling back to their internal IDs', async () => {
|
||||
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('CA-100'); expect(wrapper.text()).toContain('司机甲'); expect(wrapper.text()).not.toContain('90071992547409930'); wrapper.unmount()
|
||||
})
|
||||
it('resets only the resource being switched and keeps external resource choices independent', async () => {
|
||||
it.each([false, true])('resets only the switched resource and keeps choices independent (continuous=%s)', async continuous => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } })
|
||||
const state = wrapper.vm as unknown as { changeDriver(): void }; state.changeDriver(); await flushPromises()
|
||||
expect(wrapper.emitted('update:driver')![0]![0]).toMatchObject({ mode: 'EXTERNAL', driverId: null, name: null, evidence: { fileIds: [], licenseNo: null } })
|
||||
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(value.vehicle.vehicleId).toBe('vehicle-1'); wrapper.unmount()
|
||||
})
|
||||
it('requires each real directory permission before reading candidates', async () => {
|
||||
it.each([false, true])('requires each directory permission before reading candidates (continuous=%s)', async continuous => {
|
||||
can.mockReturnValue(false); const value = resources()
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } })
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; searchDrivers(): Promise<void> }; await state.searchVehicles(); await state.searchDrivers()
|
||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少司机档案读取权限'); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import type { Driver, Vehicle } from '../dispatchContracts'
|
||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
defineProps<{ disabled: boolean }>()
|
||||
defineProps<{ disabled: boolean; continuous?: boolean }>()
|
||||
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
|
||||
let vehicleSequence = 0, driverSequence = 0
|
||||
async function searchVehicles(keyword = '') {
|
||||
@@ -32,6 +32,21 @@ function selectDriver(id: string) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="continuous">
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<table class="density-table density-table--editable dispatch-resource-table"><tbody>
|
||||
<tr><td><el-form-item label="车辆来源"><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
|
||||
<template v-if="vehicle.mode === 'EXTERNAL'">
|
||||
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
</template>
|
||||
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
|
||||
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
|
||||
<tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
<p v-if="vehicle.mode === 'EXTERNAL' || driver.mode === 'EXTERNAL'" class="density-dialog-note" role="status">临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<div class="resource-grid">
|
||||
<el-form-item label="车辆来源"><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
|
||||
@@ -46,8 +61,11 @@ function selectDriver(id: string) {
|
||||
<el-form-item v-if="driver.evidence" label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
|
||||
</div>
|
||||
<p v-if="vehicle.mode === 'EXTERNAL' || driver.mode === 'EXTERNAL'" role="status">临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。</p>
|
||||
</template>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dispatch-resource-table { table-layout: fixed; }
|
||||
.dispatch-resource-table td:not([colspan]) { width: 50%; }
|
||||
.resource-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||
.resource-grid :deep(.el-select), .resource-grid :deep(.el-date-editor), .resource-grid :deep(.el-input-number) { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||
import DispatchArrangeFields from './DispatchArrangeFields.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/dispatches'
|
||||
import { command, detail, destination, loadingPreview, page, resources, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||
import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchCargoItem, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts'
|
||||
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||
const actions: DispatchActions = { EDIT: gate, ADD_CARGO: gate, REMOVE_CARGO: gate, CANCEL: gate, SUBMIT_REVIEW: gate, DISPATCH_REVIEW: gate, LOADING_REVIEW: gate, RETURN_TO_REVIEW: gate, LOADING_COMPLETE: gate, ARRANGE_DELIVERY: gate, DEPART: gate }
|
||||
const evidence = (fileId = '301'): DispatchEvidenceFile => ({ fileId, fileName: fileId === '301' ? '旧附件.png' : '新附件.png', contentType: 'image/png', sizeBytes: 3 })
|
||||
function item(id: string, picked = 0): DispatchCargoItem { return { dispatchItemId: id, customerId: 'C1', customerName: '客户甲', sourceKey: 'source-1', sourceVersion: 'source-v1', sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', palletNo: 'PALLET-1', orderNo: 'ORDER-1', inboundCode: 'IN-1', destination: destination(), scheduledPackages: 2, pickedPackages: picked, remove: picked ? { allowed: false, blockCode: 'PICKED', blockReason: '已经扫码,不可移除' } : gate } }
|
||||
function cargo(key: string, customerId: string): CargoLine { return { dispatchItemId: 'item-' + key, customerId, customerName: '客户' + customerId, orderId: null, orderNo: 'ORDER-' + key, sourceKey: key, sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: null, inboundCode: 'IN-' + key, planPalletId: null, warehousePalletId: 'pallet-' + key, palletNo: 'PALLET-' + key, pickId: null, attributionId: null, inventoryPositionId: null, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: 'BOX-' + key, relabelNo: null, destination: destination(), packages: 2, productQuantity: 4, grossWeightKg: 10, volumeCbm: 1, batchAllocations: [], sourceOrigin: null } }
|
||||
function arrangement(): ArrangementPreview {
|
||||
const group = (key: string, customerId: string): PreviewGroup => ({ groupKey: key, customerId, customerName: '客户' + customerId, destination: destination(), groupCargoHash: 'cargo-' + key, cargo: [cargo(key, customerId)], externalFreightHash: 'external-' + key, externalQuotes: [], feeCoverageCargoHash: 'coverage-' + key, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] })
|
||||
const first = group('A', 'C1'), second = group('B', 'C2')
|
||||
first.feeSources = [{ financeFeeId: 'original-fee', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 0, sourceHash: 'original-fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE'], sourceKeys: ['A'] }]
|
||||
second.externalQuotes = [{ customerId: 'C2', groupKey: 'B', bookingNo: 'BOOK-2', inboundCode: 'IN-B', currency: 'USD', amount: 200, sourceHash: 'quote-hash' }]
|
||||
return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', arrange: gate, groups: [first, second], arrangement: null }
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
URL.createObjectURL = vi.fn().mockReturnValue('blob:owned-dispatch'); URL.revokeObjectURL = vi.fn()
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, actions }))
|
||||
vi.mocked(api.getDispatchCommand).mockRejectedValue(new ApiResponseError('not found', 'A0404', 404))
|
||||
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
vi.mocked(api.reviewDispatch).mockImplementation(async (_, body) => command('DISPATCH_REVIEW', body.idempotencyKey))
|
||||
vi.mocked(api.reviewDispatchLoading).mockImplementation(async (_, body) => command('LOADING_REVIEW', body.idempotencyKey))
|
||||
vi.mocked(api.cancelDispatch).mockImplementation(async (_, body) => command('CANCEL', body.idempotencyKey))
|
||||
vi.mocked(api.returnDispatchToReview).mockImplementation(async (_, body) => command('RETURN_TO_REVIEW', body.idempotencyKey))
|
||||
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
|
||||
vi.mocked(api.departDispatch).mockImplementation(async (_, body) => command('DEPART', body.idempotencyKey))
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => command('ADD_CARGO', body.idempotencyKey))
|
||||
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => command('REMOVE_CARGO', body.idempotencyKey))
|
||||
vi.mocked(api.reorderDispatchRoute).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
|
||||
vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview())
|
||||
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: gate, stops: [{ index: 0, name: '起运仓', address: '起点地址', destinationKey: null }, { index: 1, name: '目的仓甲', address: '甲地址', destinationKey: 'A' }, { index: 2, name: '外接卸货点', address: '乙地址', destinationKey: 'B' }] })
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740993', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [item('9223372036854775001'), item('picked', 1)] })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page())
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.getDispatchArrangement).mockResolvedValue(arrangement())
|
||||
vi.mocked(api.getDispatchEvidence).mockImplementation(async (_, __, id) => evidence(id))
|
||||
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(evidence('302'))
|
||||
})
|
||||
async function render(action: DispatchAction, operation?: 'ROUTE', files = false) {
|
||||
const initial = detail({ actions }); if (files) initial.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(initial)
|
||||
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action, operation }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
return { wrapper, dialog: wrapper.findComponent(AppDialog) }
|
||||
}
|
||||
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
|
||||
describe('dispatch workflow native layout', () => {
|
||||
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
|
||||
const { dialog } = await render('EDIT')
|
||||
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
|
||||
await field(dialog, '车牌号').get('input').setValue('CA-NEW')
|
||||
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
|
||||
await field(dialog, '司机姓名').get('input').setValue('新司机')
|
||||
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
|
||||
await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('维护备注')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '维护备注' })
|
||||
})
|
||||
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
const { wrapper, dialog } = await render('EDIT')
|
||||
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
|
||||
await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('保留本次输入')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
|
||||
expect(dialog.text()).toContain('workflow-request')
|
||||
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
||||
await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises()
|
||||
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
|
||||
expect((dialog.get('textarea[placeholder="请输入本次备注(选填)"]').element as HTMLTextAreaElement).value).toBe('保留本次输入')
|
||||
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
|
||||
const { dialog } = await render(action)
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
|
||||
expect(dialog.text()).toContain('审核通过不代替后续现场扫码')
|
||||
field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
|
||||
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
|
||||
await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
|
||||
})
|
||||
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW', 'DEPART'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
|
||||
const { dialog } = await render(action)
|
||||
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
|
||||
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : action === 'DEPART' ? api.departDispatch : api.submitDispatchReview
|
||||
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
|
||||
await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: '本次交接' }) })
|
||||
})
|
||||
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
|
||||
const { dialog } = await render('EDIT', 'ROUTE')
|
||||
const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]!.findAll('button')[0]!.attributes('disabled')).toBeDefined()
|
||||
await rows[1]!.findAll('button').find(button => button.text() === '上移')!.trigger('click'); await flushPromises()
|
||||
await dialog.get('textarea').setValue(' 顺序调整 ')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.reorderDispatchRoute).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), destinationKeys: ['B', 'A'], reason: '顺序调整' })
|
||||
})
|
||||
it('removes the real item identity while keeping scanned cargo unavailable', async () => {
|
||||
const { dialog } = await render('REMOVE_CARGO')
|
||||
const radios = dialog.get('[aria-label="本车可移除货物"]').findAllComponents({ name: 'ElRadio' })
|
||||
expect(radios[1]!.props('disabled')).toBe(true)
|
||||
radios[0]!.vm.$emit('update:modelValue', '9223372036854775001'); await dialog.get('textarea').setValue(' 计划变更 ')
|
||||
expect(dialog.text()).not.toContain('9223372036854775001')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9223372036854775001', { recordVersion: '9007199254740993', idempotencyKey: expect.any(String), reason: '计划变更' })
|
||||
})
|
||||
it('adds the complete filtered shared-pallet group and keeps every source visible when expanded', async () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
const { dialog } = await render('ADD_CARGO')
|
||||
const source = dialog.get('details')
|
||||
await source.get('summary').trigger('click'); await flushPromises()
|
||||
expect(source.findAll('li')).toHaveLength(2)
|
||||
await dialog.get('input[type="checkbox"]').setValue(true); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), cargo: [first!.selection, second!.selection], batchAllocations: [] })
|
||||
expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
|
||||
})
|
||||
it('keeps references separate from real loading measurements and submits the exact manifest', async () => {
|
||||
const { dialog } = await render('LOADING_COMPLETE')
|
||||
const numbers = dialog.get('[aria-label="真实实载登记"]').findAllComponents({ name: 'ElInputNumber' })
|
||||
expect(numbers[1]!.props('modelValue')).toBeNull()
|
||||
expect(dialog.get('[aria-label="实载与参考依据"]').text()).toContain('仅供参考')
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.completeDispatchLoading).not.toHaveBeenCalled()
|
||||
numbers[0]!.vm.$emit('update:modelValue', 1); numbers[1]!.vm.$emit('update:modelValue', 120); numbers[2]!.vm.$emit('update:modelValue', 1.2); await flushPromises()
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 120, volumeCbm: 1.2, deliveryIndex: 1 }], remark: '真实备注' })
|
||||
})
|
||||
it('keeps grouped delivery fields, explicit old-fee coverage and only actual uncovered charges', async () => {
|
||||
const { dialog } = await render('ARRANGE_DELIVERY')
|
||||
const sections = dialog.findAll('[data-tone="work"]').filter(section => section.attributes('aria-label')?.startsWith('派送分组登记'))
|
||||
expect(sections).toHaveLength(2)
|
||||
const groups = dialog.getComponent(DispatchArrangeFields).props('modelValue')
|
||||
await dialog.get('[aria-label="派送分组依据 1"] input[type="checkbox"]').setValue(true); await flushPromises()
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const section = sections[index]!
|
||||
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'ONE_PICKUP_ONE_DROP'], ['装车类型', index ? 'PALLET' : 'FLOOR']] as const) field(section, label).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
field(section, '预计派送时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
|
||||
}
|
||||
field(sections[0]!, '预约账号').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'BUY_APPOINTMENT'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
|
||||
await field(sections[0]!, '卖家姓名').get('input').setValue('卖家甲')
|
||||
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25)
|
||||
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CNY')
|
||||
field(sections[0]!, '买约费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 40); await flushPromises()
|
||||
expect(groups[0]!.feeCoverage).toEqual([{ financeFeeId: 'original-fee', sourceVersion: 0, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }])
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.transferDispatchToDelivery).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', groups: [expect.objectContaining({ groupKey: 'A', sellerName: '卖家甲', feeCoverage: groups[0]!.feeCoverage, fees: [expect.objectContaining({ feeType: 'LOADING_FEE', amount: 25, currency: 'CNY' }), expect.objectContaining({ feeType: 'APPOINTMENT_FEE', amount: 40, currency: 'USD' })] }), expect.objectContaining({ groupKey: 'B', fees: [] })] }))
|
||||
})
|
||||
it('previews owned images fullscreen and locks the parent while a real file reference upload is pending', async () => {
|
||||
let resolve!: (value: DispatchEvidenceFile) => void
|
||||
vi.mocked(api.uploadDispatchEvidence).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const { dialog } = await render('EDIT', undefined, true)
|
||||
const files = dialog.findAllComponents(DispatchEvidenceFiles)[0]!
|
||||
await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
|
||||
const image = files.findComponent(AppDialog)
|
||||
expect(image.props('modelValue')).toBe(true)
|
||||
expect(image.get('img').attributes('src')).toBe('blob:owned-dispatch')
|
||||
await image.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
await image.findAll('button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owned-dispatch')
|
||||
const input = files.get('input[type="file"]'), file = new File(['png'], 'new.png', { type: 'image/png' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises()
|
||||
expect(dialog.props('confirmLoading')).toBe(true)
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||
resolve(evidence('302')); await flushPromises()
|
||||
expect(dialog.props('confirmLoading')).toBe(false)
|
||||
expect(files.props('modelValue')).toEqual(['301', '302'])
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: expect.objectContaining({ evidenceFileIds: ['301', '302'] }) }))
|
||||
})
|
||||
it('keeps server gates and write permissions independent from readable facts', async () => {
|
||||
can.mockReturnValue(false)
|
||||
const { dialog } = await render('EDIT')
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { candidate, command, detail, destination, loadingPreview, page } from '../dispatchTestFixtures'
|
||||
import { candidate, command, detail, destination, loadingPreview, page, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
@@ -13,12 +13,28 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
||||
interface State { submit(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail }
|
||||
it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
|
||||
const { wrapper, state } = await open('ADD_CARGO')
|
||||
const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable<typeof first>, checked: boolean): void }
|
||||
selection.selectCargo(first!, true)
|
||||
first!.selectionGroup = { ...first!.selectionGroup, groupVersion: 'composition-2' }
|
||||
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('组成已变化')
|
||||
first!.selectionGroup.complete = false; selection.selectCargo(first!, false)
|
||||
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择来源完整')
|
||||
first!.selectionGroup.complete = true
|
||||
selection.selectCargo(first!, true)
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
|
||||
await state.submit(); expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ cargo: [first!.selection, second!.selection] }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => {
|
||||
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
||||
const { wrapper, state } = await open('ADD_CARGO')
|
||||
try {
|
||||
(wrapper.vm as unknown as { selected: string[] }).selected = [value.sourceKey]
|
||||
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true)
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
|
||||
await state.submit()
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, batchAllocations: [{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }] }))
|
||||
@@ -29,7 +45,7 @@ it('blocks cargo addition when the authoritative batch version is absent', async
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
||||
const { wrapper, state } = await open('ADD_CARGO')
|
||||
try {
|
||||
(wrapper.vm as unknown as { selected: string[] }).selected = [value.sourceKey]; await state.submit()
|
||||
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true); await state.submit()
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
@@ -89,7 +105,7 @@ it('blocks an action without a literal service allowance', async () => {
|
||||
const { wrapper, state } = await open('DEPART', false); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
|
||||
})
|
||||
it('submits every customer destination group in one arrangement and does not waive another customer fee', async () => {
|
||||
const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2 } as CargoLine], externalFreightHash: `external-${customerId}`, externalQuotes: customerId === 'A' ? [{ customerId: 'A', groupKey: 'group-A', bookingNo: 'BOOK-A', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'quote-A' }] : [], feeCoverageCargoHash: `coverage-${customerId}`, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }))
|
||||
const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2, sourceOrigin: null } as CargoLine], externalFreightHash: `external-${customerId}`, externalQuotes: customerId === 'A' ? [{ customerId: 'A', groupKey: 'group-A', bookingNo: 'BOOK-A', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'quote-A' }] : [], feeCoverageCargoHash: `coverage-${customerId}`, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }))
|
||||
vi.mocked(api.getDispatchArrangement).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash', arrange: { allowed: true, blockCode: null, blockReason: null }, groups: previews, arrangement: null })
|
||||
const { wrapper, state } = await open('ARRANGE_DELIVERY')
|
||||
const groups = (wrapper.vm as unknown as { groups: ArrangeGroup[] }).groups
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candidate, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion, requireBatchVersions } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
|
||||
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
@@ -30,13 +32,14 @@ const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<Dis
|
||||
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
|
||||
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
|
||||
const candidates = ref<Candidate[]>([]), selected = ref<string[]>([]), candidateKeyword = ref('')
|
||||
const candidates = ref<Candidate[]>([]), selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string }>>([]), candidateKeyword = ref('')
|
||||
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
|
||||
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
|
||||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
const allowed = computed(() => !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
||||
@@ -45,6 +48,7 @@ const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DR
|
||||
|
||||
async function load() {
|
||||
const sequence = ++generation, no = currentNo.value, action = currentAction.value
|
||||
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { loading.value = false; return }
|
||||
loading.value = true; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
|
||||
items.value = null; itemId.value = ''; route.value = null; stops.value = []
|
||||
try {
|
||||
@@ -110,6 +114,15 @@ function requirePlanPallet(row: Candidate): string {
|
||||
if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
|
||||
return row.selection.planPalletId
|
||||
}
|
||||
function isCargoSelected(row: Candidate): boolean { return selected.value.some(value => value.groupKey === row.selectionGroup?.groupKey) }
|
||||
function selectCargo(row: Candidate, checked: unknown) {
|
||||
try {
|
||||
if (checked !== true) { selected.value = selected.value.filter(value => value.groupKey !== row.selectionGroup?.groupKey && value.sourceKey !== row.sourceKey); return }
|
||||
const group = requireSelectionGroup(row)
|
||||
selected.value = selected.value.filter(value => value.groupKey !== group.groupKey && value.sourceKey !== row.sourceKey)
|
||||
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion })
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '完整选货组无效') }
|
||||
}
|
||||
function payload(): object {
|
||||
if (!detail.value || !allowed.value) throw new DispatchInputError(detail.value?.actions[currentAction.value]?.blockReason || '当前操作未获得服务端允许,请刷新核对')
|
||||
const common = { recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID() }
|
||||
@@ -126,10 +139,14 @@ function payload(): object {
|
||||
}
|
||||
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'ADD_CARGO': {
|
||||
const chosen = candidates.value.filter(row => selected.value.includes(row.sourceKey))
|
||||
const chosen = selected.value.map(value => {
|
||||
const row = candidates.value.find(candidate => candidate.sourceKey === value.sourceKey)
|
||||
if (!row || row.selectionGroup?.groupKey !== value.groupKey || row.selectionGroup.groupVersion !== value.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
|
||||
return row
|
||||
})
|
||||
requireBatchVersions(chosen.flatMap(row => row.appointmentBatches))
|
||||
if (!chosen.length || chosen.some(row => row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
|
||||
return { ...common, cargo: chosen.map(row => row.selection), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
|
||||
return { ...common, cargo: expandSelectionGroups(chosen), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
|
||||
}
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
@@ -181,7 +198,10 @@ async function submit() {
|
||||
error.value = null
|
||||
try {
|
||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
if (!pending.value) recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(props.action === 'REMOVE_CARGO' ? { itemId: itemId.value } : {}) })
|
||||
if (!pending.value) {
|
||||
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(props.action === 'REMOVE_CARGO' ? { itemId: itemId.value } : {}) })
|
||||
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
||||
}
|
||||
const routeCommand = pending.value?.operation === 'ROUTE'
|
||||
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
|
||||
if (!result) return
|
||||
@@ -193,24 +213,61 @@ function moveStop(index: number, offset: number) { const target = index + offset
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else generation++ }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]" :width="width" :confirm-loading="busy" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
|
||||
<p>排车单号 {{ currentNo || '—' }}</p><p v-if="pending" role="status">原操作结果尚待核对,当前使用原版本和原请求内容。</p>
|
||||
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending?.rejected" :disabled="busy" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
|
||||
<p v-if="detail?.actions[currentAction]?.allowed !== true && !pending">{{ detail?.actions[currentAction]?.blockReason || '当前操作资格未返回,请刷新核对' }}</p>
|
||||
<el-form v-if="detail" class="density-form" label-position="top">
|
||||
<template v-if="currentOperation === 'ROUTE' && route"><p>起运仓:{{ route.stops[0]?.name || '—' }} · {{ route.stops[0]?.address || '—' }}</p><p>{{ route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。' }}</p><ol><li v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index">{{ stop.name }} · {{ stop.address || '—' }} <el-button :disabled="frozen || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></li></ol></template><template v-else-if="currentAction === 'REMOVE_CARGO' && items"><el-table :data="items.items" border max-height="420"><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" /><el-table-column prop="orderNo" label="来源订单" /><el-table-column prop="inboundCode" label="入仓码" /><el-table-column prop="palletNo" label="托盘码" /><el-table-column prop="scheduledPackages" label="本次箱数" /><el-table-column prop="pickedPackages" label="真实已拣" /><el-table-column label="移货资格"><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table></template><template v-else-if="currentAction === 'EDIT'"><DispatchResourceFields v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen" /><div class="form-grid"><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></div></template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'"><p>{{ detail.vehicleInfo.vehicle?.plate || '—' }} · {{ detail.vehicleInfo.driver?.name || '—' }} · {{ detail.counts.palletCount }}托/{{ detail.counts.packageCount }}箱</p><p v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status }}</p><p>请核对本车资源与本趟证据;审核通过不代替后续现场扫码。</p><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></template>
|
||||
<template v-else-if="currentAction === 'ADD_CARGO'"><div class="density-actions"><el-input v-model="candidateKeyword" :disabled="frozen" placeholder="订单号/入仓码" /><el-button :disabled="frozen" @click="searchCargo">查询</el-button></div><el-table :data="candidates" border max-height="380"><el-table-column label="选择" width="70"><template #default="{ row }"><el-checkbox :model-value="selected.includes(row.sourceKey)" @change="selected = $event === true ? [...selected, row.sourceKey] : selected.filter(key => key !== row.sourceKey)" :disabled="frozen || row.blockReasons.length > 0" :aria-label="`选择货物 ${row.palletNo || row.orderNo || '待核实'}`" /></template></el-table-column><el-table-column prop="customerName" label="客户" /><el-table-column prop="orderNo" label="订单号" /><el-table-column prop="palletNo" label="托盘码" /><el-table-column prop="availablePackages" label="可排箱数" /><el-table-column label="目的仓"><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table></template>
|
||||
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview"><p>{{ preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。' }}</p><el-table :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" /><el-table-column label="实际托位"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen" :min="1" placeholder="请确认" /></template></el-table-column><el-table-column label="实测重量(kg)"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen" :min="0.001" placeholder="请核对真实实测" /></template></el-table-column><el-table-column label="实测体积(m³)"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen" :min="0.001" placeholder="请核对真实实测" /></template></el-table-column><el-table-column label="已匹配卸货顺序"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table><p v-for="pallet in preview.pallets" :key="pallet.warehousePalletId">{{ pallet.palletNo }}:<span v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType">{{ evidence.actual ? '实测依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }} {{ evidence.value ?? '—' }};</span></p></template>
|
||||
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen" />
|
||||
<p v-else-if="currentAction === 'DEPART'">确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。</p><p v-else-if="currentAction === 'RETURN_TO_REVIEW'">仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。</p><p v-else-if="currentAction === 'CANCEL'">确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。</p>
|
||||
<template v-if="currentOperation !== 'ROUTE' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)"><DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen" @busy="evidenceBusy.VEHICLE = $event" /><DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen" @busy="evidenceBusy.DRIVER = $event" /></template><el-form-item v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen" type="textarea" :rows="3" placeholder="请输入操作原因或审核意见" /></el-form-item>
|
||||
<el-form-item v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" label="备注"><el-input v-model="form.remark" :disabled="frozen" type="textarea" :rows="3" placeholder="请输入本次备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert v-if="pending" role="status" title="原操作结果尚待核对,当前使用原版本和原请求内容。" type="warning" :closable="false" show-icon />
|
||||
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending?.rejected" :disabled="busy || loading || uploadingEvidence" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
|
||||
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
|
||||
<el-form v-if="detail" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
|
||||
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
|
||||
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
|
||||
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
|
||||
</section>
|
||||
<section v-else-if="currentAction === 'REMOVE_CARGO' && items" class="density-dialog-section" data-tone="source" aria-label="本车可移除货物"><h3 class="density-dialog-section__title">本车货物</h3>
|
||||
<el-table :data="items.items" border><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || loading || uploadingEvidence || !allowed || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="scheduledPackages" label="本次箱数" width="100" /><el-table-column prop="pickedPackages" label="真实已拣" width="100" /><el-table-column label="移货资格" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table>
|
||||
</section>
|
||||
<template v-else-if="currentAction === 'EDIT'">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><h3 class="density-dialog-section__title">审核依据</h3><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th></tr></thead><tbody><tr v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ appointment.status }}</td></tr></tbody></table>
|
||||
<el-alert title="请核对本车资源与本趟证据;审核通过不代替后续现场扫码。" type="info" :closable="false" show-icon />
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="审核结果登记"><h3 class="density-dialog-section__title">审核结果</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen || loading || uploadingEvidence || !allowed"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></td></tr></tbody></table></section>
|
||||
</template>
|
||||
<section v-else-if="currentAction === 'ADD_CARGO'" class="density-dialog-section" data-tone="source" aria-label="本车添加货物"><h3 class="density-dialog-section__title">选择完整货物组</h3>
|
||||
<div class="workflow-cargo-query"><el-input v-model="candidateKeyword" :disabled="frozen || loading || uploadingEvidence || !allowed" placeholder="订单号/入仓码" /><el-button :disabled="frozen || loading || uploadingEvidence || !allowed" @click="searchCargo">查询</el-button></div>
|
||||
<el-table :data="candidates" border><el-table-column label="选择" width="70" fixed="left"><template #default="{ row }"><el-checkbox :model-value="isCargoSelected(row as Candidate)" @change="selectCargo(row as Candidate, $event)" :disabled="frozen || loading || uploadingEvidence || !allowed || (!isCargoSelected(row as Candidate) && Boolean(selectionGroupBlockReason(row as Candidate)))" :aria-label="'选择货物 ' + (row.palletNo || row.orderNo || '待核实')" /></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip /><el-table-column label="托盘与完整来源组" min-width="240"><template #default="{ row }"><LineClampText :content="row.palletNo || '—'" /><details class="workflow-source-group"><summary>查看完整来源</summary><ul><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details><span>{{ selectionGroupBlockReason(row as Candidate) }}</span></template></el-table-column><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table>
|
||||
</section>
|
||||
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
|
||||
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="真实实载登记"><h3 class="density-dialog-section__title">逐托实载</h3><el-table class="density-table--editable" :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" show-overflow-tooltip /><el-table-column label="实际托位" min-width="140"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="1" placeholder="请确认" controls-position="right" /></template></el-table-column><el-table-column label="实测重量(kg)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="实测体积(m³)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="已匹配卸货顺序" min-width="170"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table></section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="实载与参考依据"><h3 class="density-dialog-section__title">测量依据</h3><table class="density-table"><thead><tr><th>托盘码</th><th>依据</th><th>来源业务号</th><th>值</th></tr></thead><tbody><template v-for="pallet in preview.pallets" :key="pallet.warehousePalletId"><tr v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType"><td>{{ pallet.palletNo }}</td><td>{{ evidence.actual ? '实测依据' : '仅供参考' }}</td><td>{{ evidence.sourceBusinessNo || '—' }}</td><td>{{ evidence.value ?? '—' }}</td></tr></template></tbody></table></section>
|
||||
</template>
|
||||
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen || loading || uploadingEvidence || !allowed" />
|
||||
<el-alert v-else-if="currentAction === 'DEPART'" title="确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<template v-if="currentOperation !== 'ROUTE' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
|
||||
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</template>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" placeholder="请输入操作原因或审核意见" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" placeholder="请输入本次备注(选填)" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||
.form-grid :deep(.el-date-editor) { width: 100%; }
|
||||
.workflow-editor-table { table-layout: fixed; }
|
||||
.workflow-editor-table td { width: 50%; }
|
||||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||||
.workflow-cargo-query { display: flex; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
|
||||
.workflow-cargo-query > .el-input { max-width: 320px; }
|
||||
.workflow-source-group > summary { cursor: pointer; }
|
||||
.workflow-source-group ul { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SmartDispatchDialog from './SmartDispatchDialog.vue'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { listDrivers, listVehicles, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
import * as api from '../api/dispatches'
|
||||
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||
import type { ConvertDraft, DispatchDraft, Vehicle } from '../dispatchContracts'
|
||||
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
const warehouse: PlatformWarehouseResource = { id: '321', tenantId: '0', warehouseCode: 'LAX', internalCode: null, warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }
|
||||
const vehicleRecord: VehicleDetail = { id: '9223372036854775001', plateNumber: 'CA-MASTER', model: '档案车型', maxPalletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62, fuelConsumptionPer100Km: 10, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'operator', updatedAt: '' }
|
||||
const driverRecord: DriverDetail = { id: '9223372036854775002', driverNo: 'DR-1', active: true, reviewStatus: 'APPROVED', licenseFileId: 'LICENSE-FILE', recordVersion: 0, updatedBy: 'operator', updatedAt: '', licenseState: 'VALID', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-01-01' }, details: { name: '档案司机', phone: '555-0101', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE-1', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-01-01', licenseZone: 'America/Los_Angeles', remark: null, profile: null } }
|
||||
function draft(): DispatchDraft {
|
||||
const first = candidate(), second = candidate({ sourceKey: 'source-2', customerId: 'C2', customerName: '客户乙', selection: { ...first.selection, planPalletId: '12', warehousePalletId: '22' }, palletNo: 'PALLET-2' })
|
||||
return { draftToken: 'server-token', recordVersion: 0, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [first.sourceKey] }, { draftContainerId: 'container-2', sequenceNo: 2, capacity: 15, sourceKeys: [second.sourceKey] }], sources: [first, second] }
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse])
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()])
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: draft().sources, complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
|
||||
vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [vehicleRecord], page: 1, size: 100, total: 1, pages: 1 })
|
||||
vi.mocked(listDrivers).mockResolvedValue({ records: [driverRecord], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
|
||||
})
|
||||
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
|
||||
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
|
||||
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
|
||||
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '显式恢复已有草稿')!.vm.$emit('change', 'server-token'); await flushPromises() }
|
||||
async function externalResources(dialog: VueWrapper, plate: string, name: string) {
|
||||
for (const label of ['车辆来源', '司机来源']) { const select = field(dialog, label).getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'EXTERNAL'); select.vm.$emit('change', 'EXTERNAL'); await flushPromises() }
|
||||
await field(dialog, '车牌号').get('input').setValue(plate)
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate immediately after typing').toBe(plate)
|
||||
await field(dialog, '拖车公司名称').get('input').setValue('本趟承运公司')
|
||||
for (const [label, value] of [['实际托位容量', 17], ['最大载重(kg)', 15000], ['最大容积(m³)', 62]] as const) field(dialog, label).getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', value)
|
||||
await field(dialog, '司机姓名').get('input').setValue(name); await field(dialog, '联系电话').get('input').setValue('555-0102'); await field(dialog, '驾驶证号').get('input').setValue('EXTERNAL-LICENSE')
|
||||
field(dialog, '驾驶证到期日').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2030-01-01')
|
||||
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises()
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate after completing driver fields').toBe(plate)
|
||||
}
|
||||
|
||||
describe('new dispatch native layout', () => {
|
||||
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
|
||||
const { dialog } = await render()
|
||||
expect(dialog.text()).toContain('请先选择装车组')
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
|
||||
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
|
||||
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
|
||||
await restore(dialog)
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(false)
|
||||
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
it('preserves every external field and actual capacity through fullscreen in a real empty creation', async () => {
|
||||
const { wrapper, dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
|
||||
await externalResources(dialog, 'CA-EXTERNAL', '临时司机')
|
||||
const state = wrapper.vm as unknown as { vehicle: Vehicle }
|
||||
expect(state.vehicle.plate, 'parent vehicle after external inputs').toBe('CA-EXTERNAL')
|
||||
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
|
||||
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
|
||||
await field(dialog, '备注').get('input').setValue('本趟备注')
|
||||
expect(state.vehicle.plate, 'parent vehicle after dates and remark').toBe('CA-EXTERNAL')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(state.vehicle.plate, 'parent vehicle after fullscreen').toBe('CA-EXTERNAL')
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(dialog.text()).toContain('空单不能核平、装车或发车')
|
||||
expect(dialog.find('[aria-label="排车候选货物"]').exists()).toBe(false)
|
||||
await dialog.findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
|
||||
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-EXTERNAL', carrierName: '本趟承运公司', capacity: { palletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62 }, evidenceFileIds: [] }, driver: { mode: 'EXTERNAL', driverId: null, name: '临时司机', phone: '555-0102', evidence: { licenseNo: 'EXTERNAL-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } })
|
||||
})
|
||||
it('queries real master directories and keeps business labels and task-end evidence in the command', async () => {
|
||||
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
|
||||
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises()
|
||||
const vehicleSelect = field(dialog, '车辆').getComponent({ name: 'ElSelect' })
|
||||
vehicleSelect.vm.$emit('focus'); await flushPromises()
|
||||
vehicleSelect.vm.$emit('update:modelValue', vehicleRecord.id); vehicleSelect.vm.$emit('change', vehicleRecord.id); await flushPromises()
|
||||
const driverSelect = field(dialog, '司机').getComponent({ name: 'ElSelect' })
|
||||
driverSelect.vm.$emit('focus'); await flushPromises()
|
||||
driverSelect.vm.$emit('update:modelValue', driverRecord.id); driverSelect.vm.$emit('change', driverRecord.id); await flushPromises()
|
||||
expect(listVehicles).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', status: 'IN_SERVICE' })
|
||||
expect(listDrivers).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: '2026-11-03T18:00:00Z' })
|
||||
expect(dialog.text()).not.toContain(vehicleRecord.id)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ vehicle: { mode: 'MASTER', vehicleId: vehicleRecord.id, plate: 'CA-MASTER', carrierName: null, capacity: null, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: driverRecord.id, name: '档案司机', phone: '555-0101', evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } }))
|
||||
})
|
||||
it('keeps each group configuration independent and verifies proven conversions without creating them twice', async () => {
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||
const { wrapper, dialog } = await render(); await restore(dialog)
|
||||
await externalResources(dialog, 'CA-A', '司机甲'); await field(dialog, '备注').get('input').setValue('第一组')
|
||||
const radios = dialog.get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' })
|
||||
radios[1]!.vm.$emit('update:modelValue', 'container-2'); await flushPromises()
|
||||
await externalResources(dialog, 'CA-B', '司机乙'); await field(dialog, '备注').get('input').setValue('第二组')
|
||||
radios[0]!.vm.$emit('update:modelValue', 'container-1'); await flushPromises()
|
||||
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-A')
|
||||
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
|
||||
expect(request.recordVersion).toBe(0)
|
||||
expect(request.groups.map(group => [group.dispatch.vehicle?.plate, group.dispatch.driver?.name, group.dispatch.remark])).toEqual([['CA-A', '司机甲', '第一组'], ['CA-B', '司机乙', '第二组']])
|
||||
expect(request.groups.map(group => group.dispatch.vehicle?.capacity?.palletSpaces)).toEqual([17, 17])
|
||||
expect(wrapper.emitted('created')).toBeUndefined()
|
||||
expect(dialog.text()).toContain('草稿操作结果未确认,原请求已保留')
|
||||
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
})
|
||||
it('locks the real frame and all resource edits while pending and reuses the original conversion after failure', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
vi.mocked(api.convertDispatchDraft).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockImplementationOnce(async body => ({ draftToken: body.draftToken, replayed: true, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
|
||||
const { wrapper, dialog } = await render(); await restore(dialog); await externalResources(dialog, 'CA-A', '司机甲')
|
||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
||||
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
|
||||
expect(dialog.text()).toContain('new-dispatch-request')
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original)
|
||||
})
|
||||
it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => {
|
||||
const rows = Array.from({ length: 25 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
|
||||
rows[0] = sharedPalletCandidates()[0]!
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: rows, complete: true, snapshotAt: '' })
|
||||
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||
const table = dialog.get('[aria-label="排车候选货物"]')
|
||||
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25)
|
||||
expect(table.text()).toContain('PALLET-24')
|
||||
expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined()
|
||||
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(50)
|
||||
const sourceGroup = table.findAll('details')[0]!
|
||||
await sourceGroup.get('summary').trigger('click'); await flushPromises()
|
||||
expect(sourceGroup.element.hasAttribute('open')).toBe(true)
|
||||
expect(sourceGroup.findAll('li')).toHaveLength(2)
|
||||
expect(sourceGroup.text()).toContain('客户A')
|
||||
expect(sourceGroup.text()).toContain('客户B')
|
||||
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps query conditions and regional tabs on the original candidate transport', async () => {
|
||||
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||
await dialog.get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY')
|
||||
await dialog.get('input[placeholder="目的仓码"]').setValue('ONT8')
|
||||
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', 'RELABEL'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage: 'RELABEL', draftToken: undefined })
|
||||
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
|
||||
await dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
it('shows loading and initialization diagnostics without issuing create commands', async () => {
|
||||
vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()])
|
||||
const { dialog } = await render()
|
||||
expect(dialog.text()).toContain('draft-load-request')
|
||||
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
|
||||
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
|
||||
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
|
||||
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||
await dialog.findAll('button').find(button => button.text() === '新建草稿')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.text()).toContain('draft-rejected-request')
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
|
||||
await dialog.findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
|
||||
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
|
||||
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('keeps creation unavailable without its write permission', async () => {
|
||||
can.mockReturnValue(false)
|
||||
const { dialog } = await render()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import SmartDispatchDialog from './SmartDispatchDialog.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import * as api from '../api/dispatches'
|
||||
import { candidate, command, detail } from '../dispatchTestFixtures'
|
||||
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||
import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
const identity = vi.hoisted(() => ({ id: 'smart-operator' }))
|
||||
@@ -17,6 +17,29 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||
const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
|
||||
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
|
||||
const [first, second] = sharedPalletCandidates(), original = draft()
|
||||
original.sources = [first!]; original.containers[0]!.sourceKeys = [first!.sourceKey]
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue(original)
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise<void>; submit(): Promise<void> }
|
||||
state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('LOCK', first, original.containers[0]!.draftContainerId)
|
||||
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first!.selection }))
|
||||
await state.submit(); expect(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.cargo).toEqual([first!.selection, second!.selection])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('allows one atomic removal of a restored group that is no longer selectable', async () => {
|
||||
const first = sharedPalletCandidates()[0]!, original = draft()
|
||||
first.selectionGroup = { ...first.selectionGroup, complete: false, blockReasons: ['来源待核齐'] }
|
||||
original.sources = [first]; original.containers[0]!.sourceKeys = [first.sourceKey]
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.unlockDispatchDraftPallet).mockResolvedValue(original)
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise<void> }
|
||||
state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('UNLOCK', first, original.containers[0]!.draftContainerId)
|
||||
expect(api.unlockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first.selection })); wrapper.unmount()
|
||||
})
|
||||
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
|
||||
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
|
||||
@@ -55,12 +78,12 @@ it('requires an explicit warehouse and allows saving a true empty dispatch with
|
||||
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('restores an explicit draft and submits complete server source versions in each container', async () => {
|
||||
const original = draft(); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B', customerName: '客户乙' })); original.containers[0]!.sourceKeys.push('source-B')
|
||||
const original = draft(); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B', customerName: '客户乙', selection: { ...source().selection, planPalletId: '22', warehousePalletId: '32' } })); original.containers[0]!.sourceKeys.push('source-B')
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, 'PC/1') })) }))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
|
||||
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await flushPromises()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, source().selection] }) })] }))
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, original.sources[1]!.selection] }) })] }))
|
||||
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
|
||||
|
||||
@@ -8,11 +8,13 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion, requireBatchVersions } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason } from '../dispatchContracts'
|
||||
import { dispatchActionPermissions } from '../dispatchPresentation'
|
||||
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
@@ -36,7 +38,8 @@ const visibleCandidates = computed(() => candidates.value.filter(row => matchesS
|
||||
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
|
||||
const currentSettings = computed(() => {
|
||||
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
|
||||
return settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
|
||||
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
|
||||
return settings[key]!
|
||||
})
|
||||
const form = computed(() => currentSettings.value.form)
|
||||
const vehicle = computed({ get: () => currentSettings.value.vehicle, set: (value: Vehicle) => { currentSettings.value.vehicle = value } })
|
||||
@@ -78,10 +81,11 @@ async function loadCandidates(sequence = generation) {
|
||||
async function search() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
|
||||
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
|
||||
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
|
||||
assertSelectionGroupVersions(cargo, candidates.value)
|
||||
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
|
||||
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
|
||||
const config = containerId ? settings[containerId] : currentSettings.value
|
||||
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: cargo.map(row => row.selection), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
|
||||
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
|
||||
}
|
||||
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string) {
|
||||
if (!canCreate.value || loading.value || saving.value) return
|
||||
@@ -96,7 +100,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
|
||||
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
|
||||
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
|
||||
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
|
||||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId || source.blockReasons.length) throw new DispatchInputError('请选择可排货物和装车容器'); body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') requireSelectionGroup(source); body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||||
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
|
||||
else if (action === 'RELEASE') body = common
|
||||
else {
|
||||
@@ -162,24 +166,66 @@ async function submit() {
|
||||
watch(() => props.modelValue, value => { if (value) void init(); else generation++ }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="loading || !canCreate" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="pending || recovery.pending.value" role="status">原请求结果尚待核对,请勿更改选择、版本或重新建单。</p><el-button v-if="pending?.rejected" :disabled="saving" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
|
||||
<el-form label-position="top" class="density-form"><div class="create-grid"><el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen"><el-radio value="cargo">先选货建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item><el-form-item label="起运仓" required><el-select v-model="warehouseId" :disabled="frozen || Boolean(draft)" placeholder="请选择起运仓" @change="search"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></div>
|
||||
<p v-if="mode === 'cargo'">当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? `装车组 ${draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo}` : '请先选择装车组' }};每组车辆、司机和时间独立保存。</p><DispatchResourceFields v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen" /><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen" placeholder="请输入本趟备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<p v-if="mode === 'empty'">空单可先保存,之后添加同仓各客户货物;空单不能核平、装车或发车。</p>
|
||||
<template v-else><div class="density-actions"><el-select :model-value="draft?.draftToken" :disabled="frozen" placeholder="显式恢复已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="`${warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实'} · ${saved.containers.length}组 · ${saved.sources.length}条货物`" /></el-select><el-button :disabled="frozen || !warehouseId || Boolean(draft)" @click="draftAction('CREATE')">新建草稿</el-button><el-select v-model="capacity" :disabled="frozen" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select><el-button :disabled="frozen || !draft" @click="draftAction('CONTAINER')">添加装车组</el-button><el-button :disabled="frozen || !draft" @click="draftAction('RELEASE')">释放本草稿</el-button></div>
|
||||
<div class="density-actions"><el-input v-model="query.orderNo" :disabled="frozen" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen" clearable placeholder="全部来源"><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || !warehouseId" @click="search">查询</el-button></div>
|
||||
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen" @click="warehouseTab = tab.value">{{ tab.label }}</button></div><div class="cargo-workspace"><el-table :data="visibleCandidates" border height="420"><el-table-column label="操作" width="95" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || !activeContainerId || row.blockReasons.length > 0" @click="draftAction('LOCK', row as Candidate, activeContainerId)">加入本组</el-button></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" /><el-table-column label="来源" min-width="130"><template #default="{ row }">{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}<br>{{ row.selection.relabelNo || row.planNo || '—' }}</template></el-table-column><el-table-column prop="orderNo" label="订单号" min-width="170" /><el-table-column prop="inboundCode" label="入仓码" min-width="170" /><el-table-column prop="palletNo" label="托盘码" min-width="170" /><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150"><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column><el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³<br>仅供预排参考</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" /><el-table-column label="资格" min-width="160"><template #default="{ row }">{{ row.blockReasons.join(';') || '可排' }}</template></el-table-column></el-table>
|
||||
<section><article v-for="container in draft?.containers || []" :key="container.draftContainerId" class="draft-container"><el-radio v-model="activeContainerId" :disabled="frozen" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio><el-button link :disabled="frozen" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button><ul><li v-for="key in container.sourceKeys" :key="key">{{ draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复' }}<el-button link :disabled="frozen || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></article><p v-if="!draft?.containers.length">新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></section>
|
||||
</div>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="loading || !canCreate" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
<div class="density-dialog-sections">
|
||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
|
||||
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
|
||||
<el-form label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="排车设置"><h3 class="density-dialog-section__title">建单设置 <small v-if="mode === 'cargo'">当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? '装车组 ' + draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo : '请先选择装车组' }}</small><InlineHelp v-if="mode === 'cargo'" title="装车组说明">每组车辆、司机和时间独立保存。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable dispatch-create-table"><tbody>
|
||||
<tr><td><el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item></td><td><el-form-item label="起运仓" required><el-select v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="search"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" placeholder="请输入本趟备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="排车车辆与司机"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></section>
|
||||
</el-form>
|
||||
<el-alert v-if="mode === 'empty'" title="空单可先保存,之后添加同仓各客户货物;空单不能核平、装车或发车。" type="info" :closable="false" show-icon />
|
||||
<template v-else>
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="整车草稿管理"><h3 class="density-dialog-section__title">整车草稿</h3>
|
||||
<div class="dispatch-draft-tools density-actions"><el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="显式恢复已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '组 · ' + saved.sources.length + '条货物'" /></el-select><el-button :disabled="frozen || loading || !warehouseId || Boolean(draft)" @click="draftAction('CREATE')">新建草稿</el-button><el-select class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select><el-button :disabled="frozen || loading || !draft" @click="draftAction('CONTAINER')">添加装车组</el-button><el-button :disabled="frozen || loading || !draft" @click="draftAction('RELEASE')">释放本草稿</el-button></div>
|
||||
</section>
|
||||
<div class="cargo-workspace">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
|
||||
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
|
||||
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="warehouseTab = tab.value">{{ tab.label }}</button></div>
|
||||
<el-table :data="visibleCandidates" border>
|
||||
<el-table-column label="操作" width="95" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !activeContainerId || Boolean(selectionGroupBlockReason(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">加入本组</el-button></template></el-table-column>
|
||||
<el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
|
||||
<el-table-column prop="orderNo" label="订单号" min-width="170" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="托盘与完整来源组" min-width="210"><template #default="{ row }"><details class="dispatch-source-group"><summary><div class="dispatch-group-summary"><LineClampText :content="row.palletNo || '—'" /><span>{{ row.selectionGroup.pallets.length }}个实托</span></div></summary><ul class="dispatch-source-list"><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details></template></el-table-column>
|
||||
<el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" show-overflow-tooltip /><el-table-column label="资格" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ selectionGroupBlockReason(row as Candidate) || '可排整组' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组</h3>
|
||||
<table v-for="container in draft?.containers || []" :key="container.draftContainerId" class="density-table draft-group-table"><tbody>
|
||||
<tr><td><el-radio v-model="activeContainerId" :disabled="frozen || loading" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio></td><td><el-button link :disabled="frozen || loading" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button></td></tr>
|
||||
<tr><td colspan="2"><ul class="dispatch-draft-sources"><li v-for="key in container.sourceKeys" :key="key" class="dispatch-draft-source"><LineClampText :content="draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复'" /><el-button link :disabled="frozen || loading || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></td></tr>
|
||||
</tbody></table>
|
||||
<p v-if="!draft?.containers.length" class="density-dialog-note">新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.create-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||
.create-grid :deep(.el-select), .create-grid :deep(.el-date-editor) { width: 100%; }
|
||||
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); gap: var(--admin-space-5); }
|
||||
.draft-container { padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
|
||||
.dispatch-create-table { table-layout: fixed; }
|
||||
.dispatch-create-table td:not([colspan]) { width: 50%; }
|
||||
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr); gap: var(--admin-space-5); align-items: start; }
|
||||
.cargo-workspace > .density-dialog-section { margin: 0; }
|
||||
.dispatch-draft-tools { flex-wrap: wrap; }
|
||||
.dispatch-draft-picker { width: 320px; }
|
||||
.dispatch-capacity-picker { width: 130px; }
|
||||
.dispatch-candidate-query { display: grid; grid-template-columns: minmax(160px, 1fr) minmax(120px, 1fr) minmax(130px, .8fr) auto; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
|
||||
.dispatch-group-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--admin-space-2); }
|
||||
.dispatch-source-group > summary { cursor: pointer; }
|
||||
.dispatch-source-list { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
|
||||
.draft-group-table + .draft-group-table { margin-top: var(--admin-space-4); }
|
||||
.dispatch-draft-sources { margin: 0; padding: 0; list-style: none; }
|
||||
.dispatch-draft-source { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.dispatch-draft-source :deep(.line-clamp-text) { flex: 1; }
|
||||
</style>
|
||||
|
||||
@@ -28,14 +28,13 @@ export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; s
|
||||
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
|
||||
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
|
||||
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
||||
export interface VehicleAppointment { referenceNo: string; status: string; appointmentAt: string | null }
|
||||
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
|
||||
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string }
|
||||
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; vehicleAppointment: VehicleAppointment | null; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
|
||||
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
|
||||
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
|
||||
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
|
||||
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
|
||||
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; vehicleAppointment?: { referenceNo: string; recordVersion: Version } | null; remark: string | null }
|
||||
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
|
||||
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||
export interface ReasonCommand extends VersionCommand { reason: string }
|
||||
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
|
||||
@@ -43,7 +42,10 @@ export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: n
|
||||
export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
|
||||
export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
|
||||
export interface LoadingPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; manifestHash: string; pickingReconciled: boolean; resources: Resources; pallets: Array<{ warehousePalletId: string; palletNo: string; scheduledPackages: number; pickedPackages: number; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; missingFields: string[]; evidence: Array<{ field: string; sourceType: string; sourceBusinessNo: string | null; observedAt: string | null; value: number | null; actual: boolean }> }>; confirmedLoad: ConfirmedLoad | null; loadingComplete: Gate; missingFields: string[] }
|
||||
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[] }
|
||||
export interface SelectionGroupSource { selection: CargoSelection; sourceKey: string; customerId: string; customerName: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; packages: number }
|
||||
export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number }
|
||||
export interface SelectionGroup { groupKey: string; groupVersion: string; sources: SelectionGroupSource[]; pallets: SelectionGroupPallet[]; complete: boolean; blockReasons: string[] }
|
||||
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[]; selectionGroup: SelectionGroup }
|
||||
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
|
||||
export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
|
||||
export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
|
||||
@@ -63,7 +65,8 @@ export interface FeeItem { feeType: string; feeName: string; direction: string;
|
||||
export interface ArrangeGroup { groupKey: string; groupCargoHash: string; plannedDeliveryAt: string; deliveryMethod: string; waybillNo: string | null; carrierName: string | null; pickupMethod: string; loadingType: string; vehicleType: string | null; vehicleSize: string | null; appointmentAccount: string | null; sellerName: string | null; remark: string | null; externalFreightHash: string; feeCoverageCargoHash: string; feeCoverage: CoverageSelection[]; fees: FeeItem[] }
|
||||
export interface ArrangeDelivery extends VersionCommand { cargoHash: string; loadingCompletionHash: string; groups: ArrangeGroup[] }
|
||||
export interface ArrangementResult { dispatchNo: string; recordVersion: Version; arrangementKey: string; cargoHash: string; arrangedAt: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[] }
|
||||
export interface CargoLine { dispatchItemId: string; customerId: string; customerName: string; orderId: string | null; orderNo: string | null; sourceKey: string; sourceStage: string; sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; planPalletId: string | null; warehousePalletId: string | null; palletNo: string | null; pickId: string | null; attributionId: string | null; inventoryPositionId: string | null; receiptId: string | null; returnReceiptId: string | null; returnCartonId: string | null; cartonMark: string | null; relabelNo: string | null; destination: Destination; packages: number; productQuantity: number; grossWeightKg: number | null; volumeCbm: number | null; batchAllocations: DispatchBatchAllocation[] }
|
||||
export interface SourceOrigin { dispatchItemSourceId: string | null; relabelApplicationId: string | null; relabelItemId: string | null; sourceInventoryBalanceId: string | null; sourceInboundOrderId: string | null; sourceInboundOrderNo: string | null; sourceInboundCode: string | null; productQuantity: number | null }
|
||||
export interface CargoLine { dispatchItemId: string; customerId: string; customerName: string; orderId: string | null; orderNo: string | null; sourceKey: string; sourceStage: string; sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; planPalletId: string | null; warehousePalletId: string | null; palletNo: string | null; pickId: string | null; attributionId: string | null; inventoryPositionId: string | null; receiptId: string | null; returnReceiptId: string | null; returnCartonId: string | null; cartonMark: string | null; relabelNo: string | null; destination: Destination; packages: number; productQuantity: number; grossWeightKg: number | null; volumeCbm: number | null; batchAllocations: DispatchBatchAllocation[]; sourceOrigin: SourceOrigin | null }
|
||||
export interface PreviewGroup { groupKey: string; customerId: string; customerName: string; destination: Destination; groupCargoHash: string; cargo: CargoLine[]; externalFreightHash: string; externalQuotes: Array<{ customerId: string; groupKey: string; bookingNo: string; inboundCode: string; currency: string; amount: number; sourceHash: string }>; feeCoverageCargoHash: string; feeSources: Array<{ financeFeeId: string; customerId: string; currency: string; amount: number; sourceVersion: number; sourceHash: string; status: string; services: string[]; sourceKeys: string[] }>; requiredServices: string[]; blockReasons: string[] }
|
||||
export interface ArrangementPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; cargoHash: string; loadingCompletionHash: string; arrange: Gate; groups: PreviewGroup[]; arrangement: ArrangementResult | null }
|
||||
interface CommandBase { dispatchNo: string; idempotencyKey: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; occurredAt: string; effect: 'APPLIED' | 'EXISTING'; counts: Counts; replayed: boolean; alreadyArranged: boolean }
|
||||
@@ -98,3 +101,44 @@ export function requireBatchVersions(values: readonly { batchKey: string; record
|
||||
versions.set(value.batchKey, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
function selectionIdentity(value: CargoSelection): string {
|
||||
return JSON.stringify([value.sourceType, value.planPalletId, value.returnReceiptId, value.relabelNo, value.warehousePalletId])
|
||||
}
|
||||
export function requireSelectionGroup(row: Candidate): SelectionGroup {
|
||||
const group = row.selectionGroup
|
||||
if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
|
||||
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...row.blockReasons, ...group.blockReasons].join(';') || '选货组来源尚未完整,请重新读取候选')
|
||||
if (!group.sources.length || group.sources.some(source => !source.sourceKey || !source.customerId || !source.selection?.sourceVersion || !Number.isSafeInteger(source.packages) || source.packages <= 0 || source.selection.sourceType === 'RELABEL' && (source.sourceBusinessType !== 'RELABEL_ORDER' || !source.sourceBusinessNo || source.sourceBusinessNo !== source.selection.relabelNo))) throw new DispatchInputError('选货组来源或数量不完整,请重新读取候选')
|
||||
const palletKeys = group.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo)
|
||||
if (!palletKeys.length || palletKeys.some(key => !key) || new Set(palletKeys).size !== palletKeys.length || group.pallets.some(pallet => !Array.isArray(pallet.sourceKeys) || !pallet.sourceKeys.length || pallet.sourceKeys.some(key => !group.sources.some(source => source.sourceKey === key)) || !Number.isSafeInteger(pallet.packageCount) || pallet.packageCount <= 0)) throw new DispatchInputError('选货组实托事实缺失或重复,请重新读取候选')
|
||||
if (!group.sources.some(source => source.sourceKey === row.sourceKey && source.customerId === row.customerId && selectionIdentity(source.selection) === selectionIdentity(row.selection) && source.selection.sourceVersion === row.sourceVersion && row.selection.sourceVersion === row.sourceVersion)) throw new DispatchInputError('候选与完整选货组来源不一致,请重新读取候选')
|
||||
return group
|
||||
}
|
||||
export function selectionGroupBlockReason(row: Candidate): string {
|
||||
try { requireSelectionGroup(row); return '' } catch (cause) { return cause instanceof Error ? cause.message : '完整选货组无效,请重新读取候选' }
|
||||
}
|
||||
export function assertSelectionGroupVersions(rows: readonly Candidate[], latest: readonly Candidate[]): void {
|
||||
const versions = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
const group = row.selectionGroup
|
||||
if (!group?.groupKey || !group.groupVersion) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
|
||||
if (versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
|
||||
versions.set(group.groupKey, group.groupVersion)
|
||||
}
|
||||
for (const row of latest) {
|
||||
const group = row.selectionGroup, selected = rows.find(value => value.sourceKey === row.sourceKey)
|
||||
if (selected && selected.selectionGroup.groupKey !== group?.groupKey || group && versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
|
||||
}
|
||||
}
|
||||
export function expandSelectionGroups(rows: readonly Candidate[]): CargoSelection[] {
|
||||
assertSelectionGroupVersions(rows, [])
|
||||
const sources = new Map<string, SelectionGroupSource>(), selections = new Map<string, CargoSelection>()
|
||||
for (const row of rows) for (const source of requireSelectionGroup(row).sources) {
|
||||
const previous = sources.get(source.sourceKey), identity = selectionIdentity(source.selection), selected = selections.get(identity)
|
||||
if (previous && (selectionIdentity(previous.selection) !== identity || previous.selection.sourceVersion !== source.selection.sourceVersion || previous.customerId !== source.customerId || previous.packages !== source.packages)) throw new DispatchInputError('完整选货组来源事实不一致,请重新读取候选')
|
||||
if (selected && selected.sourceVersion !== source.selection.sourceVersion) throw new DispatchInputError('完整选货组来源版本不一致,请重新读取候选')
|
||||
sources.set(source.sourceKey, source); selections.set(identity, source.selection)
|
||||
}
|
||||
return [...selections.values()]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, type SelectionGroup } from './dispatchContracts'
|
||||
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
|
||||
import { normalizeCandidateList, type CandidateWire } from './dispatchWire'
|
||||
|
||||
it('expands a filtered physical group into both exact relabel selections once while retaining one physical pallet', () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
expect(expandSelectionGroups([first!])).toEqual([first!.selection, second!.selection])
|
||||
expect(expandSelectionGroups([first!, second!, first!])).toEqual([first!.selection, second!.selection])
|
||||
expect(requireSelectionGroup(first!).pallets).toEqual([{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: 8 }])
|
||||
})
|
||||
it.each(['INBOUND_PLAN', 'RETURN_RECEIPT'] as const)('allows authoritative nullable business display fields for ordinary %s groups', sourceType => {
|
||||
const row = candidate({ selection: { ...candidate().selection, sourceType, planPalletId: sourceType === 'INBOUND_PLAN' ? '11' : null, returnReceiptId: sourceType === 'RETURN_RECEIPT' ? '12' : null } })
|
||||
expect(row.selectionGroup.sources[0]).toMatchObject({ sourceBusinessType: null, sourceBusinessNo: null })
|
||||
expect(expandSelectionGroups([row])).toEqual([row.selection])
|
||||
})
|
||||
it('requires the actual RELABEL_ORDER business identity for relabel sources without accepting a legacy alias', () => {
|
||||
const row = sharedPalletCandidates()[0]!
|
||||
row.selectionGroup.sources[0]!.sourceBusinessNo = null
|
||||
expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整')
|
||||
row.selectionGroup.sources[0]!.sourceBusinessNo = 'RL-A'; row.selectionGroup.sources[0]!.sourceBusinessType = 'RELABEL'
|
||||
expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整')
|
||||
})
|
||||
it.each([undefined, { complete: false }, { blockReasons: ['换标来源尚未核齐'] }])('rejects a missing, incomplete or blocked selection group instead of falling back to the visible row', change => {
|
||||
const row = sharedPalletCandidates()[0]!
|
||||
row.selectionGroup = change ? { ...row.selectionGroup, ...change } : undefined as unknown as SelectionGroup
|
||||
expect(() => expandSelectionGroups([row])).toThrow(/完整选货组|尚未完整|尚未核齐/)
|
||||
})
|
||||
it('rejects changed group composition and conflicting source versions before creating a command', () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
expect(() => assertSelectionGroupVersions([first!], [{ ...second!, selectionGroup: { ...second!.selectionGroup, groupVersion: 'composition-2' } }])).toThrow('组成已变化')
|
||||
const conflict = { ...second!, selectionGroup: { ...second!.selectionGroup, sources: second!.selectionGroup.sources.map((source, index) => index ? source : { ...source, selection: { ...source.selection, sourceVersion: 'changed' } }) } }
|
||||
expect(() => expandSelectionGroups([first!, conflict])).toThrow('来源事实不一致')
|
||||
})
|
||||
it('normalizes nested group Long quantities and preserves source identities and versions without loss', () => {
|
||||
const row = sharedPalletCandidates()[0]!, id = '9007199254740993'
|
||||
const value: CandidateWire = { ...row, selectionGroup: { ...row.selectionGroup, sources: row.selectionGroup.sources.map(source => ({ ...source, customerId: id, selection: { ...source.selection, sourceVersion: id }, packages: String(source.packages) })), pallets: [{ warehousePalletId: id, palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: '8' }] } }
|
||||
const result = normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
|
||||
expect(result.selectionGroup.sources.map(source => source.packages)).toEqual([3, 5]); expect(result.selectionGroup.pallets[0]!.packageCount).toBe(8)
|
||||
expect(result.selectionGroup.sources[0]!.selection.sourceVersion).toBe(id); expect(result.selectionGroup.pallets[0]!.warehousePalletId).toBe(id)
|
||||
value.selectionGroup.sources[0]!.packages = '9007199254740993'
|
||||
expect(() => normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
|
||||
})
|
||||
@@ -3,10 +3,15 @@ export const counts = () => ({ itemCount: 1, palletCount: 1, packageCount: 2, pi
|
||||
export const reviews = () => ({ dispatchReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } })
|
||||
export const resources = (): { vehicle: VehicleSnapshot; driver: Driver } => ({ vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-100', carrierName: 'Carrier', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 70 }, evidenceFileIds: [], vehicleStatus: null }, driver: { mode: 'EXTERNAL', driverId: null, name: '司机甲', phone: '555-0100', evidence: { licenseNo: 'TEST-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-01T10:00:00Z' } } })
|
||||
export const destination = () => ({ key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null })
|
||||
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1' }], ...overrides } }
|
||||
export function sharedPalletCandidates(): Candidate[] {
|
||||
const rows = ['A', 'B'].map((suffix, index) => candidate({ sourceKey: `RELABEL:${suffix}`, sourceVersion: `source-${suffix}`, customerId: `C${index + 1}`, customerName: `客户${suffix}`, availablePackages: index ? 5 : 3, selection: { sourceType: 'RELABEL', planPalletId: null, returnReceiptId: null, relabelNo: `RL-${suffix}`, warehousePalletId: '21', sourceVersion: `source-${suffix}` } }))
|
||||
const group = { groupKey: 'physical-group-21', groupVersion: 'composition-1', complete: true, blockReasons: [], sources: rows.map(row => row.selectionGroup.sources[0]!), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: rows.map(row => row.sourceKey), packageCount: 8 }] }
|
||||
return rows.map(row => ({ ...row, selectionGroup: group }))
|
||||
}
|
||||
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } }
|
||||
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
|
||||
export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
|
||||
export function candidate(overrides: Partial<Candidate> = {}): Candidate { return { selection: { sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides } }
|
||||
export function candidate(overrides: Partial<Candidate> = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } }
|
||||
export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
|
||||
const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
|
||||
return { ...base, result: action === 'DEPART' ? { departedAt: '2026-10-19T10:00:00Z', shippedPackages: 2, outbounds: [], deliveries: [] } : action === 'LOADING_COMPLETE' ? { loadingCompletionHash: 'load-hash', loadingCompletedAt: '2026-10-19T10:00:00Z', confirmedLoad: { manifestHash: 'manifest', pallets: [], operatorId: 'operator', confirmedAt: '2026-10-19T10:00:00Z' } } : { dispatchNo: no, changedItemIds: [] } } as CommandResult
|
||||
|
||||
@@ -13,6 +13,19 @@ const draft = (): wire.DispatchDraftWire => ({ draftToken: 'draft', recordVersio
|
||||
const references = () => ({ outbounds: [{ customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', status: 'WAIT_OUTBOUND', packageCount: '12', palletCount: '2' }], deliveries: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', deliveryNo: 'DEL-1', destination: destination(), status: 'WAIT_DELIVERY', packageCount: '12', palletCount: '2' }] })
|
||||
const arrangement = () => ({ dispatchNo: 'PC/1', recordVersion: id, arrangementKey: 'key', cargoHash: 'hash', arrangedAt: '', ...references() })
|
||||
|
||||
it('keeps nullable original-source provenance distinct from the fulfillment order and normalizes only its evidenced quantity', () => {
|
||||
const origin: wire.SourceOriginWire = { dispatchItemSourceId: id, relabelApplicationId: '9007199254740994', relabelItemId: '9007199254740995', sourceInventoryBalanceId: '9007199254740996', sourceInboundOrderId: '9007199254740997', sourceInboundOrderNo: 'ORIGINAL-IN-1', sourceInboundCode: null, productQuantity: '7' }
|
||||
const cargo: wire.ArrangementPreviewWire['groups'][number]['cargo'][number] = { dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'FULFILLMENT-1', sourceKey: 'source-1', sourceStage: 'RELABEL', sourceRegion: null, sourceInboundId: null, inboundCode: 'CURRENT-IN-1', planPalletId: null, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: 'RL-1', destination: destination(), packages: '2', productQuantity: '24', grossWeightKg: null, volumeCbm: null, batchAllocations: [], sourceOrigin: origin }
|
||||
const value: wire.ArrangementPreviewWire = { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'cargo', loadingCompletionHash: 'loading', arrange: gate, arrangement: null, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }] }
|
||||
const normalized = wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!
|
||||
expect(normalized.sourceOrigin).toEqual({ ...origin, productQuantity: 7 }); expect(normalized.orderId).toBe(id); expect(normalized.orderNo).toBe('FULFILLMENT-1'); expect(normalized.inboundCode).toBe('CURRENT-IN-1'); expect(normalized.productQuantity).toBe(24)
|
||||
cargo.sourceOrigin = { ...origin, sourceInboundOrderNo: null, sourceInboundCode: null, productQuantity: null }
|
||||
expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toEqual({ ...cargo.sourceOrigin })
|
||||
cargo.sourceOrigin = null; expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toBeNull()
|
||||
cargo.sourceOrigin = { ...origin, productQuantity: '9007199254740993' }
|
||||
expect(() => wire.normalizeArrangementPreview(parsed(value))).toThrow('安全整数')
|
||||
})
|
||||
|
||||
describe('native dispatch Long quantity boundary', () => {
|
||||
it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
|
||||
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
|
||||
@@ -68,7 +81,7 @@ describe('native dispatch Long quantity boundary', () => {
|
||||
expect(result).toMatchObject({ fileId: id, sizeBytes: 3 }); expect(Number.isSafeInteger(result.sizeBytes)).toBe(true); expect(new Blob(['png']).size).toBe(result.sizeBytes)
|
||||
})
|
||||
it('normalizes every arrangement cargo and existing outbound/delivery quantity', () => {
|
||||
const result = wire.normalizeArrangementPreview(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'hash', loadingCompletionHash: 'loaded', arrange: gate, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-hash', cargo: [{ dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'LM-1', sourceKey: 'source-1', sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: id, inboundCode: 'IN-1', planPalletId: id, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: id, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: null, destination: destination(), packages: '12', productQuantity: '24', grossWeightKg: 12.25, volumeCbm: null, batchAllocations: [] }], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }], arrangement: arrangement() } satisfies wire.ArrangementPreviewWire))
|
||||
const result = wire.normalizeArrangementPreview(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'hash', loadingCompletionHash: 'loaded', arrange: gate, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-hash', cargo: [{ dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'LM-1', sourceKey: 'source-1', sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: id, inboundCode: 'IN-1', planPalletId: id, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: id, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: null, destination: destination(), packages: '12', productQuantity: '24', grossWeightKg: 12.25, volumeCbm: null, batchAllocations: [], sourceOrigin: null }], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }], arrangement: arrangement() } satisfies wire.ArrangementPreviewWire))
|
||||
expect(result.groups[0]!.cargo[0]).toMatchObject({ dispatchItemId: id, packages: 12, productQuantity: 24, grossWeightKg: 12.25 }); expect(result.arrangement).toMatchObject({ recordVersion: id, outbounds: [{ packageCount: 12, palletCount: 2 }], deliveries: [{ packageCount: 12, palletCount: 2 }] })
|
||||
})
|
||||
it('normalizes typed command payloads and converted drafts and preserves immutable proof versions', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DispatchInputError, requireBatchVersions, type ArrangementPreview, type ArrangementResult, type Candidate, type CandidateList, type CargoLine, type CommandResult, type ConvertedDraft, type Counts, type DeliveryReference, type DispatchDetail, type DispatchDraft, type DispatchEvidenceFile, type DispatchItems, type DispatchListRow, type DispatchPage, type DispatchStage, type DispatchStatistics, type LoadingPreview, type OutboundReference, type PreviewGroup, type ReviewCounts } from './dispatchContracts'
|
||||
import { DispatchInputError, requireBatchVersions, type ArrangementPreview, type ArrangementResult, type Candidate, type CandidateList, type CargoLine, type CommandResult, type ConvertedDraft, type Counts, type DeliveryReference, type DispatchDetail, type DispatchDraft, type DispatchEvidenceFile, type DispatchItems, type DispatchListRow, type DispatchPage, type DispatchStage, type DispatchStatistics, type LoadingPreview, type OutboundReference, type PreviewGroup, type ReviewCounts, type SelectionGroup, type SourceOrigin } from './dispatchContracts'
|
||||
|
||||
// common-web serializes both Long and long as decimal strings. Only quantity
|
||||
// fields are normalized here; IDs, recordVersion and reviewRound stay lossless.
|
||||
@@ -17,14 +17,17 @@ type StatisticsWire = Omit<DispatchStatistics, 'matchedTotal' | 'activeTotal' |
|
||||
type ListRowWire = Omit<DispatchListRow, 'counts'> & { counts: CountsWire }
|
||||
export type DispatchPageWire = Omit<DispatchPage, 'records' | 'total' | 'page' | 'size' | 'pages' | 'statistics'> & { records: ListRowWire[]; total: LongQuantityWire; page: LongQuantityWire; size: LongQuantityWire; pages: LongQuantityWire; statistics: StatisticsWire }
|
||||
export type DispatchDetailWire = Omit<DispatchDetail, 'counts' | 'documents'> & { counts: CountsWire; documents: Omit<DispatchDetail['documents'], keyof ReferencesWire> & ReferencesWire }
|
||||
export type CandidateWire = LongFields<Candidate, 'availablePackages'>
|
||||
type SelectionGroupWire = Omit<SelectionGroup, 'sources' | 'pallets'> & { sources: Array<LongFields<SelectionGroup['sources'][number], 'packages'>>; pallets: Array<LongFields<SelectionGroup['pallets'][number], 'packageCount'>> }
|
||||
export type CandidateWire = Omit<LongFields<Candidate, 'availablePackages'>, 'selectionGroup'> & { selectionGroup: SelectionGroupWire }
|
||||
export type CandidateListWire = Omit<CandidateList, 'candidates'> & { candidates: CandidateWire[] }
|
||||
export type DispatchDraftWire = Omit<DispatchDraft, 'sources'> & { sources: CandidateWire[] }
|
||||
export type DispatchItemsWire = Omit<DispatchItems, 'items'> & { items: Array<LongFields<DispatchItems['items'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
||||
export type LoadingPreviewWire = Omit<LoadingPreview, 'pallets'> & { pallets: Array<LongFields<LoadingPreview['pallets'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
||||
export type DispatchEvidenceFileWire = LongFields<DispatchEvidenceFile, 'sizeBytes'>
|
||||
type ArrangementResultWire = Omit<ArrangementResult, keyof ReferencesWire> & ReferencesWire
|
||||
type PreviewGroupWire = Omit<PreviewGroup, 'cargo'> & { cargo: Array<LongFields<CargoLine, 'packages' | 'productQuantity'>> }
|
||||
export type SourceOriginWire = Omit<SourceOrigin, 'productQuantity'> & { productQuantity: LongQuantityWire | null }
|
||||
type CargoLineWire = Omit<LongFields<CargoLine, 'packages' | 'productQuantity'>, 'sourceOrigin'> & { sourceOrigin: SourceOriginWire | null }
|
||||
type PreviewGroupWire = Omit<PreviewGroup, 'cargo'> & { cargo: CargoLineWire[] }
|
||||
export type ArrangementPreviewWire = Omit<ArrangementPreview, 'groups' | 'arrangement'> & { groups: PreviewGroupWire[]; arrangement: ArrangementResultWire | null }
|
||||
type PickedWire = LongFields<Extract<CommandResult, { action: 'PICK' }>['result'], 'packages' | 'pickedPackages'>
|
||||
type DepartedWire = Omit<Extract<CommandResult, { action: 'DEPART' }>['result'], 'shippedPackages' | keyof ReferencesWire> & ReferencesWire & { shippedPackages: LongQuantityWire }
|
||||
@@ -46,7 +49,12 @@ function numbers<T, K extends keyof T>(value: LongFields<T, K>, fields: readonly
|
||||
const counts = (value: CountsWire) => numbers<Counts, keyof Counts>(value, ['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount'])
|
||||
const reviewCounts = (value: ReviewCountsWire) => numbers<ReviewCounts, keyof ReviewCounts>(value, ['notSubmitted', 'pending', 'approved', 'rejected'])
|
||||
const references = (value: ReferencesWire) => ({ outbounds: value.outbounds.map(row => numbers<OutboundReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])), deliveries: value.deliveries.map(row => numbers<DeliveryReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])) })
|
||||
const candidate = (value: CandidateWire): Candidate => { requireBatchVersions(value.appointmentBatches); return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages') } }
|
||||
const candidate = (value: CandidateWire): Candidate => {
|
||||
requireBatchVersions(value.appointmentBatches)
|
||||
const group = value.selectionGroup
|
||||
if (!group || typeof group.groupKey !== 'string' || typeof group.groupVersion !== 'string' || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
|
||||
return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages'), selectionGroup: { ...group, sources: group.sources.map(source => ({ ...source, packages: dispatchQuantity(source.packages, 'selectionGroup.sources.packages') })), pallets: group.pallets.map(pallet => ({ ...pallet, packageCount: dispatchQuantity(pallet.packageCount, 'selectionGroup.pallets.packageCount') })) } }
|
||||
}
|
||||
function statistics(value: StatisticsWire): DispatchStatistics {
|
||||
return { ...value, matchedTotal: dispatchQuantity(value.matchedTotal, 'matchedTotal'), activeTotal: dispatchQuantity(value.activeTotal, 'activeTotal'), cancelledTotal: dispatchQuantity(value.cancelledTotal, 'cancelledTotal'), stageCounts: Object.fromEntries(Object.entries(value.stageCounts).map(([stage, count]) => [stage, dispatchQuantity(count, `stageCounts.${stage}`)])) as Record<DispatchStage, number>, beforeLoadingReviewCounts: { vehicleInfoTotal: dispatchQuantity(value.beforeLoadingReviewCounts.vehicleInfoTotal, 'vehicleInfoTotal'), dispatchReview: reviewCounts(value.beforeLoadingReviewCounts.dispatchReview), loadingReview: reviewCounts(value.beforeLoadingReviewCounts.loadingReview) } }
|
||||
}
|
||||
@@ -58,7 +66,12 @@ export function normalizeDispatchItems(value: DispatchItemsWire): DispatchItems
|
||||
export function normalizeLoadingPreview(value: LoadingPreviewWire): LoadingPreview { return { ...value, pallets: value.pallets.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
|
||||
export function normalizeEvidenceFile(value: DispatchEvidenceFileWire): DispatchEvidenceFile { return { ...value, sizeBytes: dispatchQuantity(value.sizeBytes, 'sizeBytes') } }
|
||||
function arrangementResult(value: ArrangementResultWire): ArrangementResult { return { ...value, ...references(value) } }
|
||||
export function normalizeArrangementPreview(value: ArrangementPreviewWire): ArrangementPreview { return { ...value, groups: value.groups.map(group => ({ ...group, cargo: group.cargo.map(row => ({ ...row, packages: dispatchQuantity(row.packages, 'packages'), productQuantity: dispatchQuantity(row.productQuantity, 'productQuantity') })) })), arrangement: value.arrangement ? arrangementResult(value.arrangement) : null } }
|
||||
function normalizeSourceOrigin(value: SourceOriginWire | null): SourceOrigin | null {
|
||||
if (value == null) return null
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new DispatchInputError('排车响应原货源结构缺失,请重新读取')
|
||||
return { ...value, productQuantity: value.productQuantity == null ? null : dispatchQuantity(value.productQuantity, 'sourceOrigin.productQuantity') }
|
||||
}
|
||||
export function normalizeArrangementPreview(value: ArrangementPreviewWire): ArrangementPreview { return { ...value, groups: value.groups.map(group => ({ ...group, cargo: group.cargo.map(row => ({ ...row, packages: dispatchQuantity(row.packages, 'packages'), productQuantity: dispatchQuantity(row.productQuantity, 'productQuantity'), sourceOrigin: normalizeSourceOrigin(row.sourceOrigin) })) })), arrangement: value.arrangement ? arrangementResult(value.arrangement) : null } }
|
||||
export function normalizeDispatchCommand(value: CommandResultWire): CommandResult {
|
||||
const normalizedCounts = counts(value.counts)
|
||||
switch (value.action) {
|
||||
|
||||
@@ -9,7 +9,7 @@ export { overseasWarehousePermissions } from './permissions'
|
||||
export { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
|
||||
export type { BatchDispatchCandidate, BatchDispatchCandidates } from './api/dispatches'
|
||||
export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, DispatchDraft, ConvertDraft, ConvertedDraft, CommandResult, Candidate, DraftSelection, VersionCommand } from './dispatchContracts'
|
||||
export { DispatchInputError, requireVersion, requireBatchVersions, stageLabel } from './dispatchContracts'
|
||||
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
|
||||
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
|
||||
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
|
||||
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
|
||||
|
||||
@@ -16,6 +16,8 @@ const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTabl
|
||||
beforeEach(() => { vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
||||
describe('whole-dispatch four-tab detail', () => {
|
||||
it('uses only four accessible colored tabs and switches without another network read', async () => {
|
||||
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledWith('PC/1')
|
||||
const tabs = wrapper.findAll('[role="tab"]')
|
||||
@@ -23,14 +25,18 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
expect(wrapper.findAll('.density-tab-icon')).toHaveLength(4)
|
||||
await tabs[1]!.trigger('click'); await flushPromises()
|
||||
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`).text()).toContain('LAX → ONT8')
|
||||
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
|
||||
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
|
||||
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
|
||||
})
|
||||
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
|
||||
const value = detail(); value.orderDetails.push({ ...value.orderDetails[0]!, customerId: 'C2', customerName: '客户乙', sourceKey: 'source-2', orderNo: 'LM-2', grossWeightKg: 12.25 })
|
||||
value.orderDetails.forEach((row, index) => { row.sourceBusinessType = 'RELABEL_ORDER'; row.sourceBusinessNo = `RL-${index + 1}` })
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(wrapper.getComponent(DataTableStub).props('data')).toEqual(value.orderDetails)
|
||||
expect(wrapper.getComponent(DataTableStub).vm.$attrs.columns).toContainEqual(expect.objectContaining({ prop: 'sourceBusinessNo', label: '来源业务号' }))
|
||||
expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); wrapper.unmount()
|
||||
})
|
||||
it('renders all customer delivery references and real update time/remark without leaking internal IDs', async () => {
|
||||
|
||||
@@ -34,7 +34,6 @@ const dispatchFields = computed(() => [
|
||||
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
|
||||
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
|
||||
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
|
||||
['车辆预约', detail.value?.dispatchInfo.vehicleAppointment ? [detail.value.dispatchInfo.vehicleAppointment.referenceNo, detail.value.dispatchInfo.vehicleAppointment.status, time(detail.value.dispatchInfo.vehicleAppointment.appointmentAt)].join(' · ') : '—'],
|
||||
])
|
||||
const documentFields = computed(() => [
|
||||
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
|
||||
@@ -44,6 +43,7 @@ const relatedOrders = computed(() => detail.value?.orderDetails || [])
|
||||
const orderColumns: DataTableColumn[] = [
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
|
||||
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
|
||||
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
|
||||
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
|
||||
|
||||
@@ -258,7 +258,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
|
||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||
for (const symbol of ['listDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'result.complete !== true', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
expect(dialog).toContain('cargo: cargo.map(row => row.selection)')
|
||||
// A filtered candidate must still submit its complete server-owned selection group.
|
||||
expect(dialog).toContain('assertSelectionGroupVersions(cargo, candidates.value)')
|
||||
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
|
||||
expect(dialog).toContain('同仓可混装多个客户与目的仓')
|
||||
expect(dialog).not.toContain('一次排车只能装同一客户')
|
||||
expect(dialog).not.toContain('ensureDraftToken')
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
vi.mock('@/shared/components/AppDialog.vue', () => ({ default: { props: ['modelValue'], template: '<section v-if="modelValue"><slot /></section>' } }))
|
||||
import ElementPlus from 'element-plus'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
@@ -16,7 +15,7 @@ function data(status = 'COMPLETE'): DailyResponse {
|
||||
}
|
||||
describe('SKU daily trend axes', () => {
|
||||
it('shows both axes, intermediate dates, zero and maximum ticks for a 30-day window', () => {
|
||||
const w = mount(SkuTrend, { props: { data: data() }, global: { plugins: [ElementPlus] } })
|
||||
const w = mount(SkuTrend, { props: { data: data() } })
|
||||
const chart = w.findComponent({ name: 'VChart' })
|
||||
const option = chart.props('option')
|
||||
expect(option.xAxis).toMatchObject({ type: 'category', name: '日期(北京时间)' })
|
||||
@@ -25,11 +24,11 @@ describe('SKU daily trend axes', () => {
|
||||
expect(option.series[1].data).toEqual(Array(30).fill(1))
|
||||
expect(option.tooltip.trigger).toBe('axis')
|
||||
expect(chart.props('initOptions')).toEqual({ renderer: 'canvas' })
|
||||
expect(w.get('.sku-trend').find('svg').exists()).toBe(false)
|
||||
expect(w.find('svg').exists()).toBe(false)
|
||||
w.unmount()
|
||||
})
|
||||
it('retains axes and date markers for an unknown window without drawing zero points', () => {
|
||||
const w = mount(SkuTrend, { props: { data: data('UNKNOWN') }, global: { plugins: [ElementPlus] } })
|
||||
const w = mount(SkuTrend, { props: { data: data('UNKNOWN') } })
|
||||
expect(w.text()).toContain('未知日期不作为零销量')
|
||||
const option = w.findComponent({ name: 'VChart' }).props('option')
|
||||
expect(option.series[0].data.every((point: { value: number | null }) => point.value === null)).toBe(true)
|
||||
|
||||
@@ -213,3 +213,47 @@
|
||||
:is(.admin-density, .admin-density-dialog) .density-fields > div { display: grid; grid-template-columns: var(--admin-field-label-width, var(--admin-label-width)) minmax(0, 1fr); min-width: 0; min-height: var(--admin-row-height); gap: 0; padding: 0; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); border-radius: 0; background: var(--admin-surface); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-fields dt { display: flex; align-items: center; margin: 0; padding: var(--admin-space-2) var(--admin-space-3); color: var(--cc-muted); background: var(--admin-label-bg); font-size: var(--admin-font-caption); line-height: 20px; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-fields dd { align-self: center; min-width: 0; margin: 0; padding: var(--admin-space-2) var(--admin-space-3); color: #25324a; font-size: var(--admin-font-body); font-weight: 400; line-height: 20px; overflow-wrap: anywhere; white-space: normal; }
|
||||
|
||||
/* Dispatch dialog sections: explicit opt-in, scoped to dialogs. */
|
||||
.admin-density-dialog .density-dialog-section {
|
||||
min-width: 0;
|
||||
margin: 0 0 var(--admin-space-5);
|
||||
padding: var(--admin-space-4) var(--admin-space-5);
|
||||
border: 1px solid var(--admin-table-border);
|
||||
border-radius: var(--admin-dialog-section-radius);
|
||||
background: var(--admin-surface);
|
||||
}
|
||||
.admin-density-dialog .density-dialog-section:last-child { margin-bottom: 0; }
|
||||
.admin-density-dialog .density-dialog-sections { display: grid; gap: var(--admin-space-5); }
|
||||
.admin-density-dialog .density-dialog-sections > .density-dialog-section { margin: 0; }
|
||||
.admin-density-dialog .density-dialog-section[data-tone="summary"] {
|
||||
border-color: var(--admin-dialog-summary-border);
|
||||
background: var(--admin-dialog-summary-bg);
|
||||
}
|
||||
.admin-density-dialog .density-dialog-section[data-tone="source"] {
|
||||
border-color: var(--admin-dialog-source-border);
|
||||
background: var(--admin-dialog-source-bg);
|
||||
}
|
||||
.admin-density-dialog .density-dialog-section[data-tone="work"] {
|
||||
border-color: var(--admin-dialog-work-border);
|
||||
background: var(--admin-dialog-work-bg);
|
||||
}
|
||||
.admin-density-dialog .density-dialog-section > .density-dialog-section__title {
|
||||
display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-3);
|
||||
margin: 0 0 var(--admin-space-4); color: var(--cc-text); font-size: var(--admin-font-section); font-weight: 600;
|
||||
}
|
||||
.admin-density-dialog .density-dialog-section__title small { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: 400; }
|
||||
.admin-density-dialog .density-dialog-note { margin: 0 0 var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.admin-density-dialog .density-dialog-section > .el-form > .el-form-item:last-child { margin-bottom: 0; }
|
||||
.admin-density-dialog .density-dialog-section .el-table { --el-table-tr-bg-color: var(--admin-surface); }
|
||||
|
||||
/* Dispatch dialog continuous table editors. */
|
||||
.admin-density-dialog .density-table--editable :is(.el-input, .el-textarea) {
|
||||
--el-input-border-color: transparent;
|
||||
--el-input-hover-border-color: var(--admin-table-border);
|
||||
--el-input-bg-color: transparent;
|
||||
}
|
||||
.admin-density-dialog .density-table--editable .el-input__inner { text-align: left; }
|
||||
.admin-density-dialog .density-table--editable .el-form-item { margin-bottom: 0; }
|
||||
.admin-density-dialog .density-table--editable .el-form-item.is-error { margin-bottom: var(--admin-space-5); }
|
||||
.admin-density-dialog .density-table--editable :is(.el-input-number, .el-segmented, .el-date-editor) { width: 100%; min-width: 0; }
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
--admin-table-border: #e5e7eb;
|
||||
--admin-row-hover: #f3f7fb;
|
||||
--admin-row-stripe: #fcfcfd;
|
||||
--admin-dialog-section-radius: 8px;
|
||||
--admin-dialog-summary-bg: #f4fbf8;
|
||||
--admin-dialog-summary-border: #cce8df;
|
||||
--admin-dialog-source-bg: #f7f9ff;
|
||||
--admin-dialog-source-border: #d9e2fa;
|
||||
--admin-dialog-work-bg: #fffbf4;
|
||||
--admin-dialog-work-border: #eadfc9;
|
||||
--admin-action-neutral: #1777ff;
|
||||
--admin-action-neutral-border: #dfe3ee;
|
||||
--admin-action-warning: #c76500;
|
||||
|
||||
Reference in New Issue
Block a user