Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97277b031d | ||
|
|
8b02f1d870 |
@@ -3,8 +3,9 @@
|
||||
## 快速读取顺序
|
||||
|
||||
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
|
||||
2. Admin 样式唯一入口为 [Admin 界面规范](../../docs/Admin高密度界面规范.md#按任务阅读),按任务只读对应章节;提示小字、加载布局、蓝白风格及密度不再分散查多份规范。样板目录仅在需要视觉参考时查,模块核对记录仅在核对范围、业务例外或验收证据时查。
|
||||
视觉、布局/交互承载变更或真实页面验收再读 [前端验收规则](../../docs/Admin-Client前端原型还原验收规则.md);需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发验收矩阵。
|
||||
2. 视觉、布局/交互承载变更或真实页面验收先读 `../../docs/Admin-Client前端原型还原验收规则.md`;需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发。
|
||||
样式优化另读 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”“新模块接入规则”,保留已确认密度、彩色页签、业务语义及承载。国内仓、客户管理为样板;新增菜单按本轮范围显式接入,货柜入口见 `../../docs/Admin货柜管理样式核对记录.md`。
|
||||
蓝白样式接入见 [样板指南](../../docs/界面参考/蓝白卡片式工作台风格参考.md)。
|
||||
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.md`。
|
||||
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
|
||||
|
||||
@@ -98,7 +99,7 @@
|
||||
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
||||
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
|
||||
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
|
||||
- 页面外观遵循 Admin 界面规范,业务及操作承载核对当前契约、逐操作确认与原型证据;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
|
||||
- 页面和交互必须服从对应原型及验收规则;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
|
||||
|
||||
## 开发验收身份
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { projectWarehouseArrival } from '@/modules/container-loading'
|
||||
import { addDays } from './calendar'
|
||||
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
|
||||
|
||||
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
|
||||
@@ -36,12 +36,12 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
|
||||
if (warehouses === null) continue
|
||||
const warehouse = warehouseMap.get(row.containerBookingId)
|
||||
warehouseMap.delete(row.containerBookingId)
|
||||
const projected = projectWarehouseArrival(row.arrivalAt, warehouse)
|
||||
const planned = projected.date
|
||||
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
|
||||
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
|
||||
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
|
||||
time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse,
|
||||
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
|
||||
canEdit: warehouse?.canEdit === true,
|
||||
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : projected.isDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
||||
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
|
||||
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
|
||||
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
|
||||
|
||||
@@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('queries visible containers and updates the whole-container expected arrival', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }] })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never)
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never)
|
||||
|
||||
await listContainerArrivalTimes(['9001'])
|
||||
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
|
||||
@@ -17,14 +17,4 @@ describe('container arrival-time overseas API', () => {
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } })
|
||||
})
|
||||
|
||||
it('normalizes serialized plan counts and rejects unknown counts instead of showing a default', async () => {
|
||||
const row = { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount: '1' }] })
|
||||
expect(await listContainerArrivalTimes(['9001'])).toEqual([{ ...row, planCount: 1 }])
|
||||
for (const planCount of [undefined, null, '', 'unknown', -1, 0.5]) {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount }] })
|
||||
await expect(listContainerArrivalTimes(['9001'])).rejects.toThrow('入库计划数量无效')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,14 +6,6 @@ export interface ContainerArrivalTime {
|
||||
estimatedWarehouseArrivalAt: string | null
|
||||
actualWarehouseArrivalAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
}
|
||||
|
||||
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
|
||||
if (typeof item.planCount !== 'number' && (typeof item.planCount !== 'string' || !/^(0|[1-9]\d*)$/.test(item.planCount))) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||
const count = Number(item.planCount)
|
||||
if (item.planCount == null || !Number.isSafeInteger(count) || count < 0) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||
return { ...item, version: Number(item.version), planCount: count }
|
||||
}
|
||||
|
||||
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
|
||||
@@ -28,7 +20,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin
|
||||
queryPath,
|
||||
{ sourceContainerBookingIds },
|
||||
)
|
||||
return (unwrapApiResponse(response) || []).map(normalizeArrival)
|
||||
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
|
||||
}
|
||||
|
||||
export async function updateContainerExpectedArrival(
|
||||
@@ -41,5 +33,5 @@ export async function updateContainerExpectedArrival(
|
||||
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
|
||||
)
|
||||
const result = unwrapApiResponse(response)
|
||||
return normalizeArrival(result)
|
||||
return { ...result, version: Number(result.version || 0) }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * from './api/platformContainerLoading'
|
||||
export * from './types'
|
||||
export { projectWarehouseArrival } from './warehouseArrival'
|
||||
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
|
||||
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
|
||||
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
|
||||
|
||||
+1
-9
@@ -57,14 +57,6 @@ function accessibleText(wrapper: { element: Element } | undefined) {
|
||||
}
|
||||
|
||||
describe('ContainerDetailView empty return', () => {
|
||||
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
|
||||
const wrapper = await mountDetail()
|
||||
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
|
||||
expect(value).toBe('2026-10-23(默认推算)')
|
||||
expect(value).not.toContain('00:00')
|
||||
})
|
||||
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
|
||||
const wrapper = await mountDetail('DOMESTIC')
|
||||
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
|
||||
@@ -91,7 +83,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
|
||||
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never)
|
||||
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
|
||||
})
|
||||
|
||||
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
} from '../api/platformContainerLoading'
|
||||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
|
||||
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||
import { projectWarehouseArrival } from '../warehouseArrival'
|
||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||
|
||||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||||
@@ -92,14 +91,6 @@ async function rememberWorkbenchTab(tabName: string | number) {
|
||||
}
|
||||
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
||||
const arrivalTime = ref<ContainerArrivalTime | null>(null)
|
||||
const expectedArrivalText = computed(() => {
|
||||
const row = workbenchDetail.value
|
||||
const arrival = arrivalTime.value
|
||||
const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
|
||||
: projected.date ? `${projected.date}(默认推算)` : '—'
|
||||
})
|
||||
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
|
||||
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
|
||||
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
|
||||
@@ -735,7 +726,7 @@ onMounted(async () => {
|
||||
|
||||
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
|
||||
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||||
</section>
|
||||
|
||||
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
|
||||
|
||||
@@ -74,8 +74,8 @@ describe('ContainersView', () => {
|
||||
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
|
||||
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
|
||||
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
|
||||
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1, planCount: 0 })
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
|
||||
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
|
||||
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
|
||||
@@ -584,30 +584,6 @@ describe('ContainersView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the calendar default from actual port arrival without submitting an invented time', async () => {
|
||||
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
|
||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row,
|
||||
estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' },
|
||||
})) })
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
|
||||
const wrapper = await mountOverseasRoute()
|
||||
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')!
|
||||
expect(picker).toBeDefined()
|
||||
expect(picker.props('modelValue')).toBe('')
|
||||
expect(updateContainerExpectedArrival).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a frozen missing arrival undated and prevents editing it', async () => {
|
||||
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
|
||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row, estimatedPortArrivalAt: '2026-10-20T10:00:00' })) })
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 1 }])
|
||||
const wrapper = await mountOverseasRoute()
|
||||
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '选择预计到仓')!
|
||||
expect(picker.props('disabled')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => {
|
||||
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
|
||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
type TerminalDrayageView,
|
||||
} from '../api/terminalDrayages'
|
||||
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||
import { projectWarehouseArrival } from '../warehouseArrival'
|
||||
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||
|
||||
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||
@@ -286,13 +285,6 @@ function optional(value: string | null) { return value?.trim() || null }
|
||||
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
||||
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
|
||||
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||
function defaultArrivalDate(source: object) {
|
||||
const row = source as ContainerWorkbenchRow
|
||||
const arrival = arrivalTimes.value[row.id]
|
||||
const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return projected.isDefault ? projected.date : null
|
||||
}
|
||||
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
|
||||
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
|
||||
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
|
||||
@@ -585,7 +577,7 @@ async function saveDrayageAction() {
|
||||
}
|
||||
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
|
||||
const current = arrivalTimes.value[row.id]
|
||||
if (!value || !current || current.version < 0 || current.planCount > 0 || arrivalSavingIds.value.includes(row.id)) return
|
||||
if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return
|
||||
arrivalSavingIds.value.push(row.id)
|
||||
try {
|
||||
const updated = await updateContainerExpectedArrival(row.id, {
|
||||
@@ -768,7 +760,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
|
||||
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
|
||||
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="defaultArrivalDate(row) ? '到港日期+2个自然日(默认推算);可选择日期时间人工调整' : undefined"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" :placeholder="defaultArrivalDate(row) ? `${defaultArrivalDate(row)}(默认)` : '选择预计到仓'" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalTimes[row.id].planCount > 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
|
||||
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" placeholder="选择预计到仓" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
|
||||
<template #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
|
||||
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
||||
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
|
||||
@@ -780,7 +772,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
|
||||
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.arrivedDestinationPortAt) }}</template>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
|
||||
</DataTable></div></section>
|
||||
</ListPageLayout>
|
||||
|
||||
|
||||
@@ -224,7 +224,6 @@ export interface ContainerWorkbenchStatusSummary {
|
||||
}
|
||||
|
||||
export interface ContainerWorkbenchActualFacts {
|
||||
arrivedDestinationPortAt?: string | null
|
||||
actualSailingAt?: string | null
|
||||
actualPortArrivalAt?: string | null
|
||||
actualWarehouseArrivalAt?: string | null
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectWarehouseArrival } from './warehouseArrival'
|
||||
|
||||
describe('shared warehouse arrival projection', () => {
|
||||
it.each([
|
||||
['2026-10-21T15:49:00', '2026-10-23'],
|
||||
['2026-10-30T23:30:00-07:00', '2026-11-01'],
|
||||
['2026-12-31T23:30:00', '2027-01-02'],
|
||||
['2026-03-07T23:30:00-08:00', '2026-03-09'],
|
||||
])('adds two local calendar days to %s without manufacturing a time', (arrivalAt, date) => {
|
||||
expect(projectWarehouseArrival(arrivalAt, { plannedAt: null, version: 0, planCount: 0 })).toEqual({ date, time: '', isDefault: true })
|
||||
})
|
||||
it('keeps manually maintained and frozen dates instead of following port arrival', () => {
|
||||
for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) {
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
|
||||
}
|
||||
})
|
||||
it('does not replace inconsistent or frozen missing dates with defaults', () => {
|
||||
for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) {
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull()
|
||||
}
|
||||
for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).date).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
export interface WarehouseArrivalPlan {
|
||||
plannedAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
}
|
||||
|
||||
/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */
|
||||
export function projectWarehouseArrival(arrivalAt: string | null | undefined, plan: WarehouseArrivalPlan | undefined) {
|
||||
const canDefault = !plan || plan.version === 0 && plan.planCount === 0
|
||||
let date = plan?.plannedAt?.slice(0, 10) || null
|
||||
if (!date && canDefault && arrivalAt) {
|
||||
const day = arrivalAt.slice(0, 10)
|
||||
const calendarDate = new Date(`${day}T12:00:00Z`)
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !Number.isNaN(calendarDate.getTime()) && calendarDate.toISOString().slice(0, 10) === day) {
|
||||
calendarDate.setUTCDate(calendarDate.getUTCDate() + 2)
|
||||
date = calendarDate.toISOString().slice(0, 10)
|
||||
}
|
||||
}
|
||||
return { date, time: plan?.plannedAt?.slice(11, 16) || '', isDefault: !plan?.plannedAt && canDefault }
|
||||
}
|
||||
@@ -37,13 +37,6 @@
|
||||
|
||||
删除和文件预览在三类正式页面中均未定义,不自行补充承载方式;海外采购审核按当前对话确认的承载方式实现。
|
||||
|
||||
### 2026-10-21 本土卡车审核详情页头修复
|
||||
|
||||
| 页面 | 操作 | 确认依据与布局 | 当前实现 | 修复结论与验收 |
|
||||
|---|---|---|---|---|
|
||||
| 本土卡车详情 | 从列表点击审核、查看详情 | 用户截图 `codex-clipboard-515ba915-7b67-48de-9914-62cec7371ec5.png` 红框;当前详情密度规范要求返回、编号、状态及操作合并页头 | 标题、状态/刷新、审核/取消拆成三行,标题使用浏览器默认字号 | 接入公共详情页头与连续白底容器:左侧返回、订单号及单一状态徽标,右侧刷新与原业务操作;保留完整编号;待人工 1920×1080、100% 截图确认 |
|
||||
| 本土卡车详情 | 审核、取消及后续命令 | 本表已确认的居中操作确认弹框 | `TruckOrderActions` 保留既有表单、权限及提交保护 | 只移动页头入口,弹框承载、尺寸和命令保持现状;按既有行为测试回归 |
|
||||
|
||||
## 本土卡车需求字段调整
|
||||
|
||||
依据本轮客户端截图及用户确认,同步平台订单字段;规则唯一见 `docs/海外仓业务/README.md`“美国本土卡独立下单”。
|
||||
@@ -120,13 +113,3 @@
|
||||
| ProcurementNodeActions | 采购详情中的节点命令居中弹框 | 自定义footer绕过AppDialog默认操作区容器;三项操作加入density-actions底部容器,允许换行并保持间距 | 定向回归通过;待人工1920×1080、100%截图确认 |
|
||||
|
||||
保持既有命令、草稿、未知结果核对、权限与提交条件;只新增操作区容器。公共AppDialog的直接按钮footer修复由公共样式写者维护,本域未修改公共组件。
|
||||
|
||||
## 2026-10-21 本土运输表单布局修复
|
||||
|
||||
| 页面 | 操作 | 依据与现有承载 | 当前问题与修复结论 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 本土卡车 | 新增 | 用户截图 `codex-clipboard-1a93aa11-88f2-4e90-8130-030fa91185f5.png`;既有独立路由及上文统一运输表单矩阵 | 移除居中窄表单的宽度限制及重复分区内距,铺满内容区;接入公共密度表单与分区,保留运输双列、货物三列和字段顺序 | 自动检查通过;待人工 1920×1080、100% 截图确认 |
|
||||
| 本土卡车 | 编辑 | 既有独立编辑路由,与新增复用运输表单 | 同步宽度、密度与分区;保留回显、状态限制及提交逻辑 | 同上 |
|
||||
| 本土散板 | 新增、编辑 | 既有独立路由,与卡车复用运输表单 | 同步布局修复,保留运输双列、货物四列及散板专属字段 | 同上 |
|
||||
|
||||
本次仅调整 Admin 运输表单展示,不改采购编辑的宽度或 Client 表单。在 `apps/admin-web` 经 `local-check.ps1` 执行:`pnpm exec vitest run src/modules/overseas-business/pages/OverseasBusinessViews.test.ts src/modules/overseas-business/contract.test.ts src/styles/adminDensity.test.ts`,3 文件 115 项通过;`pnpm typecheck`、`pnpm build` 通过。共享完整 `pnpm test`(任务名 `Admin restored dispatch full regression`,13:55:33–14:05:50)退出码 0,覆盖本次未再变化的运输表单源码;其他在途排车变更由对应任务验证,不据此推定其最新快照通过。视觉仍待人工截图确认。
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -24,9 +22,10 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" class="transport-demand-form admin-density admin-workbench density-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
|
||||
<el-form ref="formRef" class="transport-demand-form admin-workbench" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
|
||||
<slot name="customer" />
|
||||
<FormSection class="density-form-section" title="运输信息">
|
||||
<section class="transport-demand-section">
|
||||
<h3>运输信息</h3>
|
||||
<div class="transport-demand-grid">
|
||||
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
|
||||
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
|
||||
@@ -40,8 +39,9 @@ defineExpose({
|
||||
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="货物信息">
|
||||
</section>
|
||||
<section class="transport-demand-section">
|
||||
<h3>货物信息</h3>
|
||||
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
|
||||
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
|
||||
@@ -50,8 +50,9 @@ defineExpose({
|
||||
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="费用与时效">
|
||||
</section>
|
||||
<section class="transport-demand-section">
|
||||
<h3>费用与时效</h3>
|
||||
<div class="transport-demand-grid">
|
||||
<el-form-item label="预算总价" prop="budgetAmount"><div class="transport-demand-budget">
|
||||
<el-input-number v-model="model.budgetAmount" :min="0" :precision="2" controls-position="right" />
|
||||
@@ -62,8 +63,8 @@ defineExpose({
|
||||
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
|
||||
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<footer class="transport-demand-actions density-actions">
|
||||
</section>
|
||||
<footer class="transport-demand-actions">
|
||||
<el-button :disabled="submitting" @click="emit('cancel')">取消</el-button>
|
||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="disabled" @click="emit('submit')">{{ editing ? '保存修改' : '提交订单' }}</el-button>
|
||||
</footer>
|
||||
@@ -71,12 +72,17 @@ defineExpose({
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-demand-form { width: 100%; min-width: 0; margin: 0; }
|
||||
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
|
||||
.transport-demand-form { --el-component-size: 36px; width: 100%; max-width: 1180px; margin: 0; background: var(--workbench-surface); }
|
||||
.transport-demand-section { padding: 20px 24px 0; border-bottom: 1px solid var(--workbench-border); }
|
||||
.transport-demand-section h3 { margin: 0 0 16px; color: var(--workbench-text); font-size: 14px; font-weight: 600; }
|
||||
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
|
||||
.transport-demand-cargo-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.transport-demand-cargo-four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.transport-demand-wide { grid-column: 1 / -1; }
|
||||
.transport-demand-form :deep(.el-form-item) { min-width: 0; }
|
||||
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: var(--admin-space-3); }
|
||||
.transport-demand-actions { justify-content: flex-end; flex-wrap: wrap; padding: var(--admin-space-5) 0; }
|
||||
.transport-demand-form :deep(.el-form-item) { min-width: 0; margin-bottom: 18px; }
|
||||
.transport-demand-form :deep(.el-form-item__label) { height: auto; padding-bottom: 6px; font-size: 13px; line-height: 20px; }
|
||||
.transport-demand-form :deep(.el-input), .transport-demand-form :deep(.el-input-number), .transport-demand-form :deep(.el-select), .transport-demand-form :deep(.el-date-editor) { width: 100%; }
|
||||
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: 10px; }
|
||||
.transport-demand-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; }
|
||||
.transport-demand-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/* Shared business form layout for purchase and local transport editors. */
|
||||
.admin-density.overseas-business-editor { width: 100%; }
|
||||
.admin-density.overseas-business-editor:not(.overseas-transport-editor) { max-width: 1120px; }
|
||||
.admin-density.overseas-business-editor { width: 100%; max-width: 1120px; }
|
||||
.overseas-business-editor .overseas-business-form-layout { gap: 0; }
|
||||
.overseas-business-editor .form-reference { min-width: 0; color: var(--cc-muted); font-size: var(--admin-font-body); overflow-wrap: anywhere; }
|
||||
.overseas-business-editor .form-panel { min-width: 0; }
|
||||
.overseas-business-editor:not(.overseas-transport-editor) .form-panel { padding: var(--admin-space-5); }
|
||||
.overseas-business-editor .form-panel { min-width: 0; padding: var(--admin-space-5); }
|
||||
.overseas-business-editor .form-grid { display: grid; min-width: 0; gap: 0 var(--admin-space-5); }
|
||||
.overseas-business-editor .form-grid :is(.el-select, .el-date-editor, .el-input-number) { width: 100%; }
|
||||
.overseas-business-editor .form-grid small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
|
||||
@@ -94,7 +94,7 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density overseas-business-editor overseas-transport-editor admin-workbench"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout workbench-panel is-flush is-page-surface" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
|
||||
<AdminPage class="admin-density overseas-business-editor admin-workbench"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout workbench-panel is-flush is-page-surface" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
|
||||
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<FleetArrangementSummary v-else-if="fleetArrangement" :arrangement="fleetArrangement" />
|
||||
@@ -102,10 +102,14 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
|
||||
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
|
||||
<TransportDemandForm ref="formRef" :model="form" :kind="isLooseboard ? 'looseboard' : 'truck'" :submitting="saving" :disabled="!editableStatus" :editing="editing" @submit="save" @cancel="back">
|
||||
<template v-if="!editing" #customer>
|
||||
<FormSection class="transport-customer-section density-form-section" title="客户信息"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
|
||||
<FormSection class="transport-customer-section" title="客户信息"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
|
||||
</template>
|
||||
</TransportDemandForm>
|
||||
<FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection>
|
||||
</section>
|
||||
</DetailPageLayout><TransportRouteDialog v-model="routeOpen" :origin="routeLocation(form.origin, form.originAddress)" :destinations="[routeLocation(form.destination, form.destinationAddress)]" /></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-customer-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
|
||||
</style>
|
||||
|
||||
@@ -56,20 +56,6 @@ beforeEach(() => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('truck review and fulfillment', () => {
|
||||
it('groups the business number, single status and actions in the shared detail header', async () => {
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
const header = wrapper.find('.detail-page-layout__header')
|
||||
expect(header.find('.density-detail-title').text()).toBe('本土卡车订单 TR-TEST')
|
||||
expect(header.find('.status-badge').text()).toBe('待审核')
|
||||
expect(wrapper.findAll('.status-badge')).toHaveLength(1)
|
||||
expect(header.find('.truck-order-actions').text()).toContain('审核')
|
||||
expect(header.find('.truck-order-actions').text()).toContain('取消订单')
|
||||
await click(wrapper, '刷新订单')
|
||||
expect(getUsTruckOrder).toHaveBeenCalledTimes(2)
|
||||
await click(wrapper, '返回列表')
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-trucks' })
|
||||
})
|
||||
|
||||
it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => {
|
||||
let current = order()
|
||||
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
|
||||
@@ -133,7 +119,6 @@ describe('truck review and fulfillment', () => {
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
for (const button of wrapper.find('.detail-page-layout__header').findAll('button')) expect(button.attributes('disabled')).toBeDefined()
|
||||
finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises()
|
||||
expect(wrapper.text()).toContain('登记实收')
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -66,25 +65,16 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="本土卡车订单详情" class="density-detail-layout workbench-panel is-flush is-page-surface">
|
||||
<template #header>
|
||||
<div class="density-detail-header truck-detail-header">
|
||||
<div class="density-detail-heading">
|
||||
<el-button link class="density-back" :disabled="saving" @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<h1 class="density-detail-title">本土卡车订单 {{ detail?.orderNo || orderNo }}</h1>
|
||||
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
|
||||
</div>
|
||||
<div v-if="detail" class="density-actions truck-detail-actions">
|
||||
<el-button :disabled="saving" @click="load">刷新订单</el-button>
|
||||
<TruckOrderActions v-if="!detail.fleetArrangement" :detail="detail" @updated="detail = $event" @busy="saving = $event" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="本土卡车订单详情" class="workbench-panel is-flush is-page-surface">
|
||||
<template #header><div class="truck-detail-header"><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button><h1>本土卡车订单 {{ detail?.orderNo || orderNo }}</h1></div></template>
|
||||
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="loading" :rows="8" animated />
|
||||
<template v-else-if="detail">
|
||||
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
|
||||
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
|
||||
<section class="truck-detail-section"><div class="truck-detail-header"><h2>{{ statusLabel(detail.status) }}</h2><el-button :disabled="saving" @click="load">刷新订单</el-button></div>
|
||||
<FleetArrangementSummary v-if="detail.fleetArrangement" :arrangement="detail.fleetArrangement" />
|
||||
<template v-else><TruckOrderActions :detail="detail" @updated="detail = $event" @busy="saving = $event" /></template>
|
||||
</section>
|
||||
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
|
||||
<section class="truck-detail-section"><h2>托盘记录</h2><div class="workbench-table"><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></div></section>
|
||||
<section class="truck-detail-section"><h2>审核与操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
|
||||
</template>
|
||||
@@ -92,9 +82,7 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.truck-detail-header { flex-wrap: wrap; }
|
||||
.truck-detail-header .density-detail-heading { flex: 1; }
|
||||
.truck-detail-header .status-badge { flex-shrink: 0; }
|
||||
.truck-detail-actions { flex-wrap: wrap; }
|
||||
.truck-detail-section { margin-bottom: var(--admin-space-6); }
|
||||
.truck-detail-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.truck-detail-header h1, .truck-detail-header h2 { margin: 0; }
|
||||
.truck-detail-section { margin-bottom: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -63,26 +63,6 @@
|
||||
|
||||
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
|
||||
|
||||
## 自有业务列表字段补齐(2026-10-21)
|
||||
|
||||
依据本轮用户字段截图 `codex-clipboard-9d532c45-a5f3-4f90-a8f2-f8a3cdfb8a3e.png`;用户确认派送仓库就是目的仓,最晚装车完成时间先空着。此前详情、运营登记、照片的承载与权限不变。
|
||||
|
||||
| 页面/操作 | 截图与当前确认 | 本次调整 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 自有业务列表/读取字段 | 总价、单价、预约到达、实际到达各为独立列 | 拆开原合并列;一车一行,各到达时间保留目的仓标识,金额沿服务端原值 | 8项定向检查通过;待修改后人工截图 |
|
||||
| 自有业务列表/仓库与时间 | 派送仓库等于目的仓;最晚装车完成时间暂无来源 | 派送仓库复用目的仓展示;最晚装车完成时间显示“—”,不拿计划发车替代;保留已有计划发车列 | 8项定向检查通过;待修改后人工截图 |
|
||||
| 自有业务列表/字段名称 | 图中第一处“托数”装的是仓库名称 | 保留正确的“提货仓库”和唯一“实际托数”;“阶段”列改名“状态”,取值不变 | 8项定向检查通过;待修改后人工截图 |
|
||||
|
||||
## 自有业务行操作样式核对(2026-10-21)
|
||||
|
||||
依据本轮用户截图 `codex-clipboard-eb6aa95b-c8a2-4b85-9294-22f3127cf72c.png` 与 Admin 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。
|
||||
|
||||
| 页面 | 操作 | 已确认承载 | 当前问题与修正 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 自有业务列表 | 查看明细 | 原独立详情路由 | 旧描边粗体按钮接入公共纯文字行操作类,保留路由和业务编号 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
|
||||
| 自有业务列表 | 登记运营信息 | 原1080px居中弹框 | 同上;保留原权限、状态及处理中禁用 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
|
||||
| 自有业务列表 | 上传/查看照片 | 原760px居中弹框 | 同上;保留原读写权限与按钮文案 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
|
||||
|
||||
## 自有业务实现核对(2026-10-20,主规则已确认)
|
||||
|
||||
用户当前要求逐页处理,先看自有业务;HTTP 扩展由既有排车后端对话独立负责。本节记录原型与已确认规则的映射,不授权外配页面改造。自有与外配均为原排车装车后的车辆归属视图,不另建任务或履约流程;自有车增加运营细项,外配管理细度后续确认。视觉沿当前 Admin 高密度规范,业务与操作承载依据用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-10/悦/悦/新版.html` 及本轮截图。
|
||||
|
||||
@@ -37,17 +37,11 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
|
||||
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
|
||||
{ key: 'destinations', label: '目的仓', minWidth: 150, slotName: 'destinations' },
|
||||
{ key: 'deliveryWarehouses', label: '派送仓库', minWidth: 150, slotName: 'destinations' },
|
||||
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
|
||||
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
|
||||
{ key: 'money', label: '总价', minWidth: 170, align: 'right', slotName: 'money' },
|
||||
{ key: 'unitPrice', label: '单价(/托)', minWidth: 160, align: 'right', slotName: 'unitPrice' },
|
||||
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' },
|
||||
{ key: 'latestLoadingCompletedAt', label: '最晚装车完成时间', minWidth: 180, slotName: 'latestLoaded' },
|
||||
{ key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
|
||||
{ key: 'scheduledArrival', label: '预约到达时间', minWidth: 190, slotName: 'scheduledArrival' },
|
||||
{ key: 'actualArrival', label: '实际到达时间', minWidth: 190, slotName: 'actualArrival' },
|
||||
{ key: 'stage', label: '状态', minWidth: 110, slotName: 'stage' },
|
||||
{ key: 'money', label: '整车总金额 / 单价', minWidth: 170, slotName: 'money' },
|
||||
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
|
||||
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
|
||||
]
|
||||
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
||||
async function load() {
|
||||
@@ -100,16 +94,12 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
|
||||
<template #toolbar-start><h1>自有业务<InlineHelp title="查询范围说明">仅统计未实际接入拼车货物的已装车排车,与车辆归属无关;日期和时间按浏览器本地时区显示,统计覆盖同一完整筛选范围。</InlineHelp></h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自有业务筛选" preference-key="owned-business-query" :loading="loading" @search="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="自有业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
|
||||
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
|
||||
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
|
||||
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination">{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</div><span v-if="!row.destinations.length">—</span></template>
|
||||
<template #scheduledArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ time(appointment.scheduledAt) }}</div><span v-if="!destination.appointments.length">—</span></div><span v-if="!row.destinations.length">—</span></template>
|
||||
<template #actualArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div>{{ time(destination.actualArrivedAt) }}</div></div><span v-if="!row.destinations.length">—</span></template>
|
||||
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
|
||||
<template #fuel="{ row }">{{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }}</template>
|
||||
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}</template><span v-else>未登记</span></template>
|
||||
<template #unitPrice="{ row }"><template v-if="row.operations.money && row.unitPrice != null">{{ row.operations.money.currency }} {{ row.unitPrice }}</template><span v-else>{{ row.operations.money ? '—' : '未登记' }}</span></template>
|
||||
<template #latestLoaded>—</template>
|
||||
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
|
||||
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ ownedBusinessStages[row.stage as OwnedBusinessStage] }}</template>
|
||||
</DataTable></div>
|
||||
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
||||
|
||||
@@ -17,7 +17,7 @@ const Button = defineComponent({ props: ['disabled'], template: '<button :disabl
|
||||
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
|
||||
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
|
||||
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
|
||||
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([name,slot]) => h('section', { 'data-column': name }, slot?.({row})))))]) } })
|
||||
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } })
|
||||
const OperationsEditor = defineComponent({ name: 'OwnedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
|
||||
const PhotosEditor = defineComponent({ name: 'OwnedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
|
||||
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OwnedBusinessOperationsEditor: OperationsEditor, OwnedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
|
||||
@@ -30,27 +30,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('owned business views', () => {
|
||||
it('keeps split prices authoritative and arrival times associated with each destination', async () => {
|
||||
const detail = ownedBusinessFixture()
|
||||
detail.row.operations.money = { currency: 'USD', amount: '0.00' }; detail.row.unitPrice = '0.00'
|
||||
detail.row.destinations[0]!.appointments[0]!.scheduledAt = '2026-10-21T09:00:00Z'
|
||||
detail.row.destinations.push({ destinationKey: 'destination-2', warehouseCode: 'FBA-2', warehouseName: null, address: null, appointments: [], actualArrivedAt: '2026-10-21T10:00:00Z' })
|
||||
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } })
|
||||
const wrapper = mount(OwnedBusinessView, { global }); await flushPromises()
|
||||
const columns = wrapper.findComponent(Table).props('columns') as { key?: string; label: string; slotName?: string }[]
|
||||
expect(columns.map(column => column.label)).toEqual(expect.arrayContaining(['目的仓', '派送仓库', '总价', '单价(/托)', '最晚装车完成时间', '预约到达时间', '实际到达时间', '状态']))
|
||||
expect(columns.find(column => column.key === 'deliveryWarehouses')!.slotName).toBe('destinations')
|
||||
expect(wrapper.get('[data-column="money"]').text()).toBe('USD 0.00')
|
||||
expect(wrapper.get('[data-column="unitPrice"]').text()).toBe('USD 0.00')
|
||||
expect(wrapper.get('[data-column="latestLoaded"]').text()).toBe('—')
|
||||
const scheduled = wrapper.findAll('[data-column="scheduledArrival"] .owned-business-destination')
|
||||
expect(scheduled.map(cell => cell.text())).toEqual(['FBA-1' + dayjs('2026-10-21T09:00:00Z').format('YYYY-MM-DD HH:mm'), 'FBA-2—'])
|
||||
const actual = wrapper.findAll('[data-column="actualArrival"] .owned-business-destination')
|
||||
expect(actual.map(cell => cell.text())).toEqual(['FBA-1—', 'FBA-2' + dayjs('2026-10-21T10:00:00Z').format('YYYY-MM-DD HH:mm')])
|
||||
expect(calls.detail).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a non-pool external vehicle returned by the server without filtering or requiring a vehicle id', async () => {
|
||||
const detail = ownedBusinessFixture()
|
||||
detail.row.vehicle = { vehicleId: null, plate: 'EXTERNAL-NO-POOL', ownership: 'DISPATCHED' }
|
||||
|
||||
@@ -34,23 +34,21 @@
|
||||
|
||||
## 履约硬边界
|
||||
|
||||
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。四个作业阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车完成自动生成派送单并直接进入派送中,无独立安排派送或发车确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
|
||||
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车与实际发车分别确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
|
||||
- 智能排车来源支持 `RELABEL`(待出库换标单),按服务端候选的`selection.relabelNo/sourceVersion`提交,不用展示订单号或原入仓号替代换标身份;沿用真实库存、托盘与箱唛,不生成新入库单。客户和仓库归属由服务端核验,不在前端拼接客户子排车。
|
||||
|
||||
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘;有 FBA 仓码的托盘与国内统一使用 Print `FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm,FBA 仓码、完整托盘号 Code 128、托盘号文字),其他托盘使用 `INBOUND_MARK_V1`。不在前端生成标签、改码或维护第二套模板。
|
||||
|
||||
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。整车派送在装车完成事务内自动生成;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
|
||||
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。整车派送安排在装车完成后、实际发车前进行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
|
||||
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
|
||||
|
||||
## 验证
|
||||
|
||||
- 排车调度/理货审核分区仅显示审核、详情;审核弹框的车辆图片、司机照片和驾驶证照片通过既有本车附件接口上传,随 `ReviewCommand.attachments` 保存,不调用资源编辑命令。已有资源与审核附件只读预览,详情合并展示;当前审核权限与服务端 Gate 控制上传及提交,列表查看权限控制读取。
|
||||
|
||||
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
|
||||
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
|
||||
- 装车完成通过`completeDispatchLoading`一次提交,结果仅接受`DELIVERING`;原请求恢复不重建单据。未知费用保持未确认,由既有后台费用管理维护,不按金额推断覆盖或重复记费。已有排车加货和移货仅从`SmartDispatchDialog.vue`内的`SmartDispatchExistingCargo.vue`进入;移货必须使用`GET items`返回的真实dispatchItemId及逐项remove Gate;路线仅重排`GET route`返回的既有全部目的地,包含外接点,不能新增或猜测目的地。附件沿`DispatchEvidenceFiles.vue`的本车鉴权上传/读取,上传与主单引用保存分开。
|
||||
- 派送安排弹框通过`getDispatchArrangement`读取整车分组、实际货物、外接报价及原费用依据,`DispatchArrangeFields.vue`按真实客户目的地组提交;不逐行查费用,不按金额推断覆盖,不把原费用再次记入新费用行。已有排车加货和移货仅从`SmartDispatchDialog.vue`内的`SmartDispatchExistingCargo.vue`进入;移货必须使用`GET items`返回的真实dispatchItemId及逐项remove Gate;路线仅重排`GET route`返回的既有全部目的地,包含外接点,不能新增或猜测目的地。附件沿`DispatchEvidenceFiles.vue`的本车鉴权上传/读取,上传与主单引用保存分开。
|
||||
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取reviewStatus/sourceStatus/voidStatus,同一详情响应展示待财务确认、待财务处理、确认作废,不逐行查询,也不把取消成功视为费用作废。货柜费用面板直接消费同一费用来源接口,待确认时同样禁止调整和重复作废,沿用原页面/弹框承载。
|
||||
- 排车列表车辆信息编辑、BOL PDF 和装车完成从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;列表和详情不另设添加/移除货物入口。货物计划统一由 `components/SmartDispatchDialog.vue` 承载:新建使用候选、草稿及托盘锁,已有排车经 `components/SmartDispatchExistingCargo.vue` 读取实际明细并按版本增减;两类服务端命令不得混用。原加货/移货未知请求也在智能排车恢复。回归为对应组件测试、`pages/DispatchesView.test.ts`、`pages/DispatchDetailView.test.ts` 及 `pages/prototypeContract.test.ts`。
|
||||
- 排车列表车辆信息编辑、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;列表和详情不另设添加/移除货物入口。货物计划统一由 `components/SmartDispatchDialog.vue` 承载:新建使用候选、草稿及托盘锁,已有排车经 `components/SmartDispatchExistingCargo.vue` 读取实际明细并按版本增减;两类服务端命令不得混用。原加货/移货未知请求也在智能排车恢复。回归为对应组件测试、`pages/DispatchesView.test.ts`、`pages/DispatchDetailView.test.ts` 及 `pages/prototypeContract.test.ts`。
|
||||
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
|
||||
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
|
||||
|
||||
|
||||
@@ -1,78 +1,5 @@
|
||||
# 海外仓原型核对矩阵
|
||||
|
||||
## 2026-10-21 智能排车查询与三栏统一对齐
|
||||
|
||||
用户确认保留蓝白配色,按最初第二张原型恢复区块与控件位置;最新截图 `codex-clipboard-40f3fcdc-8093-4fd1-8b0e-bd1abb147b3e.png` 确认已有排车查询散落、三栏错位,上轮不构成视觉验收通过。
|
||||
|
||||
| 页面/操作 | 依据与问题 | 修复目标 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 新建排车/查询与设置 | 原型 `modal-dispatch-schedule` 查询在三栏之前,现状设置占据顶部 | 起运仓进入首个查询面板;建单方式、草稿恢复按需展开;保留区域按钮与现行真实字段 | 定向179项通过;待人工截图确认 |
|
||||
| 两模式/选订单、托盘与装车 | 截图中中栏固定偏移、已有货物栏替换整个布局 | 三栏共享标题行和内容行,右栏插槽只更换标题与内容,不再自行定位;长标题换行一起撑高标题行 | 定向179项通过;待人工截图确认 |
|
||||
| 已有排车/查询、加货与移除 | 两行裸露查询,货物查询被重复单号推偏 | 同一个查询面板、统一标签和输入宽度;单号及仓库移入右栏标题;保留即时保存与移除确认 | 定向179项通过;待人工截图确认 |
|
||||
| 新建排车/搜索标签与分页 | 最新截图 `codex-clipboard-06b92e79-d28a-4eec-8d63-f0f856d1f380.png` 三栏已对齐,但起运仓换行、分页提示多余;用户要求仅做样式定向检查 | 按最新确认删除起运仓可见标签并默认首个可用仓;删除分页提示及对应说明入口;分页与订单表左边缘对齐 | 后续合并定向161项通过;待截图确认 |
|
||||
|
||||
本轮追加:用户要求打开时默认第一个起运仓并删除标签;无已有选择、草稿或订单指定仓时采用目录首项并读取候选,空目录保持空值。已有排车按新截图 `codex-clipboard-43654ee8-6c76-4141-bca5-4d108d45b828.png` 合并为可搜索单号的选择器,分页进入下拉底部、货物查询同排,删除重复标签和介绍文字。沿用当前查询API、明确选择后读取清单,不自动选择已有排车。6个文件161项定向回归通过(18.32秒),覆盖默认仓自动加载、空目录、草稿仓保留、下拉搜索分页及原有加货/移除;本轮不跑全量测试。应用类型检查与差异检查通过,真实样式待截图确认。
|
||||
|
||||
自动检查:7个文件179项定向回归通过;`pnpm build`(含应用类型检查)通过。标题共享网格行,三栏内容共同起始,不再使用中栏固定顶部偏移;已有排车自定义内容仍留在同一网格列内。本轮修改的4个测试文件及其组件依赖定向类型检查通过;新增查询断言修正后已有排车23项再次通过。额外全仓`typecheck:all`触及统一入口2GB堆上限,未提高资源限制;扩大到未改动的规划测试时存在既有`VueWrapper`类型报错,未据此宣称全仓测试代码类型检查通过。全量`pnpm test`通过:470文件、4336项(14:34启动,591.74秒)。验证期间三个生产组件哈希未变;测试断言查询方法的最后修正已另跑23项通过。差异空白检查通过。只调整本模块模板和布局,保留蓝白配色及现行排车契约;未部署,视觉验收待1920×1080、100%人工截图确认。
|
||||
|
||||
## 2026-10-21 智能排车截图复核与样式纠偏
|
||||
|
||||
依据用户截图 `codex-clipboard-03c13f2c-5e0d-46da-9f0c-10f2be9fcdad.png`:上轮仅通过自动检查,真实截图确认仍未还原,不能据上轮结论标记视觉完成。对照原型 `overlays.html:1247-1300` 的完整样式以及10月18日组件末尾覆盖规则,本轮纠正标题留白、固定空白高度和托盘明细缺表头。
|
||||
|
||||
| 操作 | 截图问题 | 本轮目标 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 打开/空态/切换区域 | 92vh外框、74px标题区和固定520px表区叠加,空数据也出现滚动;区域仍为下划线 | 外框随内容增长且保留全屏;三栏标题按控件高度对齐;订单区只限制最大高度;区域恢复独立按钮 | 排车定向108项通过;人工截图待确认 |
|
||||
| 选择订单/托盘明细 | 标题浮在边框外,缺少托盘号和箱数表头 | 恢复完整明细表格及表内空态,保留整托说明、按钮加货与拖放;中栏卡片与订单表顶部对齐 | 排车定向108项通过;人工截图待确认 |
|
||||
| 装车分组 | 右栏过窄,标题、两个说明图标、容量与按钮挤在一行 | 按原型44/22/34三栏比例,容量与添加操作独立成组,只保留一处说明入口;当前蓝白主题和业务契约继续保留 | 排车定向108项通过;人工截图待确认 |
|
||||
|
||||
本轮定向7文件178项:排车6文件108项全部通过;共享样式70项中68项通过,2项失败为预约消费者的`<FbaAppointmentFields continuous`属性顺序断言,未改动这些预约实现。`pnpm build`在预约模块`WorkCreateAppointmentForm.vue:63`被`SourceReference.destinationKey`的`null`与`PlanningSourceLookup`不兼容阻断;不据此宣称构建通过,也不扩大范围修复其他在途业务。本轮全量`pnpm test`完成:468文件中465通过、3失败;4324项中4305通过、19失败,另3个未处理错误。失败集中于预约属性顺序断言、预约组件Mock缺少`AppointmentConfirmationCargo`/`canLeave`及其卸载连带错误,未修改对应在途预约代码。排车组件输入在检查期间未变,差异空白检查通过。未部署,视觉仍待1920×1080、100%人工截图确认。
|
||||
|
||||
## 2026-10-21 装车完成直接进入派送中
|
||||
|
||||
依据用户本轮明确确认“PDA装完货后车就开走”,覆盖早期截图中的装车后等待阶段;沿用原装车完成居中弹框及实载字段,不新增操作入口。
|
||||
|
||||
| 页面/操作 | 当前调整 | 验收 |
|
||||
|---|---|---|
|
||||
| 排车列表/阶段与动作 | 显示装车前、装车中、派送中、派送完成;移除人工安排派送、确认发车,路线在派送中可读 | 定向回归通过;1920×1080人工截图待确认 |
|
||||
| 排车详情/装车完成 | 继续使用原装车完成弹框;真实拣货与实载条件保留,单次成功直接进入派送中 | 交互、原请求恢复与阶段校验通过;人工截图待确认 |
|
||||
| 装车完成/异常恢复 | 仅接受最终DELIVERING结果;读详情失败只刷新,不重复创建单据 | 定向回归及类型检查通过 |
|
||||
|
||||
## 2026-10-21 智能排车恢复10月18日布局
|
||||
|
||||
依据:本轮用户明确确认恢复10月18日版本;Admin Git `a5a19769` 的 `SmartDispatchDialog.vue:496` 起模板与 `.dispatch-workbench` 三栏样式,结合原型 `modal-dispatch-schedule`。只恢复页面布局,当前接口、整托资格、草稿与幂等恢复、15/30托位及已有排车调整继续保留,不回退旧协议。
|
||||
|
||||
| 页面/操作 | 10月18日布局 | 本轮恢复范围 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 智能排车/新建与查询 | 顶部紧凑查询、区域按钮,之后直接三栏 | 必要起运仓、建单方式、草稿合并一条工具栏;删除两块步骤表单;查询独立一行;区域恢复active选中态 | 定向177项通过;1920×1080人工截图待确认 |
|
||||
| 选订单/拖托盘/分车 | 左订单、中托盘、右装车,三栏标题对齐;托盘可拖入各车 | 恢复1.5/0.85/1三栏比例及订单、托盘工作区高度;计划托位移到右侧添加车辆旁;保留当前真实字段、分页和完整组提示,不补造旧字段 | 定向177项通过;1920×1080人工截图待确认 |
|
||||
| 空单/调整已有/生成 | 旧版不包含全部当前能力 | 保留现行入口与权限、独立时间备注、原请求恢复和提交门禁;共用三栏的已有排车同步验证 | 定向177项通过 |
|
||||
|
||||
本条覆盖此前“三栏交互”中的顶部步骤区和布局结论;不表示旧版所有字段、旧协议或紫色主题一并恢复。
|
||||
|
||||
自动验证:通过统一 `local-check.ps1` 在 `apps/admin-web` 执行7文件定向回归177项全部通过;最终 `pnpm build`(含应用类型检查)通过;`pnpm test` 全量469文件/4332项全部通过(13:55启动,614.49秒)。差异空白检查通过。本轮只改两处组件模板/局部样式、对应布局和样式合同及本段记录;保留其他对话改动,未部署。真实页面1920×1080、100%截图待人工确认。
|
||||
|
||||
## 2026-10-21 排车列表操作列收紧
|
||||
|
||||
依据本轮用户两张截图,第二张为当前操作展示参考;仓库拆分原型 `04-overseas-delivery.html:1101` 的 `page-outbound-vehicle` 仍为旧版列表,不据此覆盖现行五阶段和审核链。
|
||||
|
||||
| 页面/操作 | 原型/截图布局 | 当前调整 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 排车列表/维护车辆、详情 | 主要动作在前、详情在后 | 直接展示当前主要动作及详情,操作列左固定220px;提交审核、取消、卸货顺序及BOL沿已有更多菜单可达 | 定向49项、类型检查及构建通过;1920×1080人工截图待确认 |
|
||||
| 排车列表/调度、理货审核 | 对应分区显示“审核、详情” | 优先当前分区审核,按钮简称“审核”,可访问名称保留完整环节;原居中弹框、权限与服务端动作资格不变 | 定向49项、类型检查及构建通过;1920×1080人工截图待确认 |
|
||||
|
||||
本轮Admin完整回归4328/4329通过,唯一失败为布局合同仍要求旧260px操作列;已同步为220px及两个直接动作,随后页面、原型合同和布局合同49项全部通过。修正后未重复完整回归;未部署,最终页面待人工截图确认。
|
||||
|
||||
## 2026-10-21 审核驳回统一返回客服处理
|
||||
|
||||
依据用户本轮确认及会话原型截图1(装车前三分区)、截图2/3(居中审核弹框及必填驳回意见)。保留现有列表、详情和居中弹框承载;首分区仍称“车辆信息”,即客服最初处理位置。
|
||||
|
||||
| 页面/操作 | 目标 | 验收 |
|
||||
|---|---|---|
|
||||
| 排车列表/审核分区 | 客服处理、调度待审、理货待审按服务端流转;任一驳回回车辆信息,显示环节与原因 | 定向回归通过;1920×1080人工截图待确认 |
|
||||
| 详情/维护车辆信息 | 展示当前退回原因,客服修改期间保留;重新提交后开启完整新审核轮次 | 两级退回原因与重提清除回归通过;人工截图待确认 |
|
||||
| 调度/理货审核弹框 | 驳回必填原因,成功提示退回客服;理货员双端审核共用服务端资格 | 必填校验、原命令恢复及成功提示回归通过;人工截图待确认 |
|
||||
|
||||
|
||||
## 2026-10-21 货物调整统一在智能排车
|
||||
|
||||
依据用户排车列表截图及本轮确认:去掉车辆信息中的添加货物入口,安排后的货物只在智能排车修改,保留理货员现场真实拣货。
|
||||
@@ -384,24 +311,3 @@
|
||||
本批未修改SmartDispatchDialog及其候选分页、入库计划、换标与退货出库业务逻辑。源码复查这些入口的既有承载与表单/表格布局,未凭推测批量改宽度或删除必要横向滚动。
|
||||
|
||||
公共缺口复核:此前AppDialog后代选择器可能连带命中二级弹框;当前公共写者已将header/body/footer改为直接子级选择器,代码层已收口,本域未修改公共样式;真实嵌套预览仍待人工截图确认。
|
||||
|
||||
|
||||
### 2026-10-21 FBA 正式确认缺项补充
|
||||
|
||||
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 入库单 | 新增 / 编辑 FBA 预约 | 用户本轮截图 Image 1:既有居中弹框;保持原尺寸与底部保存按钮 | 正式确认前按入仓码展示实际缺项;弹框内补齐箱数和货件资料,分别反馈资料保存与预约确认 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
|
||||
| 入库单 | 查看预约 | 同一弹框只读 | 展示缺项及用途,禁用资料补充 | 定向回归通过;待人工截图确认 |
|
||||
| 预约列表 | 新增 / 编辑 FBA 预约 | 用户截图 Image 2 与现有 WorkCreateAppointmentForm / WorkAppointmentForm 居中弹框 | 共用逐票资料补充;已有安排明确指向原预约编辑,保留原占用规则 | 定向回归通过;待人工截图确认 |
|
||||
|
||||
自动验证:本轮 Admin 全量初跑 469 文件、4333 项,4315 项通过;2 个关联文件的 18 项失败在修复后,由包含这两文件的 45 文件、426 项定向回归全部通过。最终补充文案与表单另行复测 11 项通过;最终 `pnpm build`(含应用类型检查)通过。未触发部署、未写测试环境数据;真实页面仍待人工截图确认。
|
||||
|
||||
### 2026-10-21 排车审核入口与附件
|
||||
|
||||
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表:调度审核、理货员审核 | 行操作 | 用户本轮确认只保留审核、详情;沿现有列表 | 去掉维护车辆信息及更多菜单,仅显示本环节审核与详情 | 本轮定向回归通过;待人工截图确认 |
|
||||
| 排车审核弹框 | 通过/驳回、上传附件 | 用户附件 codex-clipboard-249614d2-efac-4494-af47-090980981900.png;沿既有弹框 | 车辆图片、司机照片、驾驶证照片三类,原证据只读,本次附件随审核结果保存;未知结果重试原请求 | 本轮定向回归通过;待人工 1920×1080、100% 截图确认 |
|
||||
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
|
||||
|
||||
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import * as api from './dispatches'
|
||||
import { candidate, command, detail, loadingPreview, page } from '../dispatchTestFixtures'
|
||||
import type { CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
|
||||
import type { ArrangeDelivery, CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
|
||||
const body = { recordVersion: 0, idempotencyKey: 'original-key', reason: '核对原因' }
|
||||
const draft = () => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [], sources: [candidate()], expiresAt: '' })
|
||||
@@ -74,7 +74,7 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
|
||||
await api.createDispatch(request); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', request)
|
||||
})
|
||||
it.each(['cancel', 'remove', 'review', 'loading-review', 'return', 'loading', 'add', 'submit', 'draft-container', 'draft-select', 'draft-release', 'draft-convert', 'draft-snapshot'])('rejects missing/null/negative versions before %s requests and accepts zero', async action => {
|
||||
it.each(['cancel', 'remove', 'review', 'loading-review', 'return', 'loading', 'depart', 'arrange', 'add', 'submit', 'draft-container', 'draft-select', 'draft-release', 'draft-convert', 'draft-snapshot'])('rejects missing/null/negative versions before %s requests and accepts zero', async action => {
|
||||
const invoke = (value: unknown) => {
|
||||
const request = { ...body, recordVersion: value as Version }
|
||||
switch (action) {
|
||||
@@ -84,6 +84,8 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
case 'loading-review': return api.reviewDispatchLoading('PC/1', request as unknown as ReviewCommand)
|
||||
case 'return': return api.returnDispatchToReview('PC/1', request)
|
||||
case 'loading': return api.completeDispatchLoading('PC/1', request as unknown as LoadingComplete)
|
||||
case 'depart': return api.departDispatch('PC/1', { ...request, remark: null })
|
||||
case 'arrange': return api.transferDispatchToDelivery('PC/1', request as unknown as ArrangeDelivery)
|
||||
case 'add': return api.addDispatchCargo('PC/1', { ...request, cargo: [], batchAllocations: [] })
|
||||
case 'submit': return api.submitDispatchReview('PC/1', { ...request, remark: null })
|
||||
case 'draft-container': return api.createDispatchDraftContainer({ ...request, draftToken: 'draft', capacity: 30 })
|
||||
@@ -105,6 +107,14 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
await api.cancelDispatch('PC/1', body); await api.removeDispatchItem('PC/1', 'item/1', body)
|
||||
expect(vi.mocked(http.post).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1/cancel', '/platform/v1/last-leg/dispatches/PC%2F1/items/item%2F1/remove']); expect(http.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
it('preserves complete grouped arrangement evidence and returns an immutable command envelope', async () => {
|
||||
const outbound = (customerId: string) => ({ customerId, customerName: `客户${customerId}`, outboundNo: `OUT-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 2, palletCount: 1 })
|
||||
const delivery = (customerId: string) => ({ ...outbound(customerId), groupKey: customerId, deliveryNo: `DEL-${customerId}`, destination: candidate().destination })
|
||||
const data = { ...command('ARRANGE_DELIVERY'), result: { dispatchNo: 'PC/1', recordVersion: '9007199254740993', arrangementKey: 'arrange', cargoHash: 'cargo', arrangedAt: '2026-10-19T10:00:00Z', outbounds: [outbound('A'), outbound('B')], deliveries: [delivery('A'), delivery('B')] } }
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ data })
|
||||
const request: ArrangeDelivery = { ...body, recordVersion: '9007199254740993', cargoHash: 'cargo', loadingCompletionHash: 'loaded', groups: [] }
|
||||
expect(await api.transferDispatchToDelivery('PC/1', request)).toEqual(data); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', request)
|
||||
})
|
||||
it('keeps draft version, server token and source evidence and converts explicit groups atomically', async () => {
|
||||
const source = candidate().selection
|
||||
await api.lockDispatchDraftPallet({ ...body, draftToken: 'server-token', draftContainerId: '190000000000000011', source })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
|
||||
import { requireVersion, requireBatchVersions, type AddCargo, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
|
||||
import { normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
|
||||
import { requireVersion, requireBatchVersions, type AddCargo, type ArrangeDelivery, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
|
||||
import { normalizeArrangementPreview, normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type ArrangementPreviewWire, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
|
||||
export type { DispatchBatchReference, DispatchBatchAllocation } from '../dispatchContracts'
|
||||
|
||||
const base = '/platform/v1/last-leg/dispatches'
|
||||
@@ -38,6 +38,9 @@ export const reviewDispatch = (no: string, body: ReviewCommand) => postCommand(`
|
||||
export const reviewDispatchLoading = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/loading-review`, versioned(body))
|
||||
export const returnDispatchToReview = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/return-to-review`, versioned(body))
|
||||
export const completeDispatchLoading = (no: string, body: LoadingComplete) => postCommand(`${path(no)}/loading-complete`, versioned(body))
|
||||
export const departDispatch = (no: string, body: VersionCommand & { remark: string | null }) => postCommand(`${path(no)}/depart`, versioned(body))
|
||||
export const getDispatchArrangement = (no: string) => get<ArrangementPreviewWire>(`${path(no)}/delivery-arrangement`).then(normalizeArrangementPreview)
|
||||
export const transferDispatchToDelivery = (no: string, body: ArrangeDelivery) => postCommand(`${path(no)}/transfer-delivery`, versioned(body))
|
||||
export const getDispatchBolPdf = (no: string) => http.get<Blob, Blob>(`${path(no)}/prints/bol`, { responseType: 'blob' })
|
||||
|
||||
export async function listDispatchCandidates(query: CandidateQuery) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
<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>
|
||||
@@ -12,15 +12,6 @@ const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHIC
|
||||
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
||||
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
|
||||
it('keeps previously provided references read-only and only submits newly uploaded audit files', async () => {
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, modelValue: [], referenceIds: ['301'], kind: 'DRIVER_PHOTO', label: '司机照片' } }); await flushPromises()
|
||||
expect(wrapper.findAll('tbody button').map(item => item.text())).toEqual(['预览'])
|
||||
await selectFile(wrapper, new File(['pdf'], 'photo.pdf', { type: 'application/pdf' })); expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
||||
await selectFile(wrapper, new File(['png'], 'photo.png', { type: 'image/png' }))
|
||||
expect(api.uploadDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER_PHOTO', expect.any(File))
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('previews and replaces real string-size metadata through the JSON parser and API boundary', async () => {
|
||||
const actual = await vi.importActual<typeof import('../api/dispatches')>('../api/dispatches')
|
||||
const envelope = (fileId: string) => parseApiJsonResponse(JSON.stringify({ code: '200', data: { ...metadata(fileId), sizeBytes: '3' } }))
|
||||
|
||||
@@ -10,16 +10,13 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
|
||||
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
|
||||
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; label?: string; saveHint?: string; referenceIds?: string[] }>()
|
||||
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean }>()
|
||||
const ids = defineModel<string[]>({ required: true })
|
||||
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])])
|
||||
const emit = defineEmits<{ busy: [value: boolean] }>()
|
||||
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
|
||||
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
|
||||
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
|
||||
const label = computed(() => props.kind === 'VEHICLE' ? '本趟车辆证据' : '本趟司机证照')
|
||||
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
|
||||
const uploadTypes = computed(() => props.kind === 'DRIVER_PHOTO' ? types.slice(1) : types)
|
||||
const uploadFormats = computed(() => props.kind === 'DRIVER_PHOTO' ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
||||
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
|
||||
function valid(value: DispatchEvidenceFile, expected?: string) {
|
||||
if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取')
|
||||
@@ -28,7 +25,7 @@ function valid(value: DispatchEvidenceFile, expected?: string) {
|
||||
function fail(cause: unknown, fallback: string) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback) }
|
||||
function closePreview() { previewGeneration++; pendingPopup?.close(); pendingPopup = null; imageOpen.value = false; previewLoading.value = false; if (imageUrl.value) { URL.revokeObjectURL(imageUrl.value); urls.delete(imageUrl.value); imageUrl.value = '' } }
|
||||
async function load() {
|
||||
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value]
|
||||
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...ids.value]
|
||||
closePreview(); metadata.value = {}; error.value = null
|
||||
if (!no) return
|
||||
const results = await Promise.allSettled(values.map(async id => valid(await getDispatchEvidence(no, kind, id), id)))
|
||||
@@ -40,7 +37,7 @@ async function upload(event: Event) {
|
||||
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
|
||||
if (!file || props.readonly || props.disabled || uploading.value) return
|
||||
error.value = null
|
||||
if (!uploadTypes.value.includes(file.type) || file.size <= 0 || file.size > maximum) { fail(new DispatchInputError(`请选择10MiB以内的${uploadFormats.value}文件`), '附件不符合要求'); return }
|
||||
if (!types.includes(file.type) || file.size <= 0 || file.size > maximum) { fail(new DispatchInputError('请选择10MiB以内的PDF、JPG或PNG文件'), '附件不符合要求'); return }
|
||||
const current = generation, no = props.dispatchNo, kind = props.kind, replaceId = replacing
|
||||
uploading.value = true; emit('busy', true)
|
||||
try {
|
||||
@@ -74,15 +71,15 @@ async function preview(id: string) {
|
||||
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
|
||||
finally { if (current === previewGeneration) previewLoading.value = false }
|
||||
}
|
||||
watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value]), load, { immediate: true })
|
||||
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value]), load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
||||
</script>
|
||||
<template>
|
||||
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></h4>
|
||||
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></InlineHelp></h4>
|
||||
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="allIds.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in allIds" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly && ids.includes(id)"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||||
<table v-if="ids.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in ids" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||||
<p v-else class="density-dialog-note">暂无本趟附件。</p>
|
||||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" :accept="uploadTypes.join(',')" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
|
||||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
|
||||
<AppDialog append-to-body class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||||
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -2,27 +2,36 @@ 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, loadingPreview, resources } from '../dispatchTestFixtures'
|
||||
import type { DispatchAction, DispatchActions, DispatchEvidenceFile, UpdateDispatch } from '../dispatchContracts'
|
||||
import { command, detail, destination, loadingPreview, resources } from '../dispatchTestFixtures'
|
||||
import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts'
|
||||
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), 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() }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||
const actions: DispatchActions = { EDIT: gate, ADD_CARGO: gate, REMOVE_CARGO: gate, CANCEL: gate, SUBMIT_REVIEW: gate, DISPATCH_REVIEW: gate, LOADING_REVIEW: gate, RETURN_TO_REVIEW: gate, LOADING_COMPLETE: gate, ARRANGE_DELIVERY: gate, DEPART: gate }
|
||||
const evidence = (fileId = '301'): DispatchEvidenceFile => ({ fileId, fileName: fileId === '301' ? '旧附件.png' : '新附件.png', contentType: 'image/png', sizeBytes: 3 })
|
||||
function 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() {} })
|
||||
@@ -35,10 +44,12 @@ 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'))
|
||||
@@ -92,42 +103,12 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
|
||||
await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(3)
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('uploads three audit attachments with only %s permission and retries the same rejected review', async action => {
|
||||
can.mockImplementation(code => code === (action === 'DISPATCH_REVIEW' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review'))
|
||||
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
|
||||
vi.mocked(review).mockRejectedValueOnce(new Error('unknown review')).mockImplementationOnce(async (_, body) => command(action, body.idempotencyKey))
|
||||
const { wrapper, dialog } = await render(action, undefined, true)
|
||||
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(fields[0]!.props('referenceIds')).toEqual(['301'])
|
||||
expect(fields[0]!.findAll('button').some(button => button.text() === '替换')).toBe(false)
|
||||
for (const [index, files] of fields.entries()) {
|
||||
let resolve!: (value: DispatchEvidenceFile) => void
|
||||
vi.mocked(api.uploadDispatchEvidence).mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||
const input = files.get('input[type="file"]'), file = new File(['png'], 'audit.png', { type: 'image/png' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(dialog.props('confirmLoading')).toBe(true)
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
|
||||
resolve(evidence(String(401 + index))); await flushPromises()
|
||||
}
|
||||
expect(vi.mocked(api.uploadDispatchEvidence).mock.calls.map(call => call[1])).toEqual(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'])
|
||||
field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
|
||||
await dialog.get('textarea').setValue('需重新核对车牌')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const original = structuredClone(vi.mocked(review).mock.calls[0]![1])
|
||||
expect(original).toMatchObject({ decision: 'REJECTED', reason: '需重新核对车牌', attachments: { vehicleFileIds: ['401'], driverPhotoFileIds: ['402'], licenseFileIds: ['403'] } })
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original)
|
||||
expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
|
||||
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW', 'DEPART'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
|
||||
const { dialog } = await render(action)
|
||||
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
|
||||
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
|
||||
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : action === 'DEPART' ? api.departDispatch : api.submitDispatchReview
|
||||
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
|
||||
await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
@@ -165,6 +146,27 @@ 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, loadingPreview } from '../dispatchTestFixtures'
|
||||
import type { CommandResult, DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||
import { command, detail, destination, loadingPreview } from '../dispatchTestFixtures'
|
||||
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } 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(), 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() }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
||||
interface State { submit(): Promise<void>; 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: 'DELIVERING', recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'AFTER_LOADING', 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.completeDispatchLoading).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
expect(api.departDispatch).not.toHaveBeenCalled(); 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,37 +40,42 @@ 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.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))
|
||||
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))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
|
||||
await state.submit(); expect(reviewApi).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(reviewApi).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
|
||||
expect(success).toHaveBeenCalledWith('已退回客服处理(车辆信息),修改后需重新提交调度审核')
|
||||
await state.submit(); expect(api.reviewDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(api.reviewDispatch).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); 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('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()
|
||||
const { wrapper, state } = await open('DEPART', false); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
|
||||
})
|
||||
it('submits every customer destination group in one arrangement and does not waive another customer fee', async () => {
|
||||
const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2, 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('does not reuse another dispatch detail when the target changes while its dialog remains open', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
||||
const { wrapper, state } = await open('DEPART')
|
||||
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.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()
|
||||
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()
|
||||
})
|
||||
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,12 +10,13 @@ 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 { CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import type { ArrangeDelivery, ArrangeGroup, ArrangementPreview, 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, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
|
||||
import { canDispatchAction, dispatchActionLabels } 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 })
|
||||
@@ -30,29 +31,28 @@ 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)
|
||||
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
|
||||
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | 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: [] })
|
||||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||
const reviewAttachments = ref(emptyReviewAttachments())
|
||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
let generation = 0
|
||||
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'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY'].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_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DRIVER)
|
||||
|
||||
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
|
||||
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null
|
||||
route.value = null; stops.value = []
|
||||
try {
|
||||
if (cargoAction.value) return
|
||||
@@ -61,7 +61,6 @@ async function load() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
reviewAttachments.value = emptyReviewAttachments()
|
||||
const oldVehicle = value.vehicleInfo.vehicle
|
||||
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
|
||||
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
|
||||
@@ -85,7 +84,13 @@ 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 }
|
||||
}
|
||||
@@ -101,7 +106,7 @@ function payload(): object {
|
||||
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
return { ...common, decision: form.decision, reason: form.reason.trim() || null, ...(Object.values(reviewAttachments.value).some(ids => ids.length) ? { attachments: reviewAttachments.value } : {}) } satisfies ReviewCommand
|
||||
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
|
||||
case 'CANCEL': case 'RETURN_TO_REVIEW':
|
||||
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因')
|
||||
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
|
||||
@@ -110,6 +115,19 @@ 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 }
|
||||
}
|
||||
}
|
||||
@@ -123,7 +141,9 @@ 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('此操作不属于当前后台作业入口')
|
||||
}
|
||||
}
|
||||
@@ -138,10 +158,9 @@ 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(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? '装车完成,已进入派送中' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
|
||||
ElMessage.success(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 ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
|
||||
@@ -182,7 +201,6 @@ 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 />
|
||||
@@ -199,7 +217,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>
|
||||
@@ -208,17 +226,16 @@ 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' && currentAction === 'EDIT'">
|
||||
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</template>
|
||||
<template v-else-if="['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" v-model="reviewAttachments[field.key]" :reference-ids="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" save-hint="上传后提交审核,附件与本次审核结果一起保存。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy[field.kind] = $event" />
|
||||
<template v-if="currentOperation !== 'ROUTE' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
|
||||
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</template>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['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 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-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>
|
||||
</el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -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="托盘明细"] tr[draggable]') }
|
||||
function pallet(wrapper: VueWrapper) { return wrapper.get('[aria-label="托盘明细"] article') }
|
||||
function transfer() { return { setData: vi.fn(), getData: vi.fn(() => '{"sourceKey":"forged","containerId":"car-2"}'), effectAllowed: '', dropEffect: '' } }
|
||||
|
||||
describe('smart dispatch three-column cargo board', () => {
|
||||
@@ -26,7 +26,6 @@ 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)
|
||||
@@ -87,7 +86,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="托盘明细"] tr[draggable]')).toHaveLength(1)
|
||||
expect(wrapper.findAll('[aria-label="托盘明细"] article')).toHaveLength(1)
|
||||
const group = pallet(wrapper)
|
||||
expect(group.text()).toContain('整托关联货物一起加入')
|
||||
expect(group.findAll('details li')).toHaveLength(2)
|
||||
@@ -135,7 +134,7 @@ describe('smart dispatch three-column cargo board', () => {
|
||||
expect(wrapper.emitted('changePage')).toEqual([[2]])
|
||||
expect(wrapper.get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
|
||||
await wrapper.setProps({ page: 2, candidates: [candidate({ orderNo: 'NEXT-PAGE', palletNo: 'NEXT-PALLET' })] })
|
||||
expect(wrapper.get('[aria-label="待排订单"]').text()).not.toContain('货物分页,同一订单可能分布在多页')
|
||||
expect(wrapper.get('[aria-label="待排订单"]').text()).toContain('货物分页,同一订单可能分布在多页')
|
||||
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
|
||||
expect(wrapper.findAll('[aria-label="车辆装载"] article')).toHaveLength(2)
|
||||
})
|
||||
@@ -157,7 +156,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="托盘明细"] tr[draggable]')).toHaveLength(2)
|
||||
expect(wrapper.findAll('[aria-label="托盘明细"] article')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses independent vehicle radio groups when two entrypoint dialogs are mounted', () => {
|
||||
@@ -174,8 +173,7 @@ describe('smart dispatch three-column cargo board', () => {
|
||||
slots: { vehicles: ({ dragOver, drop }: { dragOver: (event: DragEvent, id: string) => void; drop: (event: DragEvent, id: string) => void }) => h('section', { 'aria-label': '真实本车清单', onDragover: (event: DragEvent) => dragOver(event, 'PC/1'), onDrop: (event: DragEvent) => drop(event, 'PC/1') }, 'PALLET-EXISTING') },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
expect(wrapper.find('[aria-label="车辆装载"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[aria-label="车辆装载"]').text()).not.toContain('添加车辆')
|
||||
expect(wrapper.find('[aria-label="车辆装载"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('PALLET-EXISTING')
|
||||
await select(wrapper)
|
||||
const dataTransfer = transfer()
|
||||
|
||||
@@ -161,8 +161,7 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
<template>
|
||||
<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></header>
|
||||
<div class="cargo-board-order-content">
|
||||
<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-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,37 +177,28 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer class="cargo-board-pagination"><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>
|
||||
</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-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>
|
||||
<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>
|
||||
|
||||
<section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载">
|
||||
<header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><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></template></slot></header>
|
||||
<div class="cargo-board-vehicle-content">
|
||||
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
|
||||
<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>
|
||||
<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>
|
||||
@@ -223,25 +213,19 @@ 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, 44fr) minmax(0, 22fr) minmax(0, 34fr); grid-template-rows: auto auto; align-items: stretch; gap: var(--admin-space-5) var(--admin-space-6); }
|
||||
.cargo-board-panel { display: grid; grid-row: 1 / span 2; grid-template-rows: subgrid; align-items: start; 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 { align-self: stretch; 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; }
|
||||
.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; }
|
||||
.cargo-board-heading h3 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.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-heading > .el-button { margin-left: auto; }
|
||||
.cargo-board-muted { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.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-wrap { overflow: auto; max-height: 48vh; }
|
||||
.cargo-board-table { width: 100%; table-layout: fixed; }
|
||||
.cargo-board-table td { overflow-wrap: anywhere; }
|
||||
.cargo-board-table th:nth-child(1) { width: 82px; }
|
||||
@@ -251,21 +235,22 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
.cargo-board-table tbody tr { cursor: pointer; }
|
||||
.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; align-items: center; padding-top: var(--admin-space-5); overflow-x: auto; }
|
||||
.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-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 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: 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-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.is-draggable { cursor: grab; }
|
||||
.cargo-board-pallet.is-assigned td { background: var(--admin-label-bg); }
|
||||
.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-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); }
|
||||
.cargo-board-block { color: var(--admin-action-danger); font-size: var(--admin-font-caption); margin: var(--admin-space-2) 0; }
|
||||
.cargo-board-vehicles { min-width: 0; }
|
||||
.cargo-board-vehicle-list { display: flex; flex-direction: column; gap: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
|
||||
.cargo-board-vehicle { border: 2px dashed var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface); }
|
||||
.cargo-board-vehicle.is-current { border-color: var(--admin-action-primary-border); }
|
||||
|
||||
@@ -46,67 +46,11 @@ beforeEach(() => {
|
||||
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
|
||||
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
|
||||
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
async function selectWarehouse(dialog: VueWrapper) { const select = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!; select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
|
||||
async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
|
||||
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
|
||||
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '继续待完成排车')!.vm.$emit('change', 'server-token'); await flushPromises() }
|
||||
async function selectFirstOrder(dialog: VueWrapper) { await view(dialog).get('[aria-label="待排订单"] tbody tr button').trigger('click'); await flushPromises() }
|
||||
describe('new dispatch native layout', () => {
|
||||
it('selects the first warehouse and loads its cargo immediately without creating a draft', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }])
|
||||
const { dialog } = await render()
|
||||
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
|
||||
expect(picker.props('modelValue')).toBe('321')
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '321', page: 1 }))
|
||||
expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps the warehouse empty and skips cargo loading when no warehouse is available', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
|
||||
const { dialog } = await render()
|
||||
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(view(dialog).text()).toContain('请先选择起运仓')
|
||||
})
|
||||
it('retains the restored draft warehouse instead of selecting the first warehouse on reopen', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }])
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([{ ...draft(), warehouseId: '654', sources: [], containers: [] }])
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 0, page: 1, size: 20, pages: 0, candidates: [], complete: true, snapshotAt: '' })
|
||||
const { wrapper, dialog } = await render()
|
||||
await restore(dialog)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654' }))
|
||||
})
|
||||
|
||||
it('keeps the restored workbench controls together and shows the selected warehouse tab', async () => {
|
||||
const { dialog } = await render()
|
||||
const content = view(dialog)
|
||||
const searchPanel = content.get('[aria-label="候选货物查询"]')
|
||||
expect(searchPanel.find('.el-form-item__label').exists()).toBe(false)
|
||||
expect(searchPanel.find('[aria-label="起运仓"]').exists()).toBe(true)
|
||||
expect(searchPanel.find('input[placeholder="订单号/入仓码"]').exists()).toBe(true)
|
||||
const settingsToggle = searchPanel.findAll('button').find(button => button.text() === '排车设置')!
|
||||
expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(false)
|
||||
await settingsToggle.trigger('click')
|
||||
expect(settingsToggle.attributes('aria-expanded')).toBe('true')
|
||||
expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(true)
|
||||
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: [] }] }
|
||||
@@ -154,7 +98,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()).not.toContain('admin-density-workspace')
|
||||
expect(view(dialog).classes()).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('选择起运仓后点击')
|
||||
|
||||
@@ -89,7 +89,7 @@ async function selectOrder(dialog: VueWrapper, palletNo: string) {
|
||||
await row.get('button').trigger('click'); await flushPromises()
|
||||
}
|
||||
async function warehouseSelected(dialog: VueWrapper) {
|
||||
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
|
||||
const picker = field(dialog, '起运仓').getComponent({ name: 'ElSelect' })
|
||||
picker.vm.$emit('update:modelValue', '321'); picker.vm.$emit('change', '321'); await flushPromises()
|
||||
}
|
||||
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click'); await flushPromises() }
|
||||
|
||||
@@ -94,8 +94,7 @@ it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restor
|
||||
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('预约批次版本缺失或无效,请重新读取候选')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it('requires a warehouse when the directory is empty and allows saving a true empty dispatch with no fake cargo', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
|
||||
it('requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo', async () => {
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { mode: string; warehouseId: string; submit: () => Promise<void> }
|
||||
state.mode = 'empty'; await wrapper.getComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.createDispatch).not.toHaveBeenCalled()
|
||||
|
||||
@@ -32,7 +32,6 @@ 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)
|
||||
@@ -117,7 +116,6 @@ async function init() {
|
||||
if (!warehouseId.value) {
|
||||
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
|
||||
if (selectedWarehouses.size === 1 && directory.some(row => String(row.id) === [...selectedWarehouses][0])) warehouseId.value = [...selectedWarehouses][0]!
|
||||
else if (directory[0]) warehouseId.value = String(directory[0].id)
|
||||
}
|
||||
if (warehouseId.value) await loadCandidates(sequence)
|
||||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
|
||||
@@ -234,45 +232,46 @@ 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-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-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">
|
||||
<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>
|
||||
</div>
|
||||
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
|
||||
<template v-else-if="workspace === 'create'">
|
||||
<div class="density-dialog-sections dispatch-workspace">
|
||||
<div class="density-dialog-sections">
|
||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
|
||||
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
|
||||
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
|
||||
<div class="dispatch-candidate-query">
|
||||
<el-select class="dispatch-warehouse-picker" aria-label="起运仓" 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>
|
||||
<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="排车设置">
|
||||
<section class="density-dialog-section dispatch-setup" aria-label="排车设置">
|
||||
<h3 class="density-dialog-section__title">1. 排车设置<InlineHelp title="在途预排说明"><p>货物离港并生成入库计划后,即可安排货物并生成排车单。车辆和司机在建单后维护;实际装车仍须完成海外仓实收和 PDA 拣货核平。</p></InlineHelp></h3>
|
||||
<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'">
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
@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>
|
||||
<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' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆信息”补充。</p></InlineHelp></h3>
|
||||
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '2. 计划时间与备注' : '3. 第 ' + 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>
|
||||
@@ -286,17 +285,15 @@ 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-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
|
||||
.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-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-warehouse-picker { width: 100%; }
|
||||
.dispatch-draft-tools { flex-wrap: wrap; align-items: center; }
|
||||
.dispatch-draft-picker { width: 240px; }
|
||||
.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 > .dispatch-warehouse-picker { width: 200px; }
|
||||
.dispatch-candidate-query > .el-button { margin-left: 0; }
|
||||
.dispatch-workspace { margin-top: var(--admin-space-5); }
|
||||
.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); }
|
||||
</style>
|
||||
|
||||
@@ -35,20 +35,6 @@ beforeEach(() => {
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: [candidate()], page: query.page || 1, size: query.size || 20, total: 1, pages: 1, complete: true, snapshotAt: '' }))
|
||||
})
|
||||
|
||||
it('keeps both searches in one panel and the manifest inside the shared board column', async () => {
|
||||
const wrapper = await open()
|
||||
const search = wrapper.get('[aria-label="已有排车查询"]')
|
||||
expect(search.find('[aria-label="选择已有排车"]').exists()).toBe(true)
|
||||
expect(search.text()).not.toContain('查询排车')
|
||||
expect(search.find('input[aria-label="查找可排货物"]').exists()).toBe(true)
|
||||
expect(search.findAll('.existing-cargo-query > label')).toHaveLength(0)
|
||||
const column = wrapper.get('[aria-label="车辆装载"]')
|
||||
expect(column.get(':scope > .cargo-board-heading').text()).toContain('本车货物 · PC/1')
|
||||
expect(column.find(':scope > .cargo-board-vehicle-content [aria-label="当前排车货物"]').exists()).toBe(true)
|
||||
expect(wrapper.get('.dispatch-cargo-board').findAll(':scope > section > .cargo-board-heading')).toHaveLength(3)
|
||||
expect(column.text()).not.toContain('添加车辆')
|
||||
})
|
||||
|
||||
it('adds the complete hidden shared-pallet group through real board buttons, then reloads current versions', async () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size === 100 ? [first!, second!] : [first!], page: query.page!, size: query.size!, total: 2, pages: 1, complete: true, snapshotAt: '' }))
|
||||
@@ -191,17 +177,15 @@ it('does not let a late previous dispatch read overwrite the selected dispatch',
|
||||
const wrapper = await open()
|
||||
await wrapper.setProps({ dispatchNo: 'PC/2' }); await flushPromises()
|
||||
resolve(current()); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).toContain('PC/2')
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).not.toContain('PC/1')
|
||||
expect(wrapper.get('[aria-label="当前排车货物"]').text()).toContain('PC/2')
|
||||
expect(wrapper.get('[aria-label="当前排车货物"]').text()).not.toContain('PC/1')
|
||||
})
|
||||
|
||||
it('queries and paginates active before-loading dispatches using the entered business number', async () => {
|
||||
const wrapper = await open(null)
|
||||
vi.mocked(api.listDispatches).mockImplementation(async query => ({ ...page([listRow({ taskType: 'DELIVERY' })]), page: query.page, size: query.size, total: 22, pages: 3 }))
|
||||
const picker = wrapper.getComponent({ name: 'ElSelect' })
|
||||
expect(picker.props('remote')).toBe(true)
|
||||
await picker.get('.el-select__wrapper').trigger('click'); await flushPromises()
|
||||
await picker.props('remoteMethod')('PC2026'); await flushPromises()
|
||||
await wrapper.getComponent({ name: 'ElPagination' }).get('button.btn-next').trigger('click'); await flushPromises()
|
||||
await wrapper.get('input[aria-label="查找已有排车"]').setValue('PC2026')
|
||||
await button(wrapper, '查询排车').trigger('click'); await flushPromises()
|
||||
await wrapper.get('[aria-label="已有排车分页"] button.btn-next').trigger('click'); await flushPromises()
|
||||
expect(api.listDispatches).toHaveBeenLastCalledWith({ page: 2, size: 10, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: 'PC2026' })
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ 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'
|
||||
@@ -24,7 +23,7 @@ const error = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = useDispatchCandidates(error)
|
||||
const { candidates, page: cargoPage, size: cargoSize, total: cargoTotal } = candidatePage
|
||||
const rows = ref<DispatchListRow[]>([]), page = ref(1), size = ref(10), total = ref(0)
|
||||
const queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
|
||||
const keyword = ref(''), queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
|
||||
const selectedNo = ref(''), warehouseId = ref(''), warehouseName = ref('')
|
||||
const detail = ref<DispatchDetail | null>(null), items = ref<DispatchItems | null>(null)
|
||||
const reading = ref(false), listing = ref(false), preparing = ref(false), stale = ref(true)
|
||||
@@ -54,9 +53,9 @@ async function loadList() {
|
||||
} catch (cause) { if (alive && sequence === listGeneration) { rows.value = []; report(cause, '排车列表读取失败') } }
|
||||
finally { if (alive && sequence === listGeneration) listing.value = false }
|
||||
}
|
||||
async function searchDispatches(value: string) {
|
||||
async function searchDispatches() {
|
||||
if (frozen.value) return
|
||||
queriedKeyword.value = value.trim(); page.value = 1
|
||||
queriedKeyword.value = keyword.value.trim(); page.value = 1
|
||||
await loadList()
|
||||
}
|
||||
async function changePage(value: number) { if (!frozen.value) { page.value = value; await loadList() } }
|
||||
@@ -203,29 +202,25 @@ 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-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="输入单号搜索并选择排车" filterable remote :remote-method="searchDispatches" :loading="listing" :disabled="frozen" @update:model-value="(value: string) => selectDispatch(value, rows.find(row => row.dispatchNo === value))">
|
||||
<el-input v-model="keyword" aria-label="查找已有排车" placeholder="排车单号" :disabled="frozen || listing" @keyup.enter="searchDispatches" />
|
||||
<el-button :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 />
|
||||
<template #footer><el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" /></template>
|
||||
</el-select>
|
||||
|
||||
<template v-if="selectedNo"><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></template>
|
||||
<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>
|
||||
|
||||
</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">
|
||||
@@ -241,15 +236,12 @@ onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidate
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.existing-cargo { margin-top: var(--admin-space-5); }
|
||||
.existing-cargo-search { display: flex; flex-direction: column; gap: var(--admin-space-5); }
|
||||
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.existing-cargo-query > .el-button { margin-left: 0; }
|
||||
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
|
||||
.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-title { margin: 0; font-size: var(--admin-font-section); overflow-wrap: anywhere; }
|
||||
.existing-cargo-manifest h3 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.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; }
|
||||
|
||||
@@ -3,19 +3,10 @@ import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useDispatchCommandRecovery } from './dispatchCommandRecovery'
|
||||
import { command } from './dispatchTestFixtures'
|
||||
beforeEach(() => sessionStorage.clear())
|
||||
it('does not accept an intermediate loading stage as completion of the original command', async () => {
|
||||
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
|
||||
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
|
||||
const read = vi.fn()
|
||||
await expect(recovery.execute(vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'AFTER_LOADING' }), read, vi.fn())).rejects.toThrow('派送中')
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
expect(recovery.pending.value?.result).toBeNull()
|
||||
expect(recovery.pending.value?.body.idempotencyKey).toBe('key')
|
||||
})
|
||||
it('retains the exact original version and body when POST outcome is unknown and a newer detail was read', async () => {
|
||||
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
|
||||
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
|
||||
const send = vi.fn().mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(command('LOADING_COMPLETE'))
|
||||
recovery.begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
|
||||
const send = vi.fn().mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(command('DEPART'))
|
||||
const read = vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 50 }), find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
|
||||
await expect(recovery.execute(send, read, find)).rejects.toThrow('network unknown')
|
||||
await recovery.execute(send, read, find)
|
||||
@@ -27,7 +18,7 @@ it('keeps a proven result across GET failure and does not send the command a sec
|
||||
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
|
||||
const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn()
|
||||
await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed')
|
||||
expect(recovery.pending.value?.result?.stage).toBe('DELIVERING')
|
||||
expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING')
|
||||
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!).result).toEqual(command('LOADING_COMPLETE'))
|
||||
const reopened = useDispatchCommandRecovery(() => owner)
|
||||
expect(reopened.pending.value?.result).toEqual(command('LOADING_COMPLETE'))
|
||||
@@ -60,15 +51,15 @@ it('does not discard an earlier unknown write on a later business rejection', as
|
||||
})
|
||||
it('rejects a mismatched business number/action/key instead of treating it as successful', async () => {
|
||||
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
|
||||
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
|
||||
recovery.begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'key' })
|
||||
const read = vi.fn()
|
||||
await expect(recovery.execute(vi.fn().mockResolvedValue(command('LOADING_COMPLETE', 'different')), read, vi.fn())).rejects.toThrow('不一致')
|
||||
await expect(recovery.execute(vi.fn().mockResolvedValue(command('DEPART', 'different')), read, vi.fn())).rejects.toThrow('不一致')
|
||||
expect(read).not.toHaveBeenCalled(); expect(recovery.pending.value).not.toBeNull()
|
||||
})
|
||||
it('does not release a proven result for an older detail version, including versions beyond safe integer precision', async () => {
|
||||
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
|
||||
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: '9007199254740992', idempotencyKey: 'key' })
|
||||
const send = vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), recordVersion: '9007199254740993' }), find = vi.fn()
|
||||
recovery.begin('PC/1', 'DEPART', { recordVersion: '9007199254740992', idempotencyKey: 'key' })
|
||||
const send = vi.fn().mockResolvedValue({ ...command('DEPART'), recordVersion: '9007199254740993' }), find = vi.fn()
|
||||
await expect(recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740992' }), find)).rejects.toThrow('详情版本早于')
|
||||
expect(recovery.pending.value?.result).not.toBeNull()
|
||||
await recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740994' }), find)
|
||||
|
||||
@@ -15,7 +15,8 @@ export function validateCommandResult(result: CommandResult, pending: PendingDis
|
||||
|| result.action !== pending.action || result.idempotencyKey !== pending.body.idempotencyKey
|
||||
|| !result.dispatchNo || !result.result) throw new Error('操作结果与原排车请求不一致,请保留原请求核对')
|
||||
requireVersion(result.recordVersion)
|
||||
if (pending.action === 'LOADING_COMPLETE' && result.stage !== 'DELIVERING') throw new Error('装车结果未确认派送中,请核对原请求')
|
||||
if (pending.action === 'DEPART' && result.stage !== 'DELIVERING') throw new Error('发车结果未确认派送中,请核对原请求')
|
||||
if (pending.action === 'LOADING_COMPLETE' && result.stage !== 'AFTER_LOADING') throw new Error('装车结果未确认装车后,请核对原请求')
|
||||
}
|
||||
export function validateCurrentDispatch(value: unknown, result: CommandResult) {
|
||||
const current = value as { dispatchNo?: string; recordVersion?: unknown } | null
|
||||
|
||||
@@ -17,8 +17,7 @@ export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null;
|
||||
export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null }
|
||||
export interface Resources { vehicle: VehicleSnapshot | null; driver: Driver | null }
|
||||
export interface Destination { key: string; type: string; warehouseId: string | null; warehouseCode: string | null; warehouseName: string | null; address: string | null; postalCode: string | null; contactName: string | null; contactPhone: string | null }
|
||||
export interface ReviewAttachments { vehicleFileIds: string[]; driverPhotoFileIds: string[]; licenseFileIds: string[] }
|
||||
export interface Review { status: ReviewStatus; reason: string | null; reviewerId: string | null; reviewedAt: string | null; reviewRound: Version; basisHash: string | null; invalidatedReason: string | null; attachments?: ReviewAttachments }
|
||||
export interface Review { status: ReviewStatus; reason: string | null; reviewerId: string | null; reviewedAt: string | null; reviewRound: Version; basisHash: string | null; invalidatedReason: string | null }
|
||||
export interface Reviews { dispatchReview: Review; loadingReview: Review }
|
||||
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }>; recordVersion: Version }
|
||||
export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number; recordVersion: Version }
|
||||
@@ -38,7 +37,7 @@ export interface VersionCommand { recordVersion: Version; idempotencyKey: string
|
||||
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
|
||||
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||
export interface ReasonCommand extends VersionCommand { reason: string }
|
||||
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null; attachments?: ReviewAttachments }
|
||||
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
|
||||
export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number }
|
||||
export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
|
||||
export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
|
||||
@@ -53,7 +52,7 @@ export interface DispatchItems { dispatchNo: string; recordVersion: Version; sta
|
||||
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
|
||||
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
|
||||
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
|
||||
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
|
||||
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER'
|
||||
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }
|
||||
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
|
||||
@@ -81,7 +80,7 @@ export type CommandResult = CommandBase & (
|
||||
|
||||
export const dispatchStages: Array<{ value: DispatchStage; label: string }> = [
|
||||
{ value: 'BEFORE_LOADING', label: '装车前' }, { value: 'LOADING', label: '装车中' },
|
||||
{ value: 'DELIVERING', label: '派送中' }, { value: 'DELIVERED', label: '派送完成' },
|
||||
{ value: 'AFTER_LOADING', label: '装车后' }, { value: 'DELIVERING', label: '派送中' }, { value: 'DELIVERED', label: '派送完成' },
|
||||
]
|
||||
export const stageLabel = (stage: DispatchStage) => dispatchStages.find(row => row.value === stage)?.label || '—'
|
||||
export const reviewLabels: Record<ReviewStatus, string> = { NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
import type { DispatchAction, DispatchActions, DispatchDetail, ReviewAttachments, Reviews } from './dispatchContracts'
|
||||
export const dispatchReviewAttachmentFields = [
|
||||
{ key: 'vehicleFileIds', kind: 'VEHICLE', label: '车辆图片' },
|
||||
{ key: 'driverPhotoFileIds', kind: 'DRIVER_PHOTO', label: '司机照片' },
|
||||
{ key: 'licenseFileIds', kind: 'DRIVER', label: '驾驶证照片' },
|
||||
] as const
|
||||
export const emptyReviewAttachments = (): ReviewAttachments => ({ vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: [] })
|
||||
export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAttachments {
|
||||
const result = emptyReviewAttachments()
|
||||
if (!detail) return result
|
||||
result.vehicleFileIds = [...(detail.vehicleInfo.vehicle?.evidenceFileIds || [])]
|
||||
result.licenseFileIds = [...(detail.vehicleInfo.driver?.evidence?.fileIds || [])]
|
||||
for (const { key } of dispatchReviewAttachmentFields) {
|
||||
result[key] = [...new Set([...result[key], ...(detail.reviews.dispatchReview.attachments?.[key] || []), ...(detail.reviews.loadingReview.attachments?.[key] || [])])]
|
||||
}
|
||||
return result
|
||||
}
|
||||
import type { DispatchAction, DispatchActions } from './dispatchContracts'
|
||||
export const dispatchActionLabels: Record<DispatchAction, string> = {
|
||||
CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
||||
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
||||
@@ -28,11 +12,5 @@ export const dispatchActionPermissions: Record<DispatchAction, string> = {
|
||||
RETURN_TO_REVIEW: 'platform:overseas-warehouse:dispatch:edit', PICK: 'lastleg:pda:operate',
|
||||
LOADING_COMPLETE: 'platform:overseas-warehouse:dispatch:loading-complete', ARRANGE_DELIVERY: 'platform:overseas-warehouse:dispatch:transfer-delivery', DEPART: 'platform:overseas-warehouse:dispatch:depart',
|
||||
}
|
||||
export const canDispatchAction = (actions: DispatchActions | undefined, action: DispatchAction, can: (code: string) => boolean) => !['ARRANGE_DELIVERY', 'DEPART'].includes(action) && actions?.[action]?.allowed === true && can(dispatchActionPermissions[action])
|
||||
export const canDispatchAction = (actions: DispatchActions | undefined, action: DispatchAction, can: (code: string) => boolean) => actions?.[action]?.allowed === true && can(dispatchActionPermissions[action])
|
||||
export const displayDispatchValue = (value: unknown) => value === null || value === undefined || value === '' ? '—' : String(value)
|
||||
export function dispatchRejectionReason(reviews: Reviews | undefined): string {
|
||||
if (!reviews) return ''
|
||||
return ([['调度审核', reviews.dispatchReview], ['理货员审核', reviews.loadingReview]] as const)
|
||||
.filter(([, review]) => review.status === 'REJECTED')
|
||||
.map(([label, review]) => `${label}退回:${review.reason?.trim() || '原因未提供'}`).join(';')
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListR
|
||||
export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
|
||||
export function candidate(overrides: Partial<Candidate> = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } }
|
||||
export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
|
||||
const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'DELIVERING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
|
||||
const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
|
||||
return { ...base, result: action === 'DEPART' ? { departedAt: '2026-10-19T10:00:00Z', shippedPackages: 2, outbounds: [], deliveries: [] } : action === 'LOADING_COMPLETE' ? { loadingCompletionHash: 'load-hash', loadingCompletedAt: '2026-10-19T10:00:00Z', confirmedLoad: { manifestHash: 'manifest', pallets: [], operatorId: 'operator', confirmedAt: '2026-10-19T10:00:00Z' } } : { dispatchNo: no, changedItemIds: [] } } as CommandResult
|
||||
}
|
||||
export function loadingPreview(): LoadingPreview { return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', manifestHash: 'manifest', pickingReconciled: true, resources: resources(), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 2, palletSpaces: 1, weightKg: null, volumeCbm: null, deliveryIndex: 1, missingFields: ['weightKg', 'volumeCbm'], evidence: [{ field: 'weightKg', sourceType: 'ORDER_SNAPSHOT_ALLOCATION', sourceBusinessNo: 'IN-1', observedAt: null, value: 100, actual: false }] }], confirmedLoad: null, loadingComplete: { allowed: true, blockCode: null, blockReason: null }, missingFields: ['weightKg', 'volumeCbm'] } }
|
||||
|
||||
@@ -10,8 +10,8 @@ describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
const widths: Record<string, number> = {
|
||||
'LastMileOrdersView.vue': 330,
|
||||
'InboundOrdersView.vue': 300,
|
||||
// Primary action and detail plus the overflow menu; preserve space for business fields.
|
||||
'DispatchesView.vue': 220,
|
||||
// Three compact RowActions plus the overflow menu; long business numbers use the saved space.
|
||||
'DispatchesView.vue': 260,
|
||||
'OutboundsView.vue': 180,
|
||||
'DeliveryOrdersView.vue': 300,
|
||||
'RelabelOrdersView.vue': 180,
|
||||
@@ -24,7 +24,6 @@ describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
expect(columns).toMatch(new RegExp(`key: 'actions',[\\s\\S]*?label: '操作',[\\s\\S]*?width: ${width},[\\s\\S]*?fixed: 'left'`))
|
||||
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("label:", columns.indexOf("key: 'actions'") + 20))
|
||||
}
|
||||
expect(page('DispatchesView.vue')).toMatch(/<RowActions\b[^>]*:max-visible="2"/)
|
||||
})
|
||||
|
||||
it('keeps all inbound-order row actions on one line', () => {
|
||||
|
||||
@@ -19,31 +19,6 @@ vi.mock('@/modules/overseas-master-data', async () => ({
|
||||
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true, TransportExecutionPanel: true } } }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
||||
describe('whole-dispatch four-tab detail', () => {
|
||||
it('shows saved review attachments beside resource evidence without providing upload controls', async () => {
|
||||
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||
value.reviews.dispatchReview.attachments = { vehicleFileIds: ['301', '302'], driverPhotoFileIds: ['303'], licenseFileIds: [] }
|
||||
value.reviews.loadingReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: ['304'] }
|
||||
vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
vi.mocked(getDispatchEvidence).mockImplementation(async (_, __, fileId) => ({ fileId, fileName: `审核附件${fileId}.png`, contentType: 'image/png', sizeBytes: 3 }))
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(vi.mocked(getDispatchEvidence).mock.calls).toEqual([['PC/1', 'VEHICLE', '301'], ['PC/1', 'VEHICLE', '302'], ['PC/1', 'DRIVER_PHOTO', '303'], ['PC/1', 'DRIVER', '304']])
|
||||
expect(wrapper.findAll('input[type="file"]')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('司机照片')
|
||||
expect(wrapper.text()).toContain('审核附件304.png')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['dispatchReview', 'loadingReview'] as const)('keeps the %s rejection readable in the detail', async reviewKey => {
|
||||
const value = detail()
|
||||
value.reviews[reviewKey] = { ...value.reviews[reviewKey], status: 'REJECTED', reason: '司机资料需要更正', invalidatedReason: '资源已修改,待重新提交' }
|
||||
vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已退回客服处理(车辆信息)')
|
||||
expect(wrapper.text()).toContain('司机资料需要更正')
|
||||
vi.mocked(getDispatch).mockResolvedValue(detail({ recordVersion: 2 }))
|
||||
wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('司机资料需要更正')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps cargo additions and removals out of the detail even when authorized', async () => {
|
||||
can.mockReturnValue(true)
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||
@@ -126,18 +101,18 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
})
|
||||
it('keeps the allowed Admin action without presenting unrelated stage or PDA permission refusals as page warnings', async () => {
|
||||
can.mockReturnValue(true)
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: {
|
||||
LOADING_COMPLETE: { allowed: true, blockCode: null, blockReason: null },
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'AFTER_LOADING', actions: {
|
||||
ARRANGE_DELIVERY: { allowed: true, blockCode: null, blockReason: null },
|
||||
EDIT: { allowed: false, blockCode: 'STAGE', blockReason: '当前阶段或作业事实不满足操作条件' },
|
||||
PICK: { allowed: false, blockCode: 'PERMISSION', blockReason: '当前无此操作权限' },
|
||||
} }))
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(wrapper.text()).toContain('装车中')
|
||||
expect(wrapper.text()).toContain('装车后')
|
||||
expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件')
|
||||
expect(wrapper.text()).not.toContain('当前无此操作权限')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆信息')).toBe(false)
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认装车完成')!.trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, dispatchNo: 'PC/1', action: 'LOADING_COMPLETE' })
|
||||
await wrapper.findAll('button').find(button => button.text() === '安排派送')!.trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, dispatchNo: 'PC/1', action: 'ARRANGE_DELIVERY' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains a real attachment A0404 failure and its request diagnostics in the vehicle tab', async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||
import { stageLabel } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import { canDispatchAction, dispatchActionLabels, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||||
@@ -24,11 +24,10 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
|
||||
const detail = ref<DispatchDetail | null>(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref<ApiErrorDetails | null>(null)
|
||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
|
||||
const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
|
||||
const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
|
||||
const visibleActions = computed(() => detailActions.filter(value => canDispatchAction(detail.value?.actions, value, access.can)))
|
||||
const vehicleStatus = computed(() => {
|
||||
const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus
|
||||
@@ -86,9 +85,8 @@ onBeforeUnmount(() => { generation++ })
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" @saved="load" />
|
||||
<section v-else-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" readonly /><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></el-tab-pane>
|
||||
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
|
||||
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
|
||||
|
||||
@@ -20,22 +20,10 @@ vi.mock('@/modules/overseas-master-data', () => ({ TransportRouteDialog: { templ
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchBolPdf: vi.fn() }))
|
||||
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true } } }
|
||||
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
|
||||
it('never offers a second arrangement or departure after loading completion', async () => {
|
||||
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ stage: 'DELIVERING', actions: { ARRANGE_DELIVERY: gate, DEPART: gate } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
expect(wrapper.findAll('[role="tab"]').some(tab => tab.text().startsWith('装车后'))).toBe(false)
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
const keys = (row.props('actions') as Array<{ key: string }>).map(action => action.key)
|
||||
expect(keys).not.toContain('ARRANGE_DELIVERY'); expect(keys).not.toContain('DEPART')
|
||||
row.vm.$emit('action', 'DEPART'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('offers recovery of the original pending command independently of the filtered list rows', async () => {
|
||||
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'original-key' })
|
||||
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
expect(wrapper.text()).toContain('PC-OTHER 的确认装车完成结果尚待核对')
|
||||
expect(wrapper.text()).toContain('PC-OTHER 的确认发车结果尚待核对')
|
||||
await wrapper.findAll('button').find(button => button.text() === '核对原请求')!.trigger('click')
|
||||
const dialog = wrapper.get('dispatch-workflow-dialog-stub')
|
||||
expect(dialog.attributes('dispatchno')).toBe('PC-OTHER'); expect(dialog.attributes('modelvalue')).toBe('true'); wrapper.unmount()
|
||||
@@ -53,36 +41,6 @@ it('centralizes cargo changes in smart dispatch and never exposes row add/remove
|
||||
expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('modelValue')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps vehicle maintenance and detail visible while submitting review remains available in the menu', async () => {
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
|
||||
const wrapper = mount(DispatchesView, { ...options, attachTo: document.body }); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆信息', '详情', '更多'])
|
||||
await row.get('button[aria-label="更多操作 · PC/1"]').trigger('click'); await flushPromises()
|
||||
const submit = Array.from(document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')).find(item => item.textContent === '提交调度审核')!
|
||||
expect(submit).toBeDefined()
|
||||
submit.click(); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([['DISPATCH_REVIEW', 1, '调度审核'], ['LOADING_REVIEW', 2, '理货员审核']] as const)('prioritizes the current %s queue action with a short label', async (action, index, label) => {
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click'); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['审核', '详情'])
|
||||
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual([action, 'detail'])
|
||||
for (const key of ['EDIT', 'CANCEL', 'SUBMIT_REVIEW', 'route-order', 'bol']) {
|
||||
row.vm.$emit('action', key); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
}
|
||||
expect(getDispatchBolPdf).not.toHaveBeenCalled()
|
||||
await row.get(`button[aria-label="${label}"]`).trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action, dispatchNo: 'PC/1' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('recovers %s only inside smart dispatch', async action => {
|
||||
useDispatchCommandRecovery(() => identity.id).begin('PC-OTHER', action, { recordVersion: 0, idempotencyKey: 'cargo-key' }, action === 'REMOVE_CARGO' ? { itemId: '17' } : undefined)
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
@@ -91,41 +49,21 @@ it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('recovers %s only inside smart d
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([1, 2])('keeps only details in audit tab %s without its review permission', async index => {
|
||||
can.mockReturnValue(false)
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click'); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['detail'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows four working phases and uses server stage counts instead of current page length', async () => {
|
||||
it('shows exactly five phases and uses server stage counts instead of current page length', async () => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
|
||||
expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
|
||||
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '派送中 1', '派送完成 1'])
|
||||
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
|
||||
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
|
||||
await tabs[2]!.trigger('click'); await flushPromises()
|
||||
await tabs[3]!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('queries the current review queue without offering historical result filters', async () => {
|
||||
it('queries each review section on the server and includes only its legal review filter', async () => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[1]!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'DISPATCH_REVIEW' }))
|
||||
expect(wrapper.getComponent(QueryToolbar).props('fields').some((field: { key: string }) => field.key === 'reviewStatus')).toBe(false)
|
||||
wrapper.getComponent(QueryToolbar).vm.$emit('search', { status: 'ACTIVE', reviewStatus: 'REJECTED', dispatchNo: '', vehiclePlate: '' }); await flushPromises()
|
||||
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('reviewStatus'); wrapper.unmount()
|
||||
})
|
||||
it.each(['dispatchReview', 'loadingReview'] as const)('shows %s rejection in the initial customer-service section', async reviewKey => {
|
||||
const row = listRow()
|
||||
row.reviews[reviewKey] = { ...row.reviews[reviewKey], status: 'REJECTED', reason: '车辆证照需补齐' }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([row]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'VEHICLE_INFO' }))
|
||||
expect(wrapper.text()).toContain(`${reviewKey === 'dispatchReview' ? '调度审核' : '理货员审核'}退回:车辆证照需补齐`)
|
||||
wrapper.unmount()
|
||||
wrapper.getComponent(QueryToolbar).vm.$emit('search', { status: 'ACTIVE', reviewStatus: 'PENDING', dispatchNo: '', vehiclePlate: '' }); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ reviewStatus: 'PENDING' })); wrapper.unmount()
|
||||
})
|
||||
it('discards a late page response after switching phase', async () => {
|
||||
let old!: (value: DispatchPage) => void
|
||||
|
||||
@@ -19,7 +19,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
||||
import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, type DispatchAction, type DispatchListRow, type DispatchStage, type DispatchStatistics, type DispatchStatus, type ReviewStatus } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions, dispatchRejectionReason } from '../dispatchPresentation'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions } from '../dispatchPresentation'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
|
||||
import { transportLabels, type TransportType } from '../api/transportTasks'
|
||||
@@ -38,7 +38,7 @@ const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
|
||||
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', status: 'ACTIVE' })
|
||||
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', status: 'ACTIVE', reviewStatus: '' })
|
||||
const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
|
||||
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
@@ -48,18 +48,18 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
|
||||
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
|
||||
{ key: 'status', label: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }] },
|
||||
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'reviewStatus', label: '审核结果', type: 'select' as const, width: 140, options: Object.entries(reviewLabels).map(([value, label]) => ({ value, label })) }] : []),
|
||||
])
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
...(showSections.value && section.value === 'VEHICLE_INFO' ? [{ key: 'returnReason', label: '退回原因', minWidth: 240, slotName: 'returnReason' }] : []),
|
||||
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review' }] : []),
|
||||
{ key: 'counts', label: '托数/箱数/已拣', minWidth: 160, slotName: 'counts' }, { key: 'planned', label: '计划发车', minWidth: 165, slotName: 'planned' },
|
||||
{ key: 'departure', label: '实际发车', minWidth: 165, slotName: 'departure' }, { key: 'stage', label: '作业阶段', minWidth: 110, slotName: 'stage' },
|
||||
...(stage.value === 'DELIVERING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []),
|
||||
...(stage.value === 'AFTER_LOADING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []),
|
||||
])
|
||||
const preferenceKey = computed(() => `overseas-dispatches-${stage.value}-${section.value}`)
|
||||
let sequence = 0
|
||||
@@ -68,36 +68,23 @@ async function load() {
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ACTIVE') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
|
||||
...(showSections.value ? { beforeLoadingSection: section.value } : {}) })
|
||||
...(showSections.value ? { beforeLoadingSection: section.value, ...(section.value !== 'VEHICLE_INFO' && model.value.reviewStatus ? { reviewStatus: String(model.value.reviewStatus) as ReviewStatus } : {}) } : {}) })
|
||||
if (current !== sequence) return
|
||||
rows.value = result.records; total.value = result.total; statistics.value = result.statistics
|
||||
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search(value: QueryModel) { model.value = value; page.value = 1; void load() }
|
||||
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; page.value = 1; void load() }
|
||||
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
|
||||
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; model.value.reviewStatus = ''; page.value = 1; void load() }
|
||||
function switchSection(value: BeforeLoadingSection) { section.value = value; model.value.reviewStatus = ''; page.value = 1; void load() }
|
||||
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
||||
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
|
||||
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
|
||||
function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
||||
const review = section.value
|
||||
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||
}
|
||||
if (row.taskType && row.taskType !== 'DELIVERY') return [{ key: 'detail', label: '运输操作', priority: 500 }]
|
||||
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
|
||||
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
||||
return [...available.map((value, index) => ({
|
||||
key: value,
|
||||
label: dispatchActionLabels[value],
|
||||
ariaLabel: dispatchActionLabels[value],
|
||||
priority: value === primaryAction ? 600 : 400 - index * 10,
|
||||
tone: value === 'CANCEL' ? 'danger' as const : undefined,
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
|
||||
return [{ key: 'detail', label: '详情', priority: 500 }, ...rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can)).map((value, index) => ({ key: value, label: dispatchActionLabels[value], priority: 400 - index * 10, tone: value === 'CANCEL' ? 'danger' as const : undefined })), ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
|
||||
}
|
||||
function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'detail') return openDetail(row)
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
|
||||
if (value === 'bol') return void openBol(row)
|
||||
rejectionError.value = null
|
||||
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
|
||||
@@ -141,11 +128,10 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template>
|
||||
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
|
||||
<template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template>
|
||||
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
|
||||
</DataTable></div>
|
||||
</ListPageLayout></AdminPage>
|
||||
|
||||
@@ -139,11 +139,10 @@ describe('inbound appointment native dialogs', () => {
|
||||
})
|
||||
|
||||
describe('inbound plan and pallet move native dialogs', () => {
|
||||
it('shows plan identity and saves the selected master warehouse with the internal version through fullscreen', async () => {
|
||||
it('shows plan identity and version as text and saves the selected master warehouse through fullscreen', async () => {
|
||||
const { dialog } = await openEditPlan()
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').findAll('input')).toHaveLength(0)
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('入库单号')
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').text()).not.toContain('版本')
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('版本0')
|
||||
const select = dialog.findComponent({ name: 'ElSelect' })
|
||||
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
|
||||
@@ -264,7 +264,7 @@ onMounted(() => { void load() })
|
||||
</AppDialog>
|
||||
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
|
||||
<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>{{ planEditTarget?.orderNo || '—' }}</td><th scope="row">版本</th><td>{{ planEditForm.version }}</td></tr></tbody></table></section>
|
||||
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
</div></AppDialog>
|
||||
|
||||
@@ -262,7 +262,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
|
||||
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
|
||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
// A filtered candidate must still submit its complete server-owned selection group.
|
||||
expect(dialog).toContain('candidatePage.assertCurrent(cargo)')
|
||||
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
## 适用范围
|
||||
|
||||
- 本文件适用于 `src/shared/components/` 及其直接测试。业务页面使用这些组件时,同时遵守 `../../../AGENTS.md` 和 `../../../../../docs/Admin-Client前端原型还原验收规则.md`。
|
||||
- 这里只放与具体业务无关、已被多个模块真实复用的后台组件;纯业务字段、状态和流程留在所属模块。样式唯一入口为 [Admin 界面规范](../../../../../docs/Admin高密度界面规范.md#按任务阅读),本指引只补充组件组合与调用约束。
|
||||
- 这里只放与具体业务无关、已被多个模块真实复用的后台组件;纯业务字段、状态和流程留在所属模块。
|
||||
|
||||
## 标准列表组合
|
||||
|
||||
- 标准列表使用 `ListPageLayout` 组织查询区和表格区,保持单一 `16px` 间距;不叠加页面级空白操作区或重复面板间距。
|
||||
- 查询与筛选使用 `QueryToolbar.vue`,通过 `fields`、`v-model`、`search`、`reset` 接入;查询即刷新当前结果,不另设独立刷新按钮。
|
||||
- 数据列表使用 `DataTable.vue`,通过 `columns`、命名插槽、`v-model:page`、`v-model:page-size` 和 `total` 接入,不重复拼装 `el-table` 与分页器。
|
||||
- “操作”列放在第一列,需要固定时固定在左侧。单行操作不超过 3 个时直接显示纯文字动作;第 4 个起使用 `MoreHorizontal` 菜单,并优先直接保留详情、编辑、下载等高频操作。
|
||||
- “操作”列放在第一列,需要固定时固定在左侧。单行操作不超过 3 个时直接显示“图标 + 文案”;第 4 个起使用 `MoreHorizontal` 菜单,并优先直接保留详情、编辑、下载等高频操作。
|
||||
- 列表操作统一使用 `RowActions.vue`:默认直接展示 3 个,通过 `priority` 决定高频或关键操作的展示资格,其余自动进入带可访问名称的“更多”菜单;权限和状态由业务页面生成 `actions` 时决定,组件不推导业务资格。
|
||||
- 新增、导入、导出和批量操作放入 `DataTable` 的 `toolbar-actions`;不放入查询栏,不用 `toolbar-start` 重复列表标题、数量或说明,数量统一放在 `pagination-left`。
|
||||
- 路由标题和说明默认由 `AppLayout` 的 `page-heading` 根据路由元数据展示;标准列表正文不重复标题。只有明确关闭路由页头的详情页或工作台才自带独立页头。
|
||||
@@ -28,7 +28,6 @@
|
||||
- 原型已定义图标时复用原型资源;原型未定义时使用 Lucide:查看 `Eye`、编辑 `Pencil`、更多 `MoreHorizontal`、新增 `Plus`、删除 `Trash2`、启停 `Power`、刷新 `RefreshCw`、导入 `FileUp`、导出/下载 `Download`、返回 `ArrowLeft`、保存 `Save`、复制 `Copy`。
|
||||
- 危险操作保留明确文案、危险色、确认过程和成功/失败反馈,不只显示图标。
|
||||
- 组件按适用语义覆盖加载、空数据、错误、禁用和无权限状态;保留键盘操作、可见焦点、表单标签、图标按钮名称和颜色对比度,不只用颜色表达状态。
|
||||
- 加载反馈遵循 [提示文字与加载布局](../../../../../docs/Admin高密度界面规范.md#提示文字与加载布局),上述状态覆盖要求不授权新增独立提示小字或造成布局跳动。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ const overseasConsumers = [
|
||||
'overseas-warehouse/components/DeliveryDocumentsDialog.vue',
|
||||
]
|
||||
const overseasBusinessConsumers = [
|
||||
'overseas-business/components/TransportDemandForm.vue',
|
||||
'overseas-business/pages/PurchaseOrdersView.vue', 'overseas-business/pages/PurchaseOrderDetailView.vue',
|
||||
'overseas-business/pages/PurchaseOrderEditView.vue', 'overseas-business/pages/TransportOrdersView.vue',
|
||||
'overseas-business/pages/TruckOrderDetailView.vue',
|
||||
@@ -218,23 +217,6 @@ const cssRoot = (css: string) => {
|
||||
}
|
||||
|
||||
describe('approved Admin density ownership', () => {
|
||||
it('lets local transport editors fill the workspace and keeps form density in shared primitives', () => {
|
||||
const editor = read('modules/overseas-business/pages/TransportOrderFormView.vue')
|
||||
const demand = parse(read('modules/overseas-business/components/TransportDemandForm.vue')).descriptor
|
||||
const css = demand.styles.map(style => style.content).join('\n')
|
||||
const layout = read('modules/overseas-business/overseasBusinessDensity.css')
|
||||
expect(editor).toContain('overseas-transport-editor')
|
||||
expect(editor).toContain('transport-customer-section density-form-section')
|
||||
expect(layout).toMatch(/\.admin-density\.overseas-business-editor\s*\{\s*width: 100%;\s*\}/)
|
||||
expect(layout).toMatch(/:not\(\.overseas-transport-editor\)\s*\{\s*max-width: 1120px;/)
|
||||
expect(demand.template!.content).toContain('admin-density admin-workbench density-form')
|
||||
expect(demand.template!.content.match(/<FormSection class="density-form-section"/g)).toHaveLength(3)
|
||||
expect(css).not.toMatch(/max-width|--el-component-size|font-size|el-form-item__label|margin-bottom/)
|
||||
expect(css).toContain('repeat(2, minmax(0, 1fr))')
|
||||
expect(css).toContain('repeat(3, minmax(0, 1fr))')
|
||||
expect(css).toContain('repeat(4, minmax(0, 1fr))')
|
||||
})
|
||||
|
||||
it('separates shared list filters with the same 5px radius across density themes', () => {
|
||||
const declarations = (source: string, selector: string) => {
|
||||
const values: Record<string, string> = {}
|
||||
@@ -803,14 +785,10 @@ describe('approved Admin density ownership', () => {
|
||||
}
|
||||
const domestic = read('modules/unified-orders/pages/UnifiedOrdersView.vue')
|
||||
const overseasShell = read('modules/overseas-warehouse/components/OverseasDetailShell.vue')
|
||||
const truckDetail = read('modules/overseas-business/pages/TruckOrderDetailView.vue')
|
||||
for (const className of ['density-detail-header', 'density-detail-heading', 'density-detail-title']) {
|
||||
expect(domestic).toContain(className)
|
||||
expect(overseasShell).toContain(className)
|
||||
expect(truckDetail).toContain(className)
|
||||
}
|
||||
expect(truckDetail).toContain('density-detail-layout')
|
||||
expect(truckDetail).toContain('density-descriptions')
|
||||
expect(domestic).toContain('density-detail-overview')
|
||||
expect(read('modules/overseas-warehouse/pages/LastMileOrderDetailView.vue')).toContain('density-detail-overview')
|
||||
})
|
||||
@@ -1211,7 +1189,6 @@ describe('approved Admin density ownership', () => {
|
||||
})
|
||||
expect(actionWeights.get('.row-actions.admin-text-actions .el-button.el-button')).toBe('400')
|
||||
expect(actionWeights.get('.admin-text-actions.el-popper .el-dropdown-menu__item')).toBe('400')
|
||||
expect(read('modules/overseas-master-data/pages/OwnedBusinessView.vue')).toContain('class="row-actions density-actions admin-text-actions"')
|
||||
const dispatches = read('modules/overseas-warehouse/pages/DispatchesView.vue')
|
||||
expect(dispatches).toContain('appearance="integrated"')
|
||||
expect(dispatches).toContain('<template #filters><div class="integrated-query-group">')
|
||||
@@ -1297,31 +1274,14 @@ describe('approved Admin density ownership', () => {
|
||||
expect(source).not.toContain('<DispatchResourceFields')
|
||||
expect(source).toContain('<SmartDispatchCargoBoard')
|
||||
expect(source).toContain('label-position="left" label-width="130px"')
|
||||
expect(source).toContain('class="dispatch-context-toolbar"')
|
||||
expect(source).not.toContain('class="density-dialog-section dispatch-draft-toolbar"')
|
||||
expect(source).toContain('<template #vehicle-controls>')
|
||||
expect(source).toContain('class="density-dialog-section dispatch-setup"')
|
||||
expect(source).toContain('class="density-dialog-section dispatch-draft-toolbar"')
|
||||
expect(source).toContain('class="density-form dispatch-setup-fields"')
|
||||
expect(source).toContain('class="dispatch-warehouse-picker" aria-label="起运仓"')
|
||||
expect(source).not.toContain('class="dispatch-warehouse-field" label="起运仓"')
|
||||
expect(source).not.toContain('data-tone="work"')
|
||||
expect(source).not.toContain('data-tone="summary"')
|
||||
expect(source).not.toContain('data-tone="source"')
|
||||
expect(board).toContain('class="dispatch-cargo-board"')
|
||||
expect(board).toContain('class="density-table cargo-board-table"')
|
||||
const boardCss = parse(board).descriptor.styles.map(style => style.content).join('\n')
|
||||
const orderViewport = boardCss.match(/\.cargo-board-table-wrap\s*\{([^}]+)\}/)?.[1] || ''
|
||||
const palletViewport = boardCss.match(/\.cargo-board-pallet-list\s*\{([^}]+)\}/)?.[1] || ''
|
||||
for (const viewport of [orderViewport, palletViewport]) {
|
||||
expect(viewport).toContain('max-height:')
|
||||
expect(viewport).not.toMatch(/(?:^|;)\s*height:/)
|
||||
}
|
||||
expect(boardCss).not.toContain('min-height: 74px')
|
||||
expect(boardCss).toContain('grid-template-rows: subgrid')
|
||||
expect(boardCss).toContain('grid-row: 1 / span 2')
|
||||
expect(boardCss).not.toMatch(/\.cargo-board-pallets\s*\{[^}]*margin-top:/)
|
||||
expect(board).toContain('<slot name="vehicle-heading">')
|
||||
expect(source).not.toContain('admin-density-workspace')
|
||||
|
||||
expect(board).toContain('aria-label="待排订单"')
|
||||
expect(board).toContain('aria-label="托盘明细"')
|
||||
expect(board).toContain('aria-label="车辆装载"')
|
||||
@@ -1333,9 +1293,10 @@ describe('approved Admin density ownership', () => {
|
||||
expect(fields).toContain('density-table density-table--editable dispatch-resource-table')
|
||||
expect(fields).toContain('class="resource-grid"')
|
||||
})
|
||||
it('keeps dispatch workflow and owned evidence on shared compact sections', () => {
|
||||
it('keeps dispatch workflow, delivery arrangements and owned evidence on shared compact sections', () => {
|
||||
const workflow = read('modules/overseas-warehouse/components/DispatchWorkflowDialog.vue')
|
||||
const cargo = read('modules/overseas-warehouse/components/SmartDispatchExistingCargo.vue')
|
||||
const arrangement = read('modules/overseas-warehouse/components/DispatchArrangeFields.vue')
|
||||
const evidence = read('modules/overseas-warehouse/components/DispatchEvidenceFiles.vue')
|
||||
expect(workflow).toContain(':fullscreenable="width !== \'560px\'"')
|
||||
expect(workflow).toContain('<DispatchResourceFields continuous')
|
||||
@@ -1348,13 +1309,17 @@ describe('approved Admin density ownership', () => {
|
||||
expect(cargo).toContain('aria-label="当前排车货物"')
|
||||
for (const tone of ['source', 'work']) {
|
||||
expect(workflow).toContain(`data-tone="${tone}"`)
|
||||
expect(arrangement).toContain(`data-tone="${tone}"`)
|
||||
}
|
||||
expect(workflow).not.toMatch(/max-height="(?:380|420)"/)
|
||||
expect(arrangement).not.toContain('max-height="240"')
|
||||
expect(arrangement).toContain('density-table density-table--editable arrange-editor-table')
|
||||
expect(arrangement).toContain('本组新增费用')
|
||||
expect(evidence).toContain('class="density-table dispatch-evidence-table"')
|
||||
expect(evidence).toContain('fullscreenable :loading="previewLoading"')
|
||||
expect(evidence).toContain('<LoadErrorState')
|
||||
expect(evidence).toContain('<LineClampText')
|
||||
for (const source of [workflow, evidence]) {
|
||||
for (const source of [workflow, arrangement, evidence]) {
|
||||
const styles = parse(source).descriptor.styles.map(style => style.content).join('\n')
|
||||
expect(styles).not.toMatch(/background:|border-left:|font-size:/)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user