This commit is contained in:
2026-10-21 14:30:33 +08:00
parent 3953453090
commit 8498798a9c
47 changed files with 791 additions and 409 deletions
@@ -1,35 +0,0 @@
import ElementPlus from 'element-plus'
import { mount } from '@vue/test-utils'
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}`, 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 })) }
return { preview, group }
}
it('requires explicit per-service coverage and preserves uncovered cargo instead of treating a partial fee as full coverage', async () => {
const { preview, group } = values()
const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
const covered = (wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }).covered
expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
await wrapper.get('input[type="checkbox"]').setValue(true)
expect(group.feeCoverage).toEqual([{ financeFeeId: 'fee-1', sourceVersion: 2, sourceHash: 'fee-hash', services: ['DELIVERY_FEE'] }])
expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
preview.feeSources[0]!.sourceKeys.push('B'); expect(covered(0, preview, 'DELIVERY_FEE')).toBe(true)
expect(covered(0, preview, 'LOADING_FEE')).toBe(false)
preview.feeSources[0]!.customerId = 'C2'; expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false); wrapper.unmount()
})
it('matches external quotes to the original customer and inbound source and shows only applicable additional fees', async () => {
const { preview, group } = values(); preview.cargo = [cargo('A')]
preview.externalQuotes = [{ customerId: 'C2', groupKey: 'group-1', bookingNo: 'BOOK-1', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'external-source' }]
const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }
expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(false)
expect(wrapper.findAll('.el-input-number')).toHaveLength(1)
preview.externalQuotes[0]!.customerId = 'C1'; expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(true)
group.loadingType = 'FLOOR'; group.appointmentAccount = 'BUY_APPOINTMENT'; await wrapper.setProps({ modelValue: [{ ...group }] })
expect(wrapper.findAll('.el-input-number')).toHaveLength(2); expect(wrapper.text()).toContain('卖家姓名'); wrapper.unmount()
})
@@ -1,57 +0,0 @@
<script setup lang="ts">
import type { ArrangeGroup, PreviewGroup } from '../dispatchContracts'
const groups = defineModel<ArrangeGroup[]>({ required: true })
defineProps<{ preview: PreviewGroup[]; disabled: boolean }>()
const serviceLabels: Record<string, string> = { DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' }
function setCoverage(index: number, fee: PreviewGroup['feeSources'][number], service: string, checked: unknown) {
const group = groups.value[index]; if (!group) return
let selection = group.feeCoverage.find(row => row.financeFeeId === fee.financeFeeId)
if (!selection && checked === true) { selection = { financeFeeId: fee.financeFeeId, sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: [] }; group.feeCoverage.push(selection) }
if (!selection) return
selection.services = checked === true ? [...new Set([...selection.services, service])] : selection.services.filter(value => value !== service)
group.feeCoverage = group.feeCoverage.filter(row => row.services.length)
}
function covered(index: number, preview: PreviewGroup, service: string) {
if (!preview.cargo.length) return false
const selections = groups.value[index]?.feeCoverage || []
return preview.cargo.every(cargo => service === 'DELIVERY_FEE' && preview.externalQuotes.some(quote => quote.customerId === cargo.customerId && quote.inboundCode === cargo.inboundCode)
|| preview.feeSources.some(fee => fee.customerId === cargo.customerId && fee.sourceKeys.includes(cargo.sourceKey) && selections.some(selection => selection.financeFeeId === fee.financeFeeId && selection.services.includes(service))))
}
function required(index: number, preview: PreviewGroup, service: string) {
const group = groups.value[index]
return preview.requiredServices.includes(service)
|| service === 'LOADING_FEE' && ['FLOOR', 'MIXED'].includes(group?.loadingType || '')
|| service === 'APPOINTMENT_FEE' && group?.appointmentAccount === 'BUY_APPOINTMENT'
}
defineExpose({ covered })
</script>
<template>
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group density-dialog-sections">
<template v-if="groups[index]">
<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" /></el-form-item></td><td><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" /></el-form-item></td></tr>
<tr><td><el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" /></el-form-item></td><td><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" /></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" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" /></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" 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-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>
@@ -2,36 +2,27 @@ import ElementPlus from 'element-plus'
import { DOMWrapper, 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, resources } from '../dispatchTestFixtures'
import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts'
import { command, detail, loadingPreview, resources } from '../dispatchTestFixtures'
import type { DispatchAction, DispatchActions, DispatchEvidenceFile, 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() }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: 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 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() {} })
@@ -44,12 +35,10 @@ beforeEach(() => {
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.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.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'))
@@ -105,10 +94,10 @@ describe('dispatch workflow native layout', () => {
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 => {
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] 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
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : 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()
@@ -146,27 +135,6 @@ describe('dispatch workflow native layout', () => {
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 }))
@@ -4,14 +4,14 @@ 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 { command, detail, destination, loadingPreview } from '../dispatchTestFixtures'
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
import { command, detail, loadingPreview } from '../dispatchTestFixtures'
import type { CommandResult, DispatchAction, DispatchDetail } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
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() }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
interface State { submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
async function open(action: DispatchAction, allowed = true) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
@@ -27,10 +27,10 @@ it('keeps order allocation references out of actual measurements and submits nat
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('真实托位')
state.measurements[0]!.weightKg = 92; state.measurements[0]!.volumeCbm = 1.5
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'AFTER_LOADING', recordVersion: 1 }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
await state.submit()
expect(api.completeDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }))
expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
expect(api.completeDispatchLoading).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('accepts actual evidence and a matching confirmed manifest while refusing an unknown unloading index', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 92; value.pallets[0]!.volumeCbm = 1.5; value.pallets[0]!.deliveryIndex = null
@@ -40,42 +40,37 @@ it('accepts actual evidence and a matching confirmed manifest while refusing an
const { wrapper, state } = await open('LOADING_COMPLETE'); expect(state.measurements[0]).toMatchObject({ weightKg: 92, volumeCbm: 1.5, deliveryIndex: null })
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
})
it('requires a rejection reason and retains the full original command after an unknown review result', async () => {
const { wrapper, state } = await open('DISPATCH_REVIEW'); state.form.decision = 'REJECTED'
await state.submit(); expect(api.reviewDispatch).not.toHaveBeenCalled()
state.form.reason = '预约未核实'; vi.mocked(api.reviewDispatch).mockRejectedValue(new Error('unknown result'))
await state.submit(); state.form.reason = 'later changed'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('DISPATCH_REVIEW', key))
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('requires a rejection reason and retains the original %s decision after an unknown result', async action => {
const success = vi.spyOn(ElMessage, 'success')
const reviewApi = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
const { wrapper, state } = await open(action); state.form.decision = 'REJECTED'
await state.submit(); expect(reviewApi).not.toHaveBeenCalled()
state.form.reason = '预约未核实'; vi.mocked(reviewApi).mockRejectedValue(new Error('unknown result'))
await state.submit(); state.form.reason = 'later changed'; state.form.decision = 'APPROVED'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command(action, key))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(api.reviewDispatch).toHaveBeenCalledTimes(1)
expect(vi.mocked(api.reviewDispatch).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
await state.submit(); expect(reviewApi).toHaveBeenCalledTimes(1)
expect(vi.mocked(reviewApi).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
expect(success).toHaveBeenCalledWith('已退回客服处理(车辆信息),修改后需重新提交调度审核')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
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('shows the preserved rejection while customer service edits vehicle information', async () => {
const { wrapper, state } = await open('EDIT')
state.detail.reviews.loadingReview = { ...state.detail.reviews.loadingReview, status: 'REJECTED', reason: '车牌照片不清晰' }
await flushPromises()
expect(wrapper.getComponent({ name: 'ElDialogContent' }).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, 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
groups.forEach(group => Object.assign(group, { plannedDeliveryAt: '2026-10-20T00:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'ONE_PICKUP_MULTI_DROP', loadingType: 'PALLET' }))
await state.submit(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
groups[1]!.fees.find(fee => fee.feeType === 'DELIVERY_FEE')!.amount = 77
vi.mocked(api.transferDispatchToDelivery).mockImplementation(async (_, body): Promise<CommandResult> => ({ ...command('ARRANGE_DELIVERY', body.idempotencyKey), action: 'ARRANGE_DELIVERY', result: { dispatchNo: 'PC/1', recordVersion: 1, arrangementKey: 'arrangement', cargoHash: 'whole-cargo', arrangedAt: '2026-10-19T00:00:00Z', outbounds: [], deliveries: [] } }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, stage: 'AFTER_LOADING' })); await state.submit()
const sent = vi.mocked(api.transferDispatchToDelivery).mock.calls[0]![1]
expect(sent).toMatchObject({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash' })
expect(sent.groups).toHaveLength(2); expect(sent.groups[0]!.fees).toEqual([]); expect(sent.groups[1]!.fees).toEqual([expect.objectContaining({ feeType: 'DELIVERY_FEE', amount: 77 })])
expect(api.getDispatchArrangement).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
it('blocks an action without a literal service allowance', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW', false); await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
})
it('does not reuse another dispatch detail when the target changes while its dialog remains open', async () => {
const { wrapper, state } = await open('DEPART')
const { wrapper, state } = await open('SUBMIT_REVIEW')
let resolve!: (value: DispatchDetail) => void
vi.mocked(api.getDispatch).mockImplementationOnce(() => new Promise(done => { resolve = done }))
await wrapper.setProps({ dispatchNo: 'PC/2' }); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled()
resolve(detail({ dispatchNo: 'PC/2', actions: { DEPART: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises()
await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); wrapper.unmount()
await wrapper.setProps({ dispatchNo: 'PC/2' }); await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled()
resolve(detail({ dispatchNo: 'PC/2', actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises()
await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); wrapper.unmount()
})
it('preserves external destinations and retries route EDIT through its original route endpoint instead of a resource update', async () => {
const toast = vi.spyOn(ElMessage, 'success')
@@ -10,13 +10,12 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import type { ArrangeDelivery, ArrangeGroup, ArrangementPreview, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason } from '../dispatchPresentation'
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const open = defineModel<boolean>({ default: false })
@@ -31,8 +30,7 @@ const currentOperation = computed(() => pending.value ? pending.value.operation
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
const factsStale = ref(false)
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
const preview = ref<LoadingPreview | null>(null)
const loading = computed(() => working.value)
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: [] })
@@ -44,7 +42,7 @@ let editorRequestKey = ''
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
const allowed = computed(() => !cargoAction.value && !factsStale.value && !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'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
const frozen = computed(() => busy.value || Boolean(pending.value))
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DRIVER)
@@ -52,7 +50,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) { working.value = false; return }
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null
route.value = null; stops.value = []
try {
if (cargoAction.value) return
@@ -84,13 +82,7 @@ async function load() {
weightKg: confirmed?.weightKg ?? (actual('weightKg') ? row.weightKg : null), volumeCbm: confirmed?.volumeCbm ?? (actual('volumeCbm') ? row.volumeCbm : null) }
})
}
if (action === 'ARRANGE_DELIVERY') {
const data = await api.getDispatchArrangement(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('派送预览与排车单不一致')
arrangement.value = data
groups.value = data.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: '', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: ({ DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' })[feeType] || '服务费用', direction: 'RECEIVABLE', currency: 'USD', amount: null })) }))
}
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
finally { if (sequence === generation) working.value = false }
}
@@ -115,19 +107,6 @@ function payload(): object {
if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测')
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete
}
case 'ARRANGE_DELIVERY': {
const data = arrangement.value
if (!data || data.arrange.allowed !== true || !data.groups.length || data.groups.some(row => row.blockReasons.length)) throw new DispatchInputError(data?.arrange.blockReason || '本车派送资料未就绪')
const prepared = groups.value.map((group, index) => {
if (!group.plannedDeliveryAt || !group.deliveryMethod || !group.pickupMethod || !group.loadingType) throw new DispatchInputError('请完整填写每个客户及目的仓的派送信息')
if (group.appointmentAccount === 'BUY_APPOINTMENT' && !group.sellerName?.trim()) throw new DispatchInputError('买约时卖家姓名必填')
const required = [...new Set([...(data.groups[index]?.requiredServices || []), ...(['FLOOR', 'MIXED'].includes(group.loadingType) ? ['LOADING_FEE'] : []), ...(group.appointmentAccount === 'BUY_APPOINTMENT' ? ['APPOINTMENT_FEE'] : [])])]
const fees = group.fees.filter(fee => required.includes(fee.feeType) && !arrangeFields.value?.covered(index, data.groups[index]!, fee.feeType))
if (fees.some(fee => fee.amount == null || !Number.isFinite(fee.amount) || fee.amount <= 0)) throw new DispatchInputError('请填写本组未覆盖服务的正数新增费用')
return { ...group, fees }
})
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, cargoHash: data.cargoHash, loadingCompletionHash: data.loadingCompletionHash, groups: prepared } satisfies ArrangeDelivery
}
default: return { ...common, remark: form.remark || null }
}
}
@@ -141,9 +120,7 @@ async function send(command: PendingDispatchCommand): Promise<CommandResult> {
case 'RETURN_TO_REVIEW': return api.returnDispatchToReview(no, body as unknown as ReasonCommand)
case 'CANCEL': return api.cancelDispatch(no, body as unknown as ReasonCommand)
case 'LOADING_COMPLETE': return api.completeDispatchLoading(no, body as unknown as LoadingComplete)
case 'ARRANGE_DELIVERY': return api.transferDispatchToDelivery(no, body as unknown as ArrangeDelivery)
case 'SUBMIT_REVIEW': return api.submitDispatchReview(no, body as unknown as VersionCommand & { remark: string | null })
case 'DEPART': return api.departDispatch(no, body as unknown as VersionCommand & { remark: string | null })
default: throw new DispatchInputError('此操作不属于当前后台作业入口')
}
}
@@ -158,9 +135,10 @@ async function submit() {
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
}
const routeCommand = pending.value?.operation === 'ROUTE'
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
if (!result) return
ElMessage.success(routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? '装车完成,已进入派送中' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
emit('saved'); open.value = false
} catch (cause) {
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : pending.value?.result ? '操作已完成,页面资料待刷新' : pending.value ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
@@ -201,6 +179,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<div class="density-dialog-sections">
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-alert v-if="pending && !cargoAction" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
<LoadErrorState v-if="error && !cargoAction" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
@@ -217,7 +196,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<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 />
<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>
@@ -226,8 +205,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<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)">
@@ -235,7 +212,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<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' || ['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' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="currentAction !== 'ARRANGE_DELIVERY'" 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" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
<section v-else 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" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
</AppDialog>
@@ -1,9 +1,16 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
import { ref } from 'vue'
import type { AppointmentForm } from '../appointmentForm'
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean }>()
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; sources?: PlanningSourceLookup[] }>()
const emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
</script>
<template>
<p v-if="model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
<AppointmentConfirmationCargo v-if="sources?.length" ref="cargo" :sources="sources" :active="model.appointmentStatus === 'SUCCESS'" :disabled="disabled" @ready="emit('confirmationReady', $event)" />
<div v-if="continuous" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="预约安排登记"><h3 class="density-dialog-section__title">预约安排</h3>
@@ -18,7 +18,7 @@ function render(candidates: Candidate[] = [candidate()], extra: Record<string, u
return mount(SmartDispatchCargoBoard, { props: { candidates, draft: draft(), activeContainerId: 'car-1', capacity: 30, disabled: false, warehouseSelected: true, page: 1, size: 20, total: candidates.length, ...extra }, global: { plugins: [ElementPlus] } })
}
async function select(wrapper: VueWrapper, index = 0) { await wrapper.get('[aria-label="待排订单"]').findAll('tbody tr')[index]!.get('button').trigger('click') }
function pallet(wrapper: VueWrapper) { return wrapper.get('[aria-label="托盘明细"] article') }
function pallet(wrapper: VueWrapper) { return wrapper.get('[aria-label="托盘明细"] tr[draggable]') }
function transfer() { return { setData: vi.fn(), getData: vi.fn(() => '{"sourceKey":"forged","containerId":"car-2"}'), effectAllowed: '', dropEffect: '' } }
describe('smart dispatch three-column cargo board', () => {
@@ -26,6 +26,7 @@ describe('smart dispatch three-column cargo board', () => {
const first = candidate(), second = candidate({ sourceKey: 'source-2', orderNo: 'ORDER-2', palletNo: 'PALLET-2' })
const wrapper = render([first, second], { draft: null, activeContainerId: '' })
expect(wrapper.get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
expect(wrapper.get('[aria-label="托盘明细"]').findAll('thead th').map(cell => cell.text())).toEqual(['托盘号', '箱数'])
expect(wrapper.findAll('[aria-label="车辆装载"] article')).toHaveLength(1)
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('30 托位')
await select(wrapper, 1)
@@ -86,7 +87,7 @@ describe('smart dispatch three-column cargo board', () => {
it('keeps a shared pallet as one full source group even when only one member is on the current page', async () => {
const sources = sharedPalletCandidates(), wrapper = render([sources[0]!])
await select(wrapper)
expect(wrapper.findAll('[aria-label="托盘明细"] article')).toHaveLength(1)
expect(wrapper.findAll('[aria-label="托盘明细"] tr[draggable]')).toHaveLength(1)
const group = pallet(wrapper)
expect(group.text()).toContain('整托关联货物一起加入')
expect(group.findAll('details li')).toHaveLength(2)
@@ -156,7 +157,7 @@ describe('smart dispatch three-column cargo board', () => {
const order = wrapper.get('[aria-label="待排订单"] tbody tr')
expect(order.findAll('td')[3]!.text()).toBe('2')
await select(wrapper)
expect(wrapper.findAll('[aria-label="托盘明细"] article')).toHaveLength(2)
expect(wrapper.findAll('[aria-label="托盘明细"] tr[draggable]')).toHaveLength(2)
})
it('uses independent vehicle radio groups when two entrypoint dialogs are mounted', () => {
@@ -162,6 +162,7 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
<div class="dispatch-cargo-board" aria-label="智能排车三栏选货">
<section class="cargo-board-panel cargo-board-orders" aria-label="待排订单">
<header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span><InlineHelp title="货物分页说明"><p>货物分页,同一订单可能分布在多页。点击订单查看本页货物关联托盘;关联托数包含必须同车的完整托盘组。</p></InlineHelp></header>
<div class="cargo-board-order-content">
<div class="cargo-board-table-wrap">
<table class="density-table cargo-board-table">
<thead><tr><th class="cargo-board-operation">操作</th><th>订单号 / 来源</th><th>客户</th><th>关联托数</th><th>目的仓</th></tr></thead>
@@ -178,27 +179,36 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
</table>
</div>
<footer class="cargo-board-pagination"><span class="cargo-board-muted">货物分页,同一订单可能分布在多页</span><el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="disabled" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" /></footer>
</section>
<section class="cargo-board-panel cargo-board-pallets" aria-label="托盘明细" aria-live="polite">
<header class="cargo-board-heading"><h3>托盘明细</h3><span class="cargo-board-muted">本页货物关联托盘</span></header>
<div v-if="selectedOrder" class="cargo-board-order-caption">{{ businessNo(selectedOrder.source) }}<small v-if="!selectedOrder.source.orderNo" class="cargo-board-muted">未关联订单</small></div>
<p v-if="!selectedOrder" class="cargo-board-empty">请选择左侧订单查看托盘明细</p>
<div v-else class="cargo-board-pallet-list">
<article v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-assigned': assignedVehicle(source), 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
<div class="cargo-board-pallet-title"><strong>{{ palletLabel(source) }}</strong><span>{{ groupPackages(source) ?? '—' }} 箱</span></div>
<div class="cargo-board-muted">{{ destinationLabel(source) }}</div>
<details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物一起加入</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details>
<p v-if="selectionGroupBlockReason(source)" class="cargo-board-block">{{ selectionGroupBlockReason(source) }}</p>
<el-button link :disabled="!canAdd(source) || !validTarget(activeContainerId)" @click="add(source, activeContainerId)">{{ assignedVehicle(source) ? `已加入第 ${assignedVehicle(source)!.sequenceNo} 辆车` : '加入这辆车' }}</el-button>
</article>
<p v-if="!palletGroups.length" class="cargo-board-empty">该订单本页暂无托盘</p>
</div>
</section>
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
<section class="cargo-board-vehicles" aria-label="车辆装载">
<header class="cargo-board-heading"><h3>车辆装载</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击“加入这辆车”。已装货物需先移除才能加入其他车辆。体积、重量以装车核对为准,本区不将缺失测量按零汇总。</p></InlineHelp><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></header>
<section class="cargo-board-panel cargo-board-pallets" aria-label="托盘明细" aria-live="polite">
<header class="cargo-board-heading"><h3>托盘明细</h3><span class="cargo-board-order-caption">订单号:{{ selectedOrder ? businessNo(selectedOrder.source) : '未选择订单' }}<small v-if="selectedOrder && !selectedOrder.source.orderNo" class="cargo-board-muted">未关联订单</small></span></header>
<div class="cargo-board-pallet-list">
<table class="density-table cargo-board-pallet-table">
<thead><tr><th>托盘号</th><th class="cargo-board-pallet-count">箱数</th></tr></thead>
<tbody>
<tr v-if="!selectedOrder"><td colspan="2" class="cargo-board-empty">请选择左侧订单查看托盘明细</td></tr>
<tr v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-assigned': assignedVehicle(source), 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
<td>
<strong>{{ palletLabel(source) }}</strong>
<div class="cargo-board-muted">{{ destinationLabel(source) }}</div>
<details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物一起加入</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details>
<p v-if="selectionGroupBlockReason(source)" class="cargo-board-block">{{ selectionGroupBlockReason(source) }}</p>
<el-button link :disabled="!canAdd(source) || !validTarget(activeContainerId)" @click="add(source, activeContainerId)">{{ assignedVehicle(source) ? `已加入第 ${assignedVehicle(source)!.sequenceNo} 辆车` : '加入这辆车' }}</el-button>
</td>
<td class="cargo-board-pallet-count">{{ groupPackages(source) ?? '—' }} 箱</td>
</tr>
<tr v-if="selectedOrder && !palletGroups.length"><td colspan="2" class="cargo-board-empty">该订单本页暂无托盘</td></tr>
</tbody>
</table>
</div>
</section>
<section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载">
<header class="cargo-board-heading"><slot name="vehicle-heading"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击“加入这辆车”。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></div></slot></header>
<div class="cargo-board-vehicle-content">
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
<div class="cargo-board-vehicle-list">
<article v-for="vehicle in vehicles" :key="vehicle.draftContainerId" class="cargo-board-vehicle" :class="{ 'is-current': vehicle.draftContainerId === activeContainerId, 'is-drop-target': dropTarget === vehicle.draftContainerId }" :aria-label="`第 ${vehicle.sequenceNo} 辆车`" @dragover="dragOver($event, vehicle.draftContainerId)" @dragleave.self="dropTarget = null" @drop="drop($event, vehicle.draftContainerId)">
<header class="cargo-board-vehicle-heading"><label><input type="radio" :name="vehicleRadioName" :checked="activeContainerId === vehicle.draftContainerId" :disabled="disabled" :aria-label="`选择第 ${vehicle.sequenceNo} 辆车`" @change="emit('selectContainer', vehicle.draftContainerId)" /> 第 {{ vehicle.sequenceNo }} 辆车 · {{ vehicle.capacity }} 托位</label><span>托数 {{ vehiclePalletCount(vehicle) ?? '—' }}/{{ vehicle.capacity }}</span></header>
@@ -213,19 +223,25 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
<footer v-if="vehicle.draftContainerId" class="cargo-board-vehicle-footer"><el-button link type="danger" :disabled="disabled" @click="emit('removeContainer', vehicle.draftContainerId)">删除车辆</el-button></footer>
</article>
</div>
</slot>
</div>
</section>
</slot>
</div>
</template>
<style scoped>
.dispatch-cargo-board { display: grid; grid-template-columns: minmax(0, 42fr) minmax(0, 22fr) minmax(0, 36fr); align-items: start; gap: var(--admin-space-6); }
.cargo-board-panel { min-width: 0; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); overflow: hidden; background: var(--admin-surface); }
.cargo-board-heading { display: flex; align-items: center; gap: var(--admin-space-3); min-height: var(--admin-control-height); padding: var(--admin-space-3) var(--admin-space-5); flex-wrap: wrap; }
.dispatch-cargo-board { display: grid; grid-template-columns: minmax(0, 44fr) minmax(0, 22fr) minmax(0, 34fr); grid-template-rows: auto auto; align-items: start; gap: var(--admin-space-5) var(--admin-space-6); }
.cargo-board-panel { display: grid; grid-row: 1 / span 2; grid-template-rows: subgrid; min-width: 0; background: var(--admin-surface); }
.cargo-board-orders { grid-column: 1; }
.cargo-board-pallets { grid-column: 2; }
.cargo-board-vehicles { grid-column: 3; }
.cargo-board-order-content, .cargo-board-vehicle-content { min-width: 0; }
.cargo-board-heading { display: flex; align-items: center; gap: var(--admin-space-3); min-height: var(--admin-control-height); box-sizing: border-box; margin: 0; flex-wrap: wrap; }
.cargo-board-heading h3 { margin: 0; font-size: var(--admin-font-section); }
.cargo-board-heading > .el-button { margin-left: auto; }
.cargo-board-vehicle-tools { display: flex; align-items: center; gap: var(--admin-space-3); margin-left: auto; flex-wrap: wrap; }
.cargo-board-vehicle-tools > .el-button { margin-left: 0; }
.cargo-board-muted { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.cargo-board-table-wrap { overflow: auto; max-height: 48vh; }
.cargo-board-table-wrap { overflow: auto; max-height: min(390px, 40vh); border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); }
.cargo-board-table { width: 100%; table-layout: fixed; }
.cargo-board-table td { overflow-wrap: anywhere; }
.cargo-board-table th:nth-child(1) { width: 82px; }
@@ -236,16 +252,16 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
.cargo-board-table tr.is-selected td { background: var(--admin-action-primary-hover); }
.cargo-board-business-no { font-weight: 600; }
.cargo-board-pagination { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); padding: var(--admin-space-4); overflow-x: auto; }
.cargo-board-order-caption { padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
.cargo-board-order-caption { margin-left: auto; max-width: 65%; overflow-wrap: anywhere; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.cargo-board-order-caption small { display: block; }
.cargo-board-empty { padding: var(--admin-space-7); color: var(--cc-muted); text-align: center; margin: 0; }
.cargo-board-pallet-list { max-height: 52vh; overflow-y: auto; }
.cargo-board-pallet { padding: var(--admin-space-5); border-top: 1px solid var(--admin-table-border); }
.cargo-board-pallet-list { max-height: min(500px, 48vh); overflow-y: auto; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); }
.cargo-board-pallet-table { width: 100%; table-layout: fixed; }
.cargo-board-pallet-table .cargo-board-pallet-count { width: 72px; text-align: right; white-space: nowrap; }
.cargo-board-pallet-table td { vertical-align: top; overflow-wrap: anywhere; }
.cargo-board-pallet-table .cargo-board-empty { text-align: center; padding: var(--admin-space-5); }
.cargo-board-pallet.is-draggable { cursor: grab; }
.cargo-board-pallet.is-assigned { background: var(--admin-label-bg); }
.cargo-board-pallet-title { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--admin-space-3); overflow-wrap: anywhere; }
.cargo-board-pallet-title strong { min-width: 0; }
.cargo-board-pallet-title > span { white-space: nowrap; }
.cargo-board-pallet.is-assigned td { background: var(--admin-label-bg); }
.cargo-board-group-details { margin-top: var(--admin-space-2); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.cargo-board-group-details summary { cursor: pointer; }
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
@@ -51,6 +51,29 @@ async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').get
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '继续待完成排车')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function selectFirstOrder(dialog: VueWrapper) { await view(dialog).get('[aria-label="待排订单"] tbody tr button').trigger('click'); await flushPromises() }
describe('new dispatch native layout', () => {
it('keeps the restored workbench controls together and shows the selected warehouse tab', async () => {
const { dialog } = await render()
const content = view(dialog)
expect(content.get('[aria-label="排车设置"]').text()).toContain('建单方式')
expect(content.get('[aria-label="排车设置"]').find('[aria-label="车辆选货安排"]').exists()).toBe(true)
expect(content.text()).not.toContain('1. 排车设置')
expect(content.text()).not.toContain('2. 为车辆选货')
const vehicles = content.get('[aria-label="车辆装载"]')
expect(vehicles.text()).toContain('托位')
expect(vehicles.get('.cargo-board-vehicle-tools').text()).toContain('添加车辆')
expect(vehicles.text()).toContain('添加车辆')
const tabs = content.get('[aria-label="候选仓库展示"]').findAll('button')
expect(tabs[0]!.classes()).toContain('active')
await selectWarehouse(dialog)
await tabs[1]!.trigger('click'); await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.classes()).toContain('active')
expect(tabs[0]!.classes()).not.toContain('active')
const capacity = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '计划托位')!
capacity.vm.$emit('update:modelValue', 15); await flushPromises()
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('15 托位')
})
it('adds cargo directly, preparing the first vehicle internally and enabling submission after assignment', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
const grouped: DispatchDraft = { ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
@@ -98,7 +121,7 @@ describe('new dispatch native layout', () => {
const { dialog } = await render()
expect(view(dialog).find('[aria-label="排车设置"] table').exists()).toBe(false)
expect(view(dialog).find('[aria-label="排车设置"] .density-dialog-note').exists()).toBe(false)
expect(view(dialog).classes()).toContain('admin-density-workspace')
expect(view(dialog).classes()).not.toContain('admin-density-workspace')
expect(dialog.getComponent({ name: 'ElDialog' }).props()).toMatchObject({ appendToBody: true, lockScroll: true })
expect(view(dialog).text()).not.toContain('点击“开始选货”建立第一组')
expect(view(dialog).text()).not.toContain('选择起运仓后点击')
@@ -32,6 +32,7 @@ type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELE
const pending = ref<DraftCommand | null>(null)
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
const settingsExpanded = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
const working = ref(false), saving = ref(false), preparing = ref(false), error = ref<ApiErrorDetails | null>(null)
@@ -232,7 +233,7 @@ async function submit() {
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-density-workspace admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
@@ -243,35 +244,36 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<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>
<section class="density-dialog-section dispatch-setup" aria-label="排车设置">
<h3 class="density-dialog-section__title">1. 排车设置<InlineHelp title="在途预排说明"><p>货物离港并生成入库计划后,即可安排货物并生成排车单。车辆和司机在建单后维护;实际装车仍须完成海外仓实收和 PDA 拣货核平。</p></InlineHelp></h3>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query">
<el-form inline label-position="left" label-width="60px" class="density-form dispatch-origin-field">
<el-form-item class="dispatch-warehouse-field" label="起运仓" required><el-select class="dispatch-warehouse-picker" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><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>
<template v-if="mode === 'cargo'"><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="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button><el-button :disabled="frozen || loading || !warehouseId" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
</section>
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置">
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
<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>
<el-form-item class="dispatch-warehouse-field" label="起运仓" required><el-select class="dispatch-warehouse-picker" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><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>
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @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 v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击加入。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
</div>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-if="mode === 'cargo'">
<section class="density-dialog-section dispatch-draft-toolbar" aria-label="车辆选货安排">
<h3 class="density-dialog-section__title">2. 为车辆选货<InlineHelp title="选货步骤说明"><p>点击左侧订单查看中间托盘,再将托盘拖入右侧车辆;也可选择车辆后点击“加入这辆车”。添加车辆时,之前的车与货物会保留。每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成排车单的选货会暂存并占用货物;可继续待完成排车,或放弃本次安排以解除占用。</p></InlineHelp></h3>
<div class="dispatch-draft-tools density-actions">
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @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>
<label class="dispatch-capacity-field"><span>计划托位</span><el-select aria-label="下一辆车计划托位" 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></label>
<InlineHelp title="计划托位说明"><p>此处设置新车辆的预排托位,默认空车尚未加货时也使用此值,不代表车辆实际载重或容积。真实车辆容量与装车实测仍由服务端校验。</p></InlineHelp>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
</div>
</section>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<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="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button><el-button :disabled="frozen || loading || !warehouseId" @click="resetSearch">重置</el-button></div>
</section>
<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="changeTab(tab.value)">{{ tab.label }}</button></div>
<div class="warehouse-tabs density-actions" role="tablist" aria-label="候选仓库展示"><el-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" :class="{ active: warehouseTab === tab.value }" :type="warehouseTab === tab.value ? 'primary' : undefined" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize" />
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize">
<template #vehicle-controls>
<label class="dispatch-capacity-field"><span>托位</span><el-select aria-label="下一辆车计划托位" 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></label>
</template>
</SmartDispatchCargoBoard>
</template>
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" aria-label="排车计划时间与备注">
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '2. 计划时间与备注' : '3. 第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆信息”补充。</p></InlineHelp></h3>
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆信息”补充。</p></InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<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)" /></el-form-item></td></tr>
@@ -285,15 +287,20 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<style scoped>
.dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; }
.dispatch-setup, .dispatch-draft-toolbar { display: grid; grid-template-columns: 160px minmax(0, 1fr); align-items: center; gap: var(--admin-space-5); }
.dispatch-setup > .density-dialog-section__title, .dispatch-draft-toolbar > .density-dialog-section__title { align-items: center; margin: 0; }
.dispatch-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
.dispatch-setup-fields :deep(.dispatch-warehouse-field) { width: 310px; flex: 0 0 310px; }
.dispatch-origin-field { width: 260px; }
.dispatch-origin-field :deep(.el-form-item) { margin: 0; width: 100%; }
.dispatch-origin-field :deep(.el-form-item__content) { min-width: 0; }
.dispatch-warehouse-picker { width: 100%; }
.dispatch-draft-tools { flex-wrap: wrap; align-items: center; }
.dispatch-draft-picker { width: 240px; }
.dispatch-capacity-field { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 100px; }
.dispatch-candidate-query { display: grid; grid-template-columns: 240px 160px 170px auto auto; justify-content: start; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.dispatch-capacity-field { display: inline-flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 92px; }
.dispatch-candidate-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.dispatch-candidate-query > .el-input { width: 180px; }
.dispatch-candidate-query > .el-select { width: 170px; }
.dispatch-candidate-query > .el-button { margin-left: 0; }
.dispatch-search { margin-top: var(--admin-space-5); }
</style>
@@ -6,6 +6,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
@@ -202,25 +203,31 @@ onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidate
<el-alert v-if="pending" :title="pendingMessage" type="warning" :closable="false" show-icon />
<el-button v-if="cargoPending" :loading="busy" @click="execute">{{ pending?.result ? '刷新当前资料' : '核对并重试原请求' }}</el-button>
<LoadErrorState v-if="error" title="排车货物资料待核对" :message="error.message" :loading="loading || busy" retry-text="重新核对" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section existing-cargo-search" aria-label="已有排车查询">
<div class="existing-cargo-query">
<el-input v-model="keyword" aria-label="查找已有排车" placeholder="排车单号" :disabled="frozen || listing" @keyup.enter="searchDispatches" />
<el-button :disabled="frozen || listing" @click="searchDispatches">查询排车</el-button>
<label for="existing-dispatch-number">排车单号</label>
<el-input id="existing-dispatch-number" v-model="keyword" aria-label="查找已有排车" placeholder="排车单号" :disabled="frozen || listing" @keyup.enter="searchDispatches" />
<el-button type="primary" :disabled="frozen || listing" @click="searchDispatches">查询排车</el-button>
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="选择装车前排车" :disabled="frozen || listing" @update:model-value="(value: string) => selectDispatch(value, rows.find(row => row.dispatchNo === value))">
<el-option v-for="row in rows" :key="row.dispatchNo" :value="row.dispatchNo" :label="`${row.dispatchNo} · ${row.warehouseName || row.warehouseCode || '—'}`" :disabled="row.taskType !== 'DELIVERY' || row.status !== 'ACTIVE' || row.stage !== 'BEFORE_LOADING'" />
<el-option v-if="selectedNo && !rows.some(row => row.dispatchNo === selectedNo)" :value="selectedNo" :label="selectedNo" disabled />
</el-select>
<el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" />
</div>
<div v-if="selectedNo" class="existing-cargo-query existing-cargo-filter"><label for="existing-cargo-keyword">货物查询</label><el-input id="existing-cargo-keyword" v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button type="primary" :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></div>
</section>
<p v-if="!selectedNo" class="density-dialog-note">选择已有排车后,在这里统一增减本车计划货物。</p>
<template v-if="selectedNo">
<div class="existing-cargo-query"><strong>{{ selectedNo }} · {{ warehouseName || '—' }}</strong><el-input v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></div>
<p v-if="detail && !editable" class="density-dialog-note">仅有效、装车前的仓库派送排车允许调整计划货物。真实拣货仍在 PDA 完成。</p>
<p v-else-if="detail && !canAdd" class="density-dialog-note">{{ detail.actions.ADD_CARGO?.blockReason || '当前状态或权限不允许添加货物' }}</p>
<SmartDispatchCargoBoard :candidates="candidates" :draft="null" :active-container-id="selectedNo" :capacity="0" :disabled="locked || !canAdd" :warehouse-selected="Boolean(warehouseId)" :page="cargoPage" :size="cargoSize" :total="cargoTotal" @add="addCargo" @change-page="changeCargoPage" @change-size="changeCargoSize">
<template #vehicle-heading>
<h3 class="existing-cargo-title">本车货物 · {{ selectedNo }} · {{ warehouseName || '—' }}</h3>
<InlineHelp title="本车货物调整说明"><p>加入后立即保存;移除前填写原因并确认。货物变化后需重新核对审核。</p></InlineHelp>
</template>
<template #vehicles="{ dragOver, drop, dropTarget }">
<section class="existing-cargo-manifest" :class="{ 'is-drop-target': dropTarget === selectedNo }" aria-label="当前排车货物" @dragover="dragOver($event, selectedNo)" @drop="drop($event, selectedNo)">
<h3>本车货物 · {{ selectedNo }}</h3>
<p class="density-dialog-note">加入后立即保存;移除前填写原因并确认。货物变化后需重新核对审核。</p>
<p v-if="!items" class="density-dialog-note">请读取完整货物清单</p>
<p v-else-if="!items.items.length" class="density-dialog-note">本车暂无货物,可从左侧选择托盘加入。</p>
<div v-for="row in items?.items || []" :key="row.dispatchItemId" class="existing-cargo-item">
@@ -236,12 +243,15 @@ onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidate
</template>
<style scoped>
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.existing-cargo-search { display: flex; flex-direction: column; gap: var(--admin-space-5); margin-top: var(--admin-space-5); }
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.existing-cargo-query > label { width: 64px; flex: none; color: var(--cc-muted); }
.existing-cargo-query > .el-button { margin-left: 0; }
.existing-cargo-query > .el-input { width: 240px; }
.existing-cargo-query > .el-select { width: 350px; }
.existing-cargo-manifest { min-width: 0; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); padding: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
.existing-cargo-manifest.is-drop-target { border-color: var(--cc-primary); background: var(--admin-action-primary-hover); }
.existing-cargo-manifest h3 { margin: 0; font-size: var(--admin-font-section); }
.existing-cargo-title { margin: 0; font-size: var(--admin-font-section); overflow-wrap: anywhere; }
.existing-cargo-item { display: flex; gap: var(--admin-space-3); justify-content: space-between; padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
.existing-cargo-item > div { min-width: 0; }
.existing-cargo-item > .el-button { flex: none; }