Compare commits

...
Author SHA1 Message Date
ouyangxq b835c1cdfc fix(admin): 补齐运输资源候选显隐事件类型 2026-10-21 18:08:06 +08:00
ouyangxq ba072611dc 优化 2026-10-21 18:05:02 +08:00
ouyangxq 98049c4f8c 优化 2026-10-21 17:57:02 +08:00
ouyangxq 64667af5c6 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-21 17:56:57 +08:00
ouyangxq 4f5cec1b94 优化 2026-10-21 17:49:04 +08:00
liuyihe c367fadf09 Merge release/test and preserve pickup execution and photo workflows 2026-10-21 17:35:43 +08:00
liuyihe fde16235ea 优化 2026-10-21 17:28:04 +08:00
ouyangxq 22e60248d2 优化 2026-10-21 17:26:49 +08:00
ouyangxq f8c9334e98 优化 2026-10-21 17:22:25 +08:00
ouyangxq 67acd02ea5 优化 2026-10-21 17:08:59 +08:00
ouyangxq c510cc4e2e 优化 2026-10-21 16:58:38 +08:00
ouyangxq e2f99b4f4b 优化 2026-10-21 16:40:10 +08:00
liuyihe 5e8daad5a0 Merge origin/release/test and preserve pickup workflow 2026-10-21 16:22:08 +08:00
liuyihe 215cb7c6d5 优化 2026-10-21 16:12:24 +08:00
ouyangxq 8356e98ce3 优化 2026-10-21 15:53:21 +08:00
ouyangxq 6ab3bbe289 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-21 15:53:19 +08:00
ouyangxq a4c7f8edd5 优化 2026-10-21 15:49:35 +08:00
liuyihe a9c0285e22 优化 2026-10-21 15:20:24 +08:00
ouyangxq e6c864a2a6 优化 2026-10-21 15:10:54 +08:00
ouyangxq 457a95941d style(fleet): unify owned business row action buttons 2026-10-21 15:05:56 +08:00
ouyangxq fc4c14e420 fix(fleet): accept vehicle ownership for pool-based business lists 2026-10-21 14:57:17 +08:00
ouyangxq 8498798a9c 优化 2026-10-21 14:30:33 +08:00
104 changed files with 3322 additions and 918 deletions
+5 -5
View File
@@ -3,9 +3,8 @@
## 快速读取顺序
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
2. 视觉、布局/交互承载变更或真实页面验收先读 `../../docs/Admin-Client前端原型还原验收规则.md`;需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发。
样式优化另读 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”“新模块接入规则”,保留已确认密度、彩色页签、业务语义及承载。国内仓、客户管理为样板;新增菜单按本轮范围显式接入,货柜入口见 `../../docs/Admin货柜管理样式核对记录.md`。
蓝白样式接入见 [样板指南](../../docs/界面参考/蓝白卡片式工作台风格参考.md)。
2. Admin 样式唯一入口为 [Admin 界面规范](../../docs/Admin高密度界面规范.md#按任务阅读),按任务只读对应章节;提示小字、加载布局、蓝白风格及密度不再分散查多份规范。样板目录仅在需要视觉参考时查,模块核对记录仅在核对范围、业务例外或验收证据时查。
视觉、布局/交互承载变更或真实页面验收再读 [前端验收规则](../../docs/Admin-Client前端原型还原验收规则.md);需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发验收矩阵。
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
@@ -99,7 +98,7 @@
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
- 页面和交互必须服从对应原型及验收规则;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
- 页面外观遵循 Admin 界面规范,业务及操作承载核对当前契约、逐操作确认与原型证据;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
## 开发验收身份
@@ -121,7 +120,8 @@ pnpm typecheck
pnpm build
```
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。
- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
@@ -108,7 +108,8 @@ describe('four-type appointment calendar', () => {
expect(wrapper.text()).toContain('正在读取安排')
expect(wrapper.text()).not.toContain('当天暂无安排')
await wrapper.setProps({ loading: false, incomplete: true })
expect(wrapper.text()).toContain('部分数据不可用')
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('当前已加载 0 项安排')
expect(wrapper.text()).not.toContain('部分数据不可用')
expect(wrapper.text()).not.toContain('当天暂无安排')
})
})
@@ -70,9 +70,9 @@ function changeMode(next: CalendarMode) {
</div>
</div>
<aside class="calendar-detail workbench-panel is-flush" aria-label="当天安排">
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排</p></header>
<div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
@@ -161,7 +161,7 @@ describe('simplified appointment entry', () => {
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
it('retains a complete dispatch with its container search fields while naming a different unresolved dispatch and its reason', async () => {
it('retains a complete dispatch with its container search fields without showing an unresolved dispatch notice', async () => {
const good = dispatchEntry()
const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] })
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] })
@@ -173,11 +173,7 @@ describe('simplified appointment entry', () => {
expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
expect(panel.props('incomplete')).toBe(true)
const notice = wrapper.get('.data-notice').text()
expect(notice).toContain('1 条排车未能定位到日历日期')
expect(notice).toContain('PC-TIMEZONE-MISSING:起运仓时区待核实')
expect(notice).toContain('无法确定当地日期')
expect(notice).not.toContain('PC-CALENDAR-001')
expect(wrapper.find('.data-notice').exists()).toBe(false)
expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false)
expect(listTripCalendar).toHaveBeenCalledTimes(1)
wrapper.unmount()
@@ -16,7 +16,7 @@ import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
import { calendarRange, dateKey, type CalendarMode } from './calendar'
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
import { listTripCalendar, projectTripCalendar, tripIssues } from './tripCalendar'
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
import type { PlanningSourceLookup } from './planningApi'
import type { WorkAction } from './workApi'
@@ -37,7 +37,8 @@ const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mo
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
const hasUnresolvedTrips = ref(false)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length || hasUnresolvedTrips.value)
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
let generation = 0, controller: AbortController | null = null
@@ -46,6 +47,7 @@ async function load(next = range.value) {
const request = ++generation; controller?.abort(); controller = new AbortController()
const signal = controller.signal
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
hasUnresolvedTrips.value = false
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
await Promise.allSettled([
(async () => {
@@ -71,10 +73,7 @@ async function load(next = range.value) {
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
if (request !== generation) return
trips.value = result.events
if (result.unresolved.length) {
const details = result.unresolved.slice(0, 3).map(row => `${row.dispatchNo?.trim() || '排车单号缺失'}:${tripIssues(row) || '资料待核实'}`).join(';')
notices.value.push(`${result.unresolved.length} 条排车未能定位到日历日期(${details}${result.unresolved.length > 3 ? ';其余请到排车列表核对' : ''})。`)
}
hasUnresolvedTrips.value = result.unresolved.length > 0
} catch (cause) { fail('排车读取失败', cause) }
})(),
])
@@ -0,0 +1,98 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import AppointmentConfirmationCargo from './AppointmentConfirmationCargo.vue'
import { resolvePlanningOrder, resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
const access = vi.hoisted(() => ({ read: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.read }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanningSource: vi.fn() }))
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] }
const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } }
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
function render(active = true, disabled = false) {
return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
InlineHelp: { template: '<span />' }, ApiErrorMeta: true,
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试核对</button></div>' },
} } })
}
beforeEach(() => {
vi.clearAllMocks(); access.read = true
vi.mocked(resolvePlanningOrder).mockResolvedValue(resolved)
vi.mocked(resolvePlanningSource).mockResolvedValue(resolved)
vi.mocked(getCargoVerification).mockResolvedValue(context)
vi.mocked(saveCargoVerification).mockResolvedValue({ version: 1, sourceVersion: source.sourceVersion, destinationKey: source.destinationKey, sourcePackages: 20, packageCount: 20, shipmentReferences: ['FBA-TEST'], remark: null, operatorName: '测试员', verifiedAt: '2026-10-21T03:00:00Z' })
})
it('names the missing material and supplements the canonical booking inside the appointment', async () => {
const wrapper = render(); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).not.toHaveBeenCalled()
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'], expectedSourceVersion: source.sourceVersion }))
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
})
it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
const wrapper = render(false); await flushPromises()
expect(resolvePlanningOrder).not.toHaveBeenCalled()
await wrapper.setProps({ active: true }); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('does not ask for known materials to be entered again', async () => {
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
})
it('retains unsaved inputs when the appointment mode changes and blocks leaving an unknown save outcome', async () => {
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-KEEP')
await wrapper.setProps({ active: false })
expect(wrapper.get('textarea[aria-label="本票货件号"]').isVisible()).toBe(true)
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce(new Error('keep editing'))
expect(await wrapper.vm.canLeave()).toBe(false)
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('connection lost'))
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(await wrapper.vm.canLeave()).toBe(false)
const payload = vi.mocked(saveCargoVerification).mock.calls[0]![1]
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenNthCalledWith(2, source, payload); wrapper.unmount()
})
it('reports unknown quantity and unmaterialized cargo without inventing values', async () => {
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, source: { ...source, orderId: null } })
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: null })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('缺少本票箱数'); expect(wrapper.text()).toContain('原来货登记完成实收关联')
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false]); wrapper.unmount()
})
it('keeps failed reads blocked, supports retry, and denies reads without permission', async () => {
vi.mocked(resolvePlanningOrder).mockRejectedValueOnce(new Error('network lost'))
const wrapper = render(); await flushPromises()
expect(wrapper.find('[role=alert]').exists()).toBe(true)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false])
await wrapper.get('[role=alert] button').trigger('click'); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledTimes(2); wrapper.unmount()
vi.clearAllMocks(); access.read = false
const denied = render(); await flushPromises()
expect(resolvePlanningOrder).not.toHaveBeenCalled(); expect(denied.text()).toContain('查看权限'); denied.unmount()
})
it('keeps the supplement action disabled for read-only appointment views', async () => {
const wrapper = render(true, true); await flushPromises()
expect(wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.attributes('disabled')).toBeDefined()
expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import CargoVerificationForm from './CargoVerificationForm.vue'
import { getCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
import { resolvePlanningOrder, resolvePlanningSource, type PlanningSourceLookup, type ResolvedPlanningSource } from './planningApi'
import type { LeaveGuard } from './useWorkDraft'
const props = defineProps<{ sources: PlanningSourceLookup[]; active: boolean; disabled?: boolean }>()
const emit = defineEmits<{ ready: [value: boolean] }>()
const access = useAccessPreviewStore()
type Row = { resolved: ResolvedPlanningSource; cargo: CargoVerificationContext }
const rows = ref<Row[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const editing = ref<string | null>(null), editor = ref<InstanceType<typeof CargoVerificationForm> | null>(null)
const editingRow = computed(() => rows.value.find(row => key(row) === editing.value))
let generation = 0
const guards = new Set<LeaveGuard>()
const register = inject<((guard: LeaveGuard) => () => void) | undefined>('appointment-work-leave-guard', undefined)
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() { for (const guard of guards) if (!await guard()) return false; return true }
const unregister = register?.(canLeave)
defineExpose({ canLeave })
function key(row: Row) { return `${row.resolved.source.customerId}:${row.resolved.source.sourceKey}` }
function issues(row: Row) {
const missing: string[] = []
if (!row.resolved.source.orderId) missing.push('尚未来货建单,请先在原来货登记完成实收关联')
if (row.cargo.effectivePackages == null || row.cargo.effectivePackages <= 0) missing.push('缺少本票箱数')
if (row.resolved.coverage.missingFields.includes('FBA_SHIPMENT_REFERENCES')) missing.push('缺少本票 FBA 货件号或追踪号(至少一种)')
if (row.cargo.sourceChanged) missing.push('原货物资料已变化,请重新核对箱数和货件号')
return missing
}
const ready = computed(() => rows.value.length === props.sources.length && rows.value.length > 0 && !loading.value && !error.value && !editing.value && rows.value.every(row => !issues(row).length))
watch(ready, value => emit('ready', value), { immediate: true })
async function load() {
const current = ++generation
loading.value = true; error.value = null
try {
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) {
error.value = { message: '无法读取本票核实资料,请联系有预约列表和尾程订单查看权限的同事补齐。' }; return
}
const result: Row[] = []
// 只核对本次明确选择的货物,并由服务端解析原 ORDER / BOOKING 身份。
for (const lookup of props.sources) {
const resolved = 'sourceKey' in lookup ? await resolvePlanningSource(lookup) : await resolvePlanningOrder(lookup)
if (current !== generation) return
const cargo = await getCargoVerification(resolved.source)
if (current !== generation) return
result.push({ resolved, cargo })
}
if (current === generation) rows.value = result
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '货物缺项读取失败,请重试核对') }
finally { if (current === generation) loading.value = false }
}
async function closeEditor() {
if (editor.value && !await editor.value.canLeave()) return
editing.value = null
}
async function saved() { editing.value = null; await load() }
watch(() => [props.active, JSON.stringify(props.sources)] as const, ([active]) => { if (active && !editing.value) void load() }, { immediate: true })
onBeforeUnmount(() => { ++generation; unregister?.() })
</script>
<template>
<section v-show="active || editing" class="density-dialog-section" data-tone="source" aria-label="正式确认前的货物资料" :aria-busy="loading">
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>需要补充的资料</th></tr></thead><tbody>
<tr v-for="row in rows" :key="key(row)">
<td><el-button v-if="issues(row).length && row.resolved.source.orderId" :disabled="disabled || loading || !!editing || !row.cargo.allowedActions.includes('VERIFY_CARGO')" @click="editing = key(row)">在此补齐资料</el-button><span v-else>—</span></td>
<td>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td>
<td>{{ row.cargo.effectivePackages ?? '待补充' }}</td>
<td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td>
</tr>
</tbody></table>
<div v-if="editingRow">
<CargoVerificationForm ref="editor" :source="editingRow.resolved.source" :disabled="disabled" confirmation @saved="saved" />
<el-button :disabled="disabled" @click="closeEditor">收起补充资料</el-button>
</div>
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。暂时拿不到资料,可先选择“先保存安排,等待对方确认”。</p>
</section>
</template>
@@ -42,13 +42,13 @@ const columns: DataTableColumn[] = [
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '箱数', width: 80 },
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
{ key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' },
]
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
function reason(row: PlanningCargo) {
if (row.detained) return '货物暂扣中'
if (row.mixedDestinations) return '多个目的地,需先核对'
if (row.hasActiveAppointment) return '已有有效预约'
if (row.hasActiveAppointment) return '已有预约安排,请到入库单编辑原预约'
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
@@ -10,15 +10,16 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ source: CargoVerificationSource }>()
const props = defineProps<{ source: CargoVerificationSource; confirmation?: boolean; disabled?: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const context = ref<CargoVerificationContext | null>(null), loading = ref(false), saving = ref(false)
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<SaveCargoVerification | null>(null)
const form = reactive({ packages: '', references: '', remark: '' }), baseline = ref(JSON.stringify(form))
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
const canLeave = useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
defineExpose({ canLeave })
const canWrite = computed(() => !props.disabled && access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
async function load(preserve = false) {
loading.value = true; error.value = null
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) { error.value = { message: '当前账号没有该货源的读取权限,请联系预约负责人处理。' }; loading.value = false; return }
@@ -26,7 +27,7 @@ async function load(preserve = false) {
context.value = await getCargoVerification(props.source)
if (!preserve) {
const record = context.value.verification
Object.assign(form, { packages: record?.packageCount == null ? '' : String(record.packageCount), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
baseline.value = JSON.stringify(form)
}
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
@@ -39,6 +40,7 @@ async function save() {
if (count !== null && (!Number.isSafeInteger(count) || count < 1)) { ElMessage.warning('核实箱数须为正整数,暂未核实可留空'); return }
if (count !== null && (count < context.value.allocatedPackages || (context.value.source.packages != null && count > context.value.source.packages))) { ElMessage.warning('核实箱数不能低于已分配量,也不能超过已知原箱数'); return }
const references = [...new Set(form.references.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
if (props.confirmation && (count === null || !references.length)) { ElMessage.warning('请填写本票箱数,以及至少一个 FBA 货件号或追踪号;外部约号不能代替货件号'); return }
if (references.length > 100 || references.some(value => value.length > 128)) { ElMessage.warning('最多填写100条货件号,每条不超过128字'); return }
pending.value = { destinationKey: context.value.source.destinationKey, expectedSourceVersion: context.value.source.sourceVersion, expectedVersion: Number(context.value.verification?.version || 0), packageCount: count, shipmentReferences: references, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() }
}
@@ -56,14 +58,15 @@ onMounted(() => load())
</script>
<template>
<section aria-label="货物核实资料">
<h3>核实货物资料<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
<h3>{{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
<p v-if="confirmation">请向客户或货物负责人取得本票完整的 FBA 货件号或追踪号,每行一个;不要填写预约外部约号。下方保存只补充货物资料,完成后还需保存预约安排。</p>
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在读取货物资料…</p>
<template v-if="context">
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></dl>
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><template v-if="!confirmation"><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></template></dl>
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<form class="density-form work-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
<label>核实箱数(可暂不填)<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
<label>{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
@@ -9,7 +9,7 @@ vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn()
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), deleteAppointment: vi.fn() }))
const detail: FbaAppointmentDto = { id: '1', appointmentNo: 'AP-001', batchKey: 'FBA_APPOINTMENT:1', version: 3, originWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', selectedCount: 1, items: [], appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-001', appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', appointmentStartUtc: '2026-10-16T06:30:00Z', appointmentEndUtc: '2026-10-16T08:00:00Z', timezone: 'America/Los_Angeles' }
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', mounted() { this.$emit('confirmationReady', true) }, props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) })
it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => {
vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用'))
@@ -13,6 +13,8 @@ import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
const confirmationReady = ref(false)
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(appointmentPayload(form)))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
let initialized = false
@@ -44,6 +46,9 @@ async function save() {
pending.value = { kind: 'cancel', version: detail.value.version, reason: reason.value.trim() }
} else {
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
if (saving.value || !writable.value) return
pending.value = { kind: 'update', payload: { details: appointmentPayload(form), version: detail.value.version, idempotencyKey: crypto.randomUUID() } }
}
}
@@ -70,7 +75,7 @@ onMounted(() => load())
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" class="workbench-input" /></label></template>
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
<FbaAppointmentFields v-else ref="appointmentFields" :sources="detail.items.map(item => ({ sourceKey: item.sourceKey, customerId: item.customerId, destinationKey: item.destinationKey || `FBA:${item.fbaWarehouseCode}` }))" @confirmation-ready="confirmationReady = $event" :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
<p v-if="!writable">当前预约仅可查看。</p>
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
@@ -18,7 +18,7 @@ beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(previewPla
it('shows readable preview requirements without exposing missing-field machine codes', async () => {
vi.mocked(previewPlanningBatch).mockResolvedValue({ ...preview, missingFields: ['READY_WINDOW_UNVERIFIED', 'CARGO_QUANTITY_REVIEW'] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('货物备妥窗口待核实')
expect(wrapper.text()).not.toContain('货物备妥窗口待核实')
expect(wrapper.text()).toContain('货量或重复关联需要核对。')
expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED')
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
@@ -13,10 +13,13 @@ import { workReason } from './workPresentation'
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
const confirmationReady = ref(false)
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(form))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const pending = ref<SourceAppointmentPayload | null>(null)
const writable = computed(() => props.canWrite && access.can(overseasWarehousePermissions.inboundOrderAppointment))
const visibleMissingFields = computed(() => preview.value?.missingFields.filter(field => field !== 'READY_WINDOW_UNVERIFIED') || [])
useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
async function load() {
if (!props.sources.length || props.sources.some(source => !source.customerId || !source.sourceKey || !source.destinationKey)) { error.value = { message: '缺少完整货源、客户或目的仓范围,无法新建预约。' }; return }
@@ -29,6 +32,9 @@ async function save() {
if (!writable.value || loading.value || saving.value || error.value || !preview.value) return
if (!pending.value) {
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
if (saving.value || !writable.value) return
pending.value = { originWarehouseId: preview.value.group.originWarehouseId, fbaWarehouseCode: preview.value.destinationWarehouseCode, sources: preview.value.sources.map(source => ({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID() }
}
saving.value = true; saveError.value = null
@@ -55,10 +61,9 @@ onMounted(load)
<tr><th scope="row">票数</th><td>{{ preview.group.sourceCount }}</td><th scope="row">箱数</th><td>{{ preview.group.packages ?? '箱数待核实' }}</td></tr>
<tr><th scope="row">货物业务号</th><td colspan="3"><template v-for="(source, index) in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ index ? '、' : '' }}{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</template></td></tr>
</tbody></table>
<p v-for="field in preview.missingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
<p v-if="preview.group.readinessIncomplete" role="status" class="density-dialog-note">货物备妥窗口待核实。</p>
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
</section>
<FbaAppointmentFields continuous :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<FbaAppointmentFields continuous ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
</template>
@@ -1,2 +1,4 @@
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
export { default as AppointmentConfirmationCargo } from './AppointmentConfirmationCargo.vue'
export type { PlanningSourceLookup } from './planningApi'
+5 -5
View File
@@ -1,4 +1,4 @@
import { addDays } from './calendar'
import { projectWarehouseArrival } from '@/modules/container-loading'
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
@@ -36,12 +36,12 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
if (warehouses === null) continue
const warehouse = warehouseMap.get(row.containerBookingId)
warehouseMap.delete(row.containerBookingId)
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
const projected = projectWarehouseArrival(row.arrivalAt, warehouse)
const planned = projected.date
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,
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 },
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
@@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => {
beforeEach(() => vi.clearAllMocks())
it('queries visible containers and updates the whole-container expected arrival', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] })
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.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }] })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never)
await listContainerArrivalTimes(['9001'])
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.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
actualWarehouseArrivalAt: string | null
version: number
planCount: number
}
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
if (typeof item.planCount !== 'number' && (typeof item.planCount !== 'string' || !/^(0|[1-9]\d*)$/.test(item.planCount))) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
const count = Number(item.planCount)
if (item.planCount == null || !Number.isSafeInteger(count) || count < 0) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
return { ...item, version: Number(item.version), planCount: count }
}
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
@@ -20,7 +28,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin
queryPath,
{ sourceContainerBookingIds },
)
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
return (unwrapApiResponse(response) || []).map(normalizeArrival)
}
export async function updateContainerExpectedArrival(
@@ -33,5 +41,5 @@ export async function updateContainerExpectedArrival(
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
)
const result = unwrapApiResponse(response)
return { ...result, version: Number(result.version || 0) }
return normalizeArrival(result)
}
+1
View File
@@ -1,5 +1,6 @@
export * from './api/platformContainerLoading'
export * from './types'
export { projectWarehouseArrival } from './warehouseArrival'
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
@@ -57,6 +57,14 @@ function accessibleText(wrapper: { element: Element } | undefined) {
}
describe('ContainerDetailView empty return', () => {
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
const wrapper = await mountDetail()
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
expect(value).toBe('2026-10-23(默认推算)')
expect(value).not.toContain('00:00')
})
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
const wrapper = await mountDetail('DOMESTIC')
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
@@ -83,7 +91,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never)
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
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 () => {
@@ -46,6 +46,7 @@ import {
} from '../api/platformContainerLoading'
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import { projectWarehouseArrival } from '../warehouseArrival'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
@@ -91,6 +92,14 @@ async function rememberWorkbenchTab(tabName: string | number) {
}
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
const arrivalTime = ref<ContainerArrivalTime | null>(null)
const expectedArrivalText = computed(() => {
const row = workbenchDetail.value
const arrival = arrivalTime.value
const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt,
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
: projected.date ? `${projected.date}(默认推算)` : '—'
})
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
@@ -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">
<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 v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
@@ -74,8 +74,8 @@ describe('ContainersView', () => {
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1, planCount: 0 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
@@ -584,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) => {
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
@@ -40,6 +40,7 @@ import {
type TerminalDrayageView,
} from '../api/terminalDrayages'
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import { projectWarehouseArrival } from '../warehouseArrival'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
@@ -285,6 +286,13 @@ function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function defaultArrivalDate(source: object) {
const row = source as ContainerWorkbenchRow
const arrival = arrivalTimes.value[row.id]
const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt,
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
return projected.isDefault ? projected.date : null
}
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
@@ -577,7 +585,7 @@ async function saveDrayageAction() {
}
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
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)
try {
const updated = await updateContainerExpectedArrival(row.id, {
@@ -760,7 +768,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell"><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 as ContainerWorkbenchRow) ? '到港日期+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 as ContainerWorkbenchRow) ? `${defaultArrivalDate(row as ContainerWorkbenchRow)}(默认)` : '选择预计到仓'" :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 #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
@@ -772,7 +780,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.arrivedDestinationPortAt) }}</template>
</DataTable></div></section>
</ListPageLayout>
+1
View File
@@ -224,6 +224,7 @@ export interface ContainerWorkbenchStatusSummary {
}
export interface ContainerWorkbenchActualFacts {
arrivedDestinationPortAt?: string | null
actualSailingAt?: string | null
actualPortArrivalAt?: 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 }
}
@@ -1086,7 +1086,7 @@ onMounted(() => { void loadCustomers() })
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">基础资料</h3>
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
<tr><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
@@ -1095,7 +1095,7 @@ onMounted(() => { void loadCustomers() })
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td></tr>
<tr><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
@@ -1103,7 +1103,7 @@ onMounted(() => { void loadCustomers() })
</tbody></table>
</section>
<section class="density-dialog-section qualification-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<section class="density-dialog-section qualification-section workbench-evidence"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
<div class="qualification-table" role="table" aria-label="客户资质文件">
@@ -93,9 +93,9 @@ onMounted(() => { void load() })
</ListPageLayout>
<AppDialog class="admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="work"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
<section class="density-dialog-section workbench-evidence"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
<div class="customer-fee-tier-groups">
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section">
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
<div class="customer-fee-tier-table-wrap workbench-evidence"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
@@ -4,8 +4,9 @@
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-work-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-work-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert { background: var(--admin-surface, #fff); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
@@ -37,6 +37,13 @@
删除和文件预览在三类正式页面中均未定义,不自行补充承载方式;海外采购审核按当前对话确认的承载方式实现。
### 2026-10-21 本土卡车审核详情页头修复
| 页面 | 操作 | 确认依据与布局 | 当前实现 | 修复结论与验收 |
|---|---|---|---|---|
| 本土卡车详情 | 从列表点击审核、查看详情 | 用户截图 `codex-clipboard-515ba915-7b67-48de-9914-62cec7371ec5.png` 红框;当前详情密度规范要求返回、编号、状态及操作合并页头 | 标题、状态/刷新、审核/取消拆成三行,标题使用浏览器默认字号 | 接入公共详情页头与连续白底容器:左侧返回、订单号及单一状态徽标,右侧刷新与原业务操作;保留完整编号;待人工 1920×1080、100% 截图确认 |
| 本土卡车详情 | 审核、取消及后续命令 | 本表已确认的居中操作确认弹框 | `TruckOrderActions` 保留既有表单、权限及提交保护 | 只移动页头入口,弹框承载、尺寸和命令保持现状;按既有行为测试回归 |
## 本土卡车需求字段调整
依据本轮客户端截图及用户确认,同步平台订单字段;规则唯一见 `docs/海外仓业务/README.md`“美国本土卡独立下单”。
@@ -113,3 +120,13 @@
| ProcurementNodeActions | 采购详情中的节点命令居中弹框 | 自定义footer绕过AppDialog默认操作区容器;三项操作加入density-actions底部容器,允许换行并保持间距 | 定向回归通过;待人工1920×1080、100%截图确认 |
保持既有命令、草稿、未知结果核对、权限与提交条件;只新增操作区容器。公共AppDialog的直接按钮footer修复由公共样式写者维护,本域未修改公共组件。
## 2026-10-21 本土运输表单布局修复
| 页面 | 操作 | 依据与现有承载 | 当前问题与修复结论 | 验收 |
|---|---|---|---|---|
| 本土卡车 | 新增 | 用户截图 `codex-clipboard-1a93aa11-88f2-4e90-8130-030fa91185f5.png`;既有独立路由及上文统一运输表单矩阵 | 移除居中窄表单的宽度限制及重复分区内距,铺满内容区;接入公共密度表单与分区,保留运输双列、货物三列和字段顺序 | 自动检查通过;待人工 1920×1080、100% 截图确认 |
| 本土卡车 | 编辑 | 既有独立编辑路由,与新增复用运输表单 | 同步宽度、密度与分区;保留回显、状态限制及提交逻辑 | 同上 |
| 本土散板 | 新增、编辑 | 既有独立路由,与卡车复用运输表单 | 同步布局修复,保留运输双列、货物四列及散板专属字段 | 同上 |
本次仅调整 Admin 运输表单展示,不改采购编辑的宽度或 Client 表单。在 `apps/admin-web` 经 `local-check.ps1` 执行:`pnpm exec vitest run src/modules/overseas-business/pages/OverseasBusinessViews.test.ts src/modules/overseas-business/contract.test.ts src/styles/adminDensity.test.ts`,3 文件 115 项通过;`pnpm typecheck`、`pnpm build` 通过。共享完整 `pnpm test`(任务名 `Admin restored dispatch full regression`,13:55:33–14:05:50)退出码 0,覆盖本次未再变化的运输表单源码;其他在途排车变更由对应任务验证,不据此推定其最新快照通过。视觉仍待人工截图确认。
@@ -1,7 +1,9 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import type { FormInstance } from 'element-plus'
import FormSection from '@/shared/components/FormSection.vue'
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
const props = withDefaults(defineProps<{
@@ -22,10 +24,9 @@ defineExpose({
</script>
<template>
<el-form ref="formRef" class="transport-demand-form admin-workbench" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
<el-form ref="formRef" class="transport-demand-form admin-density admin-workbench density-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
<slot name="customer" />
<section class="transport-demand-section">
<h3>运输信息</h3>
<FormSection class="density-form-section" title="运输信息">
<div class="transport-demand-grid">
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
@@ -39,9 +40,8 @@ defineExpose({
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
</div>
</section>
<section class="transport-demand-section">
<h3>货物信息</h3>
</FormSection>
<FormSection class="density-form-section" title="货物信息">
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
@@ -50,9 +50,8 @@ defineExpose({
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
</div>
</section>
<section class="transport-demand-section">
<h3>费用与时效</h3>
</FormSection>
<FormSection class="density-form-section" title="费用与时效">
<div class="transport-demand-grid">
<el-form-item label="预算总价" prop="budgetAmount"><div class="transport-demand-budget">
<el-input-number v-model="model.budgetAmount" :min="0" :precision="2" controls-position="right" />
@@ -63,8 +62,8 @@ defineExpose({
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</div>
</section>
<footer class="transport-demand-actions">
</FormSection>
<footer class="transport-demand-actions density-actions">
<el-button :disabled="submitting" @click="emit('cancel')">取消</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="disabled" @click="emit('submit')">{{ editing ? '保存修改' : '提交订单' }}</el-button>
</footer>
@@ -72,17 +71,12 @@ defineExpose({
</template>
<style scoped>
.transport-demand-form { --el-component-size: 36px; width: 100%; max-width: 1180px; margin: 0; background: var(--workbench-surface); }
.transport-demand-section { padding: 20px 24px 0; border-bottom: 1px solid var(--workbench-border); }
.transport-demand-section h3 { margin: 0 0 16px; color: var(--workbench-text); font-size: 14px; font-weight: 600; }
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
.transport-demand-form { width: 100%; min-width: 0; margin: 0; }
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
.transport-demand-cargo-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.transport-demand-cargo-four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.transport-demand-wide { grid-column: 1 / -1; }
.transport-demand-form :deep(.el-form-item) { min-width: 0; margin-bottom: 18px; }
.transport-demand-form :deep(.el-form-item__label) { height: auto; padding-bottom: 6px; font-size: 13px; line-height: 20px; }
.transport-demand-form :deep(.el-input), .transport-demand-form :deep(.el-input-number), .transport-demand-form :deep(.el-select), .transport-demand-form :deep(.el-date-editor) { width: 100%; }
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: 10px; }
.transport-demand-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; }
.transport-demand-actions :deep(.el-button + .el-button) { margin-left: 0; }
.transport-demand-form :deep(.el-form-item) { min-width: 0; }
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: var(--admin-space-3); }
.transport-demand-actions { justify-content: flex-end; flex-wrap: wrap; padding: var(--admin-space-5) 0; }
</style>
@@ -1,8 +1,10 @@
/* Shared business form layout for purchase and local transport editors. */
.admin-density.overseas-business-editor { width: 100%; max-width: 1120px; }
.admin-density.overseas-business-editor { width: 100%; }
.admin-density.overseas-business-editor:not(.overseas-transport-editor) { max-width: 1120px; }
.overseas-business-editor .overseas-business-form-layout { gap: 0; }
.overseas-business-editor .form-reference { min-width: 0; color: var(--cc-muted); font-size: var(--admin-font-body); overflow-wrap: anywhere; }
.overseas-business-editor .form-panel { min-width: 0; padding: var(--admin-space-5); }
.overseas-business-editor .form-panel { min-width: 0; }
.overseas-business-editor:not(.overseas-transport-editor) .form-panel { padding: var(--admin-space-5); }
.overseas-business-editor .form-grid { display: grid; min-width: 0; gap: 0 var(--admin-space-5); }
.overseas-business-editor .form-grid :is(.el-select, .el-date-editor, .el-input-number) { width: 100%; }
.overseas-business-editor .form-grid small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); font-size: var(--admin-font-caption); }
@@ -94,7 +94,7 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
</script>
<template>
<AdminPage class="admin-density overseas-business-editor admin-workbench"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout workbench-panel is-flush is-page-surface" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<AdminPage class="admin-density overseas-business-editor overseas-transport-editor admin-workbench"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout workbench-panel is-flush is-page-surface" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<FleetArrangementSummary v-else-if="fleetArrangement" :arrangement="fleetArrangement" />
@@ -102,14 +102,10 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
<TransportDemandForm ref="formRef" :model="form" :kind="isLooseboard ? 'looseboard' : 'truck'" :submitting="saving" :disabled="!editableStatus" :editing="editing" @submit="save" @cancel="back">
<template v-if="!editing" #customer>
<FormSection class="transport-customer-section" title="客户信息"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
<FormSection class="transport-customer-section density-form-section" title="客户信息"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
</template>
</TransportDemandForm>
<FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection>
</section>
</DetailPageLayout><TransportRouteDialog v-model="routeOpen" :origin="routeLocation(form.origin, form.originAddress)" :destinations="[routeLocation(form.destination, form.destinationAddress)]" /></AdminPage>
</template>
<style scoped>
.transport-customer-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
</style>
@@ -56,6 +56,20 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals())
describe('truck review and fulfillment', () => {
it('groups the business number, single status and actions in the shared detail header', async () => {
const wrapper = mountDetail(); await flushPromises()
const header = wrapper.find('.detail-page-layout__header')
expect(header.find('.density-detail-title').text()).toBe('本土卡车订单 TR-TEST')
expect(header.find('.status-badge').text()).toBe('待审核')
expect(wrapper.findAll('.status-badge')).toHaveLength(1)
expect(header.find('.truck-order-actions').text()).toContain('审核')
expect(header.find('.truck-order-actions').text()).toContain('取消订单')
await click(wrapper, '刷新订单')
expect(getUsTruckOrder).toHaveBeenCalledTimes(2)
await click(wrapper, '返回列表')
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-trucks' })
})
it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => {
let current = order()
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
@@ -119,6 +133,7 @@ describe('truck review and fulfillment', () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
for (const button of wrapper.find('.detail-page-layout__header').findAll('button')) expect(button.attributes('disabled')).toBeDefined()
finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises()
expect(wrapper.text()).toContain('登记实收')
})
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -65,16 +66,25 @@ watch([orderNo, customerId], load, { immediate: true })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="本土卡车订单详情" class="workbench-panel is-flush is-page-surface">
<template #header><div class="truck-detail-header"><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button><h1>本土卡车订单 {{ detail?.orderNo || orderNo }}</h1></div></template>
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="本土卡车订单详情" class="density-detail-layout workbench-panel is-flush is-page-surface">
<template #header>
<div class="density-detail-header truck-detail-header">
<div class="density-detail-heading">
<el-button link class="density-back" :disabled="saving" @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button>
<h1 class="density-detail-title">本土卡车订单 {{ detail?.orderNo || orderNo }}</h1>
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
</div>
<div v-if="detail" class="density-actions truck-detail-actions">
<el-button :disabled="saving" @click="load">刷新订单</el-button>
<TruckOrderActions v-if="!detail.fleetArrangement" :detail="detail" @updated="detail = $event" @busy="saving = $event" />
</div>
</div>
</template>
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail">
<section class="truck-detail-section"><div class="truck-detail-header"><h2>{{ statusLabel(detail.status) }}</h2><el-button :disabled="saving" @click="load">刷新订单</el-button></div>
<FleetArrangementSummary v-if="detail.fleetArrangement" :arrangement="detail.fleetArrangement" />
<template v-else><TruckOrderActions :detail="detail" @updated="detail = $event" @busy="saving = $event" /></template>
</section>
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="truck-detail-section"><h2>托盘记录</h2><div class="workbench-table"><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></div></section>
<section class="truck-detail-section"><h2>审核与操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
@@ -82,7 +92,9 @@ watch([orderNo, customerId], load, { immediate: true })
</template>
<style scoped>
.truck-detail-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.truck-detail-header h1, .truck-detail-header h2 { margin: 0; }
.truck-detail-section { margin-bottom: 16px; }
.truck-detail-header { flex-wrap: wrap; }
.truck-detail-header .density-detail-heading { flex: 1; }
.truck-detail-header .status-badge { flex-shrink: 0; }
.truck-detail-actions { flex-wrap: wrap; }
.truck-detail-section { margin-bottom: var(--admin-space-6); }
</style>
@@ -1,5 +1,14 @@
# 海外通用资料原型核对矩阵
## 散板需求只读展示(2026-10-21)
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
| 页面/操作 | 当前承载与调整 | 验收 |
|---|---|---|
| 散板需求列表/查询、分页、详情 | 保持独立列表与原余位来源,默认展示车次余位、线路、时间、联系信息;隐藏待匹配散板、我的登记、登记及待办入口,原代码保留 | 43项定向测试及页面、测试源码定向类型检查通过;待人工1920×1080、100%截图确认 |
| 散板需求详情/查看、重新核对 | 保持独立详情,仅展示运输摘要、联系方式和分段余位;隐藏登记、意向列表、来货及履约窗口,旧登记参数不再打开窗口 | 同上 |
## 2026-10-20 列表顶部紧凑收尾
依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。
@@ -63,6 +72,26 @@
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
## 自有业务列表字段补齐(2026-10-21)
依据本轮用户字段截图 `codex-clipboard-9d532c45-a5f3-4f90-a8f2-f8a3cdfb8a3e.png`;用户确认派送仓库就是目的仓,最晚装车完成时间先空着。此前详情、运营登记、照片的承载与权限不变。
| 页面/操作 | 截图与当前确认 | 本次调整 | 验收 |
|---|---|---|---|
| 自有业务列表/读取字段 | 总价、单价、预约到达、实际到达各为独立列 | 拆开原合并列;一车一行,各到达时间保留目的仓标识,金额沿服务端原值 | 8项定向检查通过;待修改后人工截图 |
| 自有业务列表/仓库与时间 | 派送仓库等于目的仓;最晚装车完成时间暂无来源 | 派送仓库复用目的仓展示;最晚装车完成时间显示“—”,不拿计划发车替代;保留已有计划发车列 | 8项定向检查通过;待修改后人工截图 |
| 自有业务列表/字段名称 | 图中第一处“托数”装的是仓库名称 | 保留正确的“提货仓库”和唯一“实际托数”;“阶段”列改名“状态”,取值不变 | 8项定向检查通过;待修改后人工截图 |
## 自有业务行操作样式核对(2026-10-21)
依据本轮用户截图 `codex-clipboard-eb6aa95b-c8a2-4b85-9294-22f3127cf72c.png` 与 Admin 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。
| 页面 | 操作 | 已确认承载 | 当前问题与修正 | 验收 |
|---|---|---|---|---|
| 自有业务列表 | 查看明细 | 原独立详情路由 | 旧描边粗体按钮接入公共纯文字行操作类,保留路由和业务编号 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
| 自有业务列表 | 登记运营信息 | 原1080px居中弹框 | 同上;保留原权限、状态及处理中禁用 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
| 自有业务列表 | 上传/查看照片 | 原760px居中弹框 | 同上;保留原读写权限与按钮文案 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
## 自有业务实现核对(2026-10-20,主规则已确认)
用户当前要求逐页处理,先看自有业务;HTTP 扩展由既有排车后端对话独立负责。本节记录原型与已确认规则的映射,不授权外配页面改造。自有与外配均为原排车装车后的车辆归属视图,不另建任务或履约流程;自有车增加运营细项,外配管理细度后续确认。视觉沿当前 Admin 高密度规范,业务与操作承载依据用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-10/悦/悦/新版.html` 及本轮截图。
@@ -12,6 +12,19 @@ beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('outsourced business HTTP contract', () => {
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle.ownership = ownership
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
expect((await listOutsourcedBusiness({})).records[0]!.vehicle).toEqual(detail.row.vehicle)
expect((await getOutsourcedBusiness(detail.row.dispatchNo)).row.vehicle.ownership).toBe(ownership)
vi.mocked(http.put).mockResolvedValue({ data: detail })
expect((await saveOutsourcedBusinessOperations(detail.row.dispatchNo, { recordVersion: detail.row.recordVersion, idempotencyKey: 'pool-ownership', operations: detail.row.operations, reason: null })).row.vehicle.ownership).toBe(ownership)
})
it.each(['UNKNOWN', null, undefined])('rejects invalid vehicle ownership %s', async ownership => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership } } } })
await expect(getOutsourcedBusiness(detail.row.dispatchNo)).rejects.toThrow('来源或阶段')
})
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
@@ -69,10 +82,8 @@ describe('outsourced business HTTP contract', () => {
expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null })
expect(http.get).toHaveBeenCalledTimes(1)
})
it('rejects owned vehicle facts and unsupported fuel or mileage writes before any request', async () => {
it('rejects unsupported fuel or mileage writes before any request', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership: 'OWNED' } } } })
await expect(getOutsourcedBusiness('DS-OUT-1')).rejects.toThrow('来源或阶段')
for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) {
const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null }
await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗')
@@ -20,7 +20,7 @@ function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOper
}
function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配任务不支持该照片分类') }
function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'DISPATCHED') throw new Error('外配排车来源或阶段不完整,请重新读取')
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !['OWNED','DISPATCHED'].includes(value.vehicle?.ownership)) throw new Error('外配排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } }
}
function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
@@ -12,6 +12,19 @@ beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/owned-business'
const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('owned business HTTP contract', () => {
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified non-pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = ownedBusinessFixture(); detail.row.vehicle.ownership = ownership; detail.row.vehicle.vehicleId = null
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
expect((await listOwnedBusiness({})).records[0]!.vehicle).toEqual(detail.row.vehicle)
expect((await getOwnedBusiness(detail.row.dispatchNo)).row.vehicle.ownership).toBe(ownership)
vi.mocked(http.put).mockResolvedValue({ data: detail })
expect((await saveOwnedBusinessOperations(detail.row.dispatchNo, { recordVersion: detail.row.recordVersion, idempotencyKey: 'non-pool-ownership', operations: detail.row.operations, reason: null })).row.vehicle.ownership).toBe(ownership)
})
it.each(['UNKNOWN', null, undefined])('rejects invalid vehicle ownership %s', async ownership => {
const detail = ownedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership } } } })
await expect(getOwnedBusiness(detail.row.dispatchNo)).rejects.toThrow('来源或阶段')
})
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
const detail = ownedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
@@ -16,7 +16,7 @@ function operations(value: OwnedBusinessOperations): OwnedBusinessOperations {
return { ...value, money: value.money ? { ...value.money, amount: decimal(value.money.amount, 2, 18)! } : null, actualDistanceMiles: decimal(value.actualDistanceMiles,3,12), fuelConsumptionLitresPer100Km: decimal(value.fuelConsumptionLitresPer100Km,3,12) }
}
function row(value: OwnedBusinessRow): OwnedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'OWNED') throw new Error('自有排车来源或阶段不完整,请重新读取')
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !['OWNED','DISPATCHED'].includes(value.vehicle?.ownership)) throw new Error('自有排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: ownedBusinessVersion(value.recordVersion), dispatchRecordVersion: ownedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount), fuelCount: count(value.photoSummary.fuelCount) } }
}
function detail(value: OwnedBusinessDetail): OwnedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
@@ -2,11 +2,10 @@ import ElementPlus, { ElMessageBox } from 'element-plus'
import { createPinia } from 'pinia'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { defineComponent, ref } from 'vue'
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue'
import type { CapacityOffer } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
@@ -64,11 +63,13 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => {
it('preserves route-leave protection in the retained native inquiry dialog without duplicating the global prompt', async () => {
const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm')
// 登记页面入口已隐藏;保留组件自身与真实路由的未保存保护回归。
const InquiryHost = defineComponent({ components: { CapacityInquiryDialog }, setup: () => ({ offer, open: ref(false) }), template: '<button @click="open = true">打开保留的登记组件</button><CapacityInquiryDialog v-model="open" :offer="offer" :inquiry="null" :readonly="false" />' })
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '<p data-testid="pool-list">运力列表</p>' }, meta: { requiresAuth: true } },
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } },
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: InquiryHost, meta: { requiresAuth: true } },
{ path: '/outside', name: 'outside', component: { template: '<p>外部页面</p>' }, meta: { workspaceTab: false } },
] })
// Match the application's intentional policy: cross-workspace switching skips the global prompt.
@@ -81,7 +82,7 @@ it('blocks browser back with the real router, cached detail and native dialog, w
await router.push('/pool'); await router.isReady()
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
await router.push('/pool/CO-CLOSE'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
await wrapper.get('details summary').trigger('click')
const input = wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]')
await input.setValue('UI-修复后保留输入'); await flushPromises()
@@ -92,15 +93,15 @@ it('blocks browser back with the real router, cached detail and native dialog, w
expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true)
await continueEditing()
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).not.toHaveBeenCalled()
confirms.mockClear(); router.back(); await flushPromises()
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement
expect(leave).toBeTruthy(); leave.click(); await flushPromises()
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).not.toHaveBeenCalled()
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
await wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
@@ -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',
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: [],
}
}
@@ -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',
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: [],
}
}
@@ -53,11 +53,14 @@ async function load() {
try {
const value = await getCapacityOffer(String(route.params.offerNo))
let checked: CapacityInquiry | null = null
// 当前只读展示余位,旧登记深链不再打开登记窗口;原流程保留。
/*
if (typeof route.query?.inquiry === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(route.query.inquiry)) {
const returned = await getCapacityInquiry(route.query.inquiry)
if (returned.offerNo !== value.offer.offerNo) throw new Error('登记不属于当前发布')
if (current === sequence) { checked = returned; readonly.value = true; confirmMode.value = false; editorOpen.value = true }
} else if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
*/
if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) }
}
catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } }
@@ -90,18 +93,29 @@ watch([page, size], loadInquiries)
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
</script>
<template><AdminPage class="admin-density admin-workbench"><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="运力发布详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><!-- 当前暂不开放登记与拼车;原代码保留。
<el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button>
--></div></div></template>
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
<!-- 当前暂不开放登记与拼车;原代码保留。
<template><p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p></template>
--><p v-if="detail.offer.blockReason" role="status">{{ detail.offer.blockReason }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
<h2 class="capacity-section-title">分段运力</h2><div class="workbench-table"><DataTable :data="detail.offer.segments" :columns="columns" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable></div>
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
<!-- 当前暂不开放登记与拼车;原代码保留。
<template><h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template>
</LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" /></template>
-->
</template></div>
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
-->
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
-->
</DetailPageLayout></AdminPage></template>
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
@@ -26,7 +26,7 @@ function mount(component: object, props: Record<string, unknown> = {}): { wrappe
const pinia = createPinia()
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="loads" :row="row"/></div><slot/></div>' },
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total', 'columns'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="toolbar-end"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="actions" :row="row"/><slot name="loads" :row="row"/><slot name="status" :row="row"/><slot name="contact" :row="row"/><slot name="destinations" :row="row"/></div><slot/></div>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], template: '<div/>' },
CapacityInquiryTable: { name: 'CapacityInquiryTable', props: ['rows', 'total', 'failed'], emits: ['action', 'update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' },
CapacityInquiryDialog: component === CapacityInquiryDialog ? false : true,
@@ -59,35 +59,46 @@ it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labe
expect(detail.find('[label="已核实实托数"]').exists()).toBe(false)
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
})
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
it('keeps the pool read-only and sends one query after resetting from a later page', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('待调度核对')
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).exists()).toBe(false)
for (const label of ['我的登记', '待匹配散板', '登记客户货物', '待我处理']) expect(button(wrapper, label)).toBeUndefined()
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
await button(wrapper, '我的登记').trigger('click'); await flushPromises()
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'MINE' })); expect(api.getCapacityOffer).not.toHaveBeenCalled()
const query = wrapper.findComponent({ name: 'QueryToolbar' }); query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityInquiries).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'MINE' }))
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3)
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
})
it('ignores an older page response after changing tabs and retains an explicit error state', async () => {
it('ignores an older pool query response and retains errors with retry', async () => {
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done }))
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
vi.mocked(api.listCapacityOffers).mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper } = mount(CapacityOffersView)
vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([offer], 7))
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
resolve(pageOf([offer], 900)); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('total')).toBe(1)
vi.mocked(api.listCapacityInquiries).mockRejectedValue(new Error('读取失败')); wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(7)
vi.mocked(api.listCapacityOffers).mockRejectedValueOnce(new Error('读取失败'))
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('failed')).toBe(true)
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
})
it('reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes', async () => {
it('reads only the offer detail and hides registration even with write permission', async () => {
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
await button(wrapper, '登记客户货物').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(true)
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(button(wrapper, '登记客户货物')).toBeUndefined()
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).exists()).toBe(false)
expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled()
})
it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => {
vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true })
const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
@@ -120,12 +131,13 @@ it('retains an uncertain request and route protection after closing and reopenin
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, inquiry.bookingNo, first)
})
it('keeps sales from confirming even if a stale row advertises the action', async () => {
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled(); expect(ElMessageBox.confirm).not.toHaveBeenCalled()
it('retains pool route details as the only cargo row action for sales', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).exists()).toBe(false)
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
})
it('loads publishing settings only when explicitly opened, not with the trip page', async () => {
state.manage = true
const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises()
@@ -148,24 +160,22 @@ it('retries an uncertain combined save and confirmation with the original payloa
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('keeps the create button disabled with a reason after cutoff, while hiding it without write permission', async () => {
it('keeps capacity blockers visible without registration prompts after cutoff', async () => {
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
expect(button(wrapper, '登记客户货物').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('无法登记客户货物:交货截止已过')
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
state.write = false
const readonly = mount(CapacityOfferDetailView); await flushPromises()
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
expect(button(wrapper, '登记客户货物')).toBeUndefined()
expect(wrapper.text()).toContain('交货截止已过')
expect(wrapper.text()).not.toContain('无法登记客户货物')
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
})
it('defaults minimal managers to their TODO inquiries without loading offers, and explicitly switches to available offers', async () => {
it('defaults managers to the same read-only pool and preserves explicit historical queries', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'TODO' }))
expect(api.listCapacityOffers).not.toHaveBeenCalled()
expect(state.links.size).toBe(0)
await button(wrapper, '可拼车次').trigger('click'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
for (const label of ['我的登记', '待匹配散板', '待我处理', '登记客户货物']) expect(button(wrapper, label)).toBeUndefined()
const query = wrapper.findComponent({ name: 'QueryToolbar' })
query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' }))
@@ -188,21 +198,6 @@ it('requires an explicit choice for multiple destinations', async () => {
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
})
// Navigation must retain the exact bound business references and independent read permissions.
it.each([CapacityOffersView, CapacityOfferDetailView])('opens the bound trip separately from read-only dispatch detail', async component => {
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
state.links = new Set(['platform:fleet:trip:list', 'platform:overseas-warehouse:dispatch:list'])
const { wrapper } = mount(component); await flushPromises()
const dialog = wrapper.findComponent(CapacityInquiryDialog)
dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
dialog.vm.$emit('action', 'trip', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
state.links.clear(); state.push.mockClear()
dialog.vm.$emit('action', 'trip', row); dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
expect(state.push).not.toHaveBeenCalled()
})
it('shows follow-up destinations only with the matching read permission', async () => {
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
state.links.add('platform:overseas-warehouse:dispatch:list')
@@ -318,69 +313,47 @@ it('shows the business identity and exposes the order link only with its list pe
await button(wrapper, 'ORDER-1').trigger('click')
expect(wrapper.emitted('action')?.[0]).toEqual(['business-order', row])
})
it('reopens a returned inquiry through an authorized fetch and refuses a different offer', async () => {
state.route.query = { inquiry: inquiry.bookingNo }
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
expect(editor.props('inquiry').bookingNo).toBe(inquiry.bookingNo)
wrapper.unmount()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, offerNo: 'FO-OTHER' })
const rejected = mount(CapacityOfferDetailView).wrapper; await flushPromises()
expect(rejected.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
expect(rejected.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
})
it('opens refreshed server guidance after fulfillment succeeds without replaying a write', async () => {
it('ignores old inquiry deep links and renders only the offer summary', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
const updated = { ...inquiry, status: 'BOUND' as const, progress: { ...inquiry.progress, primaryAction: 'VIEW', message: '服务端下一待办', owner: '审核 / 仓库' } }
vi.mocked(api.getCapacityInquiry).mockResolvedValue(updated)
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'bind', inquiry); await flushPromises()
wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).vm.$emit('saved'); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
expect(editor.props('inquiry').progress.message).toBe('服务端下一待办')
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
it('fetches an off-page returned inquiry once per refresh and retries without falling back to stale data', async () => {
state.route.query = { inquiry: inquiry.bookingNo }
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer, inquiries: pageOf<CapacityInquiry>([], 30) })
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true)
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 7 })
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
expect(editor.props('inquiry').recordVersion).toBe(7)
vi.mocked(api.getCapacityInquiry).mockRejectedValueOnce(new Error('读取失败'))
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
const error = wrapper.findComponent({ name: 'LoadErrorState' })
expect(error.exists()).toBe(true)
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 8 })
error.vm.$emit('retry'); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
expect(editor.props('inquiry').recordVersion).toBe(8)
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-1')
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).exists()).toBe(false)
expect(wrapper.text()).toContain(offer.warehouseName)
expect(wrapper.text()).toContain(offer.contactPhone)
})
it('keeps transport, destination, remaining capacity and contact columns for manual follow-up', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as { key: string }[]
for (const key of ['warehouseName', 'destinations', 'plannedDepartureAt', 'contactName']) expect(columns.some(column => column.key === key)).toBe(true)
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
it('shows customer looseboard demand in the same pool and passes the original to the selected trip inquiry', async () => {
const source = { orderNo: 'LB-SOURCE', customerId: '19', version: 2, origin: 'LA', destination: 'ONT8', productName: '货物', estimatedPalletCount: 3, packageCount: 12, totalWeightKg: 100, totalVolumeCbm: 2, currency: 'USD', originContactName: 'Alice', originContactPhone: '555', remark: null }
vi.mocked(api.listLooseboardDemands).mockResolvedValue(pageOf([source]))
it('refreshes and retries read-only offer detail without loading an old inquiry', async () => {
state.route.query = { inquiry: inquiry.bookingNo }
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
vi.mocked(api.getCapacityOffer).mockClear()
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-1')
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
vi.mocked(api.getCapacityOffer).mockRejectedValueOnce(new Error('读取失败'))
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
const error = wrapper.findComponent({ name: 'LoadErrorState' })
expect(error.exists()).toBe(true)
error.vm.$emit('retry'); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
it('does not load customer looseboard demands or expose matching from the pool', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
await button(wrapper, '待匹配散板').trigger('click'); await flushPromises()
expect(api.listLooseboardDemands).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined })
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([source])
expect(api.getCapacityOffer).not.toHaveBeenCalled()
// 用页面实际暴露的匹配动作选择来源,再沿已有登记入口选车。
const vm = wrapper.vm as unknown as { matchDemand: (row: typeof source) => void; create: (row: CapacityOffer) => Promise<void> }
vm.matchDemand(source); await flushPromises(); await vm.create(offer); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('sourceDemand')).toEqual(source)
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('offer').offerNo).toBe(offer.offerNo)
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
expect(button(wrapper, '匹配车次')).toBeUndefined()
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
})
@@ -20,8 +20,9 @@ import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const router = useRouter(), access = useAccessPreviewStore()
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const tab = ref<'offers' | 'inquiries' | 'demands'>(manage.value ? 'inquiries' : 'offers')
const filters = ref<QueryModel>(manage.value ? { scope: 'TODO' } : {}), applied = ref<QueryModel>({ ...filters.value })
// 当前只展示余位需求池;登记、原单匹配及履约模板直接隐藏,原实现保留。
const tab = ref<'offers' | 'inquiries' | 'demands'>('offers')
const filters = ref<QueryModel>({}), applied = ref<QueryModel>({ ...filters.value })
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
@@ -56,8 +57,8 @@ let sequence = 0, editorSequence = 0
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
{ key: 'canConfirm', label: '余位状态', type: 'select', placeholder: '全部状态', width: 150, options: [{ value: 'true', label: '余位已核对' }, { value: 'false', label: '待核对或不可承运' }] },
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '当前发布', width: 180, options: [{ value: 'AVAILABLE', label: '当前发布' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
@@ -68,7 +69,7 @@ const columns = computed<DataTableColumn[]>(() => [
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
{ key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' },
...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []),
{ key: 'status', label: '接单状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
{ key: 'status', label: '发布状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
])
const canCreate = computed(() => manage.value || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create'))
async function create(row: CapacityOffer) {
@@ -77,7 +78,7 @@ async function create(row: CapacityOffer) {
try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
}
function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (value !== 'offers' || tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
@@ -129,29 +130,40 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
</script>
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求">
<p v-if="selectedDemand && tab === 'offers'">正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。<el-button link @click="selectedDemand = null">取消选择</el-button></p>
<!-- 当前暂不开放登记与拼车;原代码保留。
<div v-if="tab === 'demands'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-looseboard-demands" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="demands" :columns="demandColumns" :loading="loading" :total="total" row-key="orderNo" empty-text="暂无待匹配散板需求">
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="true" class="is-active" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="待匹配散板查询" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" @click="matchDemand(row as LooseboardDemand)">匹配车次</el-button></div></template>
</DataTable></div>
-->
<div v-if="tab === 'offers'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
<template #toolbar-start><h1>散板需求</h1><!-- 当前暂不开放登记与拼车;原代码保留。
<div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
--></template><template #toolbar-end><!-- 当前暂不开放登记与拼车;原代码保留。
<el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button>
--><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
<template #filters>
<div class="integrated-query-group">
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
</div>
</template>
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><!-- 当前暂不开放登记与拼车;原代码保留。
<el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button>
--><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
<template #destinations="{ row }"><div v-for="stop in (row as CapacityOffer).destinations" :key="stop.destinationKey">{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}</div></template>
<template #times="{ row }"><div>发车 {{ offerTime(row.plannedDepartureAt) }}</div><div>最晚送仓 {{ offerTime(row.cutoffAt) }}</div></template>
<template #loads="{ row }"><span v-if="!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }}<!-- 当前暂不开放登记与拼车;原代码保留。
<template> · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}</template>
--><div v-if="row.blockReason">{{ row.blockReason }}</div></template>
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
</DataTable></div>
<CapacityInquiryTable v-else-if="tab === 'inquiries'" integrated class="is-page-surface" v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryTable v-if="tab === 'inquiries'" integrated class="is-page-surface" v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div><span v-if="applied.scope === 'TODO' || applied.scope === 'ALL'" class="workbench-badge">{{ applied.scope === 'TODO' ? '待我处理' : '全部登记' }}</span></template>
<template #filters>
<div class="integrated-query-group">
@@ -160,6 +172,11 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
</template>
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
</CapacityInquiryTable>
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
-->
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
-->
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
-->
</ListPageLayout></AdminPage></template>
@@ -37,11 +37,17 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 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: 'money', label: '整车总金额 / 单价', minWidth: 170, slotName: 'money' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
{ key: 'money', label: '总价', minWidth: 170, align: 'right', slotName: 'money' },
{ key: 'unitPrice', label: '单价(/托)', minWidth: 160, align: 'right', slotName: 'unitPrice' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' },
{ key: 'latestLoadingCompletedAt', label: '最晚装车完成时间', minWidth: 180, slotName: 'latestLoaded' },
{ key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'scheduledArrival', label: '预约到达时间', minWidth: 190, slotName: 'scheduledArrival' },
{ key: 'actualArrival', label: '实际到达时间', minWidth: 190, slotName: 'actualArrival' },
{ key: 'stage', label: '状态', minWidth: 110, slotName: 'stage' },
]
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
async function load() {
@@ -94,12 +100,16 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
<template #toolbar-start><h1>自有业务<InlineHelp title="查询范围说明">仅统计未实际接入拼车货物的已装车排车,与车辆归属无关;日期和时间按浏览器本地时区显示,统计覆盖同一完整筛选范围。</InlineHelp></h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自有业务筛选" preference-key="owned-business-query" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="自有业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
<template #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 #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 #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>
</DataTable></div>
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
@@ -17,7 +17,7 @@ const Button = defineComponent({ props: ['disabled'], template: '<button :disabl
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,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 PhotosEditor = defineComponent({ name: 'OwnedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OwnedBusinessOperationsEditor: OperationsEditor, OwnedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
@@ -30,6 +30,27 @@ beforeEach(() => {
})
describe('owned business views', () => {
it('keeps split prices authoritative and arrival times associated with each destination', async () => {
const detail = ownedBusinessFixture()
detail.row.operations.money = { currency: 'USD', amount: '0.00' }; detail.row.unitPrice = '0.00'
detail.row.destinations[0]!.appointments[0]!.scheduledAt = '2026-10-21T09:00:00Z'
detail.row.destinations.push({ destinationKey: 'destination-2', warehouseCode: 'FBA-2', warehouseName: null, address: null, appointments: [], actualArrivedAt: '2026-10-21T10:00:00Z' })
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } })
const wrapper = mount(OwnedBusinessView, { global }); await flushPromises()
const columns = wrapper.findComponent(Table).props('columns') as { key?: string; label: string; slotName?: string }[]
expect(columns.map(column => column.label)).toEqual(expect.arrayContaining(['目的仓', '派送仓库', '总价', '单价(/托)', '最晚装车完成时间', '预约到达时间', '实际到达时间', '状态']))
expect(columns.find(column => column.key === 'deliveryWarehouses')!.slotName).toBe('destinations')
expect(wrapper.get('[data-column="money"]').text()).toBe('USD 0.00')
expect(wrapper.get('[data-column="unitPrice"]').text()).toBe('USD 0.00')
expect(wrapper.get('[data-column="latestLoaded"]').text()).toBe('—')
const scheduled = wrapper.findAll('[data-column="scheduledArrival"] .owned-business-destination')
expect(scheduled.map(cell => cell.text())).toEqual(['FBA-1' + dayjs('2026-10-21T09:00:00Z').format('YYYY-MM-DD HH:mm'), 'FBA-2—'])
const actual = wrapper.findAll('[data-column="actualArrival"] .owned-business-destination')
expect(actual.map(cell => cell.text())).toEqual(['FBA-1—', 'FBA-2' + dayjs('2026-10-21T10:00:00Z').format('YYYY-MM-DD HH:mm')])
expect(calls.detail).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps a non-pool external vehicle returned by the server without filtering or requiring a vehicle id', async () => {
const detail = ownedBusinessFixture()
detail.row.vehicle = { vehicleId: null, plate: 'EXTERNAL-NO-POOL', ownership: 'DISPATCHED' }
+2 -2
View File
@@ -6,13 +6,13 @@ export const overseasMasterDataRoutes = [
path: 'overseas-master-data/capacity-offers',
name: 'overseas-master-data-capacity-offers',
component: () => import('./pages/CapacityOffersView.vue'),
meta: { title: '散板需求', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
meta: { title: '散板需求', description: '查看车次余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
},
{
path: 'overseas-master-data/capacity-offers/:offerNo',
name: 'overseas-master-data-capacity-offer-detail',
component: () => import('./pages/CapacityOfferDetailView.vue'),
meta: { title: '散板需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
meta: { title: '散板需求详情', description: '查看车次分段余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
},
{
path: 'overseas-master-data/fleet-quotes',
+27 -15
View File
@@ -22,11 +22,12 @@
| 退货预报审核、退货驳回 | `pages/LastMileOrdersView.vue`、`api/lastMileOrders.ts` 的 `reviewReturnOrder` | 尾程订单内 `RETURN_RELABEL + PENDING_REVIEW` 行进入审核,来源退货号取 `sourceOrderNo`;请求 `/platform/v1/last-leg/return-orders/{returnNo}/review` 携带 `X-Customer-Id`,结论 `APPROVE/REJECT`,驳回必填原因。按钮权限入口为 `permissions.ts` 的 `returnOrderReview`;服务端归 `returnreceipt/`,通过后才能签收,不是仓储转运审核或换标完工确认 |
| 换标申请审核、待审核按钮与驳回原因 | `pages/RelabelOrdersView.vue`、`pages/RelabelOrderDetailView.vue`、`api/relabelOrders.ts` | 列表审核动作仅在 `PENDING_REVIEW` 显示,动作选择为审核通过/已驳回,驳回原因必填;审核后不再显示审核按钮。回归见 `pages/RelabelOrdersView.test.ts`、`pages/RelabelOrderDetailView.test.ts`;PDA 完工、客户确认与申请审核分别定位,不混用状态入口 |
| 排车资源、草稿容器与实际容量 | `components/DispatchResourceFields.vue`、`components/SmartDispatchDialog.vue`、`dispatchContracts.ts` | 车辆/司机分别选择已有档案或临时外配,容量取真实档案或本趟填写的托位、千克及立方米,不按车型名称推算。草稿容器容量不代替车辆实测或装载校验;取证、审核前补齐规则见当前排车正式契约 |
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写。新增暂扣只在 `INBOUND_COMPLETED` 开放,解除暂扣保持可用;暂扣不阻断入库计划,只限制后续排车和出库。人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 仓储转运审核、审核通过 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `permissions.ts` 的 `warehouseTransferApprove`;仅 `WAREHOUSE_TRANSFER + PENDING_REVIEW` 行开放。当前弹框仅提交 `APPROVE`,携带该行客户和转运单号;无驳回按钮 |
| 转运驳回、REJECT 接口能力 | `api/warehouseTransfers.ts` 的 `reviewWarehouseTransfer` | API 支持 `APPROVE/REJECT` 与 `remark`;不据此推定当前弹框提供驳回操作,界面能力沿上一行组件核对 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | `api/lastMileOrders.ts`;[入仓计划页面约束](../../../../../docs/业务实现边界.md#admin-入仓计划页面约束),仅计划/状态/请求行为变化时查 |
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询、整车草稿、审核及派送安排 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/SmartDispatchExistingCargo.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型,状态须覆盖 `WAIT_PICK/待拣货`、`PICKING/拣货中`、`PICKED/已拣货`、`WAIT_OUTBOUND/待出库`、`OUTBOUNDED/已出库` 和 `CANCELLED/已取消`;仅未开始的 `WAIT_PICK/WAIT_OUTBOUND` 允许取消。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/SmartDispatchExistingCargo.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;[出库与派送页面约束](../../../../../docs/业务实现边界.md#admin-出库与派送页面约束),候选选择另查下表 |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 海外库位维护与状态 | `pages/LocationsView.vue`、`api/locations.ts` | 按海外仓分页或多仓查询库位,支持库区/库位编码、类型和状态筛选;`NORMAL` 库位可编辑、停用或删除,`A0/SHARED_OVERFLOW`、`VIRTUAL` 由服务端事实保护,不能按普通库位修改。接口经 `/platform/v1/warehouse-admin/{warehouseId}/locations`,权限沿 `permissions.ts` 的库位维护/删除入口 |
@@ -34,22 +35,33 @@
## 履约硬边界
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车与实际发车分别确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五个作业阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车中可同步上传五张装车照片,确认装车完成且资料齐全自动生成派送单并进入派送中,资料未齐时留在装车后,无独立安排派送或发车确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
- 智能排车来源支持 `RELABEL`(待出库换标单),按服务端候选的`selection.relabelNo/sourceVersion`提交,不用展示订单号或原入仓号替代换标身份;沿用真实库存、托盘与箱唛,不生成新入库单。客户和仓库归属由服务端核验,不在前端拼接客户子排车。
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘;有 FBA 仓码的托盘与国内统一使用 Print `FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm,FBA 仓码、完整托盘号 Code 128、托盘号文字),其他托盘使用 `INBOUND_MARK_V1`。不在前端生成标签、改码或维护第二套模板。
- 托盘标签从 `pages/PalletsView.vue` → `api/pallets.ts` 的 `getPalletLabelPdf` 下载服务端 PDF,传该行仓库与托盘 ID,复用列表权限;不在前端生成标签或维护模板、尺寸、编码。模板/接口变化才查 [Print 指引](../../../../../baiyue-firstleg/print/AGENTS.md)的托盘入口及[标签维护](../../../../../docs/打印标签维护/README.md),纯按钮文案不追加打印资料。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。整车派送安排在装车完成后、实际发车前进行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。出库/派送建单时机按[排车现行契约](../../../../../docs/海外仓业务/排车列表统一作业改造契约.md)执行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
## 作业细则按需入口
仅接口、状态、权限或业务行为变化时读命中段;纯文案、样式沿当前代码,不逐条读取。
| 任务 | 规则正文 |
|---|---|
| 排车审核附件 | [对应条款](../../../../../docs/业务实现边界.md#admin-排车审核附件) |
| 一件代发出库候选 | [对应条款](../../../../../docs/业务实现边界.md#admin-一件代发出库候选) |
| 货物计划与装车命令 | [对应条款](../../../../../docs/业务实现边界.md#admin-货物计划与装车命令) |
| 取消与费用展示 | [对应条款](../../../../../docs/业务实现边界.md#admin-取消与费用展示) |
| 业务导出 | [对应条款](../../../../../docs/业务实现边界.md#admin-业务导出) |
| 装车照片与资料入口 | [对应条款](../../../../../docs/业务实现边界.md#admin-装车照片与资料入口) |
## 验证
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
- 派送安排弹框通过`getDispatchArrangement`读取整车分组、实际货物、外接报价及原费用依据,`DispatchArrangeFields.vue`按真实客户目的地组提交;不逐行查费用,不按金额推断覆盖,不把原费用再次记入新费用行。已有排车加货和移货仅从`SmartDispatchDialog.vue`内的`SmartDispatchExistingCargo.vue`进入;移货必须使用`GET items`返回的真实dispatchItemId及逐项remove Gate;路线仅重排`GET route`返回的既有全部目的地,包含外接点,不能新增或猜测目的地。附件沿`DispatchEvidenceFiles.vue`的本车鉴权上传/读取,上传与主单引用保存分开。
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取reviewStatus/sourceStatus/voidStatus,同一详情响应展示待财务确认、待财务处理、确认作废,不逐行查询,也不把取消成功视为费用作废。货柜费用面板直接消费同一费用来源接口,待确认时同样禁止调整和重复作废,沿用原页面/弹框承载。
- 排车列表车辆信息编辑、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;列表和详情不另设添加/移除货物入口。货物计划统一由 `components/SmartDispatchDialog.vue` 承载:新建使用候选、草稿及托盘锁,已有排车经 `components/SmartDispatchExistingCargo.vue` 读取实际明细并按版本增减;两类服务端命令不得混用。原加货/移货未知请求也在智能排车恢复。回归为对应组件测试、`pages/DispatchesView.test.ts`、`pages/DispatchDetailView.test.ts` 及 `pages/prototypeContract.test.ts`。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
- 尾程订单、库存余额与进销存记录导出统一使用 `../downloads/` 的 `useExportTask` 创建 XLSX 任务;保留当前筛选和业务导出权限,并要求 `platform:download:list`。Lastleg 内部 Provider 契约为 `lastleg-exports-v1.yaml`,业务页面不再请求同步 CSV。
- 纯文案/纯展示只核对差异;业务逻辑变化选择直接对应测试,不因文档引用某测试就自动执行。
- 转运审核组件/请求逻辑变化:定向选择 `components/WarehouseTransferReviewDialog.test.ts`、`api/warehouseTransfers.test.ts`;入口、权限或状态变化再加 `pages/prototypeContract.test.ts`。
- 出库候选/选择变化选 `pages/OutboundsView.selection.test.ts`;状态/取消变化选 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。排车选择受影响组件或详情页测试,入口变化再加原型合同。
- 命令从应用根按 `pnpm test src/modules/overseas-warehouse/<目标测试路径>` 运行,并经工作区限流入口;类型关系、模板绑定或模块引用受影响时按应用根选择最小类型检查,不自动升级全量。
- 指引/索引修改后从后端根运行一次 `./scripts/agents-index-contract.ps1`;不以离线结果代替真实 HTTP 或人工截图验收。
@@ -1,5 +1,94 @@
# 海外仓原型核对矩阵
## 2026-10-21 智能排车区域入口替代仓库搜索
用户明确删除查询区海外仓选择,仅保留“全部/洛杉矶/纽约”。界面移除顶部仓库框,区域筛选直接提交服务端;候选 warehouseId 不传时跨仓数据库分页,首托确定草稿仓并继续同仓安排,已有排车/预约仍显式指定仓库。空单模式在排车设置中保留起运仓,避免无来源时猜测仓库。正式业务边界维护于《排车列表统一作业改造契约》的区域入口确认段。
自动检查:前端7文件134项中旧区域断言调整后,相关3文件57项复验通过,其余77项结果复用;排车样式合同通过,应用类型检查通过。后端3个精确测试类10项通过,覆盖区域HTTP参数、服务校验、Mapper统计/分页范围及OpenAPI;未运行全量回归。未部署、未访问测试环境数据;需Admin与Lastleg同步发布,视觉与真实HTTP待验收。
## 2026-10-21 智能排车全屏空间利用
依据用户截图 `codex-clipboard-df3ac2ac-0fa1-4e30-98e6-e508074c0d66.png` 与 `codex-clipboard-4833c2b8-d6a3-4e34-bb5c-0099a3fc8fcf.png`:区域按钮并入待排订单标题行,移除中栏订单号 65% 宽度上限;全屏新建选货模式沿弹框可用空间分配三栏高度,待排表格取消原 390px/40vh 上限,分页位于下方,三栏各自按内容滚动。退出全屏、空单与已有排车沿用各自布局。计划时间及备注迁移至列表尚处于用户要求的梳理阶段,本轮未删除其填写区;现有列表“维护车辆信息”已支持三字段,计划发车审核前补齐。
自动检查:受影响布局、选货及排车样式合同共 41 项通过,69 项无关样式用例未执行,未跑全量;先前样式合同整文件运行另发现 DispatchesView 的取货车辆弹框缺少密度类,两项失败与本轮改动无关,未扩散修改。差异检查通过;全屏实际高度及滚动待人工截图确认。
## 2026-10-21 智能排车查询与三栏统一对齐
用户确认保留蓝白配色,按最初第二张原型恢复区块与控件位置;最新截图 `codex-clipboard-40f3fcdc-8093-4fd1-8b0e-bd1abb147b3e.png` 确认已有排车查询散落、三栏错位,上轮不构成视觉验收通过。
| 页面/操作 | 依据与问题 | 修复目标 | 验收 |
|---|---|---|---|
| 新建排车/查询与设置 | 原型 `modal-dispatch-schedule` 查询在三栏之前,现状设置占据顶部 | 起运仓进入首个查询面板;建单方式、草稿恢复按需展开;保留区域按钮与现行真实字段 | 定向179项通过;待人工截图确认 |
| 两模式/选订单、托盘与装车 | 截图中中栏固定偏移、已有货物栏替换整个布局 | 三栏共享标题行和内容行,右栏插槽只更换标题与内容,不再自行定位;长标题换行一起撑高标题行 | 定向179项通过;待人工截图确认 |
| 已有排车/查询、加货与移除 | 两行裸露查询,货物查询被重复单号推偏 | 同一个查询面板、统一标签和输入宽度;单号及仓库移入右栏标题;保留即时保存与移除确认 | 定向179项通过;待人工截图确认 |
| 新建排车/搜索标签与分页 | 最新截图 `codex-clipboard-06b92e79-d28a-4eec-8d63-f0f856d1f380.png` 三栏已对齐,但起运仓换行、分页提示多余;用户要求仅做样式定向检查 | 按最新确认删除起运仓可见标签并默认首个可用仓;删除分页提示及对应说明入口;分页与订单表左边缘对齐 | 后续合并定向161项通过;待截图确认 |
本轮追加:用户要求打开时默认第一个起运仓并删除标签;无已有选择、草稿或订单指定仓时采用目录首项并读取候选,空目录保持空值。已有排车按新截图 `codex-clipboard-43654ee8-6c76-4141-bca5-4d108d45b828.png` 合并为可搜索单号的选择器,分页进入下拉底部、货物查询同排,删除重复标签和介绍文字。沿用当前查询API、明确选择后读取清单,不自动选择已有排车。6个文件161项定向回归通过(18.32秒),覆盖默认仓自动加载、空目录、草稿仓保留、下拉搜索分页及原有加货/移除;本轮不跑全量测试。应用类型检查与差异检查通过,真实样式待截图确认。
加入货物反馈修复(截图 `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%人工截图确认。
## 2026-10-21 智能排车截图复核与样式纠偏
依据用户截图 `codex-clipboard-03c13f2c-5e0d-46da-9f0c-10f2be9fcdad.png`:上轮仅通过自动检查,真实截图确认仍未还原,不能据上轮结论标记视觉完成。对照原型 `overlays.html:1247-1300` 的完整样式以及10月18日组件末尾覆盖规则,本轮纠正标题留白、固定空白高度和托盘明细缺表头。
| 操作 | 截图问题 | 本轮目标 | 验收 |
|---|---|---|---|
| 打开/空态/切换区域 | 92vh外框、74px标题区和固定520px表区叠加,空数据也出现滚动;区域仍为下划线 | 外框随内容增长且保留全屏;三栏标题按控件高度对齐;订单区只限制最大高度;区域恢复独立按钮 | 排车定向108项通过;人工截图待确认 |
| 选择订单/托盘明细 | 标题浮在边框外,缺少托盘号和箱数表头 | 恢复完整明细表格及表内空态,保留整托说明、按钮加货与拖放;中栏卡片与订单表顶部对齐 | 排车定向108项通过;人工截图待确认 |
| 装车分组 | 右栏过窄,标题、两个说明图标、容量与按钮挤在一行 | 按原型44/22/34三栏比例,容量与添加操作独立成组,只保留一处说明入口;当前蓝白主题和业务契约继续保留 | 排车定向108项通过;人工截图待确认 |
本轮定向7文件178项:排车6文件108项全部通过;共享样式70项中68项通过,2项失败为预约消费者的`<FbaAppointmentFields continuous`属性顺序断言,未改动这些预约实现。`pnpm build`在预约模块`WorkCreateAppointmentForm.vue:63`被`SourceReference.destinationKey`的`null`与`PlanningSourceLookup`不兼容阻断;不据此宣称构建通过,也不扩大范围修复其他在途业务。本轮全量`pnpm test`完成:468文件中465通过、3失败;4324项中4305通过、19失败,另3个未处理错误。失败集中于预约属性顺序断言、预约组件Mock缺少`AppointmentConfirmationCargo`/`canLeave`及其卸载连带错误,未修改对应在途预约代码。排车组件输入在检查期间未变,差异空白检查通过。未部署,视觉仍待1920×1080、100%人工截图确认。
## 2026-10-21 装车后资料与自动派送
依据用户最新提供的排车五阶段截图和“管理资料/装车资料”五张照片截图,替代同日此前装完即派送的确认。规则以排车正式契约为准。
| 页面/操作 | 截图承载与当前实现 | 验证 |
|---|---|---|
| 排车列表/装车后页签 | 在装车中与派送中之间显示;计数与分页按服务端AFTER_LOADING查询,行内管理资料进入原居中弹框 | 定向自动检查;待1920×1080、100%人工截图确认 |
| 管理资料/装车资料 | 原居中弹框内两列共五个装车照片框;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
| 装车完成/原请求恢复 | 装车结果为AFTER_LOADING;已确认结果重读失败不重复POST,当前派送状态另读 | 定向恢复检查 |
## 2026-10-21 智能排车恢复10月18日布局
依据:本轮用户明确确认恢复10月18日版本;Admin Git `a5a19769` 的 `SmartDispatchDialog.vue:496` 起模板与 `.dispatch-workbench` 三栏样式,结合原型 `modal-dispatch-schedule`。只恢复页面布局,当前接口、整托资格、草稿与幂等恢复、15/30托位及已有排车调整继续保留,不回退旧协议。
| 页面/操作 | 10月18日布局 | 本轮恢复范围 | 验收 |
|---|---|---|---|
| 智能排车/新建与查询 | 顶部紧凑查询、区域按钮,之后直接三栏 | 必要起运仓、建单方式、草稿合并一条工具栏;删除两块步骤表单;查询独立一行;区域恢复active选中态 | 定向177项通过;1920×1080人工截图待确认 |
| 选订单/拖托盘/分车 | 左订单、中托盘、右装车,三栏标题对齐;托盘可拖入各车 | 恢复1.5/0.85/1三栏比例及订单、托盘工作区高度;计划托位移到右侧添加车辆旁;保留当前真实字段、分页和完整组提示,不补造旧字段 | 定向177项通过;1920×1080人工截图待确认 |
| 空单/调整已有/生成 | 旧版不包含全部当前能力 | 保留现行入口与权限、独立时间备注、原请求恢复和提交门禁;共用三栏的已有排车同步验证 | 定向177项通过 |
本条覆盖此前“三栏交互”中的顶部步骤区和布局结论;不表示旧版所有字段、旧协议或紫色主题一并恢复。
自动验证:通过统一 `local-check.ps1` 在 `apps/admin-web` 执行7文件定向回归177项全部通过;最终 `pnpm build`(含应用类型检查)通过;`pnpm test` 全量469文件/4332项全部通过(13:55启动,614.49秒)。差异空白检查通过。本轮只改两处组件模板/局部样式、对应布局和样式合同及本段记录;保留其他对话改动,未部署。真实页面1920×1080、100%截图待人工确认。
## 2026-10-21 排车列表操作列收紧
依据本轮用户两张截图,第二张为当前操作展示参考;仓库拆分原型 `04-overseas-delivery.html:1101` 的 `page-outbound-vehicle` 仍为旧版列表,不据此覆盖现行五阶段和审核链。
| 页面/操作 | 原型/截图布局 | 当前调整 | 验收 |
|---|---|---|---|
| 排车列表/维护车辆、详情 | 主要动作在前、详情在后 | 直接展示当前主要动作及详情,操作列左固定220px;提交审核、取消、卸货顺序及BOL沿已有更多菜单可达 | 定向49项、类型检查及构建通过;1920×1080人工截图待确认 |
| 排车列表/调度、理货审核 | 对应分区显示“审核、详情” | 优先当前分区审核,按钮简称“审核”,可访问名称保留完整环节;原居中弹框、权限与服务端动作资格不变 | 定向49项、类型检查及构建通过;1920×1080人工截图待确认 |
本轮Admin完整回归4328/4329通过,唯一失败为布局合同仍要求旧260px操作列;已同步为220px及两个直接动作,随后页面、原型合同和布局合同49项全部通过。修正后未重复完整回归;未部署,最终页面待人工截图确认。
## 2026-10-21 审核驳回统一返回客服处理
依据用户本轮确认及会话原型截图1(装车前三分区)、截图2/3(居中审核弹框及必填驳回意见)。保留现有列表、详情和居中弹框承载;首分区仍称“车辆信息”,即客服最初处理位置。
| 页面/操作 | 目标 | 验收 |
|---|---|---|
| 排车列表/审核分区 | 客服处理、调度待审、理货待审按服务端流转;任一驳回回车辆信息,显示环节与原因 | 定向回归通过;1920×1080人工截图待确认 |
| 详情/维护车辆信息 | 展示当前退回原因,客服修改期间保留;重新提交后开启完整新审核轮次 | 两级退回原因与重提清除回归通过;人工截图待确认 |
| 调度/理货审核弹框 | 驳回必填原因,成功提示退回客服;理货员双端审核共用服务端资格 | 必填校验、原命令恢复及成功提示回归通过;人工截图待确认 |
## 2026-10-21 货物调整统一在智能排车
依据用户排车列表截图及本轮确认:去掉车辆信息中的添加货物入口,安排后的货物只在智能排车修改,保留理货员现场真实拣货。
@@ -311,3 +400,41 @@
本批未修改SmartDispatchDialog及其候选分页、入库计划、换标与退货出库业务逻辑。源码复查这些入口的既有承载与表单/表格布局,未凭推测批量改宽度或删除必要横向滚动。
公共缺口复核:此前AppDialog后代选择器可能连带命中二级弹框;当前公共写者已将header/body/footer改为直接子级选择器,代码层已收口,本域未修改公共样式;真实嵌套预览仍待人工截图确认。
### 2026-10-21 FBA 正式确认缺项补充
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|---|---|---|---|---|
| 入库单 | 新增 / 编辑 FBA 预约 | 用户本轮截图 Image 1:既有居中弹框;保持原尺寸与底部保存按钮 | 正式确认前按入仓码展示实际缺项;弹框内补齐箱数和货件资料,分别反馈资料保存与预约确认 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
| 入库单 | 查看预约 | 同一弹框只读 | 展示缺项及用途,禁用资料补充 | 定向回归通过;待人工截图确认 |
| 预约列表 | 新增 / 编辑 FBA 预约 | 用户截图 Image 2 与现有 WorkCreateAppointmentForm / WorkAppointmentForm 居中弹框 | 共用逐票资料补充;已有安排明确指向原预约编辑,保留原占用规则 | 定向回归通过;待人工截图确认 |
自动验证:本轮 Admin 全量初跑 469 文件、4333 项,4315 项通过;2 个关联文件的 18 项失败在修复后,由包含这两文件的 45 文件、426 项定向回归全部通过。最终补充文案与表单另行复测 11 项通过;最终 `pnpm build`(含应用类型检查)通过。未触发部署、未写测试环境数据;真实页面仍待人工截图确认。
### 2026-10-21 排车审核入口与附件
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|---|---|---|---|---|
| 排车列表:调度审核、理货员审核 | 行操作 | 用户本轮确认只保留审核、详情;沿现有列表 | 去掉维护车辆信息及更多菜单,仅显示本环节审核与详情 | 本轮定向回归通过;待人工截图确认 |
| 排车审核弹框 | 通过/驳回、预览照片 | 用户附件 codex-clipboard-7867e8a0-a3ae-4a16-bcd5-35291cf12e63.png、codex-clipboard-6baaaa9b-89bd-49fa-ac11-a26b20be7501.png;沿既有弹框 | 三类照片等宽等高区域并排、下方名称、点击预览;底部直接驳回/审核通过,驳回原因必填,去掉结果单选框;未知结果仅重试原请求 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
| 排车维护车辆信息 | 上传车辆、司机和驾驶证照片 | 用户确认上传入口属于维护车辆司机 | 三类照片通过资源编辑保存,司机照片独立字段;审核只读,保留历史审核证据 | 定向回归通过;待人工截图确认 |
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
- 2026-10-21 排车“派送中 → 标记异常”:按用户提供的三类异常、两类退货去向截图补齐选项,沿用居中作业弹框,说明与图片选填,保存后仍派送中;现有排车信息页签回看。自动回归单独报告,1920×1080/100%真实页面待人工截图确认。
- 2026-10-21 照片维护与审核按钮调整:Admin 4 个业务定向文件共 78 项通过,公共密度样式 70 项通过,类型检查通过;前两次回归发现的提示渲染时序与局部背景样式问题已修正并定向复测。指引索引检查因本地缺少 `D:/project/docs/业务快速索引.md` 无法完成。未部署,真实页面待人工截图确认。
- 2026-10-21 取货详情:按用户截图在转运单号后展示对应取货地址、联系人和联系电话,缺失值显示“—”;沿现有取货明细接口读取,不跨行复用联系人。`TransportExecutionPanel.test.ts` 6 项通过,含两张转运单地址和联系人分别展示的回归;真实页面待人工截图确认。
- 2026-10-21 取货装货留痕:列表及执行面板统一使用“上传装货照片”,沿居中弹框展示预报箱数并要求手填实际取货箱数(1—5000),未填数量或未上传照片不能确认;到仓仅展示已保存取货数量,不改写取货事实。数量与照片通过既有版本化命令同次确认,复用服务端操作人和时间历史。执行面板 7 项自动回归通过;真实页面待人工截图确认。
## 排车管理资料统一入口(2026-10-21)
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|---|---|---|---|---|
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 |
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
@@ -0,0 +1,27 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { DispatchStage } from '../dispatchContracts'
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] as const
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string }
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
export async function getLoadingDocuments(no: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
}
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string) {
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
return unwrapApiResponse(await http.post<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form))
}
export async function recoverLoadingPhoto(no: string, key: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/uploads/${encodeURIComponent(key)}`))
}
export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body: BindLoadingPhoto) {
return unwrapApiResponse(await http.put<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(`${path(no)}/${slot}`, body))
}
export async function readLoadingPhoto(no: string, photo: LoadingPhoto) {
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
return blob
}
@@ -0,0 +1,25 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type FuelSlot = 'FUEL_START' | 'FUEL_END'
export interface VehiclePhoto { slot: FuelSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
export interface VehicleDocuments { dispatchNo: string; recordVersion: string; editable: boolean; photos: VehiclePhoto[] }
export interface BindVehiclePhoto { recordVersion: string; idempotencyKey: string; fileId: string }
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/vehicle-documents`
export async function getVehicleDocuments(no: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<VehicleDocuments>, ApiEnvelope<VehicleDocuments>>(path(no)))
}
export async function uploadVehiclePhoto(no: string, slot: FuelSlot, file: File, key: string) {
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
return unwrapApiResponse(await http.post<ApiEnvelope<VehiclePhoto>, ApiEnvelope<VehiclePhoto>>(`${path(no)}/${slot}/uploads`, form))
}
export async function recoverVehiclePhoto(no: string, key: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<VehiclePhoto>, ApiEnvelope<VehiclePhoto>>(`${path(no)}/uploads/${encodeURIComponent(key)}`))
}
export async function bindVehiclePhoto(no: string, slot: FuelSlot, body: BindVehiclePhoto) {
return unwrapApiResponse(await http.put<ApiEnvelope<VehicleDocuments>, ApiEnvelope<VehicleDocuments>>(`${path(no)}/${slot}`, body))
}
export async function readVehiclePhoto(no: string, photo: VehiclePhoto) {
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
return blob
}
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import * as api from './dispatches'
import { candidate, command, detail, loadingPreview, page } from '../dispatchTestFixtures'
import type { ArrangeDelivery, CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
import type { CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
const body = { recordVersion: 0, idempotencyKey: 'original-key', reason: '核对原因' }
const draft = () => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [], sources: [candidate()], expiresAt: '' })
@@ -74,7 +74,7 @@ describe('native whole-vehicle dispatch API', () => {
const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
await api.createDispatch(request); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', request)
})
it.each(['cancel', 'remove', 'review', 'loading-review', 'return', 'loading', 'depart', 'arrange', 'add', 'submit', 'draft-container', 'draft-select', 'draft-release', 'draft-convert', 'draft-snapshot'])('rejects missing/null/negative versions before %s requests and accepts zero', async action => {
it.each(['cancel', 'remove', 'review', 'loading-review', 'return', 'loading', 'add', 'submit', 'draft-container', 'draft-select', 'draft-release', 'draft-convert', 'draft-snapshot'])('rejects missing/null/negative versions before %s requests and accepts zero', async action => {
const invoke = (value: unknown) => {
const request = { ...body, recordVersion: value as Version }
switch (action) {
@@ -84,8 +84,6 @@ describe('native whole-vehicle dispatch API', () => {
case 'loading-review': return api.reviewDispatchLoading('PC/1', request as unknown as ReviewCommand)
case 'return': return api.returnDispatchToReview('PC/1', request)
case 'loading': return api.completeDispatchLoading('PC/1', request as unknown as LoadingComplete)
case 'depart': return api.departDispatch('PC/1', { ...request, remark: null })
case 'arrange': return api.transferDispatchToDelivery('PC/1', request as unknown as ArrangeDelivery)
case 'add': return api.addDispatchCargo('PC/1', { ...request, cargo: [], batchAllocations: [] })
case 'submit': return api.submitDispatchReview('PC/1', { ...request, remark: null })
case 'draft-container': return api.createDispatchDraftContainer({ ...request, draftToken: 'draft', capacity: 30 })
@@ -107,14 +105,6 @@ describe('native whole-vehicle dispatch API', () => {
await api.cancelDispatch('PC/1', body); await api.removeDispatchItem('PC/1', 'item/1', body)
expect(vi.mocked(http.post).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1/cancel', '/platform/v1/last-leg/dispatches/PC%2F1/items/item%2F1/remove']); expect(http.delete).not.toHaveBeenCalled()
})
it('preserves complete grouped arrangement evidence and returns an immutable command envelope', async () => {
const outbound = (customerId: string) => ({ customerId, customerName: `客户${customerId}`, outboundNo: `OUT-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 2, palletCount: 1 })
const delivery = (customerId: string) => ({ ...outbound(customerId), groupKey: customerId, deliveryNo: `DEL-${customerId}`, destination: candidate().destination })
const data = { ...command('ARRANGE_DELIVERY'), result: { dispatchNo: 'PC/1', recordVersion: '9007199254740993', arrangementKey: 'arrange', cargoHash: 'cargo', arrangedAt: '2026-10-19T10:00:00Z', outbounds: [outbound('A'), outbound('B')], deliveries: [delivery('A'), delivery('B')] } }
vi.mocked(http.post).mockResolvedValueOnce({ data })
const request: ArrangeDelivery = { ...body, recordVersion: '9007199254740993', cargoHash: 'cargo', loadingCompletionHash: 'loaded', groups: [] }
expect(await api.transferDispatchToDelivery('PC/1', request)).toEqual(data); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', request)
})
it('keeps draft version, server token and source evidence and converts explicit groups atomically', async () => {
const source = candidate().selection
await api.lockDispatchDraftPallet({ ...body, draftToken: 'server-token', draftContainerId: '190000000000000011', source })
@@ -1,7 +1,7 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
import { requireVersion, requireBatchVersions, type AddCargo, type ArrangeDelivery, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
import { normalizeArrangementPreview, normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type ArrangementPreviewWire, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
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 { 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'
const base = '/platform/v1/last-leg/dispatches'
@@ -37,10 +37,8 @@ export const submitDispatchReview = (no: string, body: VersionCommand & { remark
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 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 departDispatch = (no: string, body: VersionCommand & { remark: string | null }) => postCommand(`${path(no)}/depart`, versioned(body))
export const getDispatchArrangement = (no: string) => get<ArrangementPreviewWire>(`${path(no)}/delivery-arrangement`).then(normalizeArrangementPreview)
export const transferDispatchToDelivery = (no: string, body: ArrangeDelivery) => postCommand(`${path(no)}/transfer-delivery`, versioned(body))
export const getDispatchBolPdf = (no: string) => http.get<Blob, Blob>(`${path(no)}/prints/bol`, { responseType: 'blob' })
export async function listDispatchCandidates(query: CandidateQuery) {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
@@ -12,7 +12,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument, type DeliveryDocuments, type DeliveryDocumentCategory, type DeliveryDocumentFile } from '../api/deliveryDocuments'
const props = defineProps<{ deliveryNo: string; customerId: string }>()
const props = defineProps<{ deliveryNo: string; customerId: string; embedded?: boolean; categories?: DeliveryDocumentCategory[] }>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
@@ -29,7 +29,11 @@ const conflict = ref(false)
const canEdit = computed(() => access.can(permission.deliveryDocuments) && Boolean(snapshot.value?.allowedActions.includes('MANAGE_DOCUMENTS')))
const busy = computed(() => saving.value || uploading.value)
const uploadedCount = computed(() => draft.value.filter(item => item.files.length > 0).length)
const visibleGroups = computed(() => draft.value.filter(item => !props.categories || props.categories.includes(item.category)))
const dirty = computed(() => Boolean(snapshot.value) && (bolNo.value !== (snapshot.value?.bolNo || '') || draft.value.some(group => JSON.stringify(group.files.map(file => file.fileId)) !== JSON.stringify(snapshot.value?.categories.find(item => item.category === group.category)?.files.map(file => file.fileId) || []))))
defineExpose({ busy, dirty, canEdit, loading, conflict, save })
let sequence = 0
onBeforeUnmount(() => { sequence++ })
function isCurrent(current: number, deliveryNo: string, customerId: string) { return current === sequence && open.value && props.deliveryNo === deliveryNo && props.customerId === customerId }
let attempt: { body: string; key: string } | null = null
async function load() {
@@ -97,23 +101,27 @@ async function save() {
saving.value = true; error.value = null
const current = sequence; const { deliveryNo, customerId } = props
try {
await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
const result = await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
if (!isCurrent(current, deliveryNo, customerId)) return
const hasReceipt = body.documents.some(item => item.category === 'RECEIPT' && item.fileIds.length > 0)
open.value = false; emit('saved'); ElMessage.success(hasReceipt ? '签收文件已保存,派送单已自动完成' : '派送资料已保存')
if (props.embedded) {
snapshot.value = result; bolNo.value = result.bolNo || ''; attempt = null
draft.value = deliveryDocumentCategories.map(item => ({ ...item, files: (result.categories.find(group => group.category === item.category)?.files || []).map(file => ({ ...file })) }))
} else open.value = false
emit('saved'); ElMessage.success(hasReceipt ? '签收文件已保存,派送单已自动完成' : '派送资料已保存')
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) { error.value = getApiErrorDetails(cause, '派送资料保存失败'); conflict.value = error.value.code === 'A0409' } }
finally { if (current === sequence) saving.value = false }
}
</script>
<template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="'派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
<component :is="embedded ? 'div' : AppDialog" class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="embedded ? undefined : '派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/3 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
<template v-if="snapshot">
<section class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
<section v-for="group in draft" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
<section v-if="!categories || categories.includes('BOL')" class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
<section v-for="group in visibleGroups" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
<header><h3 class="density-dialog-section__title">{{ group.label }}</h3><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<p v-if="!group.files.length" class="density-dialog-note">尚未上传文件</p>
<table v-else class="density-table document-files"><thead><tr><th class="document-file-actions">操作</th><th>文件名称</th><th class="document-file-time">上传时间</th><th class="document-file-state">保存状态</th></tr></thead><tbody>
@@ -125,7 +133,7 @@ async function save() {
</section>
</template>
</div>
</AppDialog>
</component>
</template>
<style scoped>
@@ -1,35 +0,0 @@
import ElementPlus from 'element-plus'
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import type { ArrangeGroup, CargoLine, PreviewGroup } from '../dispatchContracts'
import { destination } from '../dispatchTestFixtures'
const cargo = (sourceKey: string, customerId = 'C1'): CargoLine => ({ sourceKey, customerId, inboundCode: `IN-${sourceKey}`, sourceOrigin: null }) as CargoLine
function values() {
const preview: PreviewGroup = { groupKey: 'group-1', customerId: 'C1', customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo('A'), cargo('B')], externalFreightHash: 'external-hash', externalQuotes: [], feeCoverageCargoHash: 'coverage-hash', feeSources: [{ financeFeeId: 'fee-1', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 2, sourceHash: 'fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE', 'LOADING_FEE'], sourceKeys: ['A'] }], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }
const group: ArrangeGroup = { groupKey: 'group-1', groupCargoHash: 'group-cargo', plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: 'PALLET', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: 'external-hash', feeCoverageCargoHash: 'coverage-hash', feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: feeType, direction: 'RECEIVABLE', currency: 'USD', amount: null })) }
return { preview, group }
}
it('requires explicit per-service coverage and preserves uncovered cargo instead of treating a partial fee as full coverage', async () => {
const { preview, group } = values()
const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
const covered = (wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }).covered
expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
await wrapper.get('input[type="checkbox"]').setValue(true)
expect(group.feeCoverage).toEqual([{ financeFeeId: 'fee-1', sourceVersion: 2, sourceHash: 'fee-hash', services: ['DELIVERY_FEE'] }])
expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
preview.feeSources[0]!.sourceKeys.push('B'); expect(covered(0, preview, 'DELIVERY_FEE')).toBe(true)
expect(covered(0, preview, 'LOADING_FEE')).toBe(false)
preview.feeSources[0]!.customerId = 'C2'; expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false); wrapper.unmount()
})
it('matches external quotes to the original customer and inbound source and shows only applicable additional fees', async () => {
const { preview, group } = values(); preview.cargo = [cargo('A')]
preview.externalQuotes = [{ customerId: 'C2', groupKey: 'group-1', bookingNo: 'BOOK-1', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'external-source' }]
const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }
expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(false)
expect(wrapper.findAll('.el-input-number')).toHaveLength(1)
preview.externalQuotes[0]!.customerId = 'C1'; expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(true)
group.loadingType = 'FLOOR'; group.appointmentAccount = 'BUY_APPOINTMENT'; await wrapper.setProps({ modelValue: [{ ...group }] })
expect(wrapper.findAll('.el-input-number')).toHaveLength(2); expect(wrapper.text()).toContain('卖家姓名'); wrapper.unmount()
})
@@ -1,57 +0,0 @@
<script setup lang="ts">
import type { ArrangeGroup, PreviewGroup } from '../dispatchContracts'
const groups = defineModel<ArrangeGroup[]>({ required: true })
defineProps<{ preview: PreviewGroup[]; disabled: boolean }>()
const serviceLabels: Record<string, string> = { DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' }
function setCoverage(index: number, fee: PreviewGroup['feeSources'][number], service: string, checked: unknown) {
const group = groups.value[index]; if (!group) return
let selection = group.feeCoverage.find(row => row.financeFeeId === fee.financeFeeId)
if (!selection && checked === true) { selection = { financeFeeId: fee.financeFeeId, sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: [] }; group.feeCoverage.push(selection) }
if (!selection) return
selection.services = checked === true ? [...new Set([...selection.services, service])] : selection.services.filter(value => value !== service)
group.feeCoverage = group.feeCoverage.filter(row => row.services.length)
}
function covered(index: number, preview: PreviewGroup, service: string) {
if (!preview.cargo.length) return false
const selections = groups.value[index]?.feeCoverage || []
return preview.cargo.every(cargo => service === 'DELIVERY_FEE' && preview.externalQuotes.some(quote => quote.customerId === cargo.customerId && quote.inboundCode === cargo.inboundCode)
|| preview.feeSources.some(fee => fee.customerId === cargo.customerId && fee.sourceKeys.includes(cargo.sourceKey) && selections.some(selection => selection.financeFeeId === fee.financeFeeId && selection.services.includes(service))))
}
function required(index: number, preview: PreviewGroup, service: string) {
const group = groups.value[index]
return preview.requiredServices.includes(service)
|| service === 'LOADING_FEE' && ['FLOOR', 'MIXED'].includes(group?.loadingType || '')
|| service === 'APPOINTMENT_FEE' && group?.appointmentAccount === 'BUY_APPOINTMENT'
}
defineExpose({ covered })
</script>
<template>
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group density-dialog-sections">
<template v-if="groups[index]">
<section class="density-dialog-section" data-tone="source" :aria-label="'派送分组依据 ' + (index + 1)"><h3 class="density-dialog-section__title">{{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}</h3>
<el-alert v-for="reason in previewGroup.blockReasons" :key="reason" :title="reason" type="warning" :closable="false" show-icon />
<el-table :data="previewGroup.cargo" border><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="实物托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="cartonMark" label="来源箱唛" min-width="150" show-overflow-tooltip /><el-table-column prop="packages" label="本次箱数" width="100" /></el-table>
<template v-if="previewGroup.externalQuotes.length"><h4 class="density-dialog-section__title">外接约定运费</h4><table class="density-table"><thead><tr><th>外接预订号</th><th>入仓码</th><th>币种</th><th>金额</th></tr></thead><tbody><tr v-for="quote in previewGroup.externalQuotes" :key="quote.bookingNo"><td>{{ quote.bookingNo }}</td><td>{{ quote.inboundCode }}</td><td>{{ quote.currency }}</td><td>{{ quote.amount }}</td></tr></tbody></table></template>
<h4 class="density-dialog-section__title">原费用覆盖</h4><p v-if="!previewGroup.feeSources.length" class="density-dialog-note">暂无可引用原费用。</p>
<table v-else class="density-table"><thead><tr><th>覆盖服务</th><th>币种</th><th>金额</th><th>状态</th></tr></thead><tbody><tr v-for="fee in previewGroup.feeSources" :key="fee.financeFeeId"><td><el-checkbox v-for="service in fee.services" :key="service" :disabled="disabled" :model-value="groups[index]!.feeCoverage.some(row => row.financeFeeId === fee.financeFeeId && row.services.includes(service))" @update:model-value="setCoverage(index, fee, service, $event)">已包含{{ serviceLabels[service] || '服务费用' }}</el-checkbox></td><td>{{ fee.currency }}</td><td>{{ fee.amount }}</td><td>{{ fee.status }}</td></tr></tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" :aria-label="'派送分组登记 ' + (index + 1)"><h3 class="density-dialog-section__title">派送信息</h3>
<table class="density-table density-table--editable arrange-editor-table"><tbody>
<tr><td><el-form-item label="预计派送时间" required><el-date-picker v-model="groups[index]!.plannedDeliveryAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择派送时间" /></el-form-item></td><td><el-form-item label="派送方式" required><el-select v-model="groups[index]!.deliveryMethod" :disabled="disabled" placeholder="请选择"><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="提货方式" required><el-select v-model="groups[index]!.pickupMethod" :disabled="disabled" placeholder="请选择"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item></td><td><el-form-item label="装车类型" required><el-select v-model="groups[index]!.loadingType" :disabled="disabled" placeholder="请选择"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="承运商"><el-input v-model="groups[index]!.carrierName" :disabled="disabled" /></el-form-item></td><td><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" /></el-form-item></td></tr>
<tr><td><el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" /></el-form-item></td><td><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" /></el-form-item></td></tr>
<tr><td><el-form-item label="预约账号"><el-select v-model="groups[index]!.appointmentAccount" :disabled="disabled" clearable placeholder="请选择"><el-option label="自有账号" value="SELF" /><el-option label="买约" value="BUY_APPOINTMENT" /></el-select></el-form-item></td><td><el-form-item v-if="groups[index]!.appointmentAccount === 'BUY_APPOINTMENT'" label="卖家姓名" required><el-input v-model="groups[index]!.sellerName" :disabled="disabled" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" /></el-form-item></td></tr>
</tbody></table>
<h4 class="density-dialog-section__title">本组新增费用</h4><table class="density-table density-table--editable"><tbody><tr v-for="fee in groups[index]!.fees.filter(item => required(index, previewGroup, item.feeType))" :key="fee.feeType"><td><el-form-item v-if="!covered(index, previewGroup, fee.feeType)" :label="(serviceLabels[fee.feeType] || fee.feeName) + '(未覆盖部分)'" required><div class="arrange-fee-controls"><el-input-number v-model="fee.amount" :disabled="disabled" :min="0.01" :precision="2" controls-position="right" /><el-select v-model="fee.currency" :disabled="disabled" placeholder="币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></div></el-form-item><p v-else class="density-dialog-note">本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。</p></td></tr></tbody></table>
</section>
</template>
</section>
</template>
<style scoped>
.arrange-editor-table { table-layout: fixed; }
.arrange-editor-table td:not([colspan]) { width: 50%; }
.arrange-fee-controls { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 120px; gap: var(--admin-space-3); }
.arrange-group h4 { margin-top: var(--admin-space-4); }
</style>
@@ -0,0 +1,71 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchDocumentsDialog from './DispatchDocumentsDialog.vue'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import { detail } from '../dispatchTestFixtures'
import { getDispatch } from '../api/dispatches'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, type DeliveryDocuments } from '../api/deliveryDocuments'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
vi.mock('../api/deliveryDocuments', async original => ({ ...await original<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: vi.fn(), saveDeliveryDocuments: vi.fn() }))
const dialogStub = defineComponent({ props: ['modelValue', 'showConfirm'], emits: ['confirm'], setup(props, { slots, emit }) { return () => props.modelValue ? h('section', [slots.default?.(), props.showConfirm ? h('button', { class: 'save', onClick: () => emit('confirm') }, '保存资料') : null]) : null } })
function documents(no = 'DL-1', customerId = '101'): DeliveryDocuments {
return { deliveryNo: no, customerId, version: 1, bolNo: 'BOL-ORIGINAL', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'RECEIPT' ? 1 : 0, files: item.category === 'RECEIPT' ? [{ fileId: '99', fileName: 'existing-pod.pdf', contentType: 'application/pdf', sizeBytes: 10, uploadedAt: '2026-10-21T00:00:00' }] : [] })) }
}
function setup() { return mount(DispatchDocumentsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) }
beforeEach(() => {
vi.clearAllMocks(); can.mockReturnValue(true)
const value = detail(); value.stage = 'DELIVERING'
value.documents.deliveries = [
{ deliveryNo: 'DL-1', customerId: '101', customerName: '客户甲', groupKey: 'A', outboundNo: 'OB-1', destination: { key: 'FBA:A', type: 'FBA', warehouseId: null, warehouseCode: 'A', warehouseName: null, address: null, postalCode: null, contactName: null, contactPhone: null }, status: 'DELIVERING', packageCount: 1, palletCount: 1 },
{ deliveryNo: 'DL-2', customerId: '102', customerName: '客户乙', groupKey: 'B', outboundNo: 'OB-2', destination: { key: 'FBA:B', type: 'FBA', warehouseId: null, warehouseCode: 'B', warehouseName: null, address: null, postalCode: null, contactName: null, contactPhone: null }, status: 'DELIVERING', packageCount: 1, palletCount: 1 },
]
vi.mocked(getDispatch).mockResolvedValue(value)
vi.mocked(getDeliveryDocuments).mockImplementation(async (no, customer) => documents(no, customer))
vi.mocked(saveDeliveryDocuments).mockResolvedValue({ ...documents(), version: 2 })
})
it.each(['LOADING', 'AFTER_LOADING'] as const)('opens loading documents in %s before delivery orders exist and without delivery-list permission', async stage => {
vi.mocked(getDispatch).mockResolvedValue(detail({ stage }))
can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list')
const wrapper = setup(); await flushPromises()
expect(wrapper.find('dispatch-loading-documents-stub').exists()).toBe(true)
expect(wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.attributes('aria-selected')).toBe('true')
expect(getDeliveryDocuments).not.toHaveBeenCalled(); wrapper.unmount()
})
it('reads existing delivery files in one dialog and saves receipt without requiring loading or BOL files', async () => {
const wrapper = setup(); await flushPromises()
expect(getDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101')
expect(wrapper.text()).toContain('existing-pod.pdf')
expect(wrapper.findAllComponents(DeliveryDocumentsDialog)).toHaveLength(1)
await wrapper.get('.save').trigger('click'); await flushPromises()
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101', expect.objectContaining({ bolNo: 'BOL-ORIGINAL', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [] }, { category: 'RECEIPT', fileIds: ['99'] }] }))
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('modelValue')).toBe(true)
wrapper.unmount()
})
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
const wrapper = setup(); await flushPromises()
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.trigger('click')
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['LOADING_PHOTO', 'BOL'])
await wrapper.getComponent(DeliveryDocumentsDialog).find('input').setValue('BOL-CHANGED')
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
wrapper.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DL-2'); await flushPromises()
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
confirmation.mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
wrapper.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DL-2'); await flushPromises()
expect(getDeliveryDocuments).toHaveBeenLastCalledWith('DL-2', '102')
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
confirmation.mockRestore(); wrapper.unmount()
})
it('does not request delivery documents without delivery list permission', async () => {
can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list')
const wrapper = setup(); await flushPromises()
expect(getDeliveryDocuments).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('当前账号无派送资料查看权限')
wrapper.unmount()
})
@@ -0,0 +1,79 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getDispatch } from '../api/dispatches'
import type { DispatchDetail } from '../dispatchContracts'
import { dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const tab = ref('RECEIPT'), selected = ref(''), fuelVisited = ref(false)
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>(), vehicleEditor = ref<InstanceType<typeof DispatchVehicleDocuments>>()
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
const deliveries = computed(() => detail.value?.documents.deliveries || [])
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
const busy = computed(() => Boolean(editor.value?.busy || vehicleEditor.value?.busy || loadingEditor.value?.busy))
let sequence = 0
onBeforeUnmount(() => { sequence++ })
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
const value = await getDispatch(props.dispatchNo)
if (current !== sequence || !open.value) return
if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致')
if (value.status !== 'ACTIVE' || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('进入装车中后可管理资料')
if (!detail.value && ['LOADING', 'AFTER_LOADING'].includes(value.stage)) tab.value = 'LOADING'
detail.value = value
if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || ''
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') }
finally { if (current === sequence) loading.value = false }
}
async function canLeave() {
if (busy.value) return false
if (!editor.value?.dirty) return true
try { await ElMessageBox.confirm('有尚未保存的派送资料,是否放弃修改?', '未保存资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }); return true } catch { return false }
}
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
async function beforeClose(done: () => void) { if (await canLeave()) done() }
function selectTab(value: string) { if (!busy.value) { tab.value = value; if (value === 'FUEL') fuelVisited.value = true } }
function saved() { emit('saved') }
async function loadingSaved() { emit('saved'); await load() }
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; fuelVisited.value = false; error.value = null
if (visible && props.dispatchNo) void load()
}, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab !== 'FUEL' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail">
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
<div v-show="tab !== 'FUEL'" v-if="!beforeDeparture">
<h3 v-if="tab === 'LOADING'" class="density-dialog-section__title">关联派送单资料</h3>
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['LOADING_PHOTO', 'BOL']" @saved="saved" />
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
</div>
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
<DispatchVehicleDocuments v-if="fuelVisited && !beforeDeparture" v-show="tab === 'FUEL'" ref="vehicleEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="saved" />
<section v-if="tab === 'LOADING'" class="density-dialog-section"><h3 class="density-dialog-section__title">车辆及司机附件</h3><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></section>
</template>
</AppDialog>
</template>
@@ -12,6 +12,69 @@ const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHIC
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
it('renders authenticated photos above their caption and releases thumbnails on unmount', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, readonly: true, label: '车辆图片' } }); await flushPromises()
expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
const figure = wrapper.get('figure')
expect(figure.get('img').attributes('src')).toBe('blob:authenticated')
expect(figure.get('figcaption').text()).toBe('车辆图片')
expect(figure.element.lastElementChild?.tagName).toBe('FIGCAPTION')
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated')
})
it('does not render a late thumbnail from the previous dispatch or an invalid image response', async () => {
let resolve!: (value: Blob) => void
vi.mocked(api.getDispatchEvidenceContent).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true } }); await flushPromises()
await wrapper.setProps({ dispatchNo: 'PC/2', modelValue: [] })
resolve(new Blob(['png'], { type: 'image/png' })); await flushPromises()
expect(URL.createObjectURL).not.toHaveBeenCalled()
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce(new Blob(['bad'], { type: 'text/html' }))
await wrapper.setProps({ modelValue: ['301'] }); await flushPromises()
expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(wrapper.find('img').exists()).toBe(false)
wrapper.unmount()
})
it('uses the dialog body for the image and keeps loading until the browser decodes it', async () => {
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises()
await button(wrapper, '预览').trigger('click'); await flushPromises()
const dialog = wrapper.get('[role="dialog"]')
expect(dialog.find('table').exists()).toBe(false)
expect(dialog.get('.evidence-preview').attributes('aria-busy')).toBe('true')
const image = dialog.get('img')
Object.defineProperties(image.element, { naturalWidth: { value: 1200 }, naturalHeight: { value: 800 } })
await image.trigger('load')
expect(dialog.get('.evidence-preview').attributes('aria-busy')).toBe('false')
expect(image.attributes('src')).toBe('blob:authenticated')
expect(dialog.text()).not.toContain('图片无法预览')
wrapper.unmount()
})
it.each(['single-pixel', 'decode-error'])('explains %s images inside the preview and retries the owned content', async failure => {
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises()
await button(wrapper, '预览').trigger('click'); await flushPromises()
const image = wrapper.get('[role="dialog"] img')
if (failure === 'single-pixel') {
Object.defineProperties(image.element, { naturalWidth: { value: 1 }, naturalHeight: { value: 1 } })
await image.trigger('load')
} else await image.trigger('error')
expect(wrapper.get('[role="dialog"]').text()).toContain(failure === 'single-pixel' ? '1 × 1 像素' : '图片无法解码或加载')
expect(wrapper.get('.evidence-preview').attributes('aria-busy')).toBe('false')
expect(wrapper.find('[role="dialog"] img').exists()).toBe(false)
await button(wrapper, '重新预览').trigger('click'); await flushPromises()
expect(api.getDispatchEvidenceContent).toHaveBeenCalledTimes(2)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated')
expect(wrapper.get('[role="dialog"] img').attributes('src')).toBe('blob:authenticated')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it('keeps previously provided references read-only and only submits newly uploaded audit files', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, modelValue: [], referenceIds: ['301'], kind: 'DRIVER_PHOTO', label: '司机照片' } }); await flushPromises()
expect(wrapper.findAll('tbody button').map(item => item.text())).toEqual(['预览'])
await selectFile(wrapper, new File(['pdf'], 'photo.pdf', { type: 'application/pdf' })); expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
await selectFile(wrapper, new File(['png'], 'photo.png', { type: 'image/png' }))
expect(api.uploadDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER_PHOTO', expect.any(File))
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']])
wrapper.unmount()
})
it('previews and replaces real string-size metadata through the JSON parser and API boundary', async () => {
const actual = await vi.importActual<typeof import('../api/dispatches')>('../api/dispatches')
const envelope = (fileId: string) => parseApiJsonResponse(JSON.stringify({ code: '200', data: { ...metadata(fileId), sizeBytes: '3' } }))
@@ -10,34 +10,62 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean }>()
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[] }>()
const ids = defineModel<string[]>({ required: true })
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])])
const emit = defineEmits<{ busy: [value: boolean] }>()
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
const label = computed(() => props.kind === 'VEHICLE' ? '本趟车辆证据' : '本趟司机证照')
const thumbnails = ref<Record<string, string>>({})
const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
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 uploadTypes = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types)
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
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('附件元数据未确认本车有效文件,请重新读取')
return { ...value, fileId: String(value.fileId) }
}
function fail(cause: unknown, fallback: string) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback) }
function closePreview() { previewGeneration++; pendingPopup?.close(); pendingPopup = null; imageOpen.value = false; previewLoading.value = false; if (imageUrl.value) { URL.revokeObjectURL(imageUrl.value); urls.delete(imageUrl.value); imageUrl.value = '' } }
function closePreview() { previewGeneration++; pendingPopup?.close(); pendingPopup = null; imageOpen.value = false; previewLoading.value = false; imageDecoding.value = false; imageFailure.value = ''; if (imageUrl.value) { URL.revokeObjectURL(imageUrl.value); urls.delete(imageUrl.value); imageUrl.value = '' } }
function imageLoaded(event: Event) {
const image = event.target as HTMLImageElement
if (!imageOpen.value || image.getAttribute('src') !== imageUrl.value) return
imageDecoding.value = false
if (image.naturalWidth <= 1 && image.naturalHeight <= 1) imageFailure.value = '原图片仅为 1 × 1 像素,无法查看有效内容,请核对上传的原附件。'
}
function imageFailed(event: Event) {
if (!imageOpen.value || (event.target as HTMLImageElement).getAttribute('src') !== imageUrl.value) return
imageDecoding.value = false
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
}
async function load() {
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...ids.value]
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value]
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
thumbnails.value = {}
closePreview(); metadata.value = {}; error.value = null
if (!no) return
const results = await Promise.allSettled(values.map(async id => valid(await getDispatchEvidence(no, kind, id), id)))
if (current !== generation) return
results.forEach(result => { if (result.status === 'fulfilled') metadata.value[result.value.fileId] = result.value; else fail(result.reason, '附件资料读取失败,请重试;原引用已保留') })
if (props.photoCard) await Promise.all(values.map(async id => {
const file = metadata.value[id]
if (!file || !file.contentType.startsWith('image/')) return
try {
const blob = await readContent(no, kind, id, file)
if (current !== generation) return
const url = URL.createObjectURL(blob); urls.add(url); thumbnails.value[id] = url
} catch (cause) { if (current === generation) fail(cause, '照片读取失败,请重试') }
}))
}
function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() }
async function upload(event: Event) {
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
if (!file || props.readonly || props.disabled || uploading.value) return
error.value = null
if (!types.includes(file.type) || file.size <= 0 || file.size > maximum) { fail(new DispatchInputError('请选择10MiB以内的PDF、JPG或PNG文件'), '附件不符合要求'); return }
if (!uploadTypes.value.includes(file.type) || file.size <= 0 || file.size > maximum) { fail(new DispatchInputError(`请选择10MiB以内的${uploadFormats.value}文件`), '附件不符合要求'); return }
const current = generation, no = props.dispatchNo, kind = props.kind, replaceId = replacing
uploading.value = true; emit('busy', true)
try {
@@ -49,39 +77,59 @@ async function upload(event: Event) {
finally { uploading.value = false; emit('busy', false) }
}
function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) }
async function readContent(no: string, kind: DispatchEvidenceKind, id: string, file: DispatchEvidenceFile) {
const blob = await getDispatchEvidenceContent(no, kind, id)
const type = blob.type.split(';')[0]?.toLowerCase()
if (type?.includes('json')) {
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
try { body = JSON.parse(await blob.text()) as typeof body } catch { throw new DispatchInputError('附件内容返回错误信息,请重新读取') }
throw new ApiResponseError(body.message || '附件读取失败', body.code || 'INVALID_FILE_CONTENT', undefined, undefined, body.requestId, body.traceId)
}
if (type !== file.contentType || blob.size <= 0 || blob.size > maximum || blob.size !== file.sizeBytes) throw new DispatchInputError('附件内容类型或大小与本车元数据不一致')
return blob
}
async function preview(id: string) {
const file = metadata.value[id]; if (!file || previewLoading.value) return
closePreview(); const current = ++previewGeneration, scope = generation
previewId.value = id
error.value = null; previewLoading.value = true
if (file.contentType === 'application/pdf') { pendingPopup = window.open('', '_blank'); if (!pendingPopup) { previewLoading.value = false; fail(new DispatchInputError('请允许本站打开PDF预览窗口'), 'PDF预览未打开'); return } }
else { imageName.value = file.fileName; imageOpen.value = true }
try {
const blob = await getDispatchEvidenceContent(props.dispatchNo, props.kind, id)
const blob = await readContent(props.dispatchNo, props.kind, id, file)
if (current !== previewGeneration || scope !== generation) return
const type = blob.type.split(';')[0]?.toLowerCase()
if (type?.includes('json')) {
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
try { body = JSON.parse(await blob.text()) as typeof body } catch { throw new DispatchInputError('附件内容返回错误信息,请重新读取') }
throw new ApiResponseError(body.message || '附件读取失败', body.code || 'INVALID_FILE_CONTENT', undefined, undefined, body.requestId, body.traceId)
}
if (type !== file.contentType || blob.size <= 0 || blob.size > maximum || blob.size !== file.sizeBytes) throw new DispatchInputError('附件内容类型或大小与本车元数据不一致')
const url = URL.createObjectURL(blob); urls.add(url)
if (file.contentType === 'application/pdf') { pendingPopup!.location.href = url; pendingPopup = null; window.setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url) }, 60_000) }
else imageUrl.value = url
else { imageDecoding.value = true; imageUrl.value = url }
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
finally { if (current === previewGeneration) previewLoading.value = false }
}
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value]), load, { immediate: true })
watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value]), load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
</script>
<template>
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></InlineHelp></h4>
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 v-if="!photoCard" class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></h4>
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table v-if="ids.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in ids" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
<p v-else class="density-dialog-note">暂无本趟附件。</p>
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
<AppDialog append-to-body class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
<figure v-if="photoCard" class="evidence-photo-card">
<div v-if="allIds.length" class="evidence-photo-list">
<div v-for="id in allIds" :key="id">
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
<div v-if="!readonly && ids.includes(id)" class="density-actions"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
</div>
</div>
<div v-else class="evidence-photo-empty">暂无照片</div>
<figcaption>{{ label }}<InlineHelp v-if="!readonly" title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></figcaption>
</figure>
<template v-else>
<table v-if="allIds.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in allIds" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly && ids.includes(id)"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
<p v-else class="density-dialog-note">暂无本趟附件。</p>
</template>
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" :accept="uploadTypes.join(',')" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
<AppDialog append-to-body class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewBusy" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
<div class="evidence-preview" :aria-busy="previewBusy">
<LoadErrorState v-if="imageFailure" title="图片无法预览" :message="imageFailure" compact retry-text="重新预览" @retry="preview(previewId)" />
<img v-else-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName" @load="imageLoaded" @error="imageFailed">
</div>
</AppDialog>
</section>
</template>
@@ -91,5 +139,12 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
.evidence-input { display: none; }
.evidence-image { display: block; max-width: 100%; max-height: 72vh; margin: auto; object-fit: contain; }
.evidence-photo-card { margin: 0; text-align: center; }
.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
.evidence-photo-preview { padding: 0; cursor: pointer; }
.evidence-photo-preview img { width: 100%; height: 100%; object-fit: contain; }
.evidence-photo-card figcaption { margin-top: var(--admin-space-3); font-weight: 600; }
.evidence-preview { display: grid; place-items: center; height: min(60vh, 600px); min-width: 0; }
.evidence-image { display: block; width: 100%; height: 100%; min-height: 0; object-fit: contain; }
</style>
@@ -0,0 +1,84 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
import * as api from '../api/dispatchLoadingDocuments'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operator' } }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await importOriginal<typeof api>(), getLoadingDocuments: vi.fn(), uploadLoadingPhoto: vi.fn(), recoverLoadingPhoto: vi.fn(), bindLoadingPhoto: vi.fn(), readLoadingPhoto: vi.fn() }))
const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null }
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) }
async function upload(wrapper: ReturnType<typeof setup>) {
const input = wrapper.get('input[aria-label="装车照片 1"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], 'loading.png', { type: 'image/png' })] })
await input.trigger('change'); await flushPromises()
}
beforeEach(() => {
vi.clearAllMocks(); sessionStorage.clear(); can.mockReturnValue(true)
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data())
vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(photo)
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '1', photos: [photo] }))
vi.mocked(api.readLoadingPhoto).mockRejectedValue(new Error('preview unavailable'))
})
it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => {
const wrapper = setup(); await flushPromises()
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
await upload(wrapper)
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String))
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('图片读取失败')
wrapper.unmount()
})
it('keeps the original binding and file after an unknown result and never uploads again', async () => {
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown'))
const wrapper = setup(); await flushPromises(); await upload(wrapper)
expect(wrapper.emitted('saved')).toBeUndefined()
const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2]
await wrapper.findAll('button').find(item => item.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', original)
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('refreshes a definitive version conflict while retaining the uploaded file', async () => {
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409'))
const wrapper = setup(); await flushPromises(); await upload(wrapper)
const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2]
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '4' }))
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'DELIVERING', complete: true, recordVersion: '5' }))
await wrapper.findAll('button').find(item => item.text() === '刷新并重试保存')!.trigger('click'); await flushPromises()
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '4', fileId: '101' }))
expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('shows existing photos without exposing upload for a read-only account', async () => {
can.mockReturnValue(false)
const wrapper = setup(); await flushPromises()
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); wrapper.unmount()
})
it('allows choosing another image after the server definitively rejects the file', async () => {
vi.mocked(api.uploadLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('图片内容无效', 'A0400'))
const wrapper = setup(); await flushPromises(); await upload(wrapper)
expect(wrapper.text()).toContain('图片内容无效')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
await upload(wrapper)
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('allows saving photos while loading and keeps the loading stage', async () => {
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING' }))
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'LOADING', recordVersion: '1', photos: [photo] }))
const wrapper = setup(); await flushPromises()
expect(wrapper.text()).toContain('可边装车边上传')
await upload(wrapper)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('可边装车边上传'); wrapper.unmount()
})
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, recoverLoadingPhoto, uploadLoadingPhoto, type BindLoadingPhoto, type LoadingDocuments, type LoadingPhoto, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: [] }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const snapshot = ref<LoadingDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
interface Pending { slot: LoadingPhotoSlot; key: string; photo?: LoadingPhoto; binding?: BindLoadingPhoto; conflict?: boolean }
const pending = ref<Pending | null>(null), corrupted = ref(false), images = ref<Record<string, string>>({})
const actor = auth.user?.id || '', number = props.dispatchNo, storageKey = `dispatch-loading-documents:${actor}:${number}`
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
let active = true
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
function accept(value: LoadingDocuments) { ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致'); snapshot.value = value }
async function previews() {
const photos = snapshot.value?.photos || []
await Promise.allSettled(photos.map(async photo => {
if (images.value[photo.fileId]) return
const blob = await readLoadingPhoto(number, photo); ensureScope(); images.value[photo.fileId] = URL.createObjectURL(blob)
}))
}
async function load() {
busy.value = true; error.value = null
try { accept(await getLoadingDocuments(number)); await previews() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车资料读取失败') }
finally { if (active) busy.value = false }
}
async function bind() {
const item = pending.value
if (!item?.photo || !snapshot.value) return
if (item.photo.slot !== item.slot) throw new Error('上传结果与装车照片框不一致')
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
try {
const result = await bindLoadingPhoto(number, item.slot, item.binding)
accept(result); pending.value = null; persist(); emit('saved')
ElMessage.success(result.stage === 'DELIVERING' && result.complete ? '装车资料已齐全,本车已进入派送中' : '装车照片已保存')
await previews()
} catch (cause) {
const failure = getApiErrorDetails(cause, '装车照片保存失败')
if (pending.value && /^(?:A0|HTTP_)?409$/.test(String(failure.code))) { pending.value.conflict = true; persist() }
throw cause
}
}
async function recover() {
if (busy.value || !editable.value || !pending.value) return
busy.value = true; error.value = null
try {
ensureScope()
if (pending.value.conflict) { accept(await getLoadingDocuments(number)); pending.value.binding = undefined; pending.value.conflict = false; persist() }
if (!pending.value.photo) { pending.value.photo = await recoverLoadingPhoto(number, pending.value.key); ensureScope(); persist() }
await bind()
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
finally { if (active) busy.value = false }
}
async function upload(slot: LoadingPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || !editable.value) return
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求')
// 文件校验明确拒绝时尚未创建上传意图,允许重新选择;未知结果仍保留原请求。
if (pending.value && !pending.value.photo && /^(?:A0|HTTP_)?400$/.test(String(error.value.code))) { pending.value = null; persist() }
}
}
finally { if (active) busy.value = false }
}
onMounted(() => {
try {
const raw = sessionStorage.getItem(storageKey)
if (raw) { const value = JSON.parse(raw) as Pending; if (!loadingPhotoSlots.includes(value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原装车照片请求记录无法读取'); pending.value = value }
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原装车照片请求记录无法读取') }
if (!corrupted.value) void load()
})
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(URL.revokeObjectURL) })
defineExpose({ busy })
</script>
<template>
<div v-loading="busy" class="density-dialog-sections">
<p v-if="snapshot?.stage === 'LOADING'">可边装车边上传 5 张装车照片;装车确认完成且照片齐全后自动进入派送中。</p>
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending" role="status">有装车照片尚未确认保存。<el-button :disabled="busy || !editable" @click="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</el-button></div>
<div class="loading-photo-grid">
<section v-for="(slot, index) in loadingPhotoSlots" :key="slot" class="density-dialog-section loading-photo-card">
<h3 class="density-dialog-section__title">装车照片 {{ index + 1 }}</h3>
<template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
</template>
<div v-if="!snapshot?.photos.some(item => item.slot === slot)" class="loading-photo-empty">未上传</div>
<label v-if="editable">上传/替换图片<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label>
</section>
</div>
</div>
</template>
<style scoped>
.loading-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.loading-photo-image, .loading-photo-empty { width: 100%; height: 150px; margin-bottom: var(--admin-space-3); }
.loading-photo-empty { display: grid; place-items: center; background: var(--cc-bg-soft, #f3f7ff); color: var(--cc-text-secondary, #64748b); }
.loading-photo-card input { display: block; margin-top: var(--admin-space-2); max-width: 100%; }
</style>
@@ -0,0 +1,63 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
import { bindVehiclePhoto, getVehicleDocuments, recoverVehiclePhoto, uploadVehiclePhoto, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operator' } }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/dispatchVehicleDocuments', () => ({ bindVehiclePhoto: vi.fn(), getVehicleDocuments: vi.fn(), recoverVehiclePhoto: vi.fn(), uploadVehiclePhoto: vi.fn(), readVehiclePhoto: vi.fn() }))
const photo: VehiclePhoto = { slot: 'FUEL_START', fileId: '9007199254740995', fileName: 'fuel.png', contentType: 'image/png', savedAt: null }
function response(version = '3'): VehicleDocuments { return { dispatchNo: 'PC/1', recordVersion: version, editable: true, photos: [] } }
function setup() { return mount(DispatchVehicleDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) }
async function upload(wrapper: ReturnType<typeof setup>) { const input = wrapper.get('input'); Object.defineProperty(input.element, 'files', { value: [new File(['image'], 'fuel.png', { type: 'image/png' })], configurable: true }); await input.trigger('change'); await flushPromises() }
beforeEach(() => {
vi.clearAllMocks(); sessionStorage.clear(); can.mockReturnValue(true)
vi.mocked(getVehicleDocuments).mockResolvedValue(response())
vi.mocked(uploadVehiclePhoto).mockResolvedValue(photo)
vi.mocked(recoverVehiclePhoto).mockResolvedValue(photo)
vi.mocked(bindVehiclePhoto).mockResolvedValue({ ...response('4'), photos: [{ ...photo, savedAt: '2026-10-21T00:00:00Z' }] })
})
it('binds the uploaded file to the car and restores the original binding after reopening without uploading twice', async () => {
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new Error('network timeout'))
const first = setup(); await flushPromises(); await upload(first)
const original = vi.mocked(bindVehiclePhoto).mock.calls[0]![2]
expect(original).toMatchObject({ recordVersion: '3', fileId: photo.fileId })
first.unmount()
const reopened = setup(); await flushPromises()
await reopened.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', original)
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
expect(reopened.text()).toContain('fuel.png')
expect(sessionStorage.length).toBe(0)
reopened.unmount()
})
it('keeps uncertain uploads for original-key recovery and never resubmits the file', async () => {
vi.mocked(uploadVehiclePhoto).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = setup(); await flushPromises(); await upload(wrapper)
const originalKey = vi.mocked(uploadVehiclePhoto).mock.calls[0]![3]
await wrapper.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
expect(recoverVehiclePhoto).toHaveBeenCalledWith('PC/1', originalKey)
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
expect(bindVehiclePhoto).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('refreshes only a known version conflict and reuses the uploaded file', async () => {
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new ApiResponseError('运营记录版本已变化,请刷新后重试', 'A0409', 409))
const wrapper = setup(); await flushPromises(); await upload(wrapper)
const originalKey = vi.mocked(bindVehiclePhoto).mock.calls[0]![2].idempotencyKey
vi.mocked(getVehicleDocuments).mockResolvedValue(response('8'))
await wrapper.findAll('button').find(button => button.text() === '刷新版本并保存已上传图片')!.trigger('click'); await flushPromises()
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', { recordVersion: '8', fileId: photo.fileId, idempotencyKey: expect.any(String) })
expect(vi.mocked(bindVehiclePhoto).mock.calls[1]![2].idempotencyKey).not.toBe(originalKey)
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('does not offer upload with list permission alone', async () => {
can.mockReturnValue(false)
const wrapper = setup(); await flushPromises()
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
expect(uploadVehiclePhoto).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, recoverVehiclePhoto, uploadVehiclePhoto, type BindVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
const props = defineProps<{ dispatchNo: string }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const emit = defineEmits<{ saved: [] }>()
const snapshot = ref<VehicleDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto; versionConflict?: boolean }
const pending = ref<Pending | null>(null), preview = ref(''), corrupted = ref(false)
const actor = auth.user?.id || '', number = props.dispatchNo
const storageKey = `dispatch-vehicle-documents:${actor}:${number}`
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
const roles: Array<{ slot: FuelSlot; label: string }> = [{ slot: 'FUEL_START', label: '起始油耗表盘' }, { slot: 'FUEL_END', label: '结束油耗表盘' }]
let active = true
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
async function load() {
busy.value = true; error.value = null
try { const result = await getVehicleDocuments(number); ensureScope(); if (result.dispatchNo !== number) throw new Error('车辆资料与排车单不一致'); snapshot.value = result }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '车辆资料读取失败') }
finally { if (active) busy.value = false }
}
async function bind() {
const item = pending.value
if (!item?.photo || !snapshot.value) return
if (item.photo.slot !== item.slot) throw new Error('上传结果与图片分类不一致')
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
let result: VehicleDocuments
try { result = await bindVehiclePhoto(number, item.slot, item.binding) }
catch (cause) {
const details = getApiErrorDetails(cause, '图片保存失败')
if (details.code === 'A0409' && /记录版本已变化|记录版本竞争失败/.test(details.message)) { item.versionConflict = true; persist() }
throw cause
}
ensureScope(); snapshot.value = result; pending.value = null; persist(); emit('saved'); ElMessage.success('油耗图片已保存')
}
async function recover() {
if (busy.value || !editable.value || !pending.value) return
busy.value = true; error.value = null
try { ensureScope(); if (!pending.value.photo) { pending.value.photo = await recoverVehiclePhoto(number, pending.value.key); ensureScope(); persist() }; await bind() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
finally { if (active) busy.value = false }
}
async function refreshBinding() {
if (busy.value || !editable.value || !pending.value?.versionConflict) return
await load()
if (error.value || !pending.value) return
pending.value.binding = undefined; pending.value.versionConflict = false; persist()
await recover()
}
async function upload(slot: FuelSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || !editable.value) return
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
pending.value.photo = await uploadVehiclePhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求')
if (!pending.value?.photo && ['A0400', 'A0403'].includes(error.value.code || '')) { pending.value = null; persist() }
}
}
finally { if (active) busy.value = false }
}
async function view(photo: VehiclePhoto) {
if (busy.value) return
busy.value = true; error.value = null
try { const blob = await readVehiclePhoto(number, photo); ensureScope(); if (preview.value) URL.revokeObjectURL(preview.value); preview.value = URL.createObjectURL(blob) }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (active) busy.value = false }
}
onMounted(() => {
try {
const raw = sessionStorage.getItem(storageKey)
if (raw) { const value = JSON.parse(raw) as Pending; if (!roles.some(role => role.slot === value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原图片请求记录无法读取'); pending.value = value }
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原图片请求记录无法读取') }
if (!corrupted.value) void load()
})
onBeforeUnmount(() => { active = false; if (preview.value) URL.revokeObjectURL(preview.value) })
defineExpose({ busy })
</script>
<template>
<div v-loading="busy" class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending" role="status">有图片尚未确认保存。<el-button v-if="pending.versionConflict" :disabled="busy || !editable" @click="refreshBinding">刷新版本并保存已上传图片</el-button><el-button v-else :disabled="busy || !editable" @click="recover">核对原请求并继续保存</el-button></div>
<section v-for="role in roles" :key="role.slot" class="density-dialog-section">
<h3 class="density-dialog-section__title">{{ role.label }}</h3>
<div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div>
<span v-if="snapshot && !snapshot.photos.some(item => item.slot === role.slot)">未上传</span>
<label v-if="editable" class="photo-upload">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy || Boolean(pending)" @change="upload(role.slot, $event)" /></label>
</section>
<img v-if="preview" :src="preview" alt="油耗图片预览" class="photo-preview" />
</div>
</template>
<style scoped>
.photo-upload { display: block; margin-top: var(--admin-space-3); }
.photo-upload input { margin-left: var(--admin-space-3); }
.photo-preview { display: block; max-width: 100%; max-height: 400px; object-fit: contain; }
</style>
@@ -2,36 +2,27 @@ import ElementPlus from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/dispatches'
import { command, detail, destination, loadingPreview, resources } from '../dispatchTestFixtures'
import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts'
import { command, detail, loadingPreview, resources } from '../dispatchTestFixtures'
import type { DispatchAction, DispatchActions, DispatchEvidenceFile, UpdateDispatch } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
const gate = { allowed: true, blockCode: null, blockReason: null }
const actions: DispatchActions = { EDIT: gate, ADD_CARGO: gate, REMOVE_CARGO: gate, CANCEL: gate, SUBMIT_REVIEW: gate, DISPATCH_REVIEW: gate, LOADING_REVIEW: gate, RETURN_TO_REVIEW: gate, LOADING_COMPLETE: gate, ARRANGE_DELIVERY: gate, DEPART: gate }
const evidence = (fileId = '301'): DispatchEvidenceFile => ({ fileId, fileName: fileId === '301' ? '旧附件.png' : '新附件.png', contentType: 'image/png', sizeBytes: 3 })
function cargo(key: string, customerId: string): CargoLine { return { dispatchItemId: 'item-' + key, customerId, customerName: '客户' + customerId, orderId: null, orderNo: 'ORDER-' + key, sourceKey: key, sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: null, inboundCode: 'IN-' + key, planPalletId: null, warehousePalletId: 'pallet-' + key, palletNo: 'PALLET-' + key, pickId: null, attributionId: null, inventoryPositionId: null, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: 'BOX-' + key, relabelNo: null, destination: destination(), packages: 2, productQuantity: 4, grossWeightKg: 10, volumeCbm: 1, batchAllocations: [], sourceOrigin: null } }
function arrangement(): ArrangementPreview {
const group = (key: string, customerId: string): PreviewGroup => ({ groupKey: key, customerId, customerName: '客户' + customerId, destination: destination(), groupCargoHash: 'cargo-' + key, cargo: [cargo(key, customerId)], externalFreightHash: 'external-' + key, externalQuotes: [], feeCoverageCargoHash: 'coverage-' + key, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] })
const first = group('A', 'C1'), second = group('B', 'C2')
first.feeSources = [{ financeFeeId: 'original-fee', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 0, sourceHash: 'original-fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE'], sourceKeys: ['A'] }]
second.externalQuotes = [{ customerId: 'C2', groupKey: 'B', bookingNo: 'BOOK-2', inboundCode: 'IN-B', currency: 'USD', amount: 200, sourceHash: 'quote-hash' }]
return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', arrange: gate, groups: [first, second], arrangement: null }
}
beforeEach(() => {
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
@@ -44,12 +35,10 @@ beforeEach(() => {
vi.mocked(api.cancelDispatch).mockImplementation(async (_, body) => command('CANCEL', body.idempotencyKey))
vi.mocked(api.returnDispatchToReview).mockImplementation(async (_, body) => command('RETURN_TO_REVIEW', body.idempotencyKey))
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
vi.mocked(api.departDispatch).mockImplementation(async (_, body) => command('DEPART', body.idempotencyKey))
vi.mocked(api.reorderDispatchRoute).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview())
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: gate, stops: [{ index: 0, name: '起运仓', address: '起点地址', destinationKey: null }, { index: 1, name: '目的仓甲', address: '甲地址', destinationKey: 'A' }, { index: 2, name: '外接卸货点', address: '乙地址', destinationKey: 'B' }] })
vi.mocked(api.getDispatchArrangement).mockResolvedValue(arrangement())
vi.mocked(api.getDispatchEvidence).mockImplementation(async (_, __, id) => evidence(id))
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(evidence('302'))
@@ -65,54 +54,140 @@ function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) {
describe('dispatch workflow native layout', () => {
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
const { dialog } = await render('EDIT')
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
expect(dialog.props('title')).toBe('维护车辆 · PC/1')
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
await field(dialog, '车牌号').get('input').setValue('CA-NEW')
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
await field(dialog, '司机姓名').get('input').setValue('新司机')
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
await dialog.get('textarea[aria-label="本次操作备注"]').setValue('维护备注')
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '维护备注' })
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
})
it('saves all three resource photos before submitting review with the refreshed version', async () => {
const { dialog } = await render('EDIT')
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
fields.forEach((field, index) => field.vm.$emit('update:modelValue', [String(401 + index)]))
await flushPromises()
await dialog.findAll('button').find(button => button.text() === '提交审核')!.trigger('click')
await flushPromises()
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.objectContaining({ evidenceFileIds: ['401'] }), driver: expect.objectContaining({ evidence: expect.objectContaining({ photoFileIds: ['402'], fileIds: ['403'] }) }) }))
expect(api.submitDispatchReview).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null }))
expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!)
})
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT')
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
await dialog.get('textarea[aria-label="本次操作备注"]').setValue('保留本次输入')
dialog.vm.$emit('confirm'); await flushPromises()
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
expect(dialog.text()).toContain('workflow-request')
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises()
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
expect(dialog.props('title')).toBe('维护车辆 · PC/1')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
expect((dialog.get('textarea[aria-label="本次操作备注"]').element as HTMLTextAreaElement).value).toBe('保留本次输入')
expect(original.remark).toBe('真实备注')
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
const { dialog } = await render(action)
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
expect(dialog.text()).toContain('审核通过不代替后续现场扫码')
field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
expect(dialog.get('[aria-label="本车审核依据"]').find('h3').exists()).toBe(false)
expect(dialog.text()).not.toContain('请核对本车资源与本趟证据')
expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
expect(dialog.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false)
expect(dialog.findAll('button').some(button => button.text() === '确认提交')).toBe(false)
const reject = dialog.findAll('button').find(button => button.text() === '驳回')!
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises()
await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled()
await vi.waitFor(() => expect(field(dialog, '驳回原因(必填)').text()).toContain('请填写驳回原因'))
await dialog.get('textarea').setValue(' '); await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 证据不完整 '); await reject.trigger('click'); await flushPromises()
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
})
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW', 'DEPART'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('approves %s directly and keeps an unknown result on the original request', async action => {
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
vi.mocked(review).mockRejectedValueOnce(new Error('结果未知'))
const { dialog } = await render(action)
await dialog.findAll('button').find(button => button.text() === '审核通过')!.trigger('click')
await flushPromises()
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'APPROVED', reason: null })
const original = structuredClone(vi.mocked(review).mock.calls[0]![1])
expect(dialog.props('showCancel')).toBe(false)
expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
expect(dialog.findAll('button').some(button => button.text() === '驳回')).toBe(false)
await dialog.findAll('button').find(button => button.text() === '核对并重试原请求')!.trigger('click')
await flushPromises()
expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original)
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('disables both %s decisions when write permission is missing', async action => {
can.mockReturnValue(false)
const { dialog } = await render(action)
for (const label of ['驳回', '审核通过']) {
const button = dialog.findAll('button').find(item => item.text() === label)!
expect(button.attributes('disabled')).toBeDefined()
await button.trigger('click')
}
expect(api.reviewDispatch).not.toHaveBeenCalled()
expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
})
it('uploads all three photos during resource maintenance and retries the exact original edit', async () => {
can.mockImplementation(code => code === 'platform:overseas-warehouse:dispatch:edit')
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('unknown edit')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT', undefined, true)
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
expect(fields[0]!.props('modelValue')).toEqual(['301'])
expect(dialog.get('.workflow-photo-grid').findAll('figure')).toHaveLength(3)
for (const [index, files] of fields.entries()) {
let resolve!: (value: DispatchEvidenceFile) => void
vi.mocked(api.uploadDispatchEvidence).mockReturnValueOnce(new Promise(done => { resolve = done }))
const input = files.get('input[type="file"]'), file = new File(['png'], 'audit.png', { type: 'image/png' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.updateDispatch).not.toHaveBeenCalled()
resolve(evidence(String(401 + index))); await flushPromises()
}
expect(vi.mocked(api.uploadDispatchEvidence).mock.calls.map(call => call[1])).toEqual(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'])
dialog.vm.$emit('confirm'); await flushPromises()
const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } })
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
expect(api.reviewDispatch).not.toHaveBeenCalled()
expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('shows saved photos and old audit evidence read-only in %s', async action => {
const { wrapper, dialog } = await render(action)
const value = detail({ actions })
value.vehicleInfo.driver!.evidence!.photoFileIds = ['402']
value.reviews.dispatchReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: ['403'], licenseFileIds: [] }
vi.mocked(api.getDispatch).mockResolvedValueOnce(value)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true)
expect(fields[1]!.props('modelValue')).toEqual(['402', '403'])
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
})
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
const { dialog } = await render(action)
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : action === 'DEPART' ? api.departDispatch : api.submitDispatchReview
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接')
if (needsReason) await dialog.get('textarea').setValue(' 现场原因 ')
else expect(dialog.find('textarea').exists()).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: '本次交接' }) })
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) })
})
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
const { dialog } = await render('EDIT', 'ROUTE')
@@ -146,37 +221,19 @@ describe('dispatch workflow native layout', () => {
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 120, volumeCbm: 1.2, deliveryIndex: 1 }], remark: '真实备注' })
})
it('keeps grouped delivery fields, explicit old-fee coverage and only actual uncovered charges', async () => {
const { dialog } = await render('ARRANGE_DELIVERY')
const sections = dialog.findAll('[data-tone="work"]').filter(section => section.attributes('aria-label')?.startsWith('派送分组登记'))
expect(sections).toHaveLength(2)
const groups = dialog.getComponent(DispatchArrangeFields).props('modelValue')
await dialog.get('[aria-label="派送分组依据 1"] input[type="checkbox"]').setValue(true); await flushPromises()
for (let index = 0; index < 2; index++) {
const section = sections[index]!
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'ONE_PICKUP_ONE_DROP'], ['装车类型', index ? 'PALLET' : 'FLOOR']] as const) field(section, label).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
field(section, '预计派送时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
}
field(sections[0]!, '预约账号').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'BUY_APPOINTMENT'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
await field(sections[0]!, '卖家姓名').get('input').setValue('卖家甲')
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25)
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CNY')
field(sections[0]!, '买约费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 40); await flushPromises()
expect(groups[0]!.feeCoverage).toEqual([{ financeFeeId: 'original-fee', sourceVersion: 0, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }])
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.transferDispatchToDelivery).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', groups: [expect.objectContaining({ groupKey: 'A', sellerName: '卖家甲', feeCoverage: groups[0]!.feeCoverage, fees: [expect.objectContaining({ feeType: 'LOADING_FEE', amount: 25, currency: 'CNY' }), expect.objectContaining({ feeType: 'APPOINTMENT_FEE', amount: 40, currency: 'USD' })] }), expect.objectContaining({ groupKey: 'B', fees: [] })] }))
})
it('previews owned images fullscreen and locks the parent while a real file reference upload is pending', async () => {
let resolve!: (value: DispatchEvidenceFile) => void
vi.mocked(api.uploadDispatchEvidence).mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await render('EDIT', undefined, true)
const files = dialog.findAllComponents(DispatchEvidenceFiles)[0]!
await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
await files.get('button.evidence-photo-preview').trigger('click'); await flushPromises()
const image = files.findComponent(AppDialog)
expect(image.props('modelValue')).toBe(true)
const preview = new DOMWrapper(document.querySelector('[aria-label="本车附件预览信息"]')!.closest('.el-dialog')!)
const preview = new DOMWrapper(document.querySelector('.evidence-preview')!.closest('.el-dialog')!)
expect(preview.get('img').attributes('src')).toBe('blob:owned-dispatch')
Object.defineProperties(preview.get('img').element, { naturalWidth: { value: 1200 }, naturalHeight: { value: 800 } })
await preview.get('img').trigger('load'); await flushPromises()
expect(preview.get('.evidence-preview').attributes('aria-busy')).toBe('false')
await preview.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(preview.classes()).toContain('is-fullscreen')
await preview.findAll('button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
@@ -4,15 +4,15 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import * as api from '../api/dispatches'
import { command, detail, destination, loadingPreview } from '../dispatchTestFixtures'
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
import { command, detail, loadingPreview } from '../dispatchTestFixtures'
import type { CommandResult, DispatchAction, DispatchDetail } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
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 }
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 { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
async function open(action: DispatchAction, allowed = true) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
@@ -27,10 +27,10 @@ it('keeps order allocation references out of actual measurements and submits nat
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('真实托位')
state.measurements[0]!.weightKg = 92; state.measurements[0]!.volumeCbm = 1.5
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'AFTER_LOADING', recordVersion: 1 }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
await state.submit()
expect(api.completeDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }))
expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
expect(api.completeDispatchLoading).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('accepts actual evidence and a matching confirmed manifest while refusing an unknown unloading index', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 92; value.pallets[0]!.volumeCbm = 1.5; value.pallets[0]!.deliveryIndex = null
@@ -40,42 +40,37 @@ it('accepts actual evidence and a matching confirmed manifest while refusing an
const { wrapper, state } = await open('LOADING_COMPLETE'); expect(state.measurements[0]).toMatchObject({ weightKg: 92, volumeCbm: 1.5, deliveryIndex: null })
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
})
it('requires a rejection reason and retains the full original command after an unknown review result', async () => {
const { wrapper, state } = await open('DISPATCH_REVIEW'); state.form.decision = 'REJECTED'
await state.submit(); expect(api.reviewDispatch).not.toHaveBeenCalled()
state.form.reason = '预约未核实'; vi.mocked(api.reviewDispatch).mockRejectedValue(new Error('unknown result'))
await state.submit(); state.form.reason = 'later changed'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('DISPATCH_REVIEW', key))
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('requires a rejection reason and retains the original %s decision after an unknown result', async action => {
const success = vi.spyOn(ElMessage, 'success')
const reviewApi = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
const { wrapper, state } = await open(action); state.form.decision = 'REJECTED'
await state.submit(); expect(reviewApi).not.toHaveBeenCalled()
state.form.reason = '预约未核实'; vi.mocked(reviewApi).mockRejectedValue(new Error('unknown result'))
await state.submit(); state.form.reason = 'later changed'; state.form.decision = 'APPROVED'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command(action, key))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(api.reviewDispatch).toHaveBeenCalledTimes(1)
expect(vi.mocked(api.reviewDispatch).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
await state.submit(); expect(reviewApi).toHaveBeenCalledTimes(1)
expect(vi.mocked(reviewApi).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
expect(success).toHaveBeenCalledWith('已退回客服处理(车辆信息),修改后需重新提交调度审核')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('blocks an action without a literal service allowance', async () => {
const { wrapper, state } = await open('DEPART', false); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
it('shows the preserved rejection while customer service edits vehicle information', async () => {
const { wrapper, state } = await open('EDIT')
state.detail.reviews.loadingReview = { ...state.detail.reviews.loadingReview, status: 'REJECTED', reason: '车牌照片不清晰' }
await flushPromises()
expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('理货员审核退回:车牌照片不清晰')
wrapper.unmount()
})
it('submits every customer destination group in one arrangement and does not waive another customer fee', async () => {
const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2, sourceOrigin: null } as CargoLine], externalFreightHash: `external-${customerId}`, externalQuotes: customerId === 'A' ? [{ customerId: 'A', groupKey: 'group-A', bookingNo: 'BOOK-A', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'quote-A' }] : [], feeCoverageCargoHash: `coverage-${customerId}`, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }))
vi.mocked(api.getDispatchArrangement).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash', arrange: { allowed: true, blockCode: null, blockReason: null }, groups: previews, arrangement: null })
const { wrapper, state } = await open('ARRANGE_DELIVERY')
const groups = (wrapper.vm as unknown as { groups: ArrangeGroup[] }).groups
groups.forEach(group => Object.assign(group, { plannedDeliveryAt: '2026-10-20T00:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'ONE_PICKUP_MULTI_DROP', loadingType: 'PALLET' }))
await state.submit(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
groups[1]!.fees.find(fee => fee.feeType === 'DELIVERY_FEE')!.amount = 77
vi.mocked(api.transferDispatchToDelivery).mockImplementation(async (_, body): Promise<CommandResult> => ({ ...command('ARRANGE_DELIVERY', body.idempotencyKey), action: 'ARRANGE_DELIVERY', result: { dispatchNo: 'PC/1', recordVersion: 1, arrangementKey: 'arrangement', cargoHash: 'whole-cargo', arrangedAt: '2026-10-19T00:00:00Z', outbounds: [], deliveries: [] } }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, stage: 'AFTER_LOADING' })); await state.submit()
const sent = vi.mocked(api.transferDispatchToDelivery).mock.calls[0]![1]
expect(sent).toMatchObject({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash' })
expect(sent.groups).toHaveLength(2); expect(sent.groups[0]!.fees).toEqual([]); expect(sent.groups[1]!.fees).toEqual([expect.objectContaining({ feeType: 'DELIVERY_FEE', amount: 77 })])
expect(api.getDispatchArrangement).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
it('blocks an action without a literal service allowance', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW', false); await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
})
it('does not reuse another dispatch detail when the target changes while its dialog remains open', async () => {
const { wrapper, state } = await open('DEPART')
const { wrapper, state } = await open('SUBMIT_REVIEW')
let resolve!: (value: DispatchDetail) => void
vi.mocked(api.getDispatch).mockImplementationOnce(() => new Promise(done => { resolve = done }))
await wrapper.setProps({ dispatchNo: 'PC/2' }); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled()
resolve(detail({ dispatchNo: 'PC/2', actions: { DEPART: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises()
await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); wrapper.unmount()
await wrapper.setProps({ dispatchNo: 'PC/2' }); await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled()
resolve(detail({ dispatchNo: 'PC/2', actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises()
await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); wrapper.unmount()
})
it('preserves external destinations and retries route EDIT through its original route endpoint instead of a resource update', async () => {
const toast = vi.spyOn(ElMessage, 'success')
@@ -202,3 +197,83 @@ it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('preserves the exact pending %s
expect(wrapper.emitted('rejected')).toBeUndefined()
} 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()
})
it('submits review from vehicle maintenance after saving the current inputs and reading the new version', async () => {
const { wrapper, state } = await open('EDIT')
const gate = { allowed: true, blockCode: null, blockReason: null }
expect(wrapper.find('[aria-label="本次操作备注"]').exists()).toBe(false)
expect(wrapper.findAll('button').some(button => button.text() === '保存')).toBe(true)
expect(wrapper.findAll('button').some(button => button.text() === '提交审核')).toBe(true)
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 }))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: gate, SUBMIT_REVIEW: gate } })).mockResolvedValueOnce(detail({ recordVersion: 2 }))
await state.submit(true)
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.any(Object), driver: expect.any(Object) }))
expect(api.submitDispatchReview).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null }))
expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!)
expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount()
})
it('save alone never submits review and a failed save cannot advance review', async () => {
const first = await open('EDIT')
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 }))
await first.state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); first.wrapper.unmount()
const second = await open('EDIT')
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('车辆资料不完整', 'A0400'))
await second.state.submit(true); expect(api.submitDispatchReview).not.toHaveBeenCalled()
expect(second.wrapper.text()).toContain('车辆资料不完整'); second.wrapper.unmount()
})
it('rechecks review eligibility after save and leaves the saved editor open if blocked', async () => {
const { wrapper, state } = await open('EDIT')
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, SUBMIT_REVIEW: { allowed: false, blockCode: 'MISSING', blockReason: '预约资料未齐全' } } }))
await state.submit(true)
expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(state.detail.recordVersion).toBe(1)
expect(wrapper.text()).toContain('预约资料未齐全'); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount()
})
it('retains the follow-up review intent through an unknown save and does not save twice on recovery', async () => {
const { wrapper, state } = await open('EDIT')
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save outcome unknown'))
await state.submit(true)
const pending = useDispatchCommandRecovery(() => identity.id).pending.value!
expect(pending.submitReviewAfterSave).toBe(true)
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${identity.id}`)!).submitReviewAfterSave).toBe(true)
vi.mocked(api.getDispatchCommand).mockResolvedValueOnce(command('EDIT', String(pending.body.idempotencyKey)))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })).mockResolvedValueOnce(detail({ recordVersion: 2 }))
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 }))
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull(); wrapper.unmount()
})
it('recovers an unknown review submission without saving the vehicle again', async () => {
const { wrapper, state } = await open('EDIT')
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('review outcome unknown'))
await state.submit(true)
const pending = useDispatchCommandRecovery(() => identity.id).pending.value!
expect(pending.action).toBe('SUBMIT_REVIEW'); expect(pending.body).toMatchObject({ recordVersion: 1, remark: null })
vi.mocked(api.getDispatchCommand).mockResolvedValueOnce({ ...command('SUBMIT_REVIEW', String(pending.body.idempotencyKey)), recordVersion: 2 })
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 2 }))
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount()
})
@@ -10,13 +10,12 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import type { ArrangeDelivery, ArrangeGroup, ArrangementPreview, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const open = defineModel<boolean>({ default: false })
@@ -31,28 +30,33 @@ const currentOperation = computed(() => pending.value ? pending.value.operation
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
const factsStale = ref(false)
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
const preview = ref<LoadingPreview | null>(null)
const loading = computed(() => working.value)
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.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: '' })
let generation = 0
let editorRequestKey = ''
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
const frozen = computed(() => busy.value || Boolean(pending.value))
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DRIVER)
const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
const submitting = ref(false)
const width = computed(() => vehicleEditor.value || currentAction.value === 'LOADING_COMPLETE' ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
async function load() {
const sequence = ++generation, no = currentNo.value, action = currentAction.value
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { working.value = false; return }
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null
route.value = null; stops.value = []
try {
if (cargoAction.value) return
@@ -61,6 +65,7 @@ async function load() {
if (sequence !== generation) return
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
detail.value = value
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
const oldVehicle = value.vehicleInfo.vehicle
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
@@ -84,13 +89,7 @@ async function load() {
weightKg: confirmed?.weightKg ?? (actual('weightKg') ? row.weightKg : null), volumeCbm: confirmed?.volumeCbm ?? (actual('volumeCbm') ? row.volumeCbm : null) }
})
}
if (action === 'ARRANGE_DELIVERY') {
const data = await api.getDispatchArrangement(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('派送预览与排车单不一致')
arrangement.value = data
groups.value = data.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: '', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: ({ DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' })[feeType] || '服务费用', direction: 'RECEIVABLE', currency: 'USD', amount: null })) }))
}
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
finally { if (sequence === generation) working.value = false }
}
@@ -103,6 +102,9 @@ function payload(): object {
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
}
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 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
@@ -115,19 +117,7 @@ function payload(): object {
if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测')
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete
}
case 'ARRANGE_DELIVERY': {
const data = arrangement.value
if (!data || data.arrange.allowed !== true || !data.groups.length || data.groups.some(row => row.blockReasons.length)) throw new DispatchInputError(data?.arrange.blockReason || '本车派送资料未就绪')
const prepared = groups.value.map((group, index) => {
if (!group.plannedDeliveryAt || !group.deliveryMethod || !group.pickupMethod || !group.loadingType) throw new DispatchInputError('请完整填写每个客户及目的仓的派送信息')
if (group.appointmentAccount === 'BUY_APPOINTMENT' && !group.sellerName?.trim()) throw new DispatchInputError('买约时卖家姓名必填')
const required = [...new Set([...(data.groups[index]?.requiredServices || []), ...(['FLOOR', 'MIXED'].includes(group.loadingType) ? ['LOADING_FEE'] : []), ...(group.appointmentAccount === 'BUY_APPOINTMENT' ? ['APPOINTMENT_FEE'] : [])])]
const fees = group.fees.filter(fee => required.includes(fee.feeType) && !arrangeFields.value?.covered(index, data.groups[index]!, fee.feeType))
if (fees.some(fee => fee.amount == null || !Number.isFinite(fee.amount) || fee.amount <= 0)) throw new DispatchInputError('请填写本组未覆盖服务的正数新增费用')
return { ...group, fees }
})
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, cargoHash: data.cargoHash, loadingCompletionHash: data.loadingCompletionHash, groups: prepared } satisfies ArrangeDelivery
}
case 'SUBMIT_REVIEW': return { ...common, remark: null }
default: return { ...common, remark: form.remark || null }
}
}
@@ -135,32 +125,52 @@ async function send(command: PendingDispatchCommand): Promise<CommandResult> {
const no = command.dispatchNo, body = command.body
if (command.operation === 'ROUTE') return api.reorderDispatchRoute(no, body as unknown as DispatchRouteCommand)
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 'DISPATCH_REVIEW': return api.reviewDispatch(no, body as unknown as ReviewCommand)
case 'LOADING_REVIEW': return api.reviewDispatchLoading(no, body as unknown as ReviewCommand)
case 'RETURN_TO_REVIEW': return api.returnDispatchToReview(no, body as unknown as ReasonCommand)
case 'CANCEL': return api.cancelDispatch(no, body as unknown as ReasonCommand)
case 'LOADING_COMPLETE': return api.completeDispatchLoading(no, body as unknown as LoadingComplete)
case 'ARRANGE_DELIVERY': return api.transferDispatchToDelivery(no, body as unknown as ArrangeDelivery)
case 'SUBMIT_REVIEW': return api.submitDispatchReview(no, body as unknown as VersionCommand & { remark: string | null })
case 'DEPART': return api.departDispatch(no, body as unknown as VersionCommand & { remark: string | null })
default: throw new DispatchInputError('此操作不属于当前后台作业入口')
}
}
async function submit() {
if (busy.value || loading.value || uploadingEvidence.value) return
async function submitReview(decision: 'APPROVED' | 'REJECTED') {
if (frozen.value || loading.value || uploadingEvidence.value || !allowed.value) return
form.decision = decision
await submit()
}
async function submit(submitReviewAfterSave = false) {
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
submitting.value = true
error.value = null
try {
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
if (!pending.value) {
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value })
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(submitReviewAfterSave && vehicleEditor.value ? { submitReviewAfterSave: true } : {}) })
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
}
const routeCommand = pending.value?.operation === 'ROUTE'
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
const continueReview = pending.value?.action === 'EDIT' && pending.value.submitReviewAfterSave === true
const readCurrent = async (no: string) => {
const value = await api.getDispatch(no)
if (value.dispatchNo === currentNo.value) detail.value = value
return value
}
let result = await recovery.execute(send, readCurrent, api.getDispatchCommand)
if (!result) return
ElMessage.success(routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
if (continueReview) {
emit('saved')
if (!canDispatchAction(detail.value?.actions, 'SUBMIT_REVIEW', access.can)) throw new DispatchInputError(detail.value?.actions.SUBMIT_REVIEW?.blockReason || '车辆信息已保存,当前不能提交审核,请核对资料')
recovery.begin(result.dispatchNo, 'SUBMIT_REVIEW', { recordVersion: requireVersion(detail.value?.recordVersion), idempotencyKey: crypto.randomUUID(), remark: null })
editorRequestKey = String(pending.value?.body.idempotencyKey || '')
result = await recovery.execute(send, readCurrent, api.getDispatchCommand)
if (!result) return
}
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? result.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成,请在装车后补齐装车资料' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
emit('saved'); open.value = false
} catch (cause) {
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : pending.value?.result ? '操作已完成,页面资料待刷新' : pending.value ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
@@ -170,7 +180,7 @@ async function submit() {
emit('rejected', error.value)
await refreshFacts()
}
}
} finally { submitting.value = false }
}
async function refreshFacts() {
const sequence = ++generation, no = currentNo.value
@@ -198,9 +208,10 @@ function moveStop(index: number, offset: number) { const target = index + offset
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density 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'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || 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">
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-alert v-if="pending && !cargoAction" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
<LoadErrorState v-if="error && !cargoAction" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
@@ -210,38 +221,58 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
</section>
<template v-else-if="currentAction === 'EDIT'">
<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="vehicleEditor">
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
</template>
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><h3 class="density-dialog-section__title">审核依据</h3><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th></tr></thead><tbody><tr v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ appointment.status }}</td></tr></tbody></table>
<el-alert title="请核对本车资源与本趟证据;审核通过不代替后续现场扫码。" type="info" :closable="false" show-icon />
</section>
<section class="density-dialog-section" data-tone="work" aria-label="审核结果登记"><h3 class="density-dialog-section__title">审核结果</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen || loading || uploadingEvidence || !allowed"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></td></tr></tbody></table></section>
</template>
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="真实实载登记"><h3 class="density-dialog-section__title">逐托实载</h3><el-table class="density-table--editable" :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" show-overflow-tooltip /><el-table-column label="实际托位" min-width="140"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="1" placeholder="请确认" controls-position="right" /></template></el-table-column><el-table-column label="实测重量(kg)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="实测体积(m³)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="已匹配卸货顺序" min-width="170"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table></section>
<section class="density-dialog-section" data-tone="source" aria-label="实载与参考依据"><h3 class="density-dialog-section__title">测量依据</h3><table class="density-table"><thead><tr><th>托盘码</th><th>依据</th><th>来源业务号</th><th>值</th></tr></thead><tbody><template v-for="pallet in preview.pallets" :key="pallet.warehousePalletId"><tr v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType"><td>{{ pallet.palletNo }}</td><td>{{ evidence.actual ? '实测依据' : '仅供参考' }}</td><td>{{ evidence.sourceBusinessNo || '—' }}</td><td>{{ evidence.value ?? '—' }}</td></tr></template></tbody></table></section>
</template>
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen || loading || uploadingEvidence || !allowed" />
<el-alert v-else-if="currentAction === 'DEPART'" title="确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
<template v-if="currentOperation !== 'ROUTE' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</template>
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="currentAction !== 'ARRANGE_DELIVERY'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
<div v-if="vehicleEditor" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</div>
<div v-else-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
</div>
<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="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_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="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
<template v-if="vehicleEditor" #footer="{ close }">
<el-button :disabled="submitting || busy || uploadingEvidence" @click="close">取消</el-button>
<el-button v-if="!pending" :disabled="submitting || loading || uploadingEvidence || !allowed" @click="submit()">保存</el-button>
<el-button type="primary" :loading="submitting || busy" :disabled="loading || uploadingEvidence || !pending && !allowed" @click="submit(true)">{{ pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '提交审核' }}</el-button>
</template>
<template v-else-if="isReview && !pending" #footer>
<div class="workflow-review-actions">
<el-button type="danger" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('REJECTED')">驳回</el-button>
<el-button type="primary" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('APPROVED')">审核通过</el-button>
</div>
</template>
</AppDialog>
</template>
<style scoped>
.workflow-editor-table { table-layout: fixed; }
.workflow-editor-table td { width: 50%; }
.workflow-order-actions { width: 160px; white-space: nowrap; }
.workflow-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
.workflow-review-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); }
.workflow-review-actions > .el-button { margin-left: 0; }
</style>
@@ -1,9 +1,16 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
import { ref } from 'vue'
import type { AppointmentForm } from '../appointmentForm'
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean }>()
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; sources?: PlanningSourceLookup[] }>()
const emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
</script>
<template>
<p v-if="model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
<AppointmentConfirmationCargo v-if="sources?.length" ref="cargo" :sources="sources" :active="model.appointmentStatus === 'SUCCESS'" :disabled="disabled" @ready="emit('confirmationReady', $event)" />
<div v-if="continuous" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="预约安排登记"><h3 class="density-dialog-section__title">预约安排</h3>
@@ -18,7 +18,7 @@ function render(candidates: Candidate[] = [candidate()], extra: Record<string, u
return mount(SmartDispatchCargoBoard, { props: { candidates, draft: draft(), activeContainerId: 'car-1', capacity: 30, disabled: false, warehouseSelected: true, page: 1, size: 20, total: candidates.length, ...extra }, global: { plugins: [ElementPlus] } })
}
async function select(wrapper: VueWrapper, index = 0) { await wrapper.get('[aria-label="待排订单"]').findAll('tbody tr')[index]!.get('button').trigger('click') }
function pallet(wrapper: VueWrapper) { return wrapper.get('[aria-label="托盘明细"] article') }
function pallet(wrapper: VueWrapper) { return wrapper.get('[aria-label="托盘明细"] tr[draggable]') }
function transfer() { return { setData: vi.fn(), getData: vi.fn(() => '{"sourceKey":"forged","containerId":"car-2"}'), effectAllowed: '', dropEffect: '' } }
describe('smart dispatch three-column cargo board', () => {
@@ -26,6 +26,7 @@ describe('smart dispatch three-column cargo board', () => {
const first = candidate(), second = candidate({ sourceKey: 'source-2', orderNo: 'ORDER-2', palletNo: 'PALLET-2' })
const wrapper = render([first, second], { draft: null, activeContainerId: '' })
expect(wrapper.get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
expect(wrapper.get('[aria-label="托盘明细"]').findAll('thead th').map(cell => cell.text())).toEqual(['托盘号', '箱数'])
expect(wrapper.findAll('[aria-label="车辆装载"] article')).toHaveLength(1)
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('30 托位')
await select(wrapper, 1)
@@ -76,7 +77,7 @@ describe('smart dispatch three-column cargo board', () => {
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('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="候选货物分页"] button.btn-next').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
@@ -86,7 +87,7 @@ describe('smart dispatch three-column cargo board', () => {
it('keeps a shared pallet as one full source group even when only one member is on the current page', async () => {
const sources = sharedPalletCandidates(), wrapper = render([sources[0]!])
await select(wrapper)
expect(wrapper.findAll('[aria-label="托盘明细"] article')).toHaveLength(1)
expect(wrapper.findAll('[aria-label="托盘明细"] tr[draggable]')).toHaveLength(1)
const group = pallet(wrapper)
expect(group.text()).toContain('整托关联货物一起加入')
expect(group.findAll('details li')).toHaveLength(2)
@@ -105,13 +106,30 @@ describe('smart dispatch three-column cargo board', () => {
expect(wrapper.get('[aria-label="第 1 辆车"]').findAll('.cargo-board-loaded-pallet')).toHaveLength(1)
for (const index of [0, 1]) {
await select(wrapper, index)
expect(pallet(wrapper).attributes('draggable')).toBe('false')
expect(pallet(wrapper).get('button').text()).toBe('已加入第 1 辆车')
expect(pallet(wrapper).get('button').attributes('disabled')).toBeDefined()
expect(wrapper.find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
}
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 () => {
const source = candidate({ suggestedWeightKg: null, suggestedVolumeCbm: null })
source.selectionGroup.complete = false
@@ -134,7 +152,7 @@ describe('smart dispatch three-column cargo board', () => {
expect(wrapper.emitted('changePage')).toEqual([[2]])
expect(wrapper.get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
await wrapper.setProps({ page: 2, candidates: [candidate({ orderNo: 'NEXT-PAGE', palletNo: 'NEXT-PALLET' })] })
expect(wrapper.get('[aria-label="待排订单"]').text()).toContain('货物分页,同一订单可能分布在多页')
expect(wrapper.get('[aria-label="待排订单"]').text()).not.toContain('货物分页,同一订单可能分布在多页')
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
expect(wrapper.findAll('[aria-label="车辆装载"] article')).toHaveLength(2)
})
@@ -156,7 +174,7 @@ describe('smart dispatch three-column cargo board', () => {
const order = wrapper.get('[aria-label="待排订单"] tbody tr')
expect(order.findAll('td')[3]!.text()).toBe('2')
await select(wrapper)
expect(wrapper.findAll('[aria-label="托盘明细"] article')).toHaveLength(2)
expect(wrapper.findAll('[aria-label="托盘明细"] tr[draggable]')).toHaveLength(2)
})
it('uses independent vehicle radio groups when two entrypoint dialogs are mounted', () => {
@@ -173,7 +191,8 @@ describe('smart dispatch three-column cargo board', () => {
slots: { vehicles: ({ dragOver, drop }: { dragOver: (event: DragEvent, id: string) => void; drop: (event: DragEvent, id: string) => void }) => h('section', { 'aria-label': '真实本车清单', onDragover: (event: DragEvent) => dragOver(event, 'PC/1'), onDrop: (event: DragEvent) => drop(event, 'PC/1') }, 'PALLET-EXISTING') },
global: { plugins: [ElementPlus] },
})
expect(wrapper.find('[aria-label="车辆装载"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="车辆装载"]').exists()).toBe(true)
expect(wrapper.get('[aria-label="车辆装载"]').text()).not.toContain('添加车辆')
expect(wrapper.text()).toContain('PALLET-EXISTING')
await select(wrapper)
const dataTransfer = transfer()
@@ -11,6 +11,7 @@ const props = defineProps<{
capacity: number
disabled: boolean
warehouseSelected: boolean
addVehicleDisabled?: boolean
page: number
size: number
total: number
@@ -63,7 +64,7 @@ const orders = computed(() => {
return [...grouped].map(([key, sources]) => ({ key, source: sources[0]!, sources }))
})
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: [] }])
function assignedVehicle(source: Candidate) {
const keys = new Set([source.sourceKey, ...(source.selectionGroup?.sources.map(row => row.sourceKey) || [])])
@@ -88,14 +89,14 @@ function dragStart(event: DragEvent, source: Candidate) {
if (event.dataTransfer) {
// Transfer only a harmless marker. The live component reference is the authority.
event.dataTransfer.setData('text/plain', 'dispatch-pallet')
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.effectAllowed = 'move'
}
}
function dragOver(event: DragEvent, id: string) {
if (!draggedSource.value || !canAdd(draggedSource.value) || !validTarget(id)) return
event.preventDefault()
dropTarget.value = id
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function drop(event: DragEvent, id: string) {
event.preventDefault()
@@ -161,7 +162,8 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
<template>
<div class="dispatch-cargo-board" aria-label="智能排车三栏选货">
<section class="cargo-board-panel cargo-board-orders" aria-label="待排订单">
<header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span><InlineHelp title="货物分页说明"><p>货物分页,同一订单可能分布在多页。点击订单查看本页货物关联托盘;关联托数包含必须同车的完整托盘组。</p></InlineHelp></header>
<header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span><div v-if="slots['order-controls']" class="cargo-board-order-tools"><slot name="order-controls" /></div></header>
<div class="cargo-board-order-content">
<div class="cargo-board-table-wrap">
<table class="density-table cargo-board-table">
<thead><tr><th class="cargo-board-operation">操作</th><th>订单号 / 来源</th><th>客户</th><th>关联托数</th><th>目的仓</th></tr></thead>
@@ -177,28 +179,35 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
</tbody>
</table>
</div>
<footer class="cargo-board-pagination"><span class="cargo-board-muted">货物分页,同一订单可能分布在多页</span><el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="disabled" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" /></footer>
</section>
<section class="cargo-board-panel cargo-board-pallets" aria-label="托盘明细" aria-live="polite">
<header class="cargo-board-heading"><h3>托盘明细</h3><span class="cargo-board-muted">本页货物关联托盘</span></header>
<div v-if="selectedOrder" class="cargo-board-order-caption">{{ businessNo(selectedOrder.source) }}<small v-if="!selectedOrder.source.orderNo" class="cargo-board-muted">未关联订单</small></div>
<p v-if="!selectedOrder" class="cargo-board-empty">请选择左侧订单查看托盘明细</p>
<div v-else class="cargo-board-pallet-list">
<article v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-assigned': assignedVehicle(source), 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
<div class="cargo-board-pallet-title"><strong>{{ palletLabel(source) }}</strong><span>{{ groupPackages(source) ?? '—' }} 箱</span></div>
<div class="cargo-board-muted">{{ destinationLabel(source) }}</div>
<details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物一起加入</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details>
<p v-if="selectionGroupBlockReason(source)" class="cargo-board-block">{{ selectionGroupBlockReason(source) }}</p>
<el-button link :disabled="!canAdd(source) || !validTarget(activeContainerId)" @click="add(source, activeContainerId)">{{ assignedVehicle(source) ? `已加入第 ${assignedVehicle(source)!.sequenceNo} 辆车` : '加入这辆车' }}</el-button>
</article>
<p v-if="!palletGroups.length" class="cargo-board-empty">该订单本页暂无托盘</p>
<footer class="cargo-board-pagination"><el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="disabled" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" /></footer>
</div>
</section>
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
<section class="cargo-board-vehicles" aria-label="车辆装载">
<header class="cargo-board-heading"><h3>车辆装载</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击“加入这辆车”。已装货物需先移除才能加入其他车辆。体积、重量以装车核对为准,本区不将缺失测量按零汇总。</p></InlineHelp><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></header>
<section class="cargo-board-panel cargo-board-pallets" aria-label="托盘明细" aria-live="polite">
<header class="cargo-board-heading"><h3>托盘明细</h3><span class="cargo-board-order-caption">订单号:{{ selectedOrder ? businessNo(selectedOrder.source) : '未选择订单' }}<small v-if="selectedOrder && !selectedOrder.source.orderNo" class="cargo-board-muted">未关联订单</small></span></header>
<div class="cargo-board-pallet-list">
<table class="density-table cargo-board-pallet-table">
<thead><tr><th>托盘号</th><th class="cargo-board-pallet-count">箱数</th></tr></thead>
<tbody>
<tr v-if="!selectedOrder"><td colspan="2" class="cargo-board-empty">请选择左侧订单查看托盘明细</td></tr>
<tr v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
<td>
<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>
<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="selectionGroupBlockReason(source)" class="cargo-board-block"><summary>暂不可排车</summary><p>{{ selectionGroupBlockReason(source) }}</p></details>
</td>
<td class="cargo-board-pallet-count">{{ groupPackages(source) ?? '—' }} 箱</td>
</tr>
<tr v-if="selectedOrder && !palletGroups.length"><td colspan="2" class="cargo-board-empty">暂无待装托盘</td></tr>
</tbody>
</table>
</div>
</section>
<section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载">
<header class="cargo-board-heading"><slot name="vehicle-heading"><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 || addVehicleDisabled" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header>
<div class="cargo-board-vehicle-content">
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
<div class="cargo-board-vehicle-list">
<article v-for="vehicle in vehicles" :key="vehicle.draftContainerId" class="cargo-board-vehicle" :class="{ 'is-current': vehicle.draftContainerId === activeContainerId, 'is-drop-target': dropTarget === vehicle.draftContainerId }" :aria-label="`第 ${vehicle.sequenceNo} 辆车`" @dragover="dragOver($event, vehicle.draftContainerId)" @dragleave.self="dropTarget = null" @drop="drop($event, vehicle.draftContainerId)">
<header class="cargo-board-vehicle-heading"><label><input type="radio" :name="vehicleRadioName" :checked="activeContainerId === vehicle.draftContainerId" :disabled="disabled" :aria-label="`选择第 ${vehicle.sequenceNo} 辆车`" @change="emit('selectContainer', vehicle.draftContainerId)" /> 第 {{ vehicle.sequenceNo }} 辆车 · {{ vehicle.capacity }} 托位</label><span>托数 {{ vehiclePalletCount(vehicle) ?? '—' }}/{{ vehicle.capacity }}</span></header>
@@ -206,26 +215,32 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
<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><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>
<p v-if="vehiclePalletCount(vehicle) === null" class="cargo-board-block">已选货物资料不完整,请重新读取草稿。</p>
</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>
</div>
</slot>
</div>
</section>
</slot>
</div>
</template>
<style scoped>
.dispatch-cargo-board { display: grid; grid-template-columns: minmax(0, 42fr) minmax(0, 22fr) minmax(0, 36fr); align-items: start; gap: var(--admin-space-6); }
.cargo-board-panel { min-width: 0; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); overflow: hidden; background: var(--admin-surface); }
.cargo-board-heading { display: flex; align-items: center; gap: var(--admin-space-3); min-height: var(--admin-control-height); padding: var(--admin-space-3) var(--admin-space-5); flex-wrap: wrap; }
.dispatch-cargo-board { display: grid; grid-template-columns: minmax(0, 44fr) minmax(0, 22fr) minmax(0, 34fr); grid-template-rows: auto auto; align-items: stretch; gap: var(--admin-space-5) var(--admin-space-6); }
.cargo-board-panel { display: grid; grid-row: 1 / span 2; grid-template-rows: subgrid; align-items: start; min-width: 0; background: var(--admin-surface); }
.cargo-board-orders { grid-column: 1; }
.cargo-board-pallets { grid-column: 2; }
.cargo-board-vehicles { grid-column: 3; }
.cargo-board-order-content, .cargo-board-vehicle-content { min-width: 0; }
.cargo-board-heading { align-self: stretch; display: flex; align-items: center; gap: var(--admin-space-3); min-height: var(--admin-control-height); box-sizing: border-box; margin: 0; flex-wrap: wrap; }
.cargo-board-heading h3 { margin: 0; font-size: var(--admin-font-section); }
.cargo-board-heading > .el-button { margin-left: auto; }
.cargo-board-vehicle-tools { display: flex; align-items: center; gap: var(--admin-space-3); margin-left: auto; flex-wrap: wrap; }
.cargo-board-vehicle-tools > .el-button { margin-left: 0; }
.cargo-board-muted { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.cargo-board-table-wrap { overflow: auto; max-height: 48vh; }
.cargo-board-table-wrap { overflow: auto; max-height: min(390px, 40vh); border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); }
.cargo-board-table { width: 100%; table-layout: fixed; }
.cargo-board-table td { overflow-wrap: anywhere; }
.cargo-board-table th:nth-child(1) { width: 82px; }
@@ -235,22 +250,25 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
.cargo-board-table tbody tr { cursor: pointer; }
.cargo-board-table tr.is-selected td { background: var(--admin-action-primary-hover); }
.cargo-board-business-no { font-weight: 600; }
.cargo-board-pagination { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); padding: var(--admin-space-4); overflow-x: auto; }
.cargo-board-order-caption { padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
.cargo-board-pagination { display: flex; align-items: center; padding-top: var(--admin-space-5); overflow-x: auto; }
.cargo-board-pallets > .cargo-board-heading { flex-wrap: nowrap; }
.cargo-board-pallets > .cargo-board-heading h3 { flex-shrink: 0; }
.cargo-board-order-tools { margin-left: auto; }
.cargo-board-order-caption { margin-left: auto; min-width: 0; text-align: right; overflow-wrap: anywhere; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.cargo-board-order-caption small { display: block; }
.cargo-board-empty { padding: var(--admin-space-7); color: var(--cc-muted); text-align: center; margin: 0; }
.cargo-board-pallet-list { max-height: 52vh; overflow-y: auto; }
.cargo-board-pallet { padding: var(--admin-space-5); border-top: 1px solid var(--admin-table-border); }
.cargo-board-pallet-list { max-height: min(500px, 48vh); overflow-y: auto; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); }
.cargo-board-pallet-table { width: 100%; table-layout: fixed; }
.cargo-board-pallet-table .cargo-board-pallet-count { width: 72px; text-align: right; white-space: nowrap; }
.cargo-board-pallet-table td { vertical-align: top; overflow-wrap: anywhere; }
.cargo-board-pallet-table .cargo-board-empty { text-align: center; padding: var(--admin-space-5); }
.cargo-board-pallet.is-draggable { cursor: grab; }
.cargo-board-pallet.is-assigned { background: var(--admin-label-bg); }
.cargo-board-pallet-title { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--admin-space-3); overflow-wrap: anywhere; }
.cargo-board-pallet-title strong { min-width: 0; }
.cargo-board-pallet-title > span { white-space: nowrap; }
.cargo-board-pallet-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 summary { cursor: pointer; }
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
.cargo-board-block { color: var(--admin-action-danger); font-size: var(--admin-font-caption); margin: var(--admin-space-2) 0; }
.cargo-board-vehicles { min-width: 0; }
.cargo-board-vehicle-list { display: flex; flex-direction: column; gap: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
.cargo-board-vehicle { border: 2px dashed var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface); }
.cargo-board-vehicle.is-current { border-color: var(--admin-action-primary-border); }
@@ -2,6 +2,7 @@ import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -46,11 +47,118 @@ beforeEach(() => {
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
async function selectWarehouse(dialog: VueWrapper) {
const select = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')
if (select) { select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321') }
await flushPromises()
}
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '继续待完成排车')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function selectFirstOrder(dialog: VueWrapper) { await view(dialog).get('[aria-label="待排订单"] tbody tr button').trigger('click'); await flushPromises() }
describe('new dispatch native layout', () => {
it('opens all regional cargo without a warehouse picker or a silently selected warehouse', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseCode: 'NYC', warehouseName: '纽约仓' }])
const { dialog } = await render()
expect(view(dialog).get('[aria-label="候选货物查询"]').find('[aria-label="起运仓"]').exists()).toBe(false)
expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: undefined, warehouseTab: 'ALL', page: 1 }))
expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').map(button => button.text())).toEqual(['全部', '洛杉矶', '纽约'])
expect(api.createDispatchDraft).not.toHaveBeenCalled()
})
it('reads regional candidates independently of the optional empty-dispatch warehouse directory', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
const { dialog } = await render()
expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: undefined }))
expect(view(dialog).text()).toContain('待排订单')
})
it('takes the first selected pallet warehouse instead of the first directory entry and locks the draft to that warehouse', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseCode: 'NYC', warehouseName: '纽约仓' }])
const row = candidate({ warehouseId: '654', warehouseCode: 'NYC', warehouseName: '纽约仓' })
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [row], total: 1, page: 1, size: 20, pages: 1, complete: true, snapshotAt: '' })
const empty = { ...draft(), warehouseId: '654', sources: [], containers: [] }
const car = { draftContainerId: 'ny-car', sequenceNo: 1, capacity: 30, sourceKeys: [] as string[] }
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockResolvedValue({ ...empty, recordVersion: 1, containers: [car] })
vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue({ ...empty, recordVersion: 2, sources: [row], containers: [{ ...car, sourceKeys: [row.sourceKey] }] })
const { dialog } = await render(); await selectFirstOrder(dialog)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '654' }))
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654', draftToken: 'server-token' }))
const tabs = view(dialog).get('[aria-label="候选仓库展示"]').findAll('button')
expect(tabs[1]!.attributes('disabled')).toBeDefined()
expect(tabs[2]!.attributes('disabled')).toBeUndefined()
})
it('retains the restored draft warehouse instead of selecting the first warehouse on reopen', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }])
vi.mocked(api.listDispatchDrafts).mockResolvedValue([{ ...draft(), warehouseId: '654', sources: [], containers: [] }])
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 0, page: 1, size: 20, pages: 0, candidates: [], complete: true, snapshotAt: '' })
const { wrapper, dialog } = await render()
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '洛杉矶')!.trigger('click'); await flushPromises()
await restore(dialog)
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654', warehouseTab: 'ALL' }))
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654' }))
})
it('keeps the restored workbench controls together and shows the selected warehouse tab', async () => {
const { dialog } = await render()
const content = view(dialog)
const searchPanel = content.get('[aria-label="候选货物查询"]')
expect(searchPanel.find('.el-form-item__label').exists()).toBe(false)
expect(searchPanel.find('[aria-label="起运仓"]').exists()).toBe(false)
expect(searchPanel.find('input[placeholder="订单号/入仓码"]').exists()).toBe(true)
const settingsToggle = searchPanel.findAll('button').find(button => button.text() === '排车设置')!
expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(false)
await settingsToggle.trigger('click')
expect(settingsToggle.attributes('aria-expanded')).toBe('true')
expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(true)
expect(content.get('[aria-label="排车设置"]').text()).toContain('建单方式')
expect(content.get('[aria-label="排车设置"]').find('[aria-label="车辆选货安排"]').exists()).toBe(true)
expect(content.text()).not.toContain('1. 排车设置')
expect(content.text()).not.toContain('2. 为车辆选货')
const vehicles = content.get('[aria-label="车辆装载"]')
expect(vehicles.text()).toContain('托位')
expect(vehicles.get('.cargo-board-vehicle-tools').text()).toContain('添加车辆')
expect(vehicles.text()).toContain('添加车辆')
const tabs = content.get('[aria-label="候选仓库展示"]').findAll('button')
expect(tabs[0]!.classes()).toContain('active')
await selectWarehouse(dialog)
await tabs[1]!.trigger('click'); await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.classes()).toContain('active')
expect(tabs[0]!.classes()).not.toContain('active')
const capacity = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '计划托位')!
capacity.vm.$emit('update:modelValue', 15); await flushPromises()
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('15 托位')
})
it('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 () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
const grouped: DispatchDraft = { ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
@@ -68,7 +176,7 @@ describe('new dispatch native layout', () => {
const candidateCalls = vi.mocked(api.listDispatchCandidates).mock.calls.length
expect(dialog.props('confirmDisabled')).toBe(true)
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.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, capacity: 30, idempotencyKey: expect.any(String) })
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(candidateCalls + 1)
@@ -76,7 +184,7 @@ describe('new dispatch native layout', () => {
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ draftContainerId: 'first-group', source: planned.selection, recordVersion: 1 }))
expect(dialog.props('confirmDisabled')).toBe(false)
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 () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
@@ -84,7 +192,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: [] }] })
const { dialog } = await render(); await selectWarehouse(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]
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
@@ -98,7 +206,7 @@ describe('new dispatch native layout', () => {
const { dialog } = await render()
expect(view(dialog).find('[aria-label="排车设置"] table').exists()).toBe(false)
expect(view(dialog).find('[aria-label="排车设置"] .density-dialog-note').exists()).toBe(false)
expect(view(dialog).classes()).toContain('admin-density-workspace')
expect(view(dialog).classes()).not.toContain('admin-density-workspace')
expect(dialog.getComponent({ name: 'ElDialog' }).props()).toMatchObject({ appendToBody: true, lockScroll: true })
expect(view(dialog).text()).not.toContain('点击“开始选货”建立第一组')
expect(view(dialog).text()).not.toContain('选择起运仓后点击')
@@ -115,6 +223,9 @@ describe('new dispatch native layout', () => {
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
await field(dialog, '备注').get('input').setValue('本趟备注')
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
@@ -139,6 +250,9 @@ describe('new dispatch native layout', () => {
await field(dialog, '备注').get('input').setValue('第二组')
await radios[0]!.setValue(true); await flushPromises()
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
@@ -157,11 +271,15 @@ describe('new dispatch native layout', () => {
const { wrapper, dialog } = await render(); await restore(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
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
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(view(dialog).text()).toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('重试原请求')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
@@ -189,7 +307,7 @@ describe('new dispatch native layout', () => {
await view(dialog).get('input[placeholder="目的仓码"]').setValue('ONT8')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', sourceStage); await flushPromises()
await view(dialog).findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ page: 1, size: 20, warehouseTab: 'ALL', warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ page: 1, size: 20, warehouseTab: 'ALL', warehouseId: undefined, orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
@@ -204,18 +322,63 @@ describe('new dispatch native layout', () => {
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
})
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
it('unfreezes after a definite rejection without automatically resubmitting', async () => {
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
const { dialog } = await render(); await selectWarehouse(dialog)
await 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(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(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).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 () => {
can.mockReturnValue(false)
@@ -89,10 +89,11 @@ async function selectOrder(dialog: VueWrapper, palletNo: string) {
await row.get('button').trigger('click'); await flushPromises()
}
async function warehouseSelected(dialog: VueWrapper) {
const picker = field(dialog, '起运仓').getComponent({ name: 'ElSelect' })
picker.vm.$emit('update:modelValue', '321'); picker.vm.$emit('change', '321'); await flushPromises()
expect(view(dialog).get('[aria-label="候选仓库展示"]').text()).toContain('全部')
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() }
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 () => {
@@ -179,7 +180,8 @@ describe('three-column cargo planning through visible controls', () => {
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CREATE' ? 0 : 1)
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 => {
lostReply = step
@@ -205,9 +207,9 @@ describe('three-column cargo planning through visible controls', () => {
vi.mocked(api.createDispatchDraft).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const dialog = await render(); await warehouseSelected(dialog)
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(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(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).not.toHaveBeenCalled(); expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
@@ -236,8 +238,7 @@ describe('three-column cargo planning through visible controls', () => {
expect(server!.containers[0]!.sourceKeys).toEqual(catalog.map(row => row.sourceKey))
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')
expect(cargoRow(dialog, 'PALLET-1').get('button').text()).toBe('已加入第 1 辆车')
expect(cargoRow(dialog, 'PALLET-1').get('button').attributes('disabled')).toBeDefined()
expect(view(dialog).find('[aria-label="托盘 PALLET-1"]').exists()).toBe(false)
}
await button(dialog, '生成排车单').trigger('click'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
@@ -94,7 +94,8 @@ it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restor
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('预约批次版本缺失或无效,请重新读取候选')
} finally { wrapper.unmount() }
})
it('requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo', async () => {
it('requires a warehouse when the directory is empty and allows saving a true empty dispatch with no fake cargo', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { mode: string; warehouseId: string; submit: () => Promise<void> }
state.mode = 'empty'; await wrapper.getComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.createDispatch).not.toHaveBeenCalled()
@@ -166,7 +167,7 @@ it('keeps server draft cargo across pages, resets filters and sends regional tab
state.query.orderNo = 'query'; await state.search()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'query' }))
state.changeTab('NYC'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseTab: 'NYC' }))
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseId: '321', warehouseTab: 'ALL' }))
state.changeSize(10); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10 }))
state.changePage(2); await flushPromises(); await state.submit()
@@ -21,7 +21,7 @@ import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatch
import type { InboundOrderListItem } from '../types'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
import type { SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
@@ -31,11 +31,15 @@ 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 }
const pending = ref<DraftCommand | null>(null)
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 settingsExpanded = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
const working = ref(false), saving = ref(false), preparing = ref(false), error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error)
const candidatePage = useDispatchCandidates(error, api.listDispatchCandidates, true)
const { candidates, page, size, total } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
@@ -48,6 +52,7 @@ const currentSettings = computed(() => {
})
const form = computed(() => currentSettings.value.form)
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 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))
@@ -66,11 +71,13 @@ async function ensureFirstVehicle(sequence: number): Promise<string | null> {
return currentContainer.value?.draftContainerId || null
}
async function addCargo(source: Candidate, targetContainerId = activeContainerId.value) {
if (frozen.value || loading.value || !warehouseId.value || assignedGroup(source)) return
if (frozen.value || loading.value || assignedGroup(source)) return
const sequence = generation, target = targetContainerId
preparing.value = true
try {
requireSelectionGroup(source); candidatePage.assertCurrent([source])
if (draft.value && source.warehouseId !== draft.value.warehouseId) throw new DispatchInputError('同一次排车安排只能选择同一起运仓的货物')
if (!draft.value) warehouseId.value = source.warehouseId
const containerId = target || await ensureFirstVehicle(sequence)
if (!containerId || sequence !== generation || !props.modelValue) return
if (!draft.value?.containers.some(row => row.draftContainerId === containerId)) throw new DispatchInputError('所选车辆已变化,请重新选择')
@@ -80,7 +87,8 @@ async function addCargo(source: Candidate, targetContainerId = activeContainerId
finally { preparing.value = false }
}
async function addVehicle() {
if (frozen.value || loading.value || !warehouseId.value) return
if (frozen.value || loading.value || !vehicleWarehouse.value) return
if (!draft.value) warehouseId.value = vehicleWarehouse.value
const sequence = generation
preparing.value = true
try {
@@ -89,7 +97,17 @@ async function addVehicle() {
} finally { preparing.value = false }
}
function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() }
function changeTab(tab: SmartDispatchWarehouseTab) { warehouseTab.value = tab; void search() }
function changeTab(tab: SmartDispatchWarehouseTab) { if (tabDisabled(tab)) return; warehouseTab.value = tab; void search() }
function tabDisabled(tab: SmartDispatchWarehouseTab) {
if (!draft.value || tab === 'ALL') return false
const warehouse = warehouses.value.find(row => String(row.id) === draft.value!.warehouseId)
return !warehouse || !matchesSmartDispatchWarehouseTab(warehouse, tab)
}
const vehicleWarehouse = computed(() => {
if (draft.value) return draft.value.warehouseId
const matches = warehouses.value.filter(row => matchesSmartDispatchWarehouseTab(row, warehouseTab.value))
return matches.length === 1 ? String(matches[0]!.id) : ''
})
function changePage(value: number) { page.value = value; void loadCandidates() }
function changeSize(value: number) { size.value = value; void search() }
@@ -111,13 +129,16 @@ async function init() {
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
if (sequence !== generation) return
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 (!warehouseId.value) {
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
if (selectedWarehouses.size === 1 && directory.some(row => String(row.id) === [...selectedWarehouses][0])) warehouseId.value = [...selectedWarehouses][0]!
else if (directory[0]) warehouseId.value = String(directory[0].id)
}
if (warehouseId.value) await loadCandidates(sequence)
await loadCandidates(sequence)
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
finally { if (sequence === generation) working.value = false }
}
@@ -130,13 +151,14 @@ function switchWorkspace(value: 'create' | 'existing') {
}
async function loadCandidates(sequence = generation) {
if (sequence !== generation) return
const filterKey = JSON.stringify([warehouseId.value, query, warehouseTab.value, planIds.value, draft.value?.draftToken])
const candidateWarehouse = draft.value?.warehouseId
const filterKey = JSON.stringify([candidateWarehouse, query, warehouseTab.value, planIds.value, draft.value?.draftToken])
if (filterKey !== candidateFilterKey) { page.value = 1; candidateFilterKey = filterKey }
await candidatePage.load({ warehouseId: warehouseId.value, page: page.value, size: size.value, warehouseTab: warehouseTab.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
await candidatePage.load({ warehouseId: candidateWarehouse, page: page.value, size: size.value, warehouseTab: warehouseTab.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
}
function resetSearch() { if (frozen.value) return; query.orderNo = ''; query.destinationWarehouseCode = ''; query.sourceStage = ''; warehouseTab.value = 'ALL'; void search() }
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; warehouseTab.value = 'ALL'; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
candidatePage.assertCurrent(cargo)
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
@@ -145,7 +167,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 } }))) }
}
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
try {
if (!pending.value) {
@@ -158,7 +180,11 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) 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 {
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
@@ -209,11 +235,14 @@ async function retryDraft() {
addedContainerId = added[0]!.draftContainerId
}
draft.value = command.action === 'RELEASE' ? null : result as DispatchDraft
if (draft.value) warehouseId.value = draft.value.warehouseId
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
// No new source mutation before the original response has been validated.
pending.value = null; persist()
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 }
}
async function submit() {
@@ -232,46 +261,46 @@ async function submit() {
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-density-workspace admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :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="智能排车工作方式">
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
</div>
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
<template v-else-if="workspace === 'create'">
<div class="density-dialog-sections">
<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>
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<section class="density-dialog-section dispatch-setup" aria-label="排车设置">
<h3 class="density-dialog-section__title">1. 排车设置<InlineHelp title="在途预排说明"><p>货物离港并生成入库计划后,即可安排货物并生成排车单。车辆和司机在建单后维护;实际装车仍须完成海外仓实收和 PDA 拣货核平。</p></InlineHelp></h3>
<el-alert v-if="needsRecovery" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query">
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading" @click="search">查询</el-button><el-button :disabled="frozen || loading" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
</section>
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置">
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
<el-form-item v-if="mode === 'empty'" label="起运仓"><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-form-item>
<el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货再建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item>
<el-form-item class="dispatch-warehouse-field" label="起运仓" required><el-select class="dispatch-warehouse-picker" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item>
</el-form>
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '辆车 · ' + saved.sources.length + '条货物'" /></el-select>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击托盘号。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
</div>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-if="mode === 'cargo'">
<section class="density-dialog-section dispatch-draft-toolbar" aria-label="车辆选货安排">
<h3 class="density-dialog-section__title">2. 为车辆选货<InlineHelp title="选货步骤说明"><p>点击左侧订单查看中间托盘,再将托盘拖入右侧车辆;也可选择车辆后点击“加入这辆车”。添加车辆时,之前的车与货物会保留。每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成排车单的选货会暂存并占用货物;可继续待完成排车,或放弃本次安排以解除占用。</p></InlineHelp></h3>
<div class="dispatch-draft-tools density-actions">
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '辆车 · ' + saved.sources.length + '条货物'" /></el-select>
<label class="dispatch-capacity-field"><span>计划托位</span><el-select aria-label="下一辆车计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
<InlineHelp title="计划托位说明"><p>此处设置新车辆的预排托位,默认空车尚未加货时也使用此值,不代表车辆实际载重或容积。真实车辆容量与装车实测仍由服务端校验。</p></InlineHelp>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
</div>
</section>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button><el-button :disabled="frozen || loading || !warehouseId" @click="resetSearch">重置</el-button></div>
</section>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全美' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</button></div>
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize" />
<SmartDispatchCargoBoard :aria-busy="candidatePage.loading.value || saving || preparing" :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="true" :add-vehicle-disabled="!vehicleWarehouse" :page="page" :size="size" :total="total"
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize">
<template #order-controls>
<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 || tabDisabled(tab.value)" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
</template>
<template #vehicle-controls>
<label class="dispatch-capacity-field"><span>托位</span><el-select aria-label="下一辆车计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
</template>
</SmartDispatchCargoBoard>
</template>
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" aria-label="排车计划时间与备注">
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '2. 计划时间与备注' : '3. 第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆信息”补充。</p></InlineHelp></h3>
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆”补充。</p></InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
@@ -283,17 +312,33 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
</AppDialog>
</template>
<style scoped>
/* In fullscreen, allocate the remaining body space to cargo instead of capping it at 390px. */
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace > .el-dialog__body) { display: flex; flex-direction: column; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace > .el-dialog__body > .app-dialog__body) { display: flex; flex-direction: column; flex: 1; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .dispatch-workspace) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .dispatch-workspace > :not(.dispatch-cargo-board)) { flex-shrink: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .dispatch-cargo-board) { flex: 1; min-height: 280px; grid-template-rows: auto minmax(0, 1fr); }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-panel) { min-height: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-order-content) { align-self: stretch; display: flex; flex-direction: column; min-height: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-table-wrap) { flex: 1; min-height: 0; max-height: none; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-pagination) { flex-shrink: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-pallet-list),
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-content) { min-height: 0; max-height: 100%; overflow-y: auto; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-list) { max-height: none; overflow: visible; }
.dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; }
.dispatch-setup, .dispatch-draft-toolbar { display: grid; grid-template-columns: 160px minmax(0, 1fr); align-items: center; gap: var(--admin-space-5); }
.dispatch-setup > .density-dialog-section__title, .dispatch-draft-toolbar > .density-dialog-section__title { align-items: center; margin: 0; }
.dispatch-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
.dispatch-setup-fields :deep(.dispatch-warehouse-field) { width: 310px; flex: 0 0 310px; }
.dispatch-warehouse-picker { width: 100%; }
.dispatch-draft-tools { flex-wrap: wrap; align-items: center; }
.dispatch-draft-picker { width: 240px; }
.dispatch-capacity-field { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 100px; }
.dispatch-candidate-query { display: grid; grid-template-columns: 240px 160px 170px auto auto; justify-content: start; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.dispatch-capacity-field { display: inline-flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 92px; }
.dispatch-candidate-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.dispatch-candidate-query > .el-input { width: 180px; }
.dispatch-candidate-query > .el-select { width: 170px; }
.dispatch-candidate-query > .dispatch-warehouse-picker { width: 200px; }
.dispatch-candidate-query > .el-button { margin-left: 0; }
.dispatch-workspace { margin-top: var(--admin-space-5); }
</style>
@@ -25,7 +25,7 @@ async function open(dispatchNo: string | null = 'PC/1') {
await flushPromises()
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(() => {
vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
@@ -35,6 +35,20 @@ beforeEach(() => {
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: [candidate()], page: query.page || 1, size: query.size || 20, total: 1, pages: 1, complete: true, snapshotAt: '' }))
})
it('keeps both searches in one panel and the manifest inside the shared board column', async () => {
const wrapper = await open()
const search = wrapper.get('[aria-label="已有排车查询"]')
expect(search.find('[aria-label="选择已有排车"]').exists()).toBe(true)
expect(search.text()).not.toContain('查询排车')
expect(search.find('input[aria-label="查找可排货物"]').exists()).toBe(true)
expect(search.findAll('.existing-cargo-query > label')).toHaveLength(0)
const column = wrapper.get('[aria-label="车辆装载"]')
expect(column.get(':scope > .cargo-board-heading').text()).toContain('本车货物 · PC/1')
expect(column.find(':scope > .cargo-board-vehicle-content [aria-label="当前排车货物"]').exists()).toBe(true)
expect(wrapper.get('.dispatch-cargo-board').findAll(':scope > section > .cargo-board-heading')).toHaveLength(3)
expect(column.text()).not.toContain('添加车辆')
})
it('adds the complete hidden shared-pallet group through real board buttons, then reloads current versions', async () => {
const [first, second] = sharedPalletCandidates()
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size === 100 ? [first!, second!] : [first!], page: query.page!, size: query.size!, total: 2, pages: 1, complete: true, snapshotAt: '' }))
@@ -77,7 +91,7 @@ it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
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(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 () => {
@@ -177,15 +191,17 @@ it('does not let a late previous dispatch read overwrite the selected dispatch',
const wrapper = await open()
await wrapper.setProps({ dispatchNo: 'PC/2' }); await flushPromises()
resolve(current()); await flushPromises()
expect(wrapper.get('[aria-label="当前排车货物"]').text()).toContain('PC/2')
expect(wrapper.get('[aria-label="当前排车货物"]').text()).not.toContain('PC/1')
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).toContain('PC/2')
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).not.toContain('PC/1')
})
it('queries and paginates active before-loading dispatches using the entered business number', async () => {
const wrapper = await open(null)
vi.mocked(api.listDispatches).mockImplementation(async query => ({ ...page([listRow({ taskType: 'DELIVERY' })]), page: query.page, size: query.size, total: 22, pages: 3 }))
await wrapper.get('input[aria-label="查找已有排车"]').setValue('PC2026')
await button(wrapper, '查询排车').trigger('click'); await flushPromises()
await wrapper.get('[aria-label="已有排车分页"] button.btn-next').trigger('click'); await flushPromises()
const picker = wrapper.getComponent({ name: 'ElSelect' })
expect(picker.props('remote')).toBe(true)
await picker.get('.el-select__wrapper').trigger('click'); await flushPromises()
await picker.props('remoteMethod')('PC2026'); await flushPromises()
await wrapper.getComponent({ name: 'ElPagination' }).get('button.btn-next').trigger('click'); await flushPromises()
expect(api.listDispatches).toHaveBeenLastCalledWith({ page: 2, size: 10, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: 'PC2026' })
})
@@ -6,6 +6,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
@@ -23,7 +24,7 @@ const error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error)
const { candidates, page: cargoPage, size: cargoSize, total: cargoTotal } = candidatePage
const rows = ref<DispatchListRow[]>([]), page = ref(1), size = ref(10), total = ref(0)
const keyword = ref(''), queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
const queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
const selectedNo = ref(''), warehouseId = ref(''), warehouseName = ref('')
const detail = ref<DispatchDetail | null>(null), items = ref<DispatchItems | null>(null)
const reading = ref(false), listing = ref(false), preparing = ref(false), stale = ref(true)
@@ -53,9 +54,9 @@ async function loadList() {
} catch (cause) { if (alive && sequence === listGeneration) { rows.value = []; report(cause, '排车列表读取失败') } }
finally { if (alive && sequence === listGeneration) listing.value = false }
}
async function searchDispatches() {
async function searchDispatches(value: string) {
if (frozen.value) return
queriedKeyword.value = keyword.value.trim(); page.value = 1
queriedKeyword.value = value.trim(); page.value = 1
await loadList()
}
async function changePage(value: number) { if (!frozen.value) { page.value = value; await loadList() } }
@@ -202,25 +203,29 @@ onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidate
<el-alert v-if="pending" :title="pendingMessage" type="warning" :closable="false" show-icon />
<el-button v-if="cargoPending" :loading="busy" @click="execute">{{ pending?.result ? '刷新当前资料' : '核对并重试原请求' }}</el-button>
<LoadErrorState v-if="error" title="排车货物资料待核对" :message="error.message" :loading="loading || busy" retry-text="重新核对" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section existing-cargo-search" aria-label="已有排车查询">
<div class="existing-cargo-query">
<el-input v-model="keyword" aria-label="查找已有排车" placeholder="排车单号" :disabled="frozen || listing" @keyup.enter="searchDispatches" />
<el-button :disabled="frozen || listing" @click="searchDispatches">查询排车</el-button>
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="选择装车前排车" :disabled="frozen || listing" @update:model-value="(value: string) => selectDispatch(value, rows.find(row => row.dispatchNo === value))">
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="输入单号搜索并选择排车" filterable remote :remote-method="searchDispatches" :loading="listing" :disabled="frozen" @update:model-value="(value: string) => selectDispatch(value, rows.find(row => row.dispatchNo === value))">
<el-option v-for="row in rows" :key="row.dispatchNo" :value="row.dispatchNo" :label="`${row.dispatchNo} · ${row.warehouseName || row.warehouseCode || '—'}`" :disabled="row.taskType !== 'DELIVERY' || row.status !== 'ACTIVE' || row.stage !== 'BEFORE_LOADING'" />
<el-option v-if="selectedNo && !rows.some(row => row.dispatchNo === selectedNo)" :value="selectedNo" :label="selectedNo" disabled />
<template #footer><el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" /></template>
</el-select>
<el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" />
<template v-if="selectedNo"><el-input id="existing-cargo-keyword" v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button type="primary" :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></template>
</div>
<p v-if="!selectedNo" class="density-dialog-note">选择已有排车后,在这里统一增减本车计划货物。</p>
</section>
<template v-if="selectedNo">
<div class="existing-cargo-query"><strong>{{ selectedNo }} · {{ warehouseName || '—' }}</strong><el-input v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></div>
<p v-if="detail && !editable" class="density-dialog-note">仅有效、装车前的仓库派送排车允许调整计划货物。真实拣货仍在 PDA 完成。</p>
<p v-else-if="detail && !canAdd" class="density-dialog-note">{{ detail.actions.ADD_CARGO?.blockReason || '当前状态或权限不允许添加货物' }}</p>
<SmartDispatchCargoBoard :candidates="candidates" :draft="null" :active-container-id="selectedNo" :capacity="0" :disabled="locked || !canAdd" :warehouse-selected="Boolean(warehouseId)" :page="cargoPage" :size="cargoSize" :total="cargoTotal" @add="addCargo" @change-page="changeCargoPage" @change-size="changeCargoSize">
<template #vehicle-heading>
<h3 class="existing-cargo-title">本车货物 · {{ selectedNo }} · {{ warehouseName || '—' }}</h3>
<InlineHelp title="本车货物调整说明"><p>加入后立即保存;移除前填写原因并确认。货物变化后需重新核对审核。</p></InlineHelp>
</template>
<template #vehicles="{ dragOver, drop, dropTarget }">
<section class="existing-cargo-manifest" :class="{ 'is-drop-target': dropTarget === selectedNo }" aria-label="当前排车货物" @dragover="dragOver($event, selectedNo)" @drop="drop($event, selectedNo)">
<h3>本车货物 · {{ selectedNo }}</h3>
<p class="density-dialog-note">加入后立即保存;移除前填写原因并确认。货物变化后需重新核对审核。</p>
<p v-if="!items" class="density-dialog-note">请读取完整货物清单</p>
<p v-else-if="!items.items.length" class="density-dialog-note">本车暂无货物,可从左侧选择托盘加入。</p>
<div v-for="row in items?.items || []" :key="row.dispatchItemId" class="existing-cargo-item">
@@ -236,12 +241,15 @@ onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidate
</template>
<style scoped>
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.existing-cargo { margin-top: var(--admin-space-5); }
.existing-cargo-search { display: flex; flex-direction: column; gap: var(--admin-space-5); }
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.existing-cargo-query > .el-button { margin-left: 0; }
.existing-cargo-query > .el-input { width: 240px; }
.existing-cargo-query > .el-select { width: 350px; }
.existing-cargo-manifest { min-width: 0; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); padding: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
.existing-cargo-manifest.is-drop-target { border-color: var(--cc-primary); background: var(--admin-action-primary-hover); }
.existing-cargo-manifest h3 { margin: 0; font-size: var(--admin-font-section); }
.existing-cargo-title { margin: 0; font-size: var(--admin-font-section); overflow-wrap: anywhere; }
.existing-cargo-item { display: flex; gap: var(--admin-space-3); justify-content: space-between; padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
.existing-cargo-item > div { min-width: 0; }
.existing-cargo-item > .el-button { flex: none; }
@@ -0,0 +1,84 @@
import { mount, flushPromises } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, expect, it, vi } from 'vitest'
import TransportExecutionPanel from './TransportExecutionPanel.vue'
import { commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货', PICKED: '已取货', ARRIVED: '已到仓' } }))
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } }))
const facts = (arriving = false): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', execution: { trip: { status: arriving ? 'IN_TRANSIT' : 'PLANNED', recordVersion: 4, warehouseName: '洛杉矶仓', vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲', driverPhone: '555' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, actualPackages: arriving ? 5 : null, status: arriving ? 'PICKED' : 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail
const options = { global: { plugins: [ElementPlus] } }
beforeEach(() => {
vi.clearAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(getTransport).mockResolvedValue(facts())
vi.mocked(uploadTransport).mockResolvedValue({ fileId: '88', fileName: '现场.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
})
it.each([false, true])('confirms the %s photo stage with its own evidence and current transport version', async arriving => {
vi.mocked(getTransport).mockResolvedValue(facts(arriving))
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises()
expect(wrapper.text()).toContain(arriving ? '派送中' : '装货中')
expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false)
await wrapper.findAll('button').find(button => button.text() === (arriving ? '上传到仓照片' : '上传装货照片'))!.trigger('click'); await flushPromises()
if (!arriving) {
const quantity = wrapper.get('input[role="spinbutton"]')
expect((quantity.element as HTMLInputElement).value).toBe('')
await quantity.setValue('3'); await quantity.trigger('change'); await flushPromises()
}
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
await input.trigger('change'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === (arriving ? '确认到仓' : '确认取货'))!.trigger('click'); await flushPromises()
expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 3 }) }))
if (arriving) expect(vi.mocked(commandTransport).mock.calls[0]![2]).not.toHaveProperty('actualPackages')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('keeps photo confirmation unavailable until both reviews are approved', async () => {
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: false } }); await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === '上传装货照片')).toBe(false)
expect(commandTransport).not.toHaveBeenCalled(); wrapper.unmount()
})
it('requires an explicitly entered pickup quantity even after a photo has uploaded', async () => {
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '上传装货照片')!.trigger('click'); await flushPromises()
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
await input.trigger('change'); await flushPromises()
const confirm = wrapper.findAll('button').find(button => button.text() === '确认取货')!
expect(confirm.attributes('disabled')).toBeDefined()
await confirm.trigger('click')
expect(commandTransport).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each([false, true])('renders read-only transport facts without resource editing or execution controls (%s)', async arriving => {
const value = facts(arriving)
value.execution.trip.vehicleId = null
value.execution.trip.driverId = null
vi.mocked(getTransport).mockResolvedValue(value)
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, readonly: true } }); await flushPromises()
expect(wrapper.text()).toContain('WT1')
expect(wrapper.text()).toContain('洛杉矶仓')
expect(wrapper.findAll('input, select, textarea')).toHaveLength(0)
expect(wrapper.findAll('button')).toHaveLength(0)
expect(wrapper.findAll('th').map(column => column.text())).not.toContain('操作')
expect(commandTransport).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps pickup addresses and contacts on their own transfer rows', async () => {
const value = facts()
value.pickups[0] = { ...value.pickups[0]!, pickupAddress: '100 Test Pickup Avenue', contactName: '取货联系人甲', contactPhone: '0000000001' }
value.pickups.push({ ...value.pickups[0]!, transferNo: 'WT2', customerId: 'C2', pickupAddress: '200 Test Pickup Road', contactName: '取货联系人乙', contactPhone: '0000000002' })
vi.mocked(getTransport).mockResolvedValue(value)
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', readonly: true } }); await flushPromises()
const rows = wrapper.get('[aria-label="取货明细"]').findAll('tbody tr')
expect(rows[0]!.text()).toContain('100 Test Pickup Avenue')
expect(rows[0]!.text()).toContain('取货联系人甲')
expect(rows[0]!.text()).toContain('0000000001')
expect(rows[0]!.text()).not.toContain('200 Test Pickup Road')
expect(rows[1]!.text()).toContain('200 Test Pickup Road')
expect(rows[1]!.text()).toContain('取货联系人乙')
expect(rows[1]!.text()).toContain('0000000002')
expect(wrapper.findAll('button')).toHaveLength(0)
wrapper.unmount()
})
@@ -1,7 +1,10 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import { Truck, Package, RefreshCw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -10,16 +13,24 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { listVehicles, listDrivers, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
const props = defineProps<{ dispatchNo: string }>(), emit = defineEmits<{ saved: [] }>()
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickupReviewState?: string; editResources?: boolean; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore(), detail = ref<TransportDetail | null>(null), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), vehicleId = ref(''), driverId = ref(''), reason = ref('')
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref(1), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
const arrivalMode = ref(false)
const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000)
const editingResources = ref(props.editResources === true)
const pickupPhotos = (row: PickupTask, phase: 'PICKUP' | 'ARRIVAL') => row.evidence.filter(file => ((file as PickupEvidence & { phase?: string }).phase || 'PICKUP') === phase)
const pickupStage = computed(() => trip.value?.status === 'COMPLETED' ? '取货派送完成' : trip.value?.status === 'CANCELLED' ? '已取消' : detail.value?.pickups.some(row => row.status === 'ASSIGNED') ? props.pickupApproved ? '装货中' : ({ DRAFT: '待提交审核', DISPATCH_PENDING: '待调度审核', LOADING_PENDING: '待理货审核', REJECTED: '审核退回' }[props.pickupReviewState || 'DRAFT'] || '待审核') : '派送中')
const releaseOpen = ref(false), releaseKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), endLocation = ref(''), actualEnd = ref('')
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' | 'release' } | null>(null)
const trip = computed(() => detail.value?.execution.trip)
const editable = computed(() => access.can(permission.EDIT))
const editable = computed(() => !props.readonly && access.can(permission.EDIT))
const canChangeResources = computed(() => trip.value?.status === 'PLANNED' && !detail.value?.pickups.some(row => row.status !== 'ASSIGNED'))
const resourceFormOpen = computed(() => canChangeResources.value && editable.value && (editingResources.value || !trip.value?.vehicleId || !trip.value?.driverId))
const locked = computed(() => busy.value || !!pending.value)
async function load() {
error.value = null
@@ -41,7 +52,7 @@ async function candidates() {
async function assign() {
if (!trip.value || locked.value || !vehicleId.value || !driverId.value) return
busy.value = true; error.value = null
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机' }); emit('saved'); ElMessage.success('车辆司机已分配') }
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机' }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机已分配') }
catch (cause) { await load(); error.value = getApiErrorDetails(cause, '分配未完成') }
finally { busy.value = false }
}
@@ -59,14 +70,15 @@ async function execute() {
}
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
if (!trip.value || locked.value) return
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
try { await ElMessageBox.confirm(message, '确认运输操作', { customClass: 'admin-density-confirm' }) } catch { return }
const prefix = detail.value?.taskType === 'PICKUP' ? 'pickup' : 'truck'
const suffix = row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${action}` : `${prefix}/${action}`
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } }
await execute()
}
function editPickup(row: PickupTask) { selected.value = row; actual.value = Number(row.packageCount); evidence.value = []; pickupOpen.value = true }
/** 实际取货数量由现场填写,预报箱数不能自动作为实提事实。 */
function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = undefined; evidence.value = []; pickupOpen.value = true }
async function download(file: PickupEvidence, row?: PickupTask) {
try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') }
@@ -81,9 +93,10 @@ async function upload(event: Event, pickup: boolean) {
finally { busy.value = false; input.value = '' }
}
async function picked() {
if (!selected.value || !trip.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length || locked.value) return
if (!selected.value || !trip.value || (!arrivalMode.value && !validPickupQuantity.value) || !evidence.value.length || locked.value) return
const row = selected.value
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/picked`, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: reason.value.trim() } }; await execute()
const common = { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), evidenceFileIds: evidence.value.map(file => file.fileId) }
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${arrivalMode.value ? 'arrive' : 'picked'}`, body: arrivalMode.value ? { ...common, reason: reason.value.trim() || '上传到仓照片,确认取货派送完成' } : { ...common, actualPackages: actual.value, remark: reason.value.trim() } }; await execute()
}
function openRelease(kind: 'VEHICLE' | 'DRIVER') { releaseKind.value = kind; endLocation.value = ''; actualEnd.value = ''; releaseOpen.value = true }
async function release() {
@@ -93,34 +106,84 @@ async function release() {
onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核对,离开会丢失原请求。确认离开?'))
</script>
<template>
<section class="detail-section" aria-label="运输任务执行">
<LoadErrorState v-if="error" title="运输操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-button v-if="pending" :loading="busy" @click="execute">按原请求重试</el-button>
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-button v-if="pending" :loading="busy" @click="execute">核对原请求</el-button>
<template v-if="detail && trip">
<h2>{{ transportLabels[detail.taskType] }} · {{ { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</h2>
<p v-if="detail.orderNo">本土卡车原单:{{ detail.orderNo }} · 客户:{{ detail.customerId }}</p>
<p>调度仓库:{{ trip.warehouseName }} · 车辆:{{ trip.vehiclePlate || '待分配' }} · 司机:{{ trip.driverName || '待分配' }} {{ trip.driverPhone }}</p>
<p v-for="(stop, index) in trip.stops" :key="index">{{ index + 1 }}. {{ stop.name }} {{ stop.address }}</p>
<el-input v-if="editable" v-model="reason" :disabled="locked" placeholder="本次操作说明" />
<el-form v-if="trip.status === 'PLANNED' && editable" label-position="top" :disabled="locked" class="density-form">
<el-button :loading="candidateBusy" @click="candidates">加载车辆司机候选</el-button>
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位${occupancy[`V:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
<el-form-item label="司机"><el-select v-model="driverId" filterable><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}${occupancy[`D:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
<el-button :loading="busy" :disabled="candidateBusy || !vehicleId || !driverId" @click="assign">保存车辆司机</el-button>
</el-form>
<div class="density-actions">
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.CANCEL)" :disabled="locked" @click="command('cancel')">取消排车</el-button>
<el-button v-if="trip.status === 'IN_TRANSIT' && editable" :disabled="locked || (detail.taskType === 'PICKUP' ? !detail.pickups.length || detail.pickups.some(row => row.status !== 'ARRIVED') : !truckEvidence.length)" @click="command('complete')">确认运输完成</el-button>
<header class="transport-toolbar">
<div class="transport-identity"><Truck :size="18" aria-hidden="true" /><strong>{{ transportLabels[detail.taskType] }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : trip.status === 'COMPLETED' ? 'success' : 'primary'">{{ detail.taskType === 'PICKUP' ? pickupStage : { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</el-tag></div>
<div v-if="!readonly" class="density-actions transport-actions">
<slot name="actions" />
<el-button v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
<el-button v-if="canChangeResources && access.can(permission.CANCEL)" type="danger" plain :disabled="locked" @click="command('cancel')">取消排车</el-button>
<el-button v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable" :disabled="locked || !truckEvidence.length" @click="command('complete')">确认运输完成</el-button>
<template v-if="trip.status === 'COMPLETED' && editable"><el-button v-if="!trip.vehicleRelease" :disabled="locked" @click="openRelease('VEHICLE')">结束车辆任务</el-button><el-button v-if="!trip.driverRelease" :disabled="locked" @click="openRelease('DRIVER')">结束司机任务</el-button></template>
<el-button :disabled="locked" @click="load">刷新运输进度</el-button>
</div>
<p v-if="detail.taskType === 'PICKUP'">送仓后由仓库独立清点收货,取货数量保留为运输事实。</p>
<el-table v-if="detail.taskType === 'PICKUP'" :data="detail.pickups"><el-table-column prop="transferNo" label="转运单号" min-width="190" /><el-table-column label="进度"><template #default="{ row }">{{ pickupStatusLabels[row.status] }}</template></el-table-column><el-table-column prop="packageCount" label="预报箱数" /><el-table-column prop="actualPackages" label="实提箱数" /><el-table-column label="凭证"><template #default="{ row }"><el-button v-for="file in row.evidence" :key="file.fileId" link @click="download(file, row as PickupTask)">{{ file.fileName }}</el-button></template></el-table-column><el-table-column label="操作" width="180"><template #default="{ row }"><template v-if="trip.status === 'IN_TRANSIT' && editable"><el-button v-if="row.status === 'ASSIGNED'" link :disabled="locked" @click="editPickup(row as PickupTask)">确认取货</el-button><el-button v-if="row.status === 'PICKED'" link :disabled="locked" @click="command('arrive', row as PickupTask)">确认送仓</el-button></template></template></el-table-column></el-table>
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>运输完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
<div v-if="detail.completionEvidence?.length"><p>已确认的运输完成凭证</p><el-button v-for="file in detail.completionEvidence" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button></div>
<el-button link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button>
</div>
</header>
<section class="workbench-panel is-compact transport-resources" aria-label="车辆信息">
<div class="transport-section-heading"><h2 class="workbench-section-title">车辆信息</h2><el-button v-if="canChangeResources && editable && !resourceFormOpen" link type="primary" :disabled="locked" @click="editingResources = true">维护车辆信息</el-button></div>
<dl class="transport-facts">
<div><dt>{{ detail.taskType === 'PICKUP' ? '收货仓库' : '所属仓库' }}</dt><dd>{{ trip.warehouseName || '—' }}</dd></div>
<div><dt>计划发车</dt><dd>{{ time(trip.plannedDepartureAt) }}</dd></div>
<div><dt>预计结束</dt><dd>{{ time(trip.expectedDriverEndAt) }}</dd></div>
<template v-if="!resourceFormOpen"><div><dt>车辆</dt><dd>{{ trip.vehiclePlate || '待分配' }}</dd></div><div><dt>司机</dt><dd>{{ trip.driverName || '待分配' }}<span v-if="trip.driverPhone" class="transport-phone">{{ trip.driverPhone }}</span></dd></div></template>
<div v-if="detail.orderNo"><dt>订单号</dt><dd>{{ detail.orderNo }}</dd></div>
<div v-if="detail.taskType === 'LOCAL_TRUCK'"><dt>路线</dt><dd>{{ trip.stops.map(stop => stop.name).join(' → ') }}</dd></div>
</dl>
<el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form">
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
<div class="density-actions"><el-button type="primary" :loading="busy || candidateBusy" :disabled="locked || !vehicleId || !driverId" @click="assign">保存车辆司机</el-button><el-button v-if="trip.vehicleId && trip.driverId" :disabled="locked" @click="editingResources = false; vehicleId = trip.vehicleId; driverId = trip.driverId">收起</el-button></div>
</el-form>
</section>
<section v-if="detail.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细">
<div class="transport-section-heading"><h2 class="workbench-section-title"><Package :size="16" aria-hidden="true" />取货明细</h2><span class="workbench-meta">{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱</span></div>
<el-table :data="detail.pickups" size="small" class="density-table" empty-text="暂无取货明细">
<el-table-column v-if="!readonly" label="操作" width="160"><template #default="{ row }"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask)">上传装货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template><span v-else class="workbench-meta">—</span></template></el-table-column>
<el-table-column prop="transferNo" label="转运单号" width="210" />
<el-table-column label="取货地址" min-width="280"><template #default="{ row }">{{ row.pickupAddress || '—' }}</template></el-table-column>
<el-table-column label="联系人" min-width="120"><template #default="{ row }">{{ row.contactName || '—' }}</template></el-table-column>
<el-table-column label="联系电话" min-width="160"><template #default="{ row }">{{ row.contactPhone || '—' }}</template></el-table-column>
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></template></el-table-column>
<el-table-column label="预报/实际取货(箱)" width="180" align="right"><template #default="{ row }">{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</template></el-table-column>
<el-table-column label="取货照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'PICKUP')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'PICKUP').length">—</span></template></el-table-column>
<el-table-column label="到仓照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'ARRIVAL')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'ARRIVAL').length">—</span></template></el-table-column>
</el-table>
</section>
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
<el-button v-for="file in detail.completionEvidence || []" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button>
</template>
<AppDialog class="admin-density-dialog" v-model="pickupOpen" title="确认原单取货" width="650px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked"><p>{{ selected?.transferNo }}</p><el-form label-position="top" :disabled="locked"><el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :precision="0" /></el-form-item><el-form-item label="取货凭证" required><input type="file" :disabled="locked" @change="upload($event, true)" /></el-form-item><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></el-form><template #footer><el-button type="primary" :disabled="locked || !evidence.length" @click="picked">确认实提</el-button></template></AppDialog>
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="650px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="top" :disabled="locked"><el-form-item label="实际任务结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认任务结束并释放占用</el-button></template></AppDialog>
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传装货照片'" width="560px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
<p>{{ selected?.transferNo }}</p>
<el-form label-position="left" label-width="150px" :disabled="locked">
<el-form-item label="预报数量(箱)">{{ selected?.packageCount ?? '—' }}</el-form-item>
<el-form-item label="实际取货数量(箱)" :required="!arrivalMode">
<span v-if="arrivalMode">{{ selected?.actualPackages ?? '—' }}</span>
<el-input-number v-else v-model="actual" :min="1" :max="5000" :precision="0" placeholder="填写实际取货箱数" aria-label="实际取货数量(箱)" />
</el-form-item>
<el-form-item label="照片" required><input type="file" accept="image/*" :disabled="locked" @change="upload($event, true)" /></el-form-item>
<p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p>
</el-form>
<template #footer><el-button type="primary" :disabled="locked || !evidence.length || (!arrivalMode && !validPickupQuantity)" @click="picked">{{ arrivalMode ? '确认到仓' : '确认取货' }}</el-button></template>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="560px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="left" label-width="100px" :disabled="locked"><el-form-item label="实际结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认结束</el-button></template></AppDialog>
</section>
</template>
<style scoped>
.transport-execution { display: grid; gap: var(--admin-space-5); min-width: 0; padding: var(--admin-space-5); }
.transport-toolbar, .transport-section-heading, .transport-identity { display: flex; align-items: center; gap: var(--admin-space-4); }
.transport-toolbar, .transport-section-heading { justify-content: space-between; flex-wrap: wrap; }
.transport-identity { color: var(--cc-text); }
.transport-identity > svg { color: var(--el-color-primary); }
.transport-actions { margin-left: auto; }
.transport-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5) var(--admin-space-7); margin: var(--admin-space-5) 0 0; }
.transport-facts > div { display: flex; align-items: baseline; gap: var(--admin-space-4); min-width: 0; }
.transport-facts dt { color: var(--cc-muted); flex-shrink: 0; }
.transport-facts dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
.transport-phone { margin-left: var(--admin-space-3); color: var(--cc-muted); }
.transport-resource-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(240px, 1fr) auto; align-items: start; gap: var(--admin-space-6); margin-top: var(--admin-space-5); }
.transport-pickups { display: grid; gap: var(--admin-space-4); min-width: 0; }
@media (max-width: 1100px) { .transport-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } .transport-resource-form { grid-template-columns: minmax(200px, 1fr) minmax(200px, 1fr); } }
</style>
@@ -3,10 +3,19 @@ import { ApiResponseError } from '@/shared/api/http'
import { useDispatchCommandRecovery } from './dispatchCommandRecovery'
import { command } from './dispatchTestFixtures'
beforeEach(() => sessionStorage.clear())
it('rejects a loading result that has not completed loading', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const read = vi.fn()
await expect(recovery.execute(vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'LOADING' }), read, vi.fn())).rejects.toThrow('装车完成')
expect(read).not.toHaveBeenCalled()
expect(recovery.pending.value?.result).toBeNull()
expect(recovery.pending.value?.body.idempotencyKey).toBe('key')
})
it('retains the exact original version and body when POST outcome is unknown and a newer detail was read', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
const send = vi.fn().mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(command('DEPART'))
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
const send = vi.fn().mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(command('LOADING_COMPLETE'))
const read = vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 50 }), find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(recovery.execute(send, read, find)).rejects.toThrow('network unknown')
await recovery.execute(send, read, find)
@@ -51,15 +60,15 @@ it('does not discard an earlier unknown write on a later business rejection', as
})
it('rejects a mismatched business number/action/key instead of treating it as successful', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'key' })
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const read = vi.fn()
await expect(recovery.execute(vi.fn().mockResolvedValue(command('DEPART', 'different')), read, vi.fn())).rejects.toThrow('不一致')
await expect(recovery.execute(vi.fn().mockResolvedValue(command('LOADING_COMPLETE', 'different')), read, vi.fn())).rejects.toThrow('不一致')
expect(read).not.toHaveBeenCalled(); expect(recovery.pending.value).not.toBeNull()
})
it('does not release a proven result for an older detail version, including versions beyond safe integer precision', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'DEPART', { recordVersion: '9007199254740992', idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue({ ...command('DEPART'), recordVersion: '9007199254740993' }), find = vi.fn()
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: '9007199254740992', idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), recordVersion: '9007199254740993' }), find = vi.fn()
await expect(recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740992' }), find)).rejects.toThrow('详情版本早于')
expect(recovery.pending.value?.result).not.toBeNull()
await recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740994' }), find)
@@ -134,3 +143,14 @@ it('only rereads a restored proven result and keeps it when the detail read stil
expect(send).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled(); expect(read).toHaveBeenCalledTimes(2)
expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
})
it('accepts a loading confirmation that already departed and recovers it without resubmitting', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'DELIVERING', recordVersion: 3 })
const read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 3 })
await expect(recovery.execute(send, read, vi.fn())).rejects.toThrow('read failed')
expect(recovery.pending.value?.result?.stage).toBe('DELIVERING')
await recovery.execute(send, read, vi.fn())
expect(send).toHaveBeenCalledTimes(1); expect(recovery.pending.value).toBeNull()
})
@@ -3,7 +3,7 @@ import { ApiResponseError } from '@/shared/api/http'
import type { CommandResult, DispatchAction } from './dispatchContracts'
import { requireVersion } from './dispatchContracts'
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; attempted: boolean; result: CommandResult | null; rejected: boolean; operation?: 'ROUTE'; itemId?: string }
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; attempted: boolean; result: CommandResult | null; rejected: boolean; operation?: 'ROUTE'; itemId?: string; submitReviewAfterSave?: boolean }
const storageKey = (scope: string) => `dispatch-command:${scope}`
const snapshot = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const sessions = new Map<string, { pending: ReturnType<typeof ref<PendingDispatchCommand | null>>; busy: ReturnType<typeof ref<boolean>> }>()
@@ -15,8 +15,7 @@ export function validateCommandResult(result: CommandResult, pending: PendingDis
|| result.action !== pending.action || result.idempotencyKey !== pending.body.idempotencyKey
|| !result.dispatchNo || !result.result) throw new Error('操作结果与原排车请求不一致,请保留原请求核对')
requireVersion(result.recordVersion)
if (pending.action === 'DEPART' && result.stage !== 'DELIVERING') throw new Error('发车结果未确认派送中,请核对原请求')
if (pending.action === 'LOADING_COMPLETE' && result.stage !== 'AFTER_LOADING') throw new Error('装车结果未确认装车后,请核对原请求')
if (pending.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage)) throw new Error('装车结果未确认装车完成,请核对原请求')
}
export function validateCurrentDispatch(value: unknown, result: CommandResult) {
const current = value as { dispatchNo?: string; recordVersion?: unknown } | null
@@ -43,7 +42,7 @@ export function useDispatchCommandRecovery(scope: () => string) {
if (pending.value) sessionStorage.setItem(storageKey(owner), JSON.stringify(pending.value))
else sessionStorage.removeItem(storageKey(owner))
}
function begin(dispatchNo: string, action: DispatchAction, body: object, target: Pick<PendingDispatchCommand, 'operation' | 'itemId'> = {}) {
function begin(dispatchNo: string, action: DispatchAction, body: object, target: Pick<PendingDispatchCommand, 'operation' | 'itemId' | 'submitReviewAfterSave'> = {}) {
if (pending.value) throw new Error('上次排车操作结果尚未核对,请先恢复原请求')
pending.value = { dispatchNo, action, body: snapshot(body) as Record<string, unknown>, attempted: false, result: null, rejected: false, ...target }
persist()
@@ -4,20 +4,21 @@ export type Version = string | number
export class DispatchInputError extends Error {}
export type DispatchStatus = 'ACTIVE' | 'CANCELLED'
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 BeforeLoadingSection = 'VEHICLE_INFO' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW'
export interface Gate { allowed: boolean; blockCode: string | null; blockReason: string | null }
export type DispatchActions = Partial<Record<DispatchAction, Gate>>
export interface Counts { itemCount: number; palletCount: number; packageCount: number; pickedPackageCount: number; shippedPackageCount: number }
export interface Capacity { palletSpaces: number | null; maxLoadKg: number | null; maxVolumeCbm: number | null }
export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null }
export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null; photoFileIds?: string[] }
export interface Vehicle { mode: 'MASTER' | 'EXTERNAL'; vehicleId: string | null; plate: string | null; carrierName: string | null; capacity: Capacity | null; evidenceFileIds: string[] }
export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null; name: string | null; phone: string | null; evidence: DriverEvidence | null }
export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null }
export interface Resources { vehicle: VehicleSnapshot | null; driver: Driver | null }
export interface Destination { key: string; type: string; warehouseId: string | null; warehouseCode: string | null; warehouseName: string | null; address: string | null; postalCode: string | null; contactName: string | null; contactPhone: string | null }
export interface Review { status: ReviewStatus; reason: string | null; reviewerId: string | null; reviewedAt: string | null; reviewRound: Version; basisHash: string | null; invalidatedReason: string | null }
export interface ReviewAttachments { vehicleFileIds: string[]; driverPhotoFileIds: string[]; licenseFileIds: string[] }
export interface Review { status: ReviewStatus; reason: string | null; reviewerId: string | null; reviewedAt: string | null; reviewRound: Version; basisHash: string | null; invalidatedReason: string | null; attachments?: ReviewAttachments }
export interface Reviews { dispatchReview: Review; loadingReview: Review }
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }>; recordVersion: Version }
export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number; recordVersion: Version }
@@ -30,14 +31,18 @@ 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 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 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 CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface ReasonCommand extends VersionCommand { reason: string }
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null; attachments?: ReviewAttachments }
export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number }
export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
@@ -52,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 DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER'
export type DispatchEvidenceKind = 'EXCEPTION' | 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
@@ -80,7 +85,8 @@ export type CommandResult = CommandBase & (
export const dispatchStages: Array<{ value: DispatchStage; label: string }> = [
{ value: 'BEFORE_LOADING', label: '装车前' }, { value: 'LOADING', label: '装车中' },
{ value: 'AFTER_LOADING', label: '装车后' }, { value: 'DELIVERING', label: '派送中' }, { value: 'DELIVERED', label: '派送完成' },
{ value: 'AFTER_LOADING', label: '装车后' },
{ value: 'DELIVERING', label: '派送中' }, { value: 'DELIVERED', label: '派送完成' },
]
export const stageLabel = (stage: DispatchStage) => dispatchStages.find(row => row.value === stage)?.label || '—'
export const reviewLabels: Record<ReviewStatus, string> = { NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
@@ -108,7 +114,7 @@ export function selectionIdentity(value: CargoSelection): string {
export function requireSelectionGroup(row: Candidate): SelectionGroup {
const group = row.selectionGroup
if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...row.blockReasons, ...group.blockReasons].join(';') || '选货组来源尚未完整,请重新读取候选')
if (group.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('选货组来源或数量不完整,请重新读取候选')
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('选货组实托事实缺失或重复,请重新读取候选')
@@ -1,16 +1,42 @@
import type { DispatchAction, DispatchActions } from './dispatchContracts'
import type { DispatchAction, DispatchActions, DispatchDetail, ReviewAttachments, Reviews } from './dispatchContracts'
export const dispatchReviewAttachmentFields = [
{ key: 'vehicleFileIds', kind: 'VEHICLE', label: '车辆图片' },
{ key: 'driverPhotoFileIds', kind: 'DRIVER_PHOTO', label: '司机照片' },
{ key: 'licenseFileIds', kind: 'DRIVER', label: '驾驶证照片' },
] as const
export const emptyReviewAttachments = (): ReviewAttachments => ({ vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: [] })
export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAttachments {
const result = emptyReviewAttachments()
if (!detail) return result
result.vehicleFileIds = [...(detail.vehicleInfo.vehicle?.evidenceFileIds || [])]
result.driverPhotoFileIds = [...(detail.vehicleInfo.driver?.evidence?.photoFileIds || [])]
result.licenseFileIds = [...(detail.vehicleInfo.driver?.evidence?.fileIds || [])]
for (const { key } of dispatchReviewAttachmentFields) {
result[key] = [...new Set([...result[key], ...(detail.reviews.dispatchReview.attachments?.[key] || []), ...(detail.reviews.loadingReview.attachments?.[key] || [])])]
}
return result
}
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: '理货员审核',
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
}
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',
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',
RETURN_TO_REVIEW: 'platform:overseas-warehouse:dispatch:edit', PICK: 'lastleg:pda:operate',
LOADING_COMPLETE: 'platform:overseas-warehouse:dispatch:loading-complete', ARRANGE_DELIVERY: 'platform:overseas-warehouse:dispatch:transfer-delivery', DEPART: 'platform:overseas-warehouse:dispatch:depart',
}
export const canDispatchAction = (actions: DispatchActions | undefined, action: DispatchAction, can: (code: string) => boolean) => actions?.[action]?.allowed === true && can(dispatchActionPermissions[action])
export const canDispatchAction = (actions: DispatchActions | undefined, action: DispatchAction, can: (code: string) => boolean) => !['ARRANGE_DELIVERY', 'DEPART'].includes(action) && actions?.[action]?.allowed === true && can(dispatchActionPermissions[action])
export const displayDispatchValue = (value: unknown) => value === null || value === undefined || value === '' ? '—' : String(value)
export function dispatchRejectionReason(reviews: Reviews | undefined): string {
if (!reviews) return ''
return ([['调度审核', reviews.dispatchReview], ['理货员审核', reviews.loadingReview]] as const)
.filter(([, review]) => review.status === 'REJECTED')
.map(([label, review]) => `${label}退回:${review.reason?.trim() || '原因未提供'}`).join(';')
}
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 { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, type SelectionGroup } from './dispatchContracts'
import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, selectionGroupBlockReason, type SelectionGroup } from './dispatchContracts'
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
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'
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 }] }
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 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 }] } } }
@@ -10,8 +10,8 @@ describe('overseas warehouse 1920px layout acceptance', () => {
const widths: Record<string, number> = {
'LastMileOrdersView.vue': 330,
'InboundOrdersView.vue': 300,
// Three compact RowActions plus the overflow menu; long business numbers use the saved space.
'DispatchesView.vue': 260,
// Primary action and detail plus the overflow menu; preserve space for business fields.
'DispatchesView.vue': 220,
'OutboundsView.vue': 180,
'DeliveryOrdersView.vue': 300,
'RelabelOrdersView.vue': 180,
@@ -24,6 +24,7 @@ describe('overseas warehouse 1920px layout acceptance', () => {
expect(columns).toMatch(new RegExp(`key: 'actions',[\\s\\S]*?label: '操作',[\\s\\S]*?width: ${width},[\\s\\S]*?fixed: 'left'`))
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("label:", columns.indexOf("key: 'actions'") + 20))
}
expect(page('DispatchesView.vue')).toMatch(/<RowActions\b[^>]*:max-visible="2"/)
})
it('keeps all inbound-order row actions on one line', () => {
@@ -330,7 +330,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, DataTable: table, DeliveryDocumentsDialog: true } } })
await flushPromises()
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.text()).toContain('签收文件(POD签收/快递签收) · 已上传')
expect(wrapper.text()).not.toContain('签收文件(POD签收/快递签收)')
expect(getDeliveryDocuments).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text().includes('管理资料'))!.trigger('click')
const dialog = wrapper.findComponent({ name: 'DeliveryDocumentsDialog' })
@@ -23,7 +23,6 @@ import { overseasWarehousePermissions as permission } from '../permissions'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import DeliveryDocumentsDialog from '../components/DeliveryDocumentsDialog.vue'
import { deliveryDocumentCategories } from '../api/deliveryDocuments'
import { returnSourceKey, returnSourceReference } from '../returnSource'
const documentsOpen = ref(false)
@@ -193,7 +192,7 @@ onMounted(() => { void load(); void loadWarehouses() })
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="error"><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as DeliveryOrderRow)">详情</el-button><el-button v-if="row.outboundNo && access.can(permission.outboundList)" class="density-action" data-tone="neutral" size="small" @click="openOutbound(row as DeliveryOrderRow)">前往出库</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryStatus) && availableStatuses(row as DeliveryOrderRow).length > 0" size="small" @click="openStatus(row as DeliveryOrderRow)">编辑状态</el-button><el-button class="density-action" data-tone="warning" v-if="canReturn(row as DeliveryOrderRow)" size="small" @click="openReturn(row as DeliveryOrderRow)">标记退货</el-button></div></template><template #documents="{ row }"><div class="delivery-document-summary"><div><strong>{{ row.documentSummary ? `${row.documentSummary.uploadedCategoryCount}/3 已上传` : '资料摘要暂无' }}</strong><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryList)" size="small" @click="openDocuments(row as DeliveryOrderRow)">管理资料</el-button></div><div class="delivery-document-categories"><span v-for="category in deliveryDocumentCategories" :key="category.category" :class="{ uploaded: row.documentSummary?.categories.some((item: { category: string; status: string }) => item.category === category.category && item.status === 'UPLOADED') }">{{ category.label }} · {{ row.documentSummary?.categories.find((item: { category: string }) => item.category === category.category)?.status === 'UPLOADED' ? '已上传' : '未上传' }}</span></div></div></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></div></section></ListPageLayout></AdminPage><AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="statusOpen" :title="'编辑派送状态 · ' + (statusRow?.deliveryNo || '—')" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving || statusLoading" :loading="statusLoading" :confirm-disabled="!statusForm.status || statusConflict || statusLoading || !access.can(permission.deliveryStatus)" @confirm="submitStatus">
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as DeliveryOrderRow)">详情</el-button><el-button v-if="row.outboundNo && access.can(permission.outboundList)" class="density-action" data-tone="neutral" size="small" @click="openOutbound(row as DeliveryOrderRow)">前往出库</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryStatus) && availableStatuses(row as DeliveryOrderRow).length > 0" size="small" @click="openStatus(row as DeliveryOrderRow)">编辑状态</el-button><el-button class="density-action" data-tone="warning" v-if="canReturn(row as DeliveryOrderRow)" size="small" @click="openReturn(row as DeliveryOrderRow)">标记退货</el-button></div></template><template #documents="{ row }"><div class="delivery-document-summary"><div><strong>{{ row.documentSummary ? `${row.documentSummary.uploadedCategoryCount}/3 已上传` : '资料摘要暂无' }}</strong><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryList)" size="small" @click="openDocuments(row as DeliveryOrderRow)">管理资料</el-button></div></div></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></div></section></ListPageLayout></AdminPage><AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="statusOpen" :title="'编辑派送状态 · ' + (statusRow?.deliveryNo || '—')" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving || statusLoading" :loading="statusLoading" :confirm-disabled="!statusForm.status || statusConflict || statusLoading || !access.can(permission.deliveryStatus)" @confirm="submitStatus">
<div class="density-dialog-sections">
<el-alert title="取消派送请先解除未执行车次关联,再前往关联出库单取消出库;已出库或已发车不能直接取消。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="source" aria-label="派送状态依据"><h3 class="density-dialog-section__title">当前资料</h3><table class="density-table"><tbody><tr><th scope="row">当前状态</th><td>{{ statusLabels[String(statusRow?.status)] || '—' }}</td></tr><tr><th scope="row">关联出库</th><td>{{ statusRow?.outboundNo || '—' }}</td></tr></tbody></table></section>
@@ -219,4 +218,4 @@ onMounted(() => { void load(); void loadWarehouses() })
</div>
</AppDialog><DeliveryDocumentsDialog v-if="documentsRow" v-model="documentsOpen" :delivery-no="documentsRow.deliveryNo" :customer-id="documentsRow.customerId" @saved="load()" /></template>
<style scoped>.delivery-document-summary{display:grid;gap:var(--admin-space-3);padding:var(--admin-space-2) 0}.delivery-document-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.delivery-document-categories{display:flex;flex-wrap:wrap;gap:var(--admin-space-2) var(--admin-space-5);font-size:var(--admin-font-caption);color:var(--workbench-muted)}.delivery-document-categories .uploaded{color:var(--el-color-success)}.delivery-return-editor { table-layout: fixed; }.delivery-return-editor td:not([colspan]) { width: 50%; }.delivery-return-sources { table-layout: fixed; }.delivery-return-select { width: 68px; }.delivery-return-quantity { width: 160px; } @media (max-width: 720px) { .delivery-return-editor, .delivery-return-editor tbody { display: block; }.delivery-return-editor tr { display: grid; grid-template-columns: 1fr; }.delivery-return-editor td:not([colspan]) { width: auto; } }</style>
<style scoped>.delivery-document-summary{display:grid;gap:var(--admin-space-3);padding:var(--admin-space-2) 0}.delivery-document-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.delivery-return-editor { table-layout: fixed; }.delivery-return-editor td:not([colspan]) { width: 50%; }.delivery-return-sources { table-layout: fixed; }.delivery-return-select { width: 68px; }.delivery-return-quantity { width: 160px; } @media (max-width: 720px) { .delivery-return-editor, .delivery-return-editor tbody { display: block; }.delivery-return-editor tr { display: grid; grid-template-columns: 1fr; }.delivery-return-editor td:not([colspan]) { width: auto; } }</style>
@@ -19,12 +19,57 @@ vi.mock('@/modules/overseas-master-data', async () => ({
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true, TransportExecutionPanel: true } } }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
describe('whole-dispatch four-tab detail', () => {
it('keeps cargo additions and removals out of the detail even when authorized', async () => {
it.each(['omitted', 'null', 'invalid'] as const)('keeps schedule fields readable when exception records are %s and allows retry', async state => {
const value = detail()
if (state === 'omitted') Reflect.deleteProperty(value.dispatchInfo, 'exceptions')
else Reflect.set(value.dispatchInfo, 'exceptions', state === 'null' ? null : {})
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const renderError = vi.fn()
const wrapper = mount(DispatchDetailView, { global: { ...options.global, config: { errorHandler: renderError } } })
await flushPromises()
await wrapper.get('#tab-dispatch').trigger('click'); await flushPromises()
expect(renderError).not.toHaveBeenCalled()
const pane = wrapper.get('#pane-dispatch')
expect(pane.isVisible()).toBe(true)
expect(pane.text()).toContain('LAX → ONT8')
expect(pane.text()).toContain('派送异常记录读取失败')
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
expect(pane.find('[aria-label="派送异常记录"]').exists()).toBe(false)
await pane.get('button').trigger('click'); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(2)
expect(wrapper.get('#pane-dispatch').isVisible()).toBe(true)
expect(wrapper.get('#pane-dispatch').text()).not.toContain('派送异常记录读取失败')
wrapper.unmount()
})
it('shows saved review attachments beside resource evidence without providing upload controls', async () => {
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
value.reviews.dispatchReview.attachments = { vehicleFileIds: ['301', '302'], driverPhotoFileIds: ['303'], licenseFileIds: [] }
value.reviews.loadingReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: ['304'] }
vi.mocked(getDispatch).mockResolvedValue(value)
vi.mocked(getDispatchEvidence).mockImplementation(async (_, __, fileId) => ({ fileId, fileName: `审核附件${fileId}.png`, contentType: 'image/png', sizeBytes: 3 }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(vi.mocked(getDispatchEvidence).mock.calls).toEqual([['PC/1', 'VEHICLE', '301'], ['PC/1', 'VEHICLE', '302'], ['PC/1', 'DRIVER_PHOTO', '303'], ['PC/1', 'DRIVER', '304']])
expect(wrapper.findAll('input[type="file"]')).toHaveLength(0)
expect(wrapper.text()).toContain('司机照片')
expect(wrapper.text()).toContain('审核附件304.png')
wrapper.unmount()
})
it.each(['dispatchReview', 'loadingReview'] as const)('keeps the %s rejection readable in the detail', async reviewKey => {
const value = detail()
value.reviews[reviewKey] = { ...value.reviews[reviewKey], status: 'REJECTED', reason: '司机资料需要更正', invalidatedReason: '资源已修改,待重新提交' }
vi.mocked(getDispatch).mockResolvedValue(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).toContain('已退回客服处理(车辆信息)')
expect(wrapper.text()).toContain('司机资料需要更正')
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
wrapper.unmount()
})
it('keeps every write action out of the detail even when authorized', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
const actions = wrapper.get('[aria-label="排车操作"]').findAll('button').map(button => button.text())
expect(actions).toContain('维护车辆信息')
const actions = wrapper.findAll('button').map(button => button.text())
expect(actions).not.toContain('维护车辆')
expect(actions).not.toContain('添加货物')
expect(actions).not.toContain('移除货物')
wrapper.unmount()
@@ -33,9 +78,33 @@ describe('whole-dispatch four-tab detail', () => {
vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail(), taskType })
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.get('transport-execution-panel-stub').attributes('dispatchno')).toBe('PC/1')
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('readonly')).toBe(true)
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
})
it('keeps detail tabs read-only when return-to-review is available from the list', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: { RETURN_TO_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises()
expect(wrapper.find('footer[aria-label="排车操作"]').exists()).toBe(false)
expect(wrapper.findAll('button').map(button => button.text())).not.toContain('退回装车前重审')
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
wrapper.unmount()
})
it('keeps dispatch labels and placeholders visible when no schedule or appointments are recorded', async () => {
const value = detail()
value.dispatchInfo = { ...value.dispatchInfo, route: null, fbaAppointments: [] }
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
for (const name of ['dispatch', 'documents', 'vehicle', 'dispatch']) {
await wrapper.get(`#tab-${name}`).trigger('click'); await flushPromises()
expect(wrapper.get(`#pane-${name}`).isVisible()).toBe(true)
}
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—', '—'])
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
wrapper.unmount()
})
it('returns to the original appointment date and view from the dispatch detail', async () => {
query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week'
const wrapper = mount(DispatchDetailView, options); await flushPromises()
@@ -62,6 +131,8 @@ describe('whole-dispatch four-tab detail', () => {
await tabs[1]!.trigger('click'); await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
expect(dispatchInfo.isVisible()).toBe(true)
expect(dispatchInfo.get('dl').isVisible()).toBe(true)
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
@@ -99,20 +170,20 @@ describe('whole-dispatch four-tab detail', () => {
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).not.toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); wrapper.unmount()
})
it('keeps the allowed Admin action without presenting unrelated stage or PDA permission refusals as page warnings', async () => {
it('shows stage facts without execution actions or unrelated permission refusals', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'AFTER_LOADING', actions: {
ARRANGE_DELIVERY: { allowed: true, blockCode: null, blockReason: null },
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: {
LOADING_COMPLETE: { allowed: true, blockCode: null, blockReason: null },
EDIT: { allowed: false, blockCode: 'STAGE', blockReason: '当前阶段或作业事实不满足操作条件' },
PICK: { allowed: false, blockCode: 'PERMISSION', blockReason: '当前无此操作权限' },
} }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).toContain('装车后')
expect(wrapper.text()).toContain('装车中')
expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件')
expect(wrapper.text()).not.toContain('当前无此操作权限')
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆信息')).toBe(false)
await wrapper.findAll('button').find(button => button.text() === '安排派送')!.trigger('click')
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, dispatchNo: 'PC/1', action: 'ARRANGE_DELIVERY' })
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆')).toBe(false)
expect(wrapper.findAll('button').some(button => button.text() === '确认装车完成')).toBe(false)
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
wrapper.unmount()
})
it('retains a real attachment A0404 failure and its request diagnostics in the vehicle tab', async () => {
@@ -136,45 +207,29 @@ describe('whole-dispatch four-tab detail', () => {
Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click')
expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount()
})
it.each(['saved', 'rejected'] as const)('refreshes detail actions after the workflow %s event and retains a real rejection after closure', async event => {
it('does not mount review or mutation workflows for an authorized administrator', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '提交调度审核')!.trigger('click')
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '已提交' } } }))
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'detail-reject-request' })
else dialog.vm.$emit('saved')
dialog.vm.$emit('update:modelValue', false)
await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(2)
expect(wrapper.findAll('button').some(button => button.text() === '提交调度审核')).toBe(false)
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
expect(wrapper.findAll('input, textarea')).toHaveLength(0)
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4)
if (event === 'rejected') {
expect(wrapper.text()).toContain('本轮审核已经提交')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('detail-reject-request')
}
expect(wrapper.text()).not.toContain('结果尚待核对')
wrapper.unmount()
})
it('retains a workflow rejection when the parent detail refresh also fails', async () => {
const wrapper = mount(DispatchDetailView, options); await flushPromises()
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('detail read failed'))
wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('rejected', { message: '已提交,请刷新', code: 'A0409' })
await flushPromises()
expect(wrapper.text()).toContain('已提交,请刷新')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('排车详情加载失败')
wrapper.unmount()
})
it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
const wrapper = mount(DispatchDetailView, options); await flushPromises()
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises()
expect(wrapper.text()).toContain('请补齐本趟资料')
dialog.vm.$emit('saved'); await flushPromises()
expect(wrapper.text()).not.toContain('请补齐本趟资料')
expect(getDispatch).toHaveBeenCalledTimes(1)
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()
})
@@ -11,11 +11,10 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getDispatch } from '../api/dispatches'
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
import type { DispatchDetail } from '../dispatchContracts'
import { stageLabel } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, displayDispatchValue as text } from '../dispatchPresentation'
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
@@ -24,11 +23,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
const detail = ref<DispatchDetail | null>(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref<ApiErrorDetails | null>(null)
const rejectionError = ref<ApiErrorDetails | null>(null)
const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
const visibleActions = computed(() => detailActions.filter(value => canDispatchAction(detail.value?.actions, value, access.can)))
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
const vehicleStatus = computed(() => {
const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus
return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—'
@@ -71,38 +66,42 @@ async function load() {
const appointmentReturn = computed(() => appointmentReturnTarget(route.query))
const backLabel = computed(() => appointmentReturn.value ? '返回预约日历' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表')
function back() { void router.push(appointmentReturn.value || inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return; if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
async function handleSaved() { rejectionError.value = null; await load() }
watch(dispatchNo, load, { immediate: true })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<OverseasDetailShell workbench class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="appointmentReturn || inquiryReturnTarget(route.query) ? backLabel : undefined" :back-label="backLabel" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" @saved="load" />
<section v-else-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
<el-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 :model-value="detail?.vehicleInfo.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" readonly /><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" readonly /></el-tab-pane>
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></el-tab-pane>
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" readonly :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" :pickup-review-state="detail.reviewState" />
<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 density-contained-tabs--flush">
<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 vehicle-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>
<LoadErrorState v-if="detail && !Array.isArray(detail.dispatchInfo.exceptions)" compact title="派送异常记录读取失败" message="排车详情返回的异常记录不完整,请重新加载。" :loading="loading" @retry="load" />
<section v-else-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>
<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 }, query: { customerId: delivery.customerId } })">{{ 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>
</el-tab-pane>
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><div class="workbench-table"><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></div></el-tab-pane>
</el-tabs>
</div>
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
</OverseasDetailShell>
</template>
<style scoped>
.detail-section { min-width: 0; padding: var(--admin-space-5); }
.dispatch-detail { min-width: 0; min-height: 0; }
.dispatch-detail .vehicle-fields { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.6fr) minmax(0, 1.2fr) minmax(0, 1.5fr) minmax(0, 0.8fr); }
.dispatch-detail .vehicle-fields > div { grid-template-columns: max-content minmax(0, 1fr); }
.linked-documents { grid-column: 1 / -1; }
.linked-documents dd { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); overflow-wrap: anywhere; }
@media (max-width: 960px) { .detail-fields { grid-template-columns: 1fr; } .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
@media (max-width: 960px) { .dispatch-detail .detail-fields { grid-template-columns: 1fr; } .dispatch-detail .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
</style>
@@ -1,12 +1,14 @@
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox, ElSelect, ElTag } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchesView from './DispatchesView.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { command, detail, listRow, page } from '../dispatchTestFixtures'
import { cancelTransport, getTransport } from '../api/transportTasks'
import { command, detail, listRow, page, reviews } from '../dispatchTestFixtures'
import type { DispatchPage } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
@@ -18,12 +20,94 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('@/modules/overseas-master-data', () => ({ TransportRouteDialog: { template: '<div />' }, routeLocation: (label: string) => ({ label }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchBolPdf: vi.fn() }))
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true } } }
vi.mock('../api/transportTasks', () => ({ transportLabels: { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }, cancelTransport: vi.fn(), getTransport: vi.fn() }))
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true, TransportExecutionPanel: true } } }
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
it('offers recovery of the original pending command independently of the filtered list rows', async () => {
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
it('offers pickup detail, transport vehicle maintenance and the shared review workflow', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(wrapper.text()).toContain('PC-OTHER 的确认发车结果尚待核对')
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆', '提交调度审核', '详情', '更多'])
row.vm.$emit('action', 'EDIT'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('editResources')).toBe(true)
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
wrapper.unmount()
})
it('confirms cancelling pickup and linked transfers using the transport version', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', recordVersion: 10, actions: { CANCEL: allowed } })]))
vi.mocked(getTransport).mockResolvedValue({ execution: { trip: { recordVersion: 4 } }, pickups: [{ transferNo: 'WT-1' }] } as Awaited<ReturnType<typeof getTransport>>)
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
row.vm.$emit('action', 'CANCEL'); await flushPromises()
expect(confirm.mock.calls[0]?.[0]).toContain('WT-1')
expect(cancelTransport).toHaveBeenCalledWith('PC/1', { recordVersion: 4, reason: '平台取消取货排车及对应转运单' })
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
expect(listDispatches).toHaveBeenCalledTimes(2)
wrapper.unmount(); confirm.mockRestore()
})
it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage, reviewState: 'APPROVED', actions: {} })]))
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.objectContaining({ key: 'transport', label }), expect.objectContaining({ key: 'detail', label: '详情' })])
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props()).toMatchObject({ dispatchNo: 'PC/1', pickupApproved: true, editResources: false })
expect(push).not.toHaveBeenCalled()
wrapper.unmount()
})
it('does not expose pickup work for a cancelled order or an operator without edit permission', async () => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage: 'DELIVERING', reviewState: 'APPROVED', status: 'CANCELLED', actions: {} })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['detail'])
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.findComponent({ name: 'TransportExecutionPanel' }).exists()).toBe(false)
wrapper.unmount()
can.mockReturnValue(false)
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage: 'DELIVERING', reviewState: 'APPROVED', actions: {} })]))
const readonly = mount(DispatchesView, options); await flushPromises()
expect(readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions').map((item: { key: string }) => item.key)).toEqual(['detail'])
readonly.unmount()
})
it('never offers a second arrangement or departure after loading completion', async () => {
const gate = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ stage: 'DELIVERING', actions: { ARRANGE_DELIVERY: gate, DEPART: gate } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(wrapper.findAll('[role="tab"]').some(tab => tab.text().startsWith('装车后'))).toBe(true)
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
const keys = (row.props('actions') as Array<{ key: string }>).map(action => action.key)
expect(keys).not.toContain('ARRANGE_DELIVERY'); expect(keys).not.toContain('DEPART')
row.vm.$emit('action', 'DEPART'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
wrapper.unmount()
})
it('keeps return-to-review on the loading list with its service gate and permission', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'LOADING', actions: { RETURN_TO_REVIEW: allowed } })]))
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: 'RETURN_TO_REVIEW' })]))
row.vm.$emit('action', 'RETURN_TO_REVIEW'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'RETURN_TO_REVIEW', 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').map((item: { key: string }) => item.key)).not.toContain('RETURN_TO_REVIEW')
deniedRow.vm.$emit('action', 'RETURN_TO_REVIEW'); await flushPromises()
expect(denied.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
denied.unmount()
})
it('offers recovery of the original pending command independently of the filtered list rows', async () => {
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(wrapper.text()).toContain('PC-OTHER 的确认装车完成结果尚待核对')
await wrapper.findAll('button').find(button => button.text() === '核对原请求')!.trigger('click')
const dialog = wrapper.get('dispatch-workflow-dialog-stub')
expect(dialog.attributes('dispatchno')).toBe('PC-OTHER'); expect(dialog.attributes('modelvalue')).toBe('true'); wrapper.unmount()
@@ -41,6 +125,37 @@ it('centralizes cargo changes in smart dispatch and never exposes row add/remove
expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('modelValue')).toBe(true)
wrapper.unmount()
})
it('keeps vehicle maintenance but hides standalone review submission and route ordering', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
const keys = (row.props('actions') as Array<{ key: string }>).map(item => item.key)
expect(keys).toContain('EDIT'); expect(keys).toContain('detail')
expect(keys).not.toContain('SUBMIT_REVIEW'); expect(keys).not.toContain('route-order')
row.vm.$emit('action', 'SUBMIT_REVIEW'); row.vm.$emit('action', 'route-order'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
row.vm.$emit('action', 'EDIT'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'EDIT', dispatchNo: 'PC/1' })
wrapper.unmount()
})
it.each([['DISPATCH_REVIEW', 1, '调度审核'], ['LOADING_REVIEW', 2, '理货员审核']] as const)('prioritizes the current %s queue action with a short label', async (action, index, label) => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click'); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.findAll('button').map(button => button.text())).toEqual(['审核', '详情'])
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual([action, 'detail'])
for (const key of ['EDIT', 'CANCEL', 'SUBMIT_REVIEW', 'route-order', 'bol']) {
row.vm.$emit('action', key); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
}
expect(getDispatchBolPdf).not.toHaveBeenCalled()
await row.get(`button[aria-label="${label}"]`).trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action, dispatchNo: 'PC/1' })
wrapper.unmount()
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('recovers %s only inside smart dispatch', async action => {
useDispatchCommandRecovery(() => identity.id).begin('PC-OTHER', action, { recordVersion: 0, idempotencyKey: 'cargo-key' }, action === 'REMOVE_CARGO' ? { itemId: '17' } : undefined)
const wrapper = mount(DispatchesView, options); await flushPromises()
@@ -49,7 +164,17 @@ it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('recovers %s only inside smart d
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
wrapper.unmount()
})
it('shows exactly five phases and uses server stage counts instead of current page length', async () => {
it.each([1, 2])('keeps only details in audit tab %s without its review permission', async index => {
can.mockReturnValue(false)
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click'); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['detail'])
wrapper.unmount()
})
it('shows five working phases and uses server stage counts instead of current page length', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
@@ -58,12 +183,133 @@ it('shows exactly five phases and uses server stage counts instead of current pa
await tabs[3]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
})
it('queries each review section on the server and includes only its legal review filter', async () => {
it.each([0, 1, 2])('preserves section %s and its query after clearing the active dispatch filter', async index => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const sectionValues = ['VEHICLE_INFO', 'DISPATCH_REVIEW', 'LOADING_REVIEW']
const sectionSelector = '[aria-label="装车前审核分区"]'
await wrapper.get(sectionSelector).findAll('button')[index]!.trigger('click'); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
toolbar.findAllComponents(ElSelect).find(select => select.props('placeholder') === '单据范围')!.vm.$emit('update:modelValue', '')
await flushPromises()
expect(wrapper.get(sectionSelector).findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
expect(wrapper.get(sectionSelector).findAll('button')[index]!.attributes('aria-selected')).toBe('true')
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[index] }))
const nextIndex = (index + 1) % 3
await wrapper.get(sectionSelector).findAll('button')[nextIndex]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
expect(wrapper.get(sectionSelector).findAll('button')).toHaveLength(3)
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises()
expect(wrapper.find(sectionSelector).exists()).toBe(false)
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
wrapper.unmount()
})
it.each([
['WAIT_DEPARTURE', 'BEFORE_LOADING', '装车前'],
['DELIVERING', 'DELIVERING', '派送中'],
['DELIVERED', 'DELIVERED', '派送完成'],
] as const)('queries %s through server pagination and selects the corresponding stage', async (vehicleStatus, stage, label) => {
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[3]!.trigger('click'); await flushPromises()
const table = wrapper.getComponent(DataTable)
table.vm.$emit('update:page', 3); await flushPromises()
const result = { ...page([listRow({ stage })]), total: 21, pages: 3 }
vi.mocked(listDispatches).mockResolvedValueOnce(result)
const toolbar = wrapper.getComponent(QueryToolbar)
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '车辆状态')!
select.vm.$emit('update:modelValue', vehicleStatus); select.vm.$emit('change', vehicleStatus); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, stage, status: 'ACTIVE' }))
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('vehicleStatus')
expect(wrapper.get('[aria-label="排车执行阶段"] [aria-selected="true"]').text()).toContain(label)
expect(table.props('total')).toBe(21)
expect(table.props('data')).toEqual(result.records)
expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(stage === 'BEFORE_LOADING')
wrapper.unmount()
})
it('keeps waiting vehicles in the current loading phase and synchronizes an active status when changing phases', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const tabs = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
await tabs[1]!.trigger('click'); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
toolbar.vm.$emit('search', { vehicleStatus: 'WAIT_DEPARTURE', status: 'ACTIVE' }); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING' }))
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
await tabs[3]!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue').vehicleStatus).toBe('DELIVERING')
await tabs[0]!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue').vehicleStatus).toBe('WAIT_DEPARTURE')
wrapper.unmount()
})
it('shows trip progress independently of master vehicle status and leaves cancelled vehicles without progress', async () => {
const records = [
listRow({ dispatchNo: 'PC-WAIT', stage: 'BEFORE_LOADING' }),
listRow({ dispatchNo: 'PC-LOADING', stage: 'LOADING' }),
listRow({ dispatchNo: 'PC-DELIVERING', stage: 'DELIVERING' }),
listRow({ dispatchNo: 'PC-DELIVERED', stage: 'DELIVERED' }),
listRow({ dispatchNo: 'PC-CANCELLED', status: 'CANCELLED' }),
]
for (const row of records) row.resources.vehicle!.vehicleStatus = 'MAINTENANCE'
vi.mocked(listDispatches).mockResolvedValueOnce(page(records))
const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
expect(toolbar.props('fields').map(field => field.key)).toEqual(['dispatchNo', 'vehiclePlate', 'vehicleStatus'])
expect(toolbar.props('fields').find(field => field.key === 'vehicleStatus')?.options?.map(option => option.label)).toEqual(['待发车', '配送中', '已送达'])
expect(wrapper.getComponent(DataTable).props('columns').some(column => column.key === 'vehicleStatus' && column.label === '车辆状态')).toBe(true)
expect(wrapper.findAllComponents(ElTag).map(tag => tag.text())).toEqual(['待发车', '待发车', '配送中', '已送达'])
expect(wrapper.text()).toContain('已取消')
expect(wrapper.text()).not.toContain('维修中')
wrapper.unmount()
})
it.each(['CANCELLED', 'ALL'])('retains advanced %s lookup and resets its hidden value', async status => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
toolbar.vm.$emit('search', { status: 'ACTIVE', vehicleStatus: 'WAIT_DEPARTURE' }); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单据范围')!
select.vm.$emit('update:modelValue', status); select.vm.$emit('change', status); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue').vehicleStatus).toBe('')
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status }))
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
await toolbar.findAll('button').find(button => button.text().startsWith('收起筛选'))!.trigger('click')
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(false)
expect(toolbar.text()).toContain(status === 'CANCELLED' ? '高级筛选 · 已取消' : '高级筛选 · 全部单据')
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue')).toMatchObject({ status: 'ACTIVE', vehicleStatus: '' })
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
wrapper.unmount()
})
it('returns to active dispatches when selecting vehicle progress from cancelled lookup', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
toolbar.vm.$emit('search', { status: 'CANCELLED', vehicleStatus: '' }); await flushPromises()
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '车辆状态')!
select.vm.$emit('update:modelValue', 'DELIVERING'); select.vm.$emit('change', 'DELIVERING'); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', stage: 'DELIVERING' }))
expect(toolbar.props('modelValue')).toMatchObject({ status: 'ACTIVE', vehicleStatus: 'DELIVERING' })
wrapper.unmount()
})
it('queries the current review queue without offering historical result filters', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[1]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'DISPATCH_REVIEW' }))
wrapper.getComponent(QueryToolbar).vm.$emit('search', { status: 'ACTIVE', reviewStatus: 'PENDING', dispatchNo: '', vehiclePlate: '' }); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ reviewStatus: 'PENDING' })); wrapper.unmount()
expect(wrapper.getComponent(QueryToolbar).props('fields').some((field: { key: string }) => field.key === 'reviewStatus')).toBe(false)
wrapper.getComponent(QueryToolbar).vm.$emit('search', { status: 'ACTIVE', reviewStatus: 'REJECTED', dispatchNo: '', vehiclePlate: '' }); await flushPromises()
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('reviewStatus'); wrapper.unmount()
})
it.each(['dispatchReview', 'loadingReview'] as const)('shows %s rejection in the initial customer-service section', async reviewKey => {
const row = listRow()
row.reviews[reviewKey] = { ...row.reviews[reviewKey], status: 'REJECTED', reason: '车辆证照需补齐' }
vi.mocked(listDispatches).mockResolvedValue(page([row]))
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'VEHICLE_INFO' }))
expect(wrapper.text()).toContain(`${reviewKey === 'dispatchReview' ? '调度审核' : '理货员审核'}退回:车辆证照需补齐`)
wrapper.unmount()
})
it('discards a late page response after switching phase', async () => {
let old!: (value: DispatchPage) => void
@@ -78,7 +324,7 @@ it('gates every mutation with both its literal service capability and independen
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>
expect(actions.map(row => row.key)).toContain('EDIT'); expect(actions.map(row => row.key)).not.toContain('DEPART')
can.mockReturnValue(false); wrapper.unmount()
const readonly = mount(DispatchesView, options); await flushPromises(); expect((readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>).map(row => row.key)).toEqual(['detail', 'bol']); readonly.unmount()
const readonly = mount(DispatchesView, options); await flushPromises(); expect((readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>).map(row => row.key)).toEqual(['detail']); readonly.unmount()
})
it('keeps server errors visible with the integrated query and retry context', async () => {
vi.mocked(listDispatches).mockRejectedValueOnce(new Error('service unavailable'))
@@ -92,27 +338,57 @@ it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispa
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType })]))
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.objectContaining({ key: 'detail', label: '运输操作' })])
expect(row.props('actions')).toEqual([...(taskType === 'LOCAL_TRUCK' ? [expect.objectContaining({ key: 'transport', label: '运输操作' })] : []), expect.objectContaining({ key: 'detail', label: '详情' })])
row.vm.$emit('action', 'detail'); await flushPromises()
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
})
it('keeps pickup workflow actions but hides route ordering and BOL even when editing is allowed', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, TransportExecutionPanel: true, AppDialog: { template: '<div><slot /></div>' } } } }); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
row.vm.$emit('action', 'EDIT'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
wrapper.unmount()
})
it('reads one BOL on demand and preserves popup failure handling', async () => {
const current = reviews()
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ reviews: { ...current, dispatchReview: { ...current.dispatchReview, status: 'APPROVED' } } })]))
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
})
it.each(['NOT_SUBMITTED', 'PENDING', 'REJECTED', 'APPROVED'] as const)('offers BOL only after current dispatch review is approved: %s', async status => {
const current = reviews()
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ reviews: { ...current, dispatchReview: { ...current.dispatchReview, status } } })]))
const popup = { close: vi.fn(), location: { href: '' } }
vi.stubGlobal('open', vi.fn().mockReturnValue(popup))
vi.mocked(getDispatchBolPdf).mockRejectedValue(new Error('print unavailable'))
const wrapper = mount(DispatchesView, options); await flushPromises()
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((actions.props('actions') as Array<{ key: string }>).some(item => item.key === 'bol')).toBe(status === 'APPROVED')
actions.vm.$emit('action', 'bol'); await flushPromises()
expect(getDispatchBolPdf).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
expect(window.open).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
if (status === 'APPROVED') expect(popup.close).toHaveBeenCalledOnce()
wrapper.unmount()
})
it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %s without retaining a stale submit button', async event => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })]))
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })]))
expect(row.props('actions')).toContainEqual(expect.objectContaining({ key: 'EDIT' }))
row.vm.$emit('action', 'EDIT'); await flushPromises()
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ recordVersion: 1, actions: { EDIT: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })]))
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'list-reject-request' })
else dialog.vm.$emit('saved')
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect(listDispatches).toHaveBeenCalledTimes(2)
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'EDIT' }))
if (event === 'rejected') {
expect(wrapper.text()).toContain('本轮审核已经提交')
expect(wrapper.text()).toContain('A0409')
@@ -142,3 +418,18 @@ it('clears an earlier input rejection after the corrected workflow succeeds', as
expect(wrapper.text()).not.toContain('请补齐本趟资料')
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()
})
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -19,12 +19,15 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, type DispatchAction, type DispatchListRow, type DispatchStage, type DispatchStatistics, type DispatchStatus, type ReviewStatus } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions } from '../dispatchPresentation'
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions, dispatchRejectionReason } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
import { transportLabels, type TransportType } from '../api/transportTasks'
import AppDialog from '@/shared/components/AppDialog.vue'
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import { cancelTransport, getTransport, transportLabels, type TransportType } from '../api/transportTasks'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchDocumentsDialog from '../components/DispatchDocumentsDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
@@ -33,33 +36,51 @@ const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | n
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
const rejectionError = ref<ApiErrorDetails | null>(null)
const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP')
const transportEditOpen = ref(false), transportEditNo = ref('')
const transportEditTitle = ref('维护取货车辆信息'), transportResourceMode = ref(false)
const activeTransport = computed(() => rows.value.find(row => row.dispatchNo === transportEditNo.value))
const cancellingPickup = ref('')
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
const documentsOpen = ref(false), documentsNo = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', status: 'ACTIVE', reviewStatus: '' })
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' })
const showAdvancedFilters = ref(false)
const vehicleStatuses = [
{ value: 'WAIT_DEPARTURE', label: '待发车', type: 'warning' },
{ value: 'DELIVERING', label: '配送中', type: 'primary' },
{ value: 'DELIVERED', label: '已送达', type: 'success' },
] as const
function vehicleStatusForStage(value: DispatchStage) {
if (value === 'BEFORE_LOADING' || value === 'LOADING' || value === 'AFTER_LOADING') return vehicleStatuses[0]
return vehicleStatuses.find(item => item.value === value)
}
const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }]
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && model.value.status === 'ACTIVE')
const fields = computed<QueryField[]>(() => [
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && (model.value.status || 'ACTIVE') === 'ACTIVE')
const availableFields = computed<QueryField[]>(() => [
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
{ key: 'status', label: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }] },
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'reviewStatus', label: '审核结果', type: 'select' as const, width: 140, options: Object.entries(reviewLabels).map(([value, label]) => ({ value, label })) }] : []),
{ key: 'vehicleStatus', label: '车辆状态', placeholder: '车辆状态', type: 'select', width: 140, options: [...vehicleStatuses], props: { 'aria-label': '车辆状态', onChange: (value: unknown) => { if (value) model.value.status = 'ACTIVE' } } },
{ key: 'status', label: '单据范围', placeholder: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }], props: { 'aria-label': '单据范围', onChange: (value: unknown) => { if (value && value !== 'ACTIVE') model.value.vehicleStatus = '' } } },
])
const fields = computed(() => availableFields.value.filter(field => showAdvancedFilters.value || field.key !== 'status'))
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : 220, fixed: 'left', slotName: 'actions' },
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
{ key: 'vehicleStatus', label: '车辆状态', minWidth: 110, slotName: 'vehicleStatus' },
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
...(showSections.value && section.value === 'VEHICLE_INFO' ? [{ key: 'returnReason', label: '退回原因', minWidth: 240, slotName: 'returnReason' }] : []),
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review' }] : []),
{ key: 'counts', label: '托数/箱数/已拣', minWidth: 160, slotName: 'counts' }, { key: 'planned', label: '计划发车', minWidth: 165, slotName: 'planned' },
{ key: 'departure', label: '实际发车', minWidth: 165, slotName: 'departure' }, { key: 'stage', label: '作业阶段', minWidth: 110, slotName: 'stage' },
...(stage.value === 'AFTER_LOADING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []),
...(stage.value === 'DELIVERING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []),
])
const preferenceKey = computed(() => `overseas-dispatches-${stage.value}-${section.value}`)
let sequence = 0
@@ -68,32 +89,93 @@ async function load() {
loading.value = true; loadError.value = null
try {
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ACTIVE') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
...(showSections.value ? { beforeLoadingSection: section.value, ...(section.value !== 'VEHICLE_INFO' && model.value.reviewStatus ? { reviewStatus: String(model.value.reviewStatus) as ReviewStatus } : {}) } : {}) })
...(showSections.value ? { beforeLoadingSection: section.value } : {}) })
if (current !== sequence) return
rows.value = result.records; total.value = result.total; statistics.value = result.statistics
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
finally { if (current === sequence) loading.value = false }
}
function search(value: QueryModel) { model.value = value; page.value = 1; void load() }
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; model.value.reviewStatus = ''; page.value = 1; void load() }
function switchSection(value: BeforeLoadingSection) { section.value = value; model.value.reviewStatus = ''; page.value = 1; void load() }
function search(value: QueryModel) {
model.value = { ...value, status: value.status || 'ACTIVE' }
const selected = vehicleStatuses.find(item => item.value === model.value.vehicleStatus)
if (model.value.status !== 'ACTIVE') model.value.vehicleStatus = ''
else if (selected && vehicleStatusForStage(stage.value)?.value !== selected.value) {
// 车辆运输进度与阶段联动,查询仍由服务端按阶段分页,避免交叉条件恒为空。
stage.value = selected.value === 'WAIT_DEPARTURE' ? 'BEFORE_LOADING' : selected.value
section.value = 'VEHICLE_INFO'
}
page.value = 1; void load()
}
function resetSearch() { search({ dispatchNo: '', vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' }) }
function switchStage(value: DispatchStage) {
stage.value = value
if (model.value.vehicleStatus) model.value.vehicleStatus = vehicleStatusForStage(value)?.value || ''
section.value = 'VEHICLE_INFO'; 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 } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(row.stage) }
// 仅当前调度审核通过的仓库派送排车可下载,退回或审核失效后重新收起入口。
function canDownloadBol(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.reviews?.dispatchReview?.status === 'APPROVED' }
function rowActions(row: DispatchListRow): RowActionItem[] {
if (row.taskType && row.taskType !== 'DELIVERY') return [{ key: 'detail', label: '运输操作', priority: 500 }]
return [{ key: 'detail', label: '详情', priority: 500 }, ...rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can)).map((value, index) => ({ key: value, label: dispatchActionLabels[value], priority: 400 - index * 10, tone: value === 'CANCEL' ? 'danger' as const : undefined })), ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
if (showSections.value && section.value !== 'VEHICLE_INFO') {
const review = section.value
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
}
if (row.taskType === 'LOCAL_TRUCK') return [...(row.status === 'ACTIVE' && (access.can(dispatchActionPermissions.EDIT) || access.can(dispatchActionPermissions.CANCEL) || access.can(dispatchActionPermissions.DEPART)) ? [{ key: 'transport', label: '运输操作', priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
const available = rowActionOrder.filter(value => (value !== 'SUBMIT_REVIEW' || row.taskType === 'PICKUP') && canDispatchAction(row.actions, value, access.can))
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
const execution = pickupExecutionLabel(row)
return [...(execution ? [{ key: 'transport', label: execution, priority: 650 }] : []), ...(canManageDocuments(row) ? [{ key: 'documents', label: '管理资料', priority: 700 }] : []), ...available.map((value, index) => ({
key: value,
label: dispatchActionLabels[value],
ariaLabel: dispatchActionLabels[value],
priority: value === primaryAction ? 600 : 400 - index * 10,
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? 'danger' as const : undefined,
loading: value === 'CANCEL' && cancellingPickup.value === row.dispatchNo,
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDownloadBol(row) ? [{ key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }] : [])]
}
/** 详情只读,照片和资源结束操作从列表进入对应运输面板。 */
function pickupExecutionLabel(row: DispatchListRow) {
if (row.taskType !== 'PICKUP' || row.status !== 'ACTIVE' || !access.can(dispatchActionPermissions.EDIT)) return ''
if (row.stage === 'DELIVERED') return '结束资源任务'
if (row.reviewState !== 'APPROVED') return ''
return row.stage === 'LOADING' ? '上传装货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : ''
}
function openTransport(row: DispatchListRow, resources = false) {
transportResourceMode.value = resources; transportEditNo.value = row.dispatchNo
transportEditTitle.value = resources ? '维护取货车辆信息' : row.taskType === 'LOCAL_TRUCK' ? '运输操作' : pickupExecutionLabel(row)
transportEditOpen.value = true
}
function handleAction(value: string, row: DispatchListRow) {
if (value === 'detail') return openDetail(row)
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
if (value === 'transport') { if (rowActions(row).some(item => item.key === value)) openTransport(row); return }
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
if (value === 'bol') return void openBol(row)
rejectionError.value = null
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
if (value === 'route-order' || value === 'SUBMIT_REVIEW' && row.taskType !== 'PICKUP') return
operation.value = undefined
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
const requested = value as DispatchAction
if (!canDispatchAction(row.actions, requested, access.can)) return
if (row.taskType === 'PICKUP' && requested === 'CANCEL') return void cancelPickup(row)
if (row.taskType === 'PICKUP' && requested === 'EDIT') { openTransport(row, true); return }
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
}
/** 取货取消会同时取消本车全部对应转运单,确认后按运输当前版本提交。 */
async function cancelPickup(row: DispatchListRow) {
if (cancellingPickup.value) return
cancellingPickup.value = row.dispatchNo; mutationError.value = null
try {
const value = await getTransport(row.dispatchNo)
try { await ElMessageBox.confirm(`取消排车后,对应转运单(${value.pickups.map(item => item.transferNo).join('、')})也将取消。确认继续?`, '取消取货排车及转运单', { type: 'warning', customClass: 'admin-density-confirm', confirmButtonText: '确认取消', cancelButtonText: '保留' }) } catch { return }
await cancelTransport(row.dispatchNo, { recordVersion: value.execution.trip.recordVersion, reason: '平台取消取货排车及对应转运单' })
ElMessage.success('排车及对应转运单已取消'); await load()
} catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消未完成,请刷新核对排车及转运单状态') }
finally { cancellingPickup.value = '' }
}
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
async function handleSaved() { rejectionError.value = null; await load() }
function recoverPending() {
@@ -103,7 +185,7 @@ function recoverPending() {
operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
}
async function openBol(row: DispatchListRow) {
if (bolLoadingNo.value) return
if (bolLoadingNo.value || !canDownloadBol(row)) return
const windowHandle = window.open('', '_blank')
if (!windowHandle) { ElMessage.warning('请允许本站打开BOL预览窗口'); return }
bolLoadingNo.value = row.dispatchNo
@@ -126,17 +208,21 @@ onBeforeUnmount(() => { sequence++ })
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div></template>
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template>
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="dispatch-query-filters" @click="showAdvancedFilters = !showAdvancedFilters">{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}{{ model.status === 'CANCELLED' ? ' · 已取消' : model.status === 'ALL' ? ' · 全部单据' : '' }}</el-button></template></QueryToolbar></div></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
<template #vehicleStatus="{ row }"><el-tag v-if="row.status === 'ACTIVE' && vehicleStatusForStage(row.stage)" :type="vehicleStatusForStage(row.stage)?.type">{{ vehicleStatusForStage(row.stage)?.label }}</el-tag><span v-else>—</span></template>
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
<template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template>
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
</DataTable></div>
</ListPageLayout></AdminPage>
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
<AppDialog v-model="transportEditOpen" class="admin-density-dialog" :title="transportEditTitle" width="1000px" :show-confirm="false" cancel-text="关闭"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :edit-resources="transportResourceMode" :pickup-approved="activeTransport?.reviewState === 'APPROVED'" :pickup-review-state="activeTransport?.reviewState" @saved="load" /></AppDialog>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
<DispatchDocumentsDialog v-model="documentsOpen" :dispatch-no="documentsNo" @saved="load" />
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</template>
@@ -19,7 +19,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: 'C-1' } }), useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null, AppointmentConfirmationCargo: { name: 'AppointmentConfirmationCargo', mounted() { this.$emit('ready', true) }, methods: { canLeave: async () => true }, template: '<div />' } }))
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', template: '<div />' } }))
vi.mock('../api/inboundOrders', () => ({ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
@@ -99,7 +99,7 @@ describe('inbound appointment native dialogs', () => {
expect(dialog.text()).toContain('appointment-popup-request')
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) expect(await (vi.mocked(hook).mock.calls[0]![0] as () => Promise<boolean>)()).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(updateFbaAppointment).mock.calls[1]).toEqual(original)
expect(dialog.props('modelValue')).toBe(false)
@@ -139,10 +139,11 @@ describe('inbound appointment native dialogs', () => {
})
describe('inbound plan and pallet move native dialogs', () => {
it('shows plan identity and version as text and saves the selected master warehouse through fullscreen', async () => {
it('shows plan identity and saves the selected master warehouse with the internal version through fullscreen', async () => {
const { dialog } = await openEditPlan()
expect(dialog.get('[aria-label="入库计划来源"]').findAll('input')).toHaveLength(0)
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('版本0')
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('入库单号')
expect(dialog.get('[aria-label="入库计划来源"]').text()).not.toContain('版本')
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
@@ -42,11 +42,11 @@ it('keeps the inbound appointment retry across close, reopen and route attempts'
const done=vi.fn();dialog.props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
expect(wrapper.find('[data-save]').exists()).toBe(true)
await open();await flushPromises();expect(getFbaAppointment).toHaveBeenCalledTimes(1)
for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect(await (vi.mocked(hook).mock.calls[0]![0] as () => Promise<boolean>)()).toBe(false)
await wrapper.get('[data-save]').trigger('click');await flushPromises()
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2,'AP-1',original)
expect(wrapper.find('[data-save]').exists()).toBe(false)
expect((vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => boolean)()).toBe(true)
expect(await (vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => Promise<boolean>)()).toBe(true)
wrapper.unmount()
})
it('renders MULTIPLE as multiple appointments and opens the exact source/customer instead of choosing an arbitrary appointment', async () => {
@@ -0,0 +1,46 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import InboundOrdersView from './InboundOrdersView.vue'
import { getFbaAppointment, listInboundOrders, updateFbaAppointment, type FbaAppointmentDto } from '../api/inboundOrders'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
vi.mock('../api/inboundOrders', async original => ({ ...await original<typeof import('../api/inboundOrders')>(), listInboundOrders: vi.fn(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
const appointment: FbaAppointmentDto = { id: '1', appointmentNo: 'YY-TEST', version: 2, originWarehouseId: 'warehouse-1', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ONT8', timezone: 'America/Los_Angeles', items: [{ id: 'item-1', customerId: 'customer-1', sourceKey: 'BOOKING:BK-TEST', orderId: '9007199254740993', inboundCode: 'IN-TEST', fbaWarehouseCode: 'ONT8', packageCount: 20 }] }
function render() {
return mount(InboundOrdersView, { global: { stubs: {
AdminPage: { template: '<main><slot /></main>' }, ListPageLayout: { template: '<div><slot /></div>' },
DataTable: { props: ['data'], template: '<div><slot name="actions" v-if="data.length" :row="data[0]" /></div>' },
AppDialog: { props: ['modelValue', 'showConfirm', 'confirmText'], template: '<section v-if="modelValue"><slot /><button v-if="showConfirm" @click="$emit(\'confirm\')">{{ confirmText }}</button></section>' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'sources', 'disabled'], template: '<div />' },
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
SmartDispatchDialog: true, QueryToolbar: true, OperationErrorToast: true, LoadErrorState: true, ApiErrorMeta: true,
} } })
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ lineId: 'line-1', planId: 'plan-1', planNo: 'PLAN-TEST', customerId: 'customer-1', orderId: '9007199254740993', overseasWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', packageCount: 20, status: 'COMPLETED', appointmentNo: appointment.appointmentNo, appointmentStatus: 'PENDING' }], total: 1, page: 1, size: 10 })
vi.mocked(getFbaAppointment).mockResolvedValue(appointment)
vi.mocked(updateFbaAppointment).mockResolvedValue({ ...appointment, version: 3, appointmentStatus: 'SUCCESS' })
})
it('uses all canonical appointment items and blocks formal submission until cargo is verified', async () => {
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises()
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
expect(fields.props('sources')).toEqual([{ sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', destinationKey: 'FBA:ONT8' }])
Object.assign(fields.props('model'), { appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-TEST', appointmentAt: '2026-10-21T10:00:00' })
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled()
fields.vm.$emit('confirmationReady', true); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(updateFbaAppointment).toHaveBeenCalledWith('YY-TEST', expect.objectContaining({ version: 2, details: expect.objectContaining({ appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-TEST' }) }))
wrapper.unmount()
})
it('keeps cargo supplementation disabled when viewing an appointment', async () => {
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '查看预约')!.trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('disabled')).toBe(true)
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -17,6 +17,7 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
import type { PlanningSourceLookup } from '@/modules/appointment-calendar'
import { createAppointmentForm, appointmentPayload, appointmentFormError } from '../appointmentForm'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
@@ -59,6 +60,9 @@ const appointmentFbaWarehouse = ref('—')
const appointmentVersion = ref(0)
const appointmentForm = reactive(createAppointmentForm())
const appointmentTimezone = ref<string | null>(null)
const appointmentSources = ref<PlanningSourceLookup[]>([])
const confirmationReady = ref(false)
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
const pendingAppointment = ref<{appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: ReturnType<typeof appointmentPayload>; idempotencyKey: string} | null>(null)
const appointmentViewOnly = ref(false)
const appointmentReplacementNos = ref<string[]>([])
@@ -132,13 +136,15 @@ function selectionError() {
return ''
}
function openAppointment() {
if (!access.can(permission.inboundOrderAppointment) || !canLeaveAppointment()) return
async function openAppointment() {
if (!access.can(permission.inboundOrderAppointment) || !await canLeaveAppointment()) return
appointmentViewOnly.value = false
appointmentReplacementNos.value = []
const error = selectionError()
if (error) { ElMessage.warning(error); return }
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
confirmationReady.value = false
appointmentSources.value = selectedRows.value.map(row => ({ orderId: row.orderId, customerId: row.customerId, destinationKey: `FBA:${row.fbaWarehouse}` }))
appointmentSelectedCount.value = selectedRows.value.length; appointmentFbaWarehouse.value = selectedRows.value[0]?.fbaWarehouse || '—'
appointmentVersion.value = 0
Object.assign(appointmentForm, createAppointmentForm()); appointmentTimezone.value = null; pendingAppointment.value = null
@@ -152,7 +158,7 @@ function openSmartDispatch() {
}
async function openAppointmentEdit(source: object, readOnly = false) {
if (!canLeaveAppointment()) return
if (!await canLeaveAppointment()) return
const row = source as InboundOrderListItem
if (row.appointmentStatus === '多批预约') { openMultipleAppointments(row); return }
if (!row.appointmentNo || row.appointmentNo === '—') return
@@ -166,24 +172,27 @@ async function openAppointmentEdit(source: object, readOnly = false) {
appointmentSelectedCount.value = detail.selectedCount
appointmentFbaWarehouse.value = detail.fbaWarehouseCode || row.fbaWarehouse
appointmentVersion.value = detail.version
confirmationReady.value = false
appointmentSources.value = detail.items.map(item => ({ sourceKey: item.sourceKey, customerId: item.customerId, destinationKey: item.destinationKey || `FBA:${item.fbaWarehouseCode}` }))
Object.assign(appointmentForm, createAppointmentForm(detail)); appointmentTimezone.value = detail.timezone || null; pendingAppointment.value = null
} catch (caught) {
appointmentOpen.value = false
mutationError.value = getApiErrorDetails(caught, 'FBA 预约详情加载失败,请重试')
} finally { appointmentLoading.value = false }
}
function openMultipleAppointments(row: InboundOrderListItem) {
if (!canLeaveAppointment() || !access.can('platform:appointment:list')) return
async function openMultipleAppointments(row: InboundOrderListItem) {
if (!await canLeaveAppointment() || !access.can('platform:appointment:list')) return
if (!row.orderId || !row.customerId || !row.fbaWarehouse || row.fbaWarehouse === '—') { ElMessage.warning('原货源或客户范围缺失,请重新读取入库单'); return }
void router.push({ path: '/appointment-calendar', query: { orderId: row.orderId, customerId: row.customerId, destinationKey: `FBA:${row.fbaWarehouse}` } })
}
function canLeaveAppointment() {
async function canLeaveAppointment() {
if (!appointmentOpen.value) return true
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
return true
return await appointmentFields.value?.canLeave?.() ?? true
}
function closeAppointment() { if (canLeaveAppointment()) appointmentOpen.value = false }
function beforeCloseAppointment(done: () => void) { if (canLeaveAppointment()) done() }
async function closeAppointment() { if (await canLeaveAppointment()) appointmentOpen.value = false }
async function beforeCloseAppointment(done: () => void) { if (await canLeaveAppointment()) { appointmentOpen.value = false; done() } }
onBeforeRouteLeave(canLeaveAppointment)
onBeforeRouteUpdate(canLeaveAppointment)
async function submitAppointment() {
@@ -191,6 +200,9 @@ async function submitAppointment() {
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
const validation = appointmentFormError(appointmentForm)
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
if (!pendingAppointment.value && appointmentForm.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料,再保存预约安排'); return }
if (!pendingAppointment.value && appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
if (saving.value || appointmentReadOnly.value) return
saving.value = true; mutationError.value = null
try {
pendingAppointment.value ||= {appointmentNo: appointmentMode.value === 'edit' ? editingAppointmentNo.value : null, version: appointmentVersion.value, originWarehouseId: selectedRows.value[0]?.originWarehouseId || '', fbaWarehouseCode: selectedRows.value[0]?.fbaWarehouse || '', lineIds: selectedRows.value.map(row => row.lineId), details: appointmentPayload(appointmentForm), idempotencyKey: crypto.randomUUID()}
@@ -247,12 +259,12 @@ onMounted(() => { void load() })
<section class="density-dialog-section" data-tone="summary" aria-label="预约范围核对"><h3 class="density-dialog-section__title">预约范围</h3><table class="density-table"><tbody><tr><th scope="row">已选择</th><td>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
<el-alert v-if="appointmentReplacementNos.length" :title="`原批次已被替代,当前只读。替代预约:${appointmentReplacementNos.join('、')}`" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="mutationError" compact title="预约保存失败" :message="mutationError.message" :loading="saving" :show-retry="!appointmentReadOnly" :retry-text="pendingAppointment ? '重试本次保存' : '重新提交'" @retry="submitAppointment"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<FbaAppointmentFields continuous :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
<FbaAppointmentFields continuous v-if="appointmentOpen && !appointmentLoading" ref="appointmentFields" :sources="appointmentSources" @confirmation-ready="confirmationReady = $event" :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td><th scope="row">版本</th><td>{{ planEditForm.version }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="work" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
</div></AppDialog>
@@ -74,21 +74,12 @@ describe('inventory customer-name snapshots', () => {
expect(wrapper.text()).not.toContain(balance.id)
})
it('keeps the formal customer and carton list when piece projection fails', async () => {
vi.mocked(getInventoryPieceProjection).mockRejectedValueOnce(new Error('projection unavailable'))
it('keeps carton totals without the removed SKU and piece columns or their projection request', async () => {
const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('可用件数及箱内SKU加载失败')
expect(wrapper.text()).not.toContain('库存数据加载失败')
})
it('renders every mixed-carton SKU and real piece count alongside carton totals', async () => {
vi.mocked(getInventoryPieceProjection).mockResolvedValueOnce({ availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', groups: [{ customerId, warehouseId: '200', destinationCode: null, anchorSkuCode: 'SKU-1', availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', skuContents: [{ skuCode: 'SKU-1', quantity: '5' }, { skuCode: 'SKU-SECOND', quantity: '7' }], cartons: [] }] })
const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('SKU-1 × 5 件;SKU-SECOND × 7 件')
expect(wrapper.text()).not.toContain('可用件数')
expect(getInventoryPieceProjection).not.toHaveBeenCalled()
expect(wrapper.find('[aria-label="可用库存件箱托汇总"]').exists()).toBe(false)
expect(wrapper.get('[aria-label="库存汇总"]').text()).toContain('可用库存(箱)10')
})
@@ -13,7 +13,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceGroup } from '../api/inventory'
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { ElMessage } from 'element-plus'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
@@ -26,8 +26,6 @@ const access = useAccessPreviewStore()
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
const rows = ref<InventoryListItem[]>([])
const summary = ref<InventoryDashboardDto | null>(null)
const rowPieces = ref<Record<string, InventoryPieceGroup | undefined>>({})
const pieceError = ref('')
const loading = ref(false)
const contextLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -53,8 +51,6 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'owner', prop: 'owner', label: '客户', minWidth: 160 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'sku', prop: 'sku', label: '箱内SKU / 可用件数', minWidth: 220, slotName: 'skuContents', singleLine: false, className: 'inventory-sku-contents' },
{ key: 'availablePieces', label: '可用件数', minWidth: 130, slotName: 'availablePieces' },
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'location', prop: 'location', label: '库位', minWidth: 140, defaultVisible: false },
{ key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
@@ -79,17 +75,13 @@ function query() {
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true; loadError.value = null; rowPieces.value = {}; pieceError.value = ''
loading.value = true; loadError.value = null
try {
const currentQuery = query()
const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(currentQuery), getInventorySummary(currentQuery)])
rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult
try {
const pieceResult = await getInventoryPieceProjection(currentQuery)
rowPieces.value = Object.fromEntries(pageResult.records.map(dto => [String(dto.id), pieceResult.groups.find(group => String(group.customerId) === String(dto.customerId) && String(group.warehouseId) === String(dto.overseasWarehouseId) && (group.destinationCode ?? '') === (dto.destinationWarehouseCode ?? '') && (group.anchorSkuCode ?? '') === (dto.skuCode ?? ''))]))
} catch { pieceError.value = '可用件数及箱内SKU加载失败,请重新查询;箱数列表仍可查看。' }
}
catch (error) { rows.value = []; total.value = 0; summary.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
finally { loading.value = false }
}
@@ -107,20 +99,17 @@ onMounted(() => { void loadWarehouses(); void load() })
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" empty-text="暂无库存记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>库存管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><p v-if="pieceError" role="alert">{{ pieceError }}</p></template>
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><section class="inventory-metrics workbench-panel is-flush" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}(箱)</span><strong>{{ metric[1] }}</strong></div></section></template>
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
<template #skuContents="{ row }"><span v-if="rowPieces[row.id]?.skuContents.length">{{ rowPieces[row.id]!.skuContents.map(sku => `${sku.skuCode} × ${sku.quantity} 件`).join(';') }}<template v-if="Number(rowPieces[row.id]!.unknownCartonCount) > 0">;另有 {{ rowPieces[row.id]!.unknownCartonCount }} 箱内容未知</template></span><span v-else>记账SKU:{{ row.sku }};箱内件数未记录</span></template>
<template #availablePieces="{ row }">{{ rowPieces[row.id]?.availablePieceCount ?? (row.available === 0 ? 0 : '未记录') }}</template>
<template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></div></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.admin-density :deep(.inventory-sku-contents .cell) { white-space: normal; overflow-wrap: anywhere; }
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; }
.inventory-metric:last-child { border-right: 0; }
.inventory-metric span { color: #64748b; font-size: var(--admin-font-caption); }
@@ -262,7 +262,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
const dialog = source('../components/SmartDispatchDialog.vue')
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-actions', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
// A filtered candidate must still submit its complete server-owned selection group.
expect(dialog).toContain('candidatePage.assertCurrent(cargo)')
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
@@ -383,7 +383,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
])
expect(columnKeys('InventoryView.vue')).toEqual([
'actions', 'owner', 'warehouse', 'sku', 'availablePieces', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
'actions', 'owner', 'warehouse', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
'inTransit', 'updatedAt',
])
expect(columnKeys('RelabelOrdersView.vue')).toEqual([
@@ -71,3 +71,41 @@ it.each(['warehouse', 'page', 'version'])('rejects a mismatched %s response inst
await state.load({ warehouseId: '321' })
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('尚未读取完成')
}
})
it('allows mixed-warehouse pages only for the explicitly regional workspace and pins restored drafts to their warehouse', async () => {
const error = ref<ApiErrorDetails | null>(null)
const state = useDispatchCandidates(error, listDispatchCandidates, true)
const second = candidate({ sourceKey: 'ny-source', warehouseId: '654' })
vi.mocked(listDispatchCandidates).mockResolvedValueOnce(response({ candidates: [candidate(), second] }))
await state.load({ warehouseTab: 'ALL' })
expect(state.candidates.value).toHaveLength(2)
expect(() => state.assertCurrent([second])).not.toThrow()
vi.mocked(listDispatchCandidates).mockResolvedValueOnce(response({ candidates: [candidate()] }))
await state.load({ warehouseId: '321', warehouseTab: 'ALL', draftToken: 'server-token' })
expect(() => state.assertCurrent([second])).toThrow('组成已变化')
const scoped = setup()
await scoped.load({ warehouseTab: 'ALL' })
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
})

Some files were not shown because too many files have changed in this diff Show More