优化
This commit is contained in:
@@ -120,7 +120,8 @@ pnpm typecheck
|
|||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
|
- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。
|
||||||
|
- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。
|
||||||
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
||||||
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
||||||
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { addDays } from './calendar'
|
import { projectWarehouseArrival } from '@/modules/container-loading'
|
||||||
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
|
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
|
||||||
|
|
||||||
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
|
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
|
||||||
@@ -36,12 +36,12 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
|
|||||||
if (warehouses === null) continue
|
if (warehouses === null) continue
|
||||||
const warehouse = warehouseMap.get(row.containerBookingId)
|
const warehouse = warehouseMap.get(row.containerBookingId)
|
||||||
warehouseMap.delete(row.containerBookingId)
|
warehouseMap.delete(row.containerBookingId)
|
||||||
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
|
const projected = projectWarehouseArrival(row.arrivalAt, warehouse)
|
||||||
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
|
const planned = projected.date
|
||||||
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
|
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
|
||||||
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
|
time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse,
|
||||||
canEdit: warehouse?.canEdit === true,
|
canEdit: warehouse?.canEdit === true,
|
||||||
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : projected.isDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
||||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
|
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
|
||||||
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
|
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
|
||||||
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
|
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => {
|
|||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
it('queries visible containers and updates the whole-container expected arrival', async () => {
|
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 }] })
|
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 } } as never)
|
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never)
|
||||||
|
|
||||||
await listContainerArrivalTimes(['9001'])
|
await listContainerArrivalTimes(['9001'])
|
||||||
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
|
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
|
||||||
@@ -17,4 +17,14 @@ describe('container arrival-time overseas API', () => {
|
|||||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] })
|
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-') } })
|
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,6 +6,14 @@ export interface ContainerArrivalTime {
|
|||||||
estimatedWarehouseArrivalAt: string | null
|
estimatedWarehouseArrivalAt: string | null
|
||||||
actualWarehouseArrivalAt: string | null
|
actualWarehouseArrivalAt: string | null
|
||||||
version: number
|
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'
|
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
|
||||||
@@ -20,7 +28,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin
|
|||||||
queryPath,
|
queryPath,
|
||||||
{ sourceContainerBookingIds },
|
{ sourceContainerBookingIds },
|
||||||
)
|
)
|
||||||
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
|
return (unwrapApiResponse(response) || []).map(normalizeArrival)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContainerExpectedArrival(
|
export async function updateContainerExpectedArrival(
|
||||||
@@ -33,5 +41,5 @@ export async function updateContainerExpectedArrival(
|
|||||||
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
|
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
|
||||||
)
|
)
|
||||||
const result = unwrapApiResponse(response)
|
const result = unwrapApiResponse(response)
|
||||||
return { ...result, version: Number(result.version || 0) }
|
return normalizeArrival(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './api/platformContainerLoading'
|
export * from './api/platformContainerLoading'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
export { projectWarehouseArrival } from './warehouseArrival'
|
||||||
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
|
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
|
||||||
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
|
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
|
||||||
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
|
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
|
||||||
|
|||||||
+9
-1
@@ -57,6 +57,14 @@ function accessibleText(wrapper: { element: Element } | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ContainerDetailView empty return', () => {
|
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 () => {
|
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
|
||||||
const wrapper = await mountDetail('DOMESTIC')
|
const wrapper = await mountDetail('DOMESTIC')
|
||||||
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
|
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
|
||||||
@@ -83,7 +91,7 @@ describe('ContainerDetailView empty return', () => {
|
|||||||
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
|
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
|
||||||
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } 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(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 }])
|
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
|
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
} from '../api/platformContainerLoading'
|
} from '../api/platformContainerLoading'
|
||||||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
|
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 { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||||
|
import { projectWarehouseArrival } from '../warehouseArrival'
|
||||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||||||
@@ -91,6 +92,14 @@ async function rememberWorkbenchTab(tabName: string | number) {
|
|||||||
}
|
}
|
||||||
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
||||||
const arrivalTime = ref<ContainerArrivalTime | 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 hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
|
||||||
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
|
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'))
|
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
|
||||||
@@ -726,7 +735,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
|
<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>
|
<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>{{ 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>
|
<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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
|
<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(getTerminalDrayageHistory).mockResolvedValue([])
|
||||||
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
|
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(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 }])
|
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 })
|
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(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(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(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: [] })
|
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,6 +584,30 @@ 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) => {
|
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')
|
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
|
||||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
|
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
type TerminalDrayageView,
|
type TerminalDrayageView,
|
||||||
} from '../api/terminalDrayages'
|
} from '../api/terminalDrayages'
|
||||||
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||||
|
import { projectWarehouseArrival } from '../warehouseArrival'
|
||||||
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||||
|
|
||||||
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||||
@@ -285,6 +286,12 @@ function optional(value: string | null) { return value?.trim() || null }
|
|||||||
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
||||||
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', 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 formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||||
|
function defaultArrivalDate(row: 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 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 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] }
|
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
|
||||||
@@ -577,7 +584,7 @@ async function saveDrayageAction() {
|
|||||||
}
|
}
|
||||||
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
|
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
|
||||||
const current = arrivalTimes.value[row.id]
|
const current = arrivalTimes.value[row.id]
|
||||||
if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return
|
if (!value || !current || current.version < 0 || current.planCount > 0 || arrivalSavingIds.value.includes(row.id)) return
|
||||||
arrivalSavingIds.value.push(row.id)
|
arrivalSavingIds.value.push(row.id)
|
||||||
try {
|
try {
|
||||||
const updated = await updateContainerExpectedArrival(row.id, {
|
const updated = await updateContainerExpectedArrival(row.id, {
|
||||||
@@ -760,7 +767,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 #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 #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 #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"><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 #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 #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
|
<template #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
|
||||||
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
||||||
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
|
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
|
||||||
@@ -772,7 +779,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
|||||||
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
||||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
|
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
|
||||||
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
||||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
|
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.arrivedDestinationPortAt) }}</template>
|
||||||
</DataTable></div></section>
|
</DataTable></div></section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export interface ContainerWorkbenchStatusSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerWorkbenchActualFacts {
|
export interface ContainerWorkbenchActualFacts {
|
||||||
|
arrivedDestinationPortAt?: string | null
|
||||||
actualSailingAt?: string | null
|
actualSailingAt?: string | null
|
||||||
actualPortArrivalAt?: string | null
|
actualPortArrivalAt?: string | null
|
||||||
actualWarehouseArrivalAt?: string | null
|
actualWarehouseArrivalAt?: string | null
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -63,6 +63,16 @@
|
|||||||
|
|
||||||
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
|
续批验证: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)
|
## 自有业务行操作样式核对(2026-10-21)
|
||||||
|
|
||||||
依据本轮用户截图 `codex-clipboard-eb6aa95b-c8a2-4b85-9294-22f3127cf72c.png` 与 Admin 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。
|
依据本轮用户截图 `codex-clipboard-eb6aa95b-c8a2-4b85-9294-22f3127cf72c.png` 与 Admin 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
|
|||||||
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
|
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
|
||||||
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
|
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
|
||||||
},
|
},
|
||||||
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
||||||
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
|
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function ownedBusinessFixture(): OwnedBusinessDetail {
|
|||||||
actualPalletCount: 1, operations: { money: null, actualDistanceMiles: null, fuelConsumptionLitresPer100Km: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
|
actualPalletCount: 1, operations: { money: null, actualDistanceMiles: null, fuelConsumptionLitresPer100Km: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
|
||||||
photoSummary: { loadingCount: 0, fuelCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
|
photoSummary: { loadingCount: 0, fuelCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
|
||||||
},
|
},
|
||||||
dispatch: { dispatchNo: 'DS-OWN-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OWN-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
dispatch: { dispatchNo: 'DS-OWN-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'DS-OWN-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
||||||
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
|
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,17 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
|
{ 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 },
|
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
|
||||||
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
|
{ key: 'destinations', label: '目的仓', minWidth: 150, slotName: 'destinations' },
|
||||||
|
{ key: 'deliveryWarehouses', label: '派送仓库', minWidth: 150, slotName: 'destinations' },
|
||||||
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
|
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
|
||||||
{ key: 'money', label: '整车总金额 / 单价', minWidth: 170, slotName: 'money' },
|
{ key: 'money', label: '总价', minWidth: 170, align: 'right', slotName: 'money' },
|
||||||
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
|
{ key: 'unitPrice', label: '单价(/托)', minWidth: 160, align: 'right', slotName: 'unitPrice' },
|
||||||
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
|
{ 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' },
|
||||||
]
|
]
|
||||||
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -97,9 +103,13 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
|
|||||||
<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="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 #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</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 #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"><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 #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 #fuel="{ row }">{{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }}</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 }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></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 #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ ownedBusinessStages[row.stage as OwnedBusinessStage] }}</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>
|
</DataTable></div>
|
||||||
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
<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 Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
|
||||||
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
|
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
|
||||||
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
|
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(([,slot]) => 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(([name,slot]) => h('section', { 'data-column': name }, slot?.({row})))))]) } })
|
||||||
const OperationsEditor = defineComponent({ name: 'OwnedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
|
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 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: () => {} } }
|
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,6 +30,27 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('owned business views', () => {
|
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 () => {
|
it('keeps a non-pool external vehicle returned by the server without filtering or requiring a vehicle id', async () => {
|
||||||
const detail = ownedBusinessFixture()
|
const detail = ownedBusinessFixture()
|
||||||
detail.row.vehicle = { vehicleId: null, plate: 'EXTERNAL-NO-POOL', ownership: 'DISPATCHED' }
|
detail.row.vehicle = { vehicleId: null, plate: 'EXTERNAL-NO-POOL', ownership: 'DISPATCHED' }
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
|
|
||||||
本轮追加:用户要求打开时默认第一个起运仓并删除标签;无已有选择、草稿或订单指定仓时采用目录首项并读取候选,空目录保持空值。已有排车按新截图 `codex-clipboard-43654ee8-6c76-4141-bca5-4d108d45b828.png` 合并为可搜索单号的选择器,分页进入下拉底部、货物查询同排,删除重复标签和介绍文字。沿用当前查询API、明确选择后读取清单,不自动选择已有排车。6个文件161项定向回归通过(18.32秒),覆盖默认仓自动加载、空目录、草稿仓保留、下拉搜索分页及原有加货/移除;本轮不跑全量测试。应用类型检查与差异检查通过,真实样式待截图确认。
|
本轮追加:用户要求打开时默认第一个起运仓并删除标签;无已有选择、草稿或订单指定仓时采用目录首项并读取候选,空目录保持空值。已有排车按新截图 `codex-clipboard-43654ee8-6c76-4141-bca5-4d108d45b828.png` 合并为可搜索单号的选择器,分页进入下拉底部、货物查询同排,删除重复标签和介绍文字。沿用当前查询API、明确选择后读取清单,不自动选择已有排车。6个文件161项定向回归通过(18.32秒),覆盖默认仓自动加载、空目录、草稿仓保留、下拉搜索分页及原有加货/移除;本轮不跑全量测试。应用类型检查与差异检查通过,真实样式待截图确认。
|
||||||
|
|
||||||
|
加入货物反馈修复(截图 `codex-clipboard-6ef9c642-96b3-4c97-a234-dbfefc5821f8.png`):正常请求暂存也触发待核对警告、重试按钮,候选刷新清空列表且触发整窗遮罩。目标为仅在请求空闲且仍待确认时展示恢复提示;同仓刷新保留不可操作的旧列表直至原子替换,失败或换仓清空,刷新期间拒绝使用旧来源;整窗遮罩只用于初始化。共享候选读取还有预约排车消费者,追加该消费者定向验证,不改其业务逻辑。6个文件110项定向检查通过,包含慢加入、刷新期间列表保留且禁止操作、刷新失败清空、原请求重试和空单创建;未运行全量测试。应用类型检查、差异检查通过;视觉效果待人工截图确认。
|
||||||
|
|
||||||
|
托盘移动与删除反馈(用户截图 `codex-clipboard-3317655c-a74e-4775-b0ff-65aa46ccc4be.png`、`codex-clipboard-2459ecf1-8447-4835-a571-486fd540289a.png`,参考原型 `app.js` 的 `renderDispatchPalletDetails/renderDispatchTrucks`):中栏保留托盘号与箱数,拖拽或点击编号移入选中车辆;按用户“直接移过去”要求,服务端确认加入后从中栏移走,右栏“×”移回,删除每行加入/已加入和目的仓文字。整托关联来源仍可展开,阻断保留“暂不可排车”及可展开原因,候选与整组重复原因去重。非空车辆隐藏删除入口且命令层拦截;首次明确拒绝解除待核对锁,历史已标记明确拒绝的浏览器记录重开时清除锁并恢复原草稿;未知结果及未知后再次拒绝继续保留原请求。自动检查:6文件107项定向测试通过;补充恢复断言后3文件102项(含样式合同)通过,未运行全量。应用类型检查被其他在途模块阻断:ContainersView.vue 行类型、OwnedBusinessFixtures.ts/OutsourcedBusinessFixtures.ts 缺少 exceptions;本轮文件未报告类型错误。差异检查通过,未部署;视觉效果待人工截图确认。换标单轮次/实托/库存来源校验仅确认代码来源,未核实该单据实际数据,不宣称业务阻断已解决。
|
||||||
|
|
||||||
自动检查:7个文件179项定向回归通过;`pnpm build`(含应用类型检查)通过。标题共享网格行,三栏内容共同起始,不再使用中栏固定顶部偏移;已有排车自定义内容仍留在同一网格列内。本轮修改的4个测试文件及其组件依赖定向类型检查通过;新增查询断言修正后已有排车23项再次通过。额外全仓`typecheck:all`触及统一入口2GB堆上限,未提高资源限制;扩大到未改动的规划测试时存在既有`VueWrapper`类型报错,未据此宣称全仓测试代码类型检查通过。全量`pnpm test`通过:470文件、4336项(14:34启动,591.74秒)。验证期间三个生产组件哈希未变;测试断言查询方法的最后修正已另跑23项通过。差异空白检查通过。只调整本模块模板和布局,保留蓝白配色及现行排车契约;未部署,视觉验收待1920×1080、100%人工截图确认。
|
自动检查: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 智能排车截图复核与样式纠偏
|
## 2026-10-21 智能排车截图复核与样式纠偏
|
||||||
@@ -405,3 +409,5 @@
|
|||||||
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
|
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
|
||||||
|
|
||||||
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
|
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
|
||||||
|
|
||||||
|
- 2026-10-21 排车“派送中 → 标记异常”:按用户提供的三类异常、两类退货去向截图补齐选项,沿用居中作业弹框,说明与图片选填,保存后仍派送中;现有排车信息页签回看。自动回归单独报告,1920×1080/100%真实页面待人工截图确认。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
|
import type { MarkDispatchException, 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 { 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 { 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'
|
||||||
export type { DispatchBatchReference, DispatchBatchAllocation } from '../dispatchContracts'
|
export type { DispatchBatchReference, DispatchBatchAllocation } from '../dispatchContracts'
|
||||||
@@ -37,6 +37,7 @@ export const submitDispatchReview = (no: string, body: VersionCommand & { remark
|
|||||||
export const reviewDispatch = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/dispatch-review`, versioned(body))
|
export const reviewDispatch = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/dispatch-review`, versioned(body))
|
||||||
export const reviewDispatchLoading = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/loading-review`, versioned(body))
|
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 returnDispatchToReview = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/return-to-review`, versioned(body))
|
||||||
|
export const markDispatchException = (no: string, body: MarkDispatchException) => postCommand(`${path(no)}/exceptions`, versioned(body))
|
||||||
export const completeDispatchLoading = (no: string, body: LoadingComplete) => postCommand(`${path(no)}/loading-complete`, versioned(body))
|
export const completeDispatchLoading = (no: string, body: LoadingComplete) => postCommand(`${path(no)}/loading-complete`, versioned(body))
|
||||||
export const getDispatchBolPdf = (no: string) => http.get<Blob, Blob>(`${path(no)}/prints/bol`, { responseType: 'blob' })
|
export const getDispatchBolPdf = (no: string) => http.get<Blob, Blob>(`${path(no)}/prints/bol`, { responseType: 'blob' })
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiE
|
|||||||
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
|
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.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
|
||||||
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
|
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 uploadTypes = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types)
|
||||||
const uploadFormats = computed(() => props.kind === 'DRIVER_PHOTO' ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
const uploadFormats = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
||||||
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
|
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
|
||||||
function valid(value: DispatchEvidenceFile, expected?: string) {
|
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('附件元数据未确认本车有效文件,请重新读取')
|
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('附件元数据未确认本车有效文件,请重新读取')
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const identity = vi.hoisted(() => ({ id: '' }))
|
|||||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
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', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||||
interface State { submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; 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) {
|
async function open(action: DispatchAction, allowed = true) {
|
||||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
||||||
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||||
@@ -197,3 +197,21 @@ it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('preserves the exact pending %s
|
|||||||
expect(wrapper.emitted('rejected')).toBeUndefined()
|
expect(wrapper.emitted('rejected')).toBeUndefined()
|
||||||
} finally { wrapper.unmount() }
|
} finally { wrapper.unmount() }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requires an exception type and submits optional direction, description and images once', async () => {
|
||||||
|
const { wrapper, state } = await open('MARK_EXCEPTION')
|
||||||
|
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('请选择异常类型')
|
||||||
|
Object.assign(state.exception, { exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: ' 现场部分签收 ', imageFileIds: ['91'] })
|
||||||
|
vi.mocked(api.markDispatchException).mockImplementation(async (_, body) => ({ ...command('MARK_EXCEPTION', body.idempotencyKey), stage: 'DELIVERING' }))
|
||||||
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
|
||||||
|
await state.submit()
|
||||||
|
expect(api.markDispatchException).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: '现场部分签收', imageFileIds: ['91'] }))
|
||||||
|
expect(api.markDispatchException).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||||
|
expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not submit an exception when the server disallows the action', async () => {
|
||||||
|
const { wrapper, state } = await open('MARK_EXCEPTION', false)
|
||||||
|
state.exception.exceptionType = 'REFUSED'; await state.submit()
|
||||||
|
expect(api.markDispatchException).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
|
})
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
|||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import * as api from '../api/dispatches'
|
import * as api from '../api/dispatches'
|
||||||
import type { CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||||
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||||||
import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||||
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
|
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
|
||||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||||
@@ -38,15 +38,16 @@ const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null,
|
|||||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||||
const reviewAttachments = ref(emptyReviewAttachments())
|
const reviewAttachments = ref(emptyReviewAttachments())
|
||||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||||
|
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
|
||||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||||
let generation = 0
|
let generation = 0
|
||||||
let editorRequestKey = ''
|
let editorRequestKey = ''
|
||||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||||
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
|
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 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'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
||||||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
const frozen = computed(() => busy.value || Boolean(pending.value))
|
||||||
const evidenceBusy = reactive({ VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -62,6 +63,7 @@ async function load() {
|
|||||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||||
detail.value = value
|
detail.value = value
|
||||||
reviewAttachments.value = emptyReviewAttachments()
|
reviewAttachments.value = emptyReviewAttachments()
|
||||||
|
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||||||
const oldVehicle = value.vehicleInfo.vehicle
|
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()
|
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()
|
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
|
||||||
@@ -98,6 +100,9 @@ function payload(): object {
|
|||||||
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
|
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
|
||||||
}
|
}
|
||||||
switch (currentAction.value) {
|
switch (currentAction.value) {
|
||||||
|
case 'MARK_EXCEPTION':
|
||||||
|
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
|
||||||
|
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
|
||||||
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 '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':
|
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||||
@@ -117,6 +122,7 @@ async function send(command: PendingDispatchCommand): Promise<CommandResult> {
|
|||||||
const no = command.dispatchNo, body = command.body
|
const no = command.dispatchNo, body = command.body
|
||||||
if (command.operation === 'ROUTE') return api.reorderDispatchRoute(no, body as unknown as DispatchRouteCommand)
|
if (command.operation === 'ROUTE') return api.reorderDispatchRoute(no, body as unknown as DispatchRouteCommand)
|
||||||
switch (command.action) {
|
switch (command.action) {
|
||||||
|
case 'MARK_EXCEPTION': return api.markDispatchException(no, body as unknown as MarkDispatchException)
|
||||||
case 'EDIT': return api.updateDispatch(no, body as unknown as UpdateDispatch)
|
case 'EDIT': return api.updateDispatch(no, body as unknown as UpdateDispatch)
|
||||||
case 'DISPATCH_REVIEW': return api.reviewDispatch(no, body as unknown as ReviewCommand)
|
case 'DISPATCH_REVIEW': return api.reviewDispatch(no, body as unknown as ReviewCommand)
|
||||||
case 'LOADING_REVIEW': return api.reviewDispatchLoading(no, body as unknown as ReviewCommand)
|
case 'LOADING_REVIEW': return api.reviewDispatchLoading(no, body as unknown as ReviewCommand)
|
||||||
@@ -179,7 +185,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
|
|||||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<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">
|
<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 ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit">
|
||||||
<div class="density-dialog-sections">
|
<div class="density-dialog-sections">
|
||||||
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
<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="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||||
@@ -192,6 +198,12 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
|||||||
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
|
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
|
||||||
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
|
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
|
||||||
</section>
|
</section>
|
||||||
|
<template v-else-if="currentAction === 'MARK_EXCEPTION'">
|
||||||
|
<el-form-item label="异常类型" required><el-select v-model="exception.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in dispatchExceptionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="退货去向"><el-select v-model="exception.returnDirection" clearable placeholder="请选择退货去向"><el-option v-for="(label, value) in dispatchReturnDirectionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
|
||||||
|
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
|
||||||
|
</template>
|
||||||
<template v-else-if="currentAction === 'EDIT'">
|
<template v-else-if="currentAction === 'EDIT'">
|
||||||
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
||||||
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
||||||
@@ -218,7 +230,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
|||||||
<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" />
|
<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>
|
</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-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 !== 'MARK_EXCEPTION'" 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>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe('smart dispatch three-column cargo board', () => {
|
|||||||
await select(wrapper); await wrapper.setProps({ disabled: true })
|
await select(wrapper); await wrapper.setProps({ disabled: true })
|
||||||
expect(wrapper.findAll('button').filter(button => ['添加车辆', '移除货物', '删除车辆', '查看托盘'].includes(button.text())).every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
expect(wrapper.findAll('button').filter(button => ['添加车辆', '移除货物', '删除车辆', '查看托盘'].includes(button.text())).every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||||
expect(wrapper.findAll('input[type="radio"]').every(input => input.attributes('disabled') !== undefined)).toBe(true)
|
expect(wrapper.findAll('input[type="radio"]').every(input => input.attributes('disabled') !== undefined)).toBe(true)
|
||||||
await pallet(wrapper).trigger('dragstart', { dataTransfer: transfer() })
|
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
||||||
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop')
|
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop')
|
||||||
await wrapper.get('[aria-label="候选货物分页"] button.btn-next').trigger('click')
|
await wrapper.get('[aria-label="候选货物分页"] button.btn-next').trigger('click')
|
||||||
expect(wrapper.emitted('add')).toBeUndefined()
|
expect(wrapper.emitted('add')).toBeUndefined()
|
||||||
@@ -106,13 +106,30 @@ describe('smart dispatch three-column cargo board', () => {
|
|||||||
expect(wrapper.get('[aria-label="第 1 辆车"]').findAll('.cargo-board-loaded-pallet')).toHaveLength(1)
|
expect(wrapper.get('[aria-label="第 1 辆车"]').findAll('.cargo-board-loaded-pallet')).toHaveLength(1)
|
||||||
for (const index of [0, 1]) {
|
for (const index of [0, 1]) {
|
||||||
await select(wrapper, index)
|
await select(wrapper, index)
|
||||||
expect(pallet(wrapper).attributes('draggable')).toBe('false')
|
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
||||||
expect(pallet(wrapper).get('button').text()).toBe('已加入第 1 辆车')
|
|
||||||
expect(pallet(wrapper).get('button').attributes('disabled')).toBeDefined()
|
|
||||||
}
|
}
|
||||||
expect(wrapper.emitted('add')).toBeUndefined()
|
expect(wrapper.emitted('add')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('moves a confirmed pallet out of the middle list and restores it when removed; only empty cars can be deleted', async () => {
|
||||||
|
const source = candidate(), state = draft(), wrapper = render([source])
|
||||||
|
await select(wrapper)
|
||||||
|
expect(pallet(wrapper).findAll('td')).toHaveLength(2)
|
||||||
|
expect(pallet(wrapper).get('button').text()).toBe('PALLET-1')
|
||||||
|
expect(wrapper.text()).not.toContain('加入这辆车')
|
||||||
|
state.sources = [source]; state.containers[0]!.sourceKeys = [source.sourceKey]
|
||||||
|
await wrapper.setProps({ draft: clone(state) })
|
||||||
|
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
||||||
|
const loaded = wrapper.get('[aria-label="第 1 辆车"]')
|
||||||
|
expect(loaded.text()).not.toContain('删除车辆')
|
||||||
|
expect(wrapper.get('[aria-label="第 2 辆车"]').text()).toContain('删除车辆')
|
||||||
|
await loaded.get('[aria-label="移回托盘 PALLET-1"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('removeCargo')).toEqual([[source, 'car-1']])
|
||||||
|
await wrapper.setProps({ draft: draft() })
|
||||||
|
expect(pallet(wrapper).text()).toContain('PALLET-1')
|
||||||
|
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('删除车辆')
|
||||||
|
})
|
||||||
|
|
||||||
it('blocks incomplete server-owned groups and does not convert missing measurements to zero', async () => {
|
it('blocks incomplete server-owned groups and does not convert missing measurements to zero', async () => {
|
||||||
const source = candidate({ suggestedWeightKg: null, suggestedVolumeCbm: null })
|
const source = candidate({ suggestedWeightKg: null, suggestedVolumeCbm: null })
|
||||||
source.selectionGroup.complete = false
|
source.selectionGroup.complete = false
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const orders = computed(() => {
|
|||||||
return [...grouped].map(([key, sources]) => ({ key, source: sources[0]!, sources }))
|
return [...grouped].map(([key, sources]) => ({ key, source: sources[0]!, sources }))
|
||||||
})
|
})
|
||||||
const selectedOrder = computed(() => orders.value.find(order => order.key === selectedOrderKey.value))
|
const selectedOrder = computed(() => orders.value.find(order => order.key === selectedOrderKey.value))
|
||||||
const palletGroups = computed(() => uniqueGroups(selectedOrder.value?.sources || []))
|
const palletGroups = computed(() => uniqueGroups(selectedOrder.value?.sources || []).filter(source => !assignedVehicle(source)))
|
||||||
const vehicles = computed<DraftContainer[]>(() => props.draft?.containers.length ? props.draft.containers : [{ draftContainerId: '', sequenceNo: 1, capacity: props.capacity, sourceKeys: [] }])
|
const vehicles = computed<DraftContainer[]>(() => props.draft?.containers.length ? props.draft.containers : [{ draftContainerId: '', sequenceNo: 1, capacity: props.capacity, sourceKeys: [] }])
|
||||||
function assignedVehicle(source: Candidate) {
|
function assignedVehicle(source: Candidate) {
|
||||||
const keys = new Set([source.sourceKey, ...(source.selectionGroup?.sources.map(row => row.sourceKey) || [])])
|
const keys = new Set([source.sourceKey, ...(source.selectionGroup?.sources.map(row => row.sourceKey) || [])])
|
||||||
@@ -88,14 +88,14 @@ function dragStart(event: DragEvent, source: Candidate) {
|
|||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
// Transfer only a harmless marker. The live component reference is the authority.
|
// Transfer only a harmless marker. The live component reference is the authority.
|
||||||
event.dataTransfer.setData('text/plain', 'dispatch-pallet')
|
event.dataTransfer.setData('text/plain', 'dispatch-pallet')
|
||||||
event.dataTransfer.effectAllowed = 'copy'
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function dragOver(event: DragEvent, id: string) {
|
function dragOver(event: DragEvent, id: string) {
|
||||||
if (!draggedSource.value || !canAdd(draggedSource.value) || !validTarget(id)) return
|
if (!draggedSource.value || !canAdd(draggedSource.value) || !validTarget(id)) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
dropTarget.value = id
|
dropTarget.value = id
|
||||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||||||
}
|
}
|
||||||
function drop(event: DragEvent, id: string) {
|
function drop(event: DragEvent, id: string) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -189,24 +189,22 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
|||||||
<thead><tr><th>托盘号</th><th class="cargo-board-pallet-count">箱数</th></tr></thead>
|
<thead><tr><th>托盘号</th><th class="cargo-board-pallet-count">箱数</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="!selectedOrder"><td colspan="2" class="cargo-board-empty">请选择左侧订单查看托盘明细</td></tr>
|
<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">
|
<tr v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
|
||||||
<td>
|
<td>
|
||||||
<strong>{{ palletLabel(source) }}</strong>
|
<el-button class="cargo-board-pallet-move" link :disabled="!canAdd(source) || !validTarget(activeContainerId)" :aria-label="`将托盘 ${palletLabel(source)} 移入选中车辆`" @click="add(source, activeContainerId)">{{ palletLabel(source) }}</el-button>
|
||||||
<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>
|
<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>
|
<details v-if="selectionGroupBlockReason(source)" class="cargo-board-block"><summary>暂不可排车</summary><p>{{ selectionGroupBlockReason(source) }}</p></details>
|
||||||
<el-button link :disabled="!canAdd(source) || !validTarget(activeContainerId)" @click="add(source, activeContainerId)">{{ assignedVehicle(source) ? `已加入第 ${assignedVehicle(source)!.sequenceNo} 辆车` : '加入这辆车' }}</el-button>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="cargo-board-pallet-count">{{ groupPackages(source) ?? '—' }} 箱</td>
|
<td class="cargo-board-pallet-count">{{ groupPackages(source) ?? '—' }} 箱</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="selectedOrder && !palletGroups.length"><td colspan="2" class="cargo-board-empty">该订单本页暂无托盘</td></tr>
|
<tr v-if="selectedOrder && !palletGroups.length"><td colspan="2" class="cargo-board-empty">暂无待装托盘</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载">
|
<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>
|
<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">
|
<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">
|
||||||
<div class="cargo-board-vehicle-list">
|
<div class="cargo-board-vehicle-list">
|
||||||
@@ -216,11 +214,11 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
|||||||
<p v-if="!vehicle.sourceKeys.length" class="cargo-board-empty">拖入托盘</p>
|
<p v-if="!vehicle.sourceKeys.length" class="cargo-board-empty">拖入托盘</p>
|
||||||
<div v-for="source in vehicleSources(vehicle)" :key="source.selectionGroup.groupKey" class="cargo-board-loaded-pallet">
|
<div v-for="source in vehicleSources(vehicle)" :key="source.selectionGroup.groupKey" class="cargo-board-loaded-pallet">
|
||||||
<div><strong>{{ palletLabel(source) }}</strong><small class="cargo-board-muted">{{ businessNo(source) }} · {{ groupPackages(source) ?? '—' }} 箱</small><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></div>
|
<div><strong>{{ palletLabel(source) }}</strong><small class="cargo-board-muted">{{ businessNo(source) }} · {{ groupPackages(source) ?? '—' }} 箱</small><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></div>
|
||||||
<el-button link :disabled="disabled" @click="emit('removeCargo', source, vehicle.draftContainerId)">移除货物</el-button>
|
<el-button link :disabled="disabled" :aria-label="`移回托盘 ${palletLabel(source)}`" title="移回托盘" @click="emit('removeCargo', source, vehicle.draftContainerId)">×</el-button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="vehiclePalletCount(vehicle) === null" class="cargo-board-block">已选货物资料不完整,请重新读取草稿。</p>
|
<p v-if="vehiclePalletCount(vehicle) === null" class="cargo-board-block">已选货物资料不完整,请重新读取草稿。</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<footer v-if="vehicle.draftContainerId && !vehicle.sourceKeys.length" class="cargo-board-vehicle-footer"><el-button link type="danger" :disabled="disabled" @click="emit('removeContainer', vehicle.draftContainerId)">删除车辆</el-button></footer>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
@@ -261,7 +259,8 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
|||||||
.cargo-board-pallet-table td { vertical-align: top; overflow-wrap: anywhere; }
|
.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-table .cargo-board-empty { text-align: center; padding: var(--admin-space-5); }
|
||||||
.cargo-board-pallet.is-draggable { cursor: grab; }
|
.cargo-board-pallet.is-draggable { cursor: grab; }
|
||||||
.cargo-board-pallet.is-assigned td { background: var(--admin-label-bg); }
|
.cargo-board-pallet-move { max-width: 100%; height: auto; white-space: normal; text-align: left; justify-content: flex-start; }
|
||||||
|
.cargo-board-pallet.is-draggable { user-select: none; }
|
||||||
.cargo-board-group-details { margin-top: var(--admin-space-2); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
.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 summary { cursor: pointer; }
|
||||||
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
|
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import ElementPlus from 'element-plus'
|
|||||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import SmartDispatchDialog from './SmartDispatchDialog.vue'
|
import SmartDispatchDialog from './SmartDispatchDialog.vue'
|
||||||
|
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
|
||||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
@@ -107,6 +108,34 @@ describe('new dispatch native layout', () => {
|
|||||||
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('15 托位')
|
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('15 托位')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not show a recovery warning or blank the board during a successful slow addition', async () => {
|
||||||
|
const row = candidate()
|
||||||
|
const empty = { ...draft(), sources: [], containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
|
||||||
|
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
|
||||||
|
const { dialog } = await render(); await restore(dialog); await selectFirstOrder(dialog)
|
||||||
|
let finishLock!: (value: DispatchDraft) => void
|
||||||
|
let finishRefresh!: (value: Awaited<ReturnType<typeof api.listDispatchCandidates>>) => void
|
||||||
|
vi.mocked(api.lockDispatchDraftPallet).mockReturnValueOnce(new Promise(resolve => { finishLock = resolve }))
|
||||||
|
vi.mocked(api.listDispatchCandidates).mockReturnValueOnce(new Promise(resolve => { finishRefresh = resolve }))
|
||||||
|
const content = view(dialog)
|
||||||
|
await content.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
|
||||||
|
expect(content.text()).not.toContain('原请求结果尚待核对')
|
||||||
|
expect(dialog.props('confirmText')).toBe('生成排车单')
|
||||||
|
expect(dialog.props('confirmLoading')).toBe(true)
|
||||||
|
finishLock({ ...empty, recordVersion: 1, sources: [row], containers: [{ ...empty.containers[0]!, sourceKeys: [row.sourceKey] }] })
|
||||||
|
await flushPromises()
|
||||||
|
expect(dialog.props('loading')).toBe(false)
|
||||||
|
expect(content.get('[aria-label="待排订单"]').text()).toContain(row.orderNo)
|
||||||
|
expect(content.get('[aria-label="托盘明细"]').text()).not.toContain('请选择左侧订单')
|
||||||
|
expect(content.get('[aria-label="智能排车三栏选货"]').attributes('aria-busy')).toBe('true')
|
||||||
|
expect(content.text()).not.toContain('原请求结果尚待核对')
|
||||||
|
finishRefresh({ total: 1, page: 1, size: 20, pages: 1, candidates: [row], complete: true, snapshotAt: '' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(content.get('[aria-label="智能排车三栏选货"]').attributes('aria-busy')).toBe('false')
|
||||||
|
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
|
||||||
|
expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('adds cargo directly, preparing the first vehicle internally and enabling submission after assignment', async () => {
|
it('adds cargo directly, preparing the first vehicle internally and enabling submission after assignment', async () => {
|
||||||
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
|
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
|
||||||
const grouped: DispatchDraft = { ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
|
const grouped: DispatchDraft = { ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
|
||||||
@@ -124,7 +153,7 @@ describe('new dispatch native layout', () => {
|
|||||||
const candidateCalls = vi.mocked(api.listDispatchCandidates).mock.calls.length
|
const candidateCalls = vi.mocked(api.listDispatchCandidates).mock.calls.length
|
||||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||||
await selectFirstOrder(dialog)
|
await selectFirstOrder(dialog)
|
||||||
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
|
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
|
||||||
expect(api.createDispatchDraft).toHaveBeenCalledExactlyOnceWith({ warehouseId: '321', idempotencyKey: expect.any(String) })
|
expect(api.createDispatchDraft).toHaveBeenCalledExactlyOnceWith({ warehouseId: '321', idempotencyKey: expect.any(String) })
|
||||||
expect(api.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, capacity: 30, idempotencyKey: expect.any(String) })
|
expect(api.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, capacity: 30, idempotencyKey: expect.any(String) })
|
||||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(candidateCalls + 1)
|
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(candidateCalls + 1)
|
||||||
@@ -132,7 +161,7 @@ describe('new dispatch native layout', () => {
|
|||||||
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ draftContainerId: 'first-group', source: planned.selection, recordVersion: 1 }))
|
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ draftContainerId: 'first-group', source: planned.selection, recordVersion: 1 }))
|
||||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||||
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
|
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
|
||||||
expect(view(dialog).findAll('button').find(button => button.text() === '已加入第 1 辆车')!.attributes('disabled')).toBeDefined()
|
expect(view(dialog).find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
it('retains the exact first-group command after an unknown result without creating a second draft', async () => {
|
it('retains the exact first-group command after an unknown result without creating a second draft', async () => {
|
||||||
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
|
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
|
||||||
@@ -140,7 +169,7 @@ describe('new dispatch native layout', () => {
|
|||||||
vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] })
|
vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] })
|
||||||
const { dialog } = await render(); await selectWarehouse(dialog)
|
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||||
await selectFirstOrder(dialog)
|
await selectFirstOrder(dialog)
|
||||||
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
|
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
|
||||||
const original = vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0]
|
const original = vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0]
|
||||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||||
dialog.vm.$emit('confirm'); await flushPromises()
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
@@ -213,11 +242,15 @@ describe('new dispatch native layout', () => {
|
|||||||
const { wrapper, dialog } = await render(); await restore(dialog)
|
const { wrapper, dialog } = await render(); await restore(dialog)
|
||||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||||
|
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||||
|
expect(dialog.props('confirmText')).toBe('生成排车单')
|
||||||
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
|
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
|
||||||
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||||
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
|
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
|
||||||
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
|
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
|
||||||
expect(view(dialog).text()).toContain('new-dispatch-request')
|
expect(view(dialog).text()).toContain('new-dispatch-request')
|
||||||
|
expect(view(dialog).text()).toContain('原请求结果尚待核对')
|
||||||
|
expect(dialog.props('confirmText')).toBe('重试原请求')
|
||||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||||
dialog.vm.$emit('confirm'); await flushPromises()
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
@@ -260,18 +293,63 @@ describe('new dispatch native layout', () => {
|
|||||||
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
|
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
|
||||||
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
|
it('unfreezes after a definite rejection without automatically resubmitting', async () => {
|
||||||
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
|
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
|
||||||
const { dialog } = await render(); await selectWarehouse(dialog)
|
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||||
await selectFirstOrder(dialog)
|
await selectFirstOrder(dialog)
|
||||||
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
|
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
|
||||||
expect(view(dialog).text()).toContain('draft-rejected-request')
|
expect(view(dialog).text()).toContain('draft-rejected-request')
|
||||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||||
await view(dialog).findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
|
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||||
|
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||||
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
||||||
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
|
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
|
||||||
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
|
||||||
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
|
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
it('rejects removal of a populated car before sending a command and keeps the draft editable', async () => {
|
||||||
|
const { dialog } = await render(); await restore(dialog)
|
||||||
|
dialog.getComponent(SmartDispatchCargoBoard).vm.$emit('removeContainer', 'container-1'); await flushPromises()
|
||||||
|
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
|
||||||
|
expect(view(dialog).text()).toContain('请先移回车内托盘')
|
||||||
|
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||||
|
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
|
||||||
|
})
|
||||||
|
it.each(['rejection', 'unknown'] as const)('handles an empty-car deletion %s without confusing rejected and unknown results', async failure => {
|
||||||
|
const empty = { ...draft(), sources: [], containers: [{ ...draft().containers[0]!, sourceKeys: [] }] }
|
||||||
|
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
|
||||||
|
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(failure === 'rejection'
|
||||||
|
? new ApiResponseError('仅空容器可以删除', 'A0409', 409, null, 'delete-request') : new Error('network unknown'))
|
||||||
|
const { dialog } = await render(); await restore(dialog)
|
||||||
|
await view(dialog).findAll('button').find(button => button.text() === '删除车辆')!.trigger('click'); await flushPromises()
|
||||||
|
expect(api.deleteDispatchDraftContainer).toHaveBeenCalledTimes(1)
|
||||||
|
expect(view(dialog).find('[aria-label="第 1 辆车"]').exists()).toBe(true)
|
||||||
|
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(failure === 'unknown')
|
||||||
|
if (failure === 'rejection') {
|
||||||
|
expect(view(dialog).text()).toContain('仅空容器可以删除')
|
||||||
|
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||||
|
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||||
|
} else {
|
||||||
|
const original = vi.mocked(api.deleteDispatchDraftContainer).mock.calls[0]!
|
||||||
|
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(new ApiResponseError('仅空容器可以删除', 'A0409', 409))
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(2, ...original)
|
||||||
|
expect(view(dialog).text()).toContain('原请求结果尚待核对')
|
||||||
|
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).not.toBeNull()
|
||||||
|
vi.mocked(api.deleteDispatchDraftContainer).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [] })
|
||||||
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
|
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(3, ...original)
|
||||||
|
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('clears a saved definite rejection on reopen while restoring the original draft', async () => {
|
||||||
|
sessionStorage.setItem(`dispatch-draft-command:${identity.id}`, JSON.stringify({ action: 'DELETE', attempted: true, rejected: true, containerId: 'container-1', body: { draftToken: 'server-token', recordVersion: 0, idempotencyKey: 'rejected-key' } }))
|
||||||
|
const { dialog } = await render()
|
||||||
|
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||||
|
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||||
|
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
|
||||||
|
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
|
||||||
|
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
|
||||||
})
|
})
|
||||||
it('keeps creation unavailable without its write permission', async () => {
|
it('keeps creation unavailable without its write permission', async () => {
|
||||||
can.mockReturnValue(false)
|
can.mockReturnValue(false)
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async function warehouseSelected(dialog: VueWrapper) {
|
|||||||
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
|
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
|
||||||
picker.vm.$emit('update:modelValue', '321'); picker.vm.$emit('change', '321'); await flushPromises()
|
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() }
|
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
|
||||||
async function chooseCar(dialog: VueWrapper, index: number) { await car(dialog, index).get('input[type="radio"]').setValue(true); await flushPromises() }
|
async function chooseCar(dialog: VueWrapper, index: number) { await car(dialog, index).get('input[type="radio"]').setValue(true); await flushPromises() }
|
||||||
describe('three-column cargo planning through visible controls', () => {
|
describe('three-column cargo planning through visible controls', () => {
|
||||||
it('opens and selects a warehouse as reads only with one empty 30-space placeholder and no start prerequisite', async () => {
|
it('opens and selects a warehouse as reads only with one empty 30-space placeholder and no start prerequisite', async () => {
|
||||||
@@ -179,7 +179,8 @@ describe('three-column cargo planning through visible controls', () => {
|
|||||||
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
||||||
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CREATE' ? 0 : 1)
|
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CREATE' ? 0 : 1)
|
||||||
expect(view(dialog).text()).toContain(`request-${step}`)
|
expect(view(dialog).text()).toContain(`request-${step}`)
|
||||||
expect(view(dialog).text()).toContain('按明确驳回结果重新核对')
|
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
|
||||||
|
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
|
||||||
})
|
})
|
||||||
it.each(['CREATE', 'CONTAINER'] as const)('stops after an unknown %s and retries its exact command without auto-locking or recreating', async step => {
|
it.each(['CREATE', 'CONTAINER'] as const)('stops after an unknown %s and retries its exact command without auto-locking or recreating', async step => {
|
||||||
lostReply = step
|
lostReply = step
|
||||||
@@ -205,9 +206,9 @@ describe('three-column cargo planning through visible controls', () => {
|
|||||||
vi.mocked(api.createDispatchDraft).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
vi.mocked(api.createDispatchDraft).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||||
const dialog = await render(); await warehouseSelected(dialog)
|
const dialog = await render(); await warehouseSelected(dialog)
|
||||||
await selectOrder(dialog, 'PALLET-1')
|
await selectOrder(dialog, 'PALLET-1')
|
||||||
await cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click')
|
await cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').trigger('click')
|
||||||
expect(button(dialog, '添加车辆').attributes('disabled')).toBeDefined()
|
expect(button(dialog, '添加车辆').attributes('disabled')).toBeDefined()
|
||||||
expect(cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.attributes('disabled')).toBeDefined()
|
expect(cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
|
||||||
expect(view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr button').every(row => row.attributes('disabled') !== undefined)).toBe(true)
|
expect(view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr button').every(row => row.attributes('disabled') !== undefined)).toBe(true)
|
||||||
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
|
||||||
expect(api.createDispatchDraftContainer).not.toHaveBeenCalled(); expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
|
expect(api.createDispatchDraftContainer).not.toHaveBeenCalled(); expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||||
@@ -236,8 +237,7 @@ describe('three-column cargo planning through visible controls', () => {
|
|||||||
expect(server!.containers[0]!.sourceKeys).toEqual(catalog.map(row => row.sourceKey))
|
expect(server!.containers[0]!.sourceKeys).toEqual(catalog.map(row => row.sourceKey))
|
||||||
for (const source of catalog) {
|
for (const source of catalog) {
|
||||||
await view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr').find(row => row.text().includes(source.orderNo!) && row.text().includes(source.customerName!))!.get('button').trigger('click')
|
await view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr').find(row => row.text().includes(source.orderNo!) && row.text().includes(source.customerName!))!.get('button').trigger('click')
|
||||||
expect(cargoRow(dialog, 'PALLET-1').get('button').text()).toBe('已加入第 1 辆车')
|
expect(view(dialog).find('[aria-label="托盘 PALLET-1"]').exists()).toBe(false)
|
||||||
expect(cargoRow(dialog, 'PALLET-1').get('button').attributes('disabled')).toBeDefined()
|
|
||||||
}
|
}
|
||||||
await button(dialog, '生成排车单').trigger('click'); await flushPromises()
|
await button(dialog, '生成排车单').trigger('click'); await flushPromises()
|
||||||
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}
|
|||||||
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
|
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
|
||||||
const pending = ref<DraftCommand | null>(null)
|
const pending = ref<DraftCommand | null>(null)
|
||||||
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
|
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
|
||||||
|
// Only a recorded first-attempt rejection proves that no write occurred.
|
||||||
|
let rejectedDraftToken = pending.value?.rejected && !pending.value.result ? String(pending.value.body.draftToken || '') : ''
|
||||||
|
if (pending.value?.rejected && !pending.value.result) { pending.value = null; sessionStorage.removeItem(storageKey) }
|
||||||
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
|
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
|
||||||
const settingsExpanded = ref(false)
|
const settingsExpanded = ref(false)
|
||||||
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
|
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
|
||||||
@@ -49,6 +52,7 @@ const currentSettings = computed(() => {
|
|||||||
})
|
})
|
||||||
const form = computed(() => currentSettings.value.form)
|
const form = computed(() => currentSettings.value.form)
|
||||||
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
|
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
|
||||||
|
const needsRecovery = computed(() => Boolean(pending.value || recovery.pending.value) && !saving.value && !preparing.value && !recovery.busy.value)
|
||||||
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
|
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
|
||||||
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
|
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
|
||||||
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
|
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
|
||||||
@@ -112,7 +116,9 @@ async function init() {
|
|||||||
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
|
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
|
||||||
if (sequence !== generation) return
|
if (sequence !== generation) return
|
||||||
warehouses.value = directory; drafts.value = saved
|
warehouses.value = directory; drafts.value = saved
|
||||||
if (pending.value?.body.draftToken) draft.value = saved.find(row => row.draftToken === pending.value!.body.draftToken) || null
|
const resumeToken = pending.value?.body.draftToken || rejectedDraftToken
|
||||||
|
if (resumeToken) draft.value = saved.find(row => row.draftToken === resumeToken) || null
|
||||||
|
rejectedDraftToken = ''
|
||||||
if (draft.value) warehouseId.value = draft.value.warehouseId
|
if (draft.value) warehouseId.value = draft.value.warehouseId
|
||||||
if (!warehouseId.value) {
|
if (!warehouseId.value) {
|
||||||
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
|
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
|
||||||
@@ -147,7 +153,7 @@ function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
|
|||||||
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
|
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
|
||||||
}
|
}
|
||||||
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
|
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
|
||||||
if (!canCreate.value || loading.value || saving.value) return false
|
if (!canCreate.value || loading.value || saving.value || pending.value) return false
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
if (!pending.value) {
|
if (!pending.value) {
|
||||||
@@ -160,7 +166,11 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
|
|||||||
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
|
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
|
||||||
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
|
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
|
||||||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
|
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||||||
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
|
else if (action === 'DELETE') {
|
||||||
|
const container = current.containers.find(row => row.draftContainerId === containerId)
|
||||||
|
if (!container || container.sourceKeys.length) throw new DispatchInputError('请先移回车内托盘,再删除空车辆')
|
||||||
|
body = { ...common, reason: '移除本草稿装车容器' }
|
||||||
|
}
|
||||||
else if (action === 'RELEASE') body = common
|
else if (action === 'RELEASE') body = common
|
||||||
else {
|
else {
|
||||||
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
|
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
|
||||||
@@ -215,7 +225,9 @@ async function retryDraft() {
|
|||||||
// No new source mutation before the original response has been validated.
|
// No new source mutation before the original response has been validated.
|
||||||
pending.value = null; persist()
|
pending.value = null; persist()
|
||||||
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
|
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
|
||||||
} catch (cause) { if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) command.rejected = true; persist(); throw cause }
|
} catch (cause) { // A later rejection cannot erase an earlier unknown write result.
|
||||||
|
if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) pending.value = null
|
||||||
|
persist(); throw cause }
|
||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
}
|
}
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -234,7 +246,7 @@ async function submit() {
|
|||||||
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
|
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<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-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="needsRecovery ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||||
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
|
<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="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>
|
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
|
||||||
@@ -243,8 +255,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
|||||||
<template v-else-if="workspace === 'create'">
|
<template v-else-if="workspace === 'create'">
|
||||||
<div class="density-dialog-sections dispatch-workspace">
|
<div class="density-dialog-sections dispatch-workspace">
|
||||||
<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>
|
<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-alert v-if="needsRecovery" 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="候选货物查询">
|
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
|
||||||
<div class="dispatch-candidate-query">
|
<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>
|
<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>
|
||||||
@@ -257,13 +268,13 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
|||||||
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
|
<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-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>
|
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
|
||||||
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击加入。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
|
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击托盘号。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
|
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
|
||||||
<template v-if="mode === 'cargo'">
|
<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>
|
<div class="warehouse-tabs density-actions" role="tablist" aria-label="候选仓库展示"><el-button v-for="tab in ([{ value: 'ALL', label: '全美' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :class="{ active: warehouseTab === tab.value }" :type="warehouseTab === tab.value ? 'primary' : undefined" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
|
||||||
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
|
<SmartDispatchCargoBoard :aria-busy="candidatePage.loading.value || saving || preparing" :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
|
||||||
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize">
|
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize">
|
||||||
<template #vehicle-controls>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async function open(dispatchNo: string | null = 'PC/1') {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
return wrapper
|
return wrapper
|
||||||
}
|
}
|
||||||
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await button(wrapper, '加入这辆车').trigger('click'); await flushPromises() }
|
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await wrapper.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true
|
vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true
|
||||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||||
@@ -91,7 +91,7 @@ it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation
|
|||||||
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
|
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
|
||||||
const wrapper = await open(); await add(wrapper)
|
const wrapper = await open(); await add(wrapper)
|
||||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
|
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
|
||||||
expect(button(wrapper, '加入这辆车').attributes('disabled')).toBeDefined()
|
expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
|
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type Version = string | number
|
|||||||
export class DispatchInputError extends Error {}
|
export class DispatchInputError extends Error {}
|
||||||
export type DispatchStatus = 'ACTIVE' | 'CANCELLED'
|
export type DispatchStatus = 'ACTIVE' | 'CANCELLED'
|
||||||
export type DispatchStage = 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
|
export type DispatchStage = 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
|
||||||
export type DispatchAction = 'CREATE' | 'EDIT' | 'ADD_CARGO' | 'REMOVE_CARGO' | 'CANCEL' | 'SUBMIT_REVIEW' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW' | 'RETURN_TO_REVIEW' | 'PICK' | 'LOADING_COMPLETE' | 'ARRANGE_DELIVERY' | 'DEPART'
|
export type DispatchAction = 'MARK_EXCEPTION' | 'CREATE' | 'EDIT' | 'ADD_CARGO' | 'REMOVE_CARGO' | 'CANCEL' | 'SUBMIT_REVIEW' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW' | 'RETURN_TO_REVIEW' | 'PICK' | 'LOADING_COMPLETE' | 'ARRANGE_DELIVERY' | 'DEPART'
|
||||||
export type ReviewStatus = 'NOT_SUBMITTED' | 'PENDING' | 'APPROVED' | 'REJECTED'
|
export type ReviewStatus = 'NOT_SUBMITTED' | 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||||
export type BeforeLoadingSection = 'VEHICLE_INFO' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW'
|
export type BeforeLoadingSection = 'VEHICLE_INFO' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW'
|
||||||
export interface Gate { allowed: boolean; blockCode: string | null; blockReason: string | null }
|
export interface Gate { allowed: boolean; blockCode: string | null; blockReason: string | null }
|
||||||
@@ -31,7 +31,11 @@ export interface OutboundReference { customerId: string; customerName: string; o
|
|||||||
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
||||||
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
|
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
|
||||||
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
|
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
|
||||||
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
|
export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXCEPTION'
|
||||||
|
export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER'
|
||||||
|
export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string }
|
||||||
|
export interface MarkDispatchException extends VersionCommand { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[] }
|
||||||
|
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
|
||||||
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
|
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
|
||||||
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||||
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
|
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
|
||||||
@@ -53,7 +57,7 @@ export interface DispatchItems { dispatchNo: string; recordVersion: Version; sta
|
|||||||
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
|
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 DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
|
||||||
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
|
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
|
||||||
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
|
export type DispatchEvidenceKind = 'EXCEPTION' | 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
|
||||||
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
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 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[] }
|
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
|
||||||
@@ -109,7 +113,7 @@ export function selectionIdentity(value: CargoSelection): string {
|
|||||||
export function requireSelectionGroup(row: Candidate): SelectionGroup {
|
export function requireSelectionGroup(row: Candidate): SelectionGroup {
|
||||||
const group = row.selectionGroup
|
const group = row.selectionGroup
|
||||||
if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
|
if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
|
||||||
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...row.blockReasons, ...group.blockReasons].join(';') || '选货组来源尚未完整,请重新读取候选')
|
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...new Set([...row.blockReasons, ...group.blockReasons])].join(';') || '选货组来源尚未完整,请重新读取候选')
|
||||||
if (!group.sources.length || group.sources.some(source => !source.sourceKey || !source.customerId || !source.selection?.sourceVersion || !Number.isSafeInteger(source.packages) || source.packages <= 0 || source.selection.sourceType === 'RELABEL' && (source.sourceBusinessType !== 'RELABEL_ORDER' || !source.sourceBusinessNo || source.sourceBusinessNo !== source.selection.relabelNo))) throw new DispatchInputError('选货组来源或数量不完整,请重新读取候选')
|
if (!group.sources.length || group.sources.some(source => !source.sourceKey || !source.customerId || !source.selection?.sourceVersion || !Number.isSafeInteger(source.packages) || source.packages <= 0 || source.selection.sourceType === 'RELABEL' && (source.sourceBusinessType !== 'RELABEL_ORDER' || !source.sourceBusinessNo || source.sourceBusinessNo !== source.selection.relabelNo))) throw new DispatchInputError('选货组来源或数量不完整,请重新读取候选')
|
||||||
const palletKeys = group.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo)
|
const palletKeys = group.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo)
|
||||||
if (!palletKeys.length || palletKeys.some(key => !key) || new Set(palletKeys).size !== palletKeys.length || group.pallets.some(pallet => !Array.isArray(pallet.sourceKeys) || !pallet.sourceKeys.length || pallet.sourceKeys.some(key => !group.sources.some(source => source.sourceKey === key)) || !Number.isSafeInteger(pallet.packageCount) || pallet.packageCount <= 0)) throw new DispatchInputError('选货组实托事实缺失或重复,请重新读取候选')
|
if (!palletKeys.length || palletKeys.some(key => !key) || new Set(palletKeys).size !== palletKeys.length || group.pallets.some(pallet => !Array.isArray(pallet.sourceKeys) || !pallet.sourceKeys.length || pallet.sourceKeys.some(key => !group.sources.some(source => source.sourceKey === key)) || !Number.isSafeInteger(pallet.packageCount) || pallet.packageCount <= 0)) throw new DispatchInputError('选货组实托事实缺失或重复,请重新读取候选')
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
export const dispatchActionLabels: Record<DispatchAction, string> = {
|
export const dispatchActionLabels: Record<DispatchAction, string> = {
|
||||||
CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
MARK_EXCEPTION: '标记异常', CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
||||||
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
||||||
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
|
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
|
||||||
}
|
}
|
||||||
export const dispatchActionPermissions: Record<DispatchAction, string> = {
|
export const dispatchActionPermissions: Record<DispatchAction, string> = {
|
||||||
CREATE: 'platform:overseas-warehouse:dispatch:create', EDIT: 'platform:overseas-warehouse:dispatch:edit',
|
MARK_EXCEPTION: 'platform:overseas-warehouse:dispatch:edit', CREATE: 'platform:overseas-warehouse:dispatch:create', EDIT: 'platform:overseas-warehouse:dispatch:edit',
|
||||||
ADD_CARGO: 'platform:overseas-warehouse:dispatch:edit', REMOVE_CARGO: 'platform:overseas-warehouse:dispatch:edit',
|
ADD_CARGO: 'platform:overseas-warehouse:dispatch:edit', REMOVE_CARGO: 'platform:overseas-warehouse:dispatch:edit',
|
||||||
CANCEL: 'platform:overseas-warehouse:dispatch:cancel', SUBMIT_REVIEW: 'platform:overseas-warehouse:dispatch:edit',
|
CANCEL: 'platform:overseas-warehouse:dispatch:cancel', SUBMIT_REVIEW: 'platform:overseas-warehouse:dispatch:edit',
|
||||||
DISPATCH_REVIEW: 'platform:fleet:trip:dispatch-review', LOADING_REVIEW: 'platform:fleet:trip:loading-review',
|
DISPATCH_REVIEW: 'platform:fleet:trip:dispatch-review', LOADING_REVIEW: 'platform:fleet:trip:loading-review',
|
||||||
@@ -36,3 +36,6 @@ export function dispatchRejectionReason(reviews: Reviews | undefined): string {
|
|||||||
.filter(([, review]) => review.status === 'REJECTED')
|
.filter(([, review]) => review.status === 'REJECTED')
|
||||||
.map(([label, review]) => `${label}退回:${review.reason?.trim() || '原因未提供'}`).join(';')
|
.map(([label, review]) => `${label}退回:${review.reason?.trim() || '原因未提供'}`).join(';')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dispatchExceptionLabels = { REFUSED: '拒收', PARTIAL_RECEIPT: '部分签收', VEHICLE_EXCEPTION: '车辆异常' } as const
|
||||||
|
export const dispatchReturnDirectionLabels = { OVERSEAS_WAREHOUSE: '退回海外仓', OTHER: '其他' } as const
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, type SelectionGroup } from './dispatchContracts'
|
import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, selectionGroupBlockReason, type SelectionGroup } from './dispatchContracts'
|
||||||
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
|
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
|
||||||
import { normalizeCandidateList, type CandidateWire } from './dispatchWire'
|
import { normalizeCandidateList, type CandidateWire } from './dispatchWire'
|
||||||
|
|
||||||
@@ -41,3 +41,9 @@ it('normalizes nested group Long quantities and preserves source identities and
|
|||||||
value.selectionGroup.sources[0]!.packages = '9007199254740993'
|
value.selectionGroup.sources[0]!.packages = '9007199254740993'
|
||||||
expect(() => normalizeCandidateList({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
|
expect(() => normalizeCandidateList({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('deduplicates row and full-group blockers while preserving distinct reasons', () => {
|
||||||
|
const row = candidate({ blockReasons: ['换标作业轮次缺失', '实托绑定缺失'] })
|
||||||
|
row.selectionGroup.blockReasons = ['换标作业轮次缺失', '实托绑定缺失', '同托库存未核齐']
|
||||||
|
expect(selectionGroupBlockReason(row)).toBe('换标作业轮次缺失;实托绑定缺失;同托库存未核齐')
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function sharedPalletCandidates(): Candidate[] {
|
|||||||
const group = { groupKey: 'physical-group-21', groupVersion: 'composition-1', complete: true, blockReasons: [], sources: rows.map(row => row.selectionGroup.sources[0]!), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: rows.map(row => row.sourceKey), packageCount: 8 }] }
|
const group = { groupKey: 'physical-group-21', groupVersion: 'composition-1', complete: true, blockReasons: [], sources: rows.map(row => row.selectionGroup.sources[0]!), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: rows.map(row => row.sourceKey), packageCount: 8 }] }
|
||||||
return rows.map(row => ({ ...row, selectionGroup: group }))
|
return rows.map(row => ({ ...row, selectionGroup: group }))
|
||||||
}
|
}
|
||||||
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } }
|
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } }
|
||||||
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
|
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
|
||||||
export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
|
export function 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 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 }] } } }
|
||||||
|
|||||||
@@ -203,3 +203,16 @@ describe('whole-dispatch four-tab detail', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows saved dispatch exceptions and their own images in the existing dispatch tab', async () => {
|
||||||
|
const value = detail({ stage: 'DELIVERING' })
|
||||||
|
value.dispatchInfo.exceptions = [{ exceptionType: 'REFUSED', returnDirection: 'OVERSEAS_WAREHOUSE', description: '现场拒收待处理', imageFileIds: ['901'], occurredAt: '2026-10-21T10:00:00Z' }]
|
||||||
|
vi.mocked(getDispatch).mockResolvedValue(value)
|
||||||
|
vi.mocked(getDispatchEvidence).mockResolvedValue({ fileId: '901', fileName: '异常现场.png', contentType: 'image/png', sizeBytes: 3 })
|
||||||
|
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||||
|
await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('拒收'); expect(wrapper.text()).toContain('退回海外仓'); expect(wrapper.text()).toContain('现场拒收待处理')
|
||||||
|
expect(getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'EXCEPTION', '901')
|
||||||
|
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4); expect(wrapper.findAll('input[type="file"]')).toHaveLength(0)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|||||||
import { getDispatch } from '../api/dispatches'
|
import { getDispatch } from '../api/dispatches'
|
||||||
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
|
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||||
import { stageLabel } from '../dispatchContracts'
|
import { stageLabel } from '../dispatchContracts'
|
||||||
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||||||
@@ -89,7 +89,13 @@ onBeforeUnmount(() => { generation++ })
|
|||||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" 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-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
<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 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="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="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><section v-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
|
||||||
|
<h3>派送异常记录</h3>
|
||||||
|
<article v-for="(record, index) in detail.dispatchInfo.exceptions" :key="index">
|
||||||
|
<dl class="density-fields"><div class="density-field"><dt>异常类型</dt><dd>{{ dispatchExceptionLabels[record.exceptionType] }}</dd></div><div class="density-field"><dt>登记时间</dt><dd>{{ time(record.occurredAt) }}</dd></div><div class="density-field"><dt>退货去向</dt><dd>{{ record.returnDirection ? dispatchReturnDirectionLabels[record.returnDirection] : '—' }}</dd></div><div class="density-field"><dt>异常说明</dt><dd>{{ record.description || '—' }}</dd></div></dl>
|
||||||
|
<DispatchEvidenceFiles v-if="record.imageFileIds.length" :model-value="record.imageFileIds" :dispatch-no="dispatchNo" kind="EXCEPTION" label="异常图片" readonly />
|
||||||
|
</article>
|
||||||
|
</section></el-tab-pane>
|
||||||
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
|
<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>
|
<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>
|
||||||
<div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div>
|
<div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div>
|
||||||
|
|||||||
@@ -204,3 +204,18 @@ it('clears an earlier input rejection after the corrected workflow succeeds', as
|
|||||||
expect(wrapper.text()).not.toContain('请补齐本趟资料')
|
expect(wrapper.text()).not.toContain('请补齐本趟资料')
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens the exception form from the dispatching row and hides it without permission', async () => {
|
||||||
|
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||||
|
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'DELIVERING', actions: { MARK_EXCEPTION: gate } })]))
|
||||||
|
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||||
|
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||||
|
expect(row.props('actions')).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'MARK_EXCEPTION', label: '标记异常', tone: 'danger' })]))
|
||||||
|
row.vm.$emit('action', 'MARK_EXCEPTION'); await flushPromises()
|
||||||
|
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'MARK_EXCEPTION', dispatchNo: 'PC/1' })
|
||||||
|
wrapper.unmount(); can.mockReturnValue(false)
|
||||||
|
const denied = mount(DispatchesView, options); await flushPromises()
|
||||||
|
const deniedRow = denied.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||||
|
expect((deniedRow.props('actions') as Array<{ key: string }>).map(item => item.key)).not.toContain('MARK_EXCEPTION')
|
||||||
|
denied.unmount()
|
||||||
|
})
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function search(value: QueryModel) { model.value = value; page.value = 1; void l
|
|||||||
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; 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 switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
|
||||||
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
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', 'MARK_EXCEPTION', 'CANCEL']
|
||||||
function rowActions(row: DispatchListRow): RowActionItem[] {
|
function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||||
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
||||||
const review = section.value
|
const review = section.value
|
||||||
@@ -92,7 +92,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
|||||||
label: dispatchActionLabels[value],
|
label: dispatchActionLabels[value],
|
||||||
ariaLabel: dispatchActionLabels[value],
|
ariaLabel: dispatchActionLabels[value],
|
||||||
priority: value === primaryAction ? 600 : 400 - index * 10,
|
priority: value === primaryAction ? 600 : 400 - index * 10,
|
||||||
tone: value === 'CANCEL' ? 'danger' as const : undefined,
|
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? '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 }]
|
})), { 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 }]
|
||||||
}
|
}
|
||||||
function handleAction(value: string, row: DispatchListRow) {
|
function handleAction(value: string, row: DispatchListRow) {
|
||||||
|
|||||||
@@ -71,3 +71,25 @@ it.each(['warehouse', 'page', 'version'])('rejects a mismatched %s response inst
|
|||||||
await state.load({ warehouseId: '321' })
|
await state.load({ warehouseId: '321' })
|
||||||
expect(state.candidates.value).toEqual([]); expect(state.error.value).not.toBeNull()
|
expect(state.candidates.value).toEqual([]); expect(state.error.value).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['success', 'failure'])('keeps rows stable during a refresh but rejects their use until %s is resolved', async outcome => {
|
||||||
|
const state = setup(), refresh = deferred()
|
||||||
|
vi.mocked(listDispatchCandidates).mockResolvedValueOnce(response()).mockReturnValueOnce(refresh.promise)
|
||||||
|
await state.load({ warehouseId: '321' })
|
||||||
|
const previous = state.candidates.value
|
||||||
|
const request = state.load({ warehouseId: '321' })
|
||||||
|
expect(state.candidates.value).toBe(previous)
|
||||||
|
expect(state.total.value).toBe(41)
|
||||||
|
expect(() => state.assertCurrent(previous)).toThrow('尚未读取完成')
|
||||||
|
if (outcome === 'success') refresh.resolve(response({ total: 1 }))
|
||||||
|
else refresh.reject(new Error('refresh failed'))
|
||||||
|
await request
|
||||||
|
if (outcome === 'success') {
|
||||||
|
expect(state.total.value).toBe(1)
|
||||||
|
expect(() => state.assertCurrent(state.candidates.value)).not.toThrow()
|
||||||
|
} else {
|
||||||
|
expect(state.candidates.value).toEqual([])
|
||||||
|
expect(state.error.value?.message).toBeTruthy()
|
||||||
|
expect(() => state.assertCurrent(previous)).toThrow('尚未读取完成')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ import { DispatchInputError, assertSelectionGroupVersions, type Candidate, type
|
|||||||
/** One visible server page; selected cargo remains owned by the caller or server draft. */
|
/** One visible server page; selected cargo remains owned by the caller or server draft. */
|
||||||
export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates) {
|
export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates) {
|
||||||
const candidates = ref<Candidate[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
|
const candidates = ref<Candidate[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
|
||||||
let generation = 0, warehouse = ''
|
let generation = 0, warehouse = '', current = false
|
||||||
let pending: { key: string; promise: Promise<void> } | undefined
|
let pending: { key: string; promise: Promise<void> } | undefined
|
||||||
const groupVersions = new Map<string, string>()
|
const groupVersions = new Map<string, string>()
|
||||||
const sourceGroups = new Map<string, { groupKey: string; sourceVersion: string }>()
|
const sourceGroups = new Map<string, { groupKey: string; sourceVersion: string }>()
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
generation++; pending = undefined; warehouse = ''
|
generation++; pending = undefined; warehouse = ''; current = false
|
||||||
candidates.value = []; page.value = 1; total.value = 0; loading.value = false
|
candidates.value = []; page.value = 1; total.value = 0; loading.value = false
|
||||||
groupVersions.clear(); sourceGroups.clear()
|
groupVersions.clear(); sourceGroups.clear()
|
||||||
}
|
}
|
||||||
function assertCurrent(rows: Candidate[]) {
|
function assertCurrent(rows: Candidate[]) {
|
||||||
|
if (rows.length && (loading.value || !current)) throw new DispatchInputError('货物候选尚未读取完成,请刷新后重试')
|
||||||
assertSelectionGroupVersions(rows, candidates.value)
|
assertSelectionGroupVersions(rows, candidates.value)
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const group = row.selectionGroup
|
const group = row.selectionGroup
|
||||||
@@ -34,8 +35,9 @@ export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchC
|
|||||||
const key = JSON.stringify(request)
|
const key = JSON.stringify(request)
|
||||||
if (pending?.key === key) return pending.promise
|
if (pending?.key === key) return pending.promise
|
||||||
const sequence = ++generation
|
const sequence = ++generation
|
||||||
// Preserve the last total while loading: zero would make the pager emit page 1.
|
// Keep the previous page visible but non-actionable until its replacement is validated.
|
||||||
loading.value = true; error.value = null; candidates.value = []
|
// Preserve the total too: zero would make the pager emit page 1.
|
||||||
|
loading.value = true; current = false; error.value = null
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await fetchCandidates(request)
|
const result = await fetchCandidates(request)
|
||||||
@@ -46,9 +48,13 @@ export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchC
|
|||||||
groupVersions.set(row.selectionGroup.groupKey, row.selectionGroup.groupVersion)
|
groupVersions.set(row.selectionGroup.groupKey, row.selectionGroup.groupVersion)
|
||||||
for (const source of row.selectionGroup.sources) sourceGroups.set(source.sourceKey, { groupKey: row.selectionGroup.groupKey, sourceVersion: source.selection.sourceVersion })
|
for (const source of row.selectionGroup.sources) sourceGroups.set(source.sourceKey, { groupKey: row.selectionGroup.groupKey, sourceVersion: source.selection.sourceVersion })
|
||||||
}
|
}
|
||||||
|
current = true
|
||||||
candidates.value = result.candidates; page.value = result.page; size.value = result.size; total.value = result.total
|
candidates.value = result.candidates; page.value = result.page; size.value = result.size; total.value = result.total
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败')
|
if (sequence === generation) {
|
||||||
|
candidates.value = []; current = false
|
||||||
|
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (sequence === generation) { loading.value = false; pending = undefined }
|
if (sequence === generation) { loading.value = false; pending = undefined }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user