Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e60248d2 | ||
|
|
f8c9334e98 | ||
|
|
67acd02ea5 | ||
|
|
c510cc4e2e | ||
|
|
e2f99b4f4b | ||
|
|
8356e98ce3 | ||
|
|
6ab3bbe289 | ||
|
|
a4c7f8edd5 | ||
|
|
a9c0285e22 |
@@ -120,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) }
|
||||
})(),
|
||||
])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -19,6 +19,7 @@ const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(fo
|
||||
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 }
|
||||
@@ -60,8 +61,7 @@ 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 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>
|
||||
|
||||
@@ -63,6 +63,16 @@
|
||||
|
||||
续批验证: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 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
@@ -97,9 +103,13 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
|
||||
<template #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' }
|
||||
|
||||
@@ -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,24 +35,31 @@
|
||||
|
||||
## 履约硬边界
|
||||
|
||||
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。四个作业阶段及审核分区、逐动作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-装车照片与资料入口) |
|
||||
|
||||
## 验证
|
||||
|
||||
- 排车调度/理货审核分区仅显示审核、详情;审核弹框的车辆图片、司机照片和驾驶证照片通过既有本车附件接口上传,随 `ReviewCommand.attachments` 保存,不调用资源编辑命令。已有资源与审核附件只读预览,详情合并展示;当前审核权限与服务端 Gate 控制上传及提交,列表查看权限控制读取。
|
||||
|
||||
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
|
||||
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
|
||||
- 装车完成通过`completeDispatchLoading`一次提交,结果仅接受`DELIVERING`;原请求恢复不重建单据。未知费用保持未确认,由既有后台费用管理维护,不按金额推断覆盖或重复记费。已有排车加货和移货仅从`SmartDispatchDialog.vue`内的`SmartDispatchExistingCargo.vue`进入;移货必须使用`GET items`返回的真实dispatchItemId及逐项remove Gate;路线仅重排`GET route`返回的既有全部目的地,包含外接点,不能新增或猜测目的地。附件沿`DispatchEvidenceFiles.vue`的本车鉴权上传/读取,上传与主单引用保存分开。
|
||||
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取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 和人工截图验收。
|
||||
|
||||
- 尾程订单、库存余额与进销存记录导出统一使用 `../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,17 @@
|
||||
# 海外仓原型核对矩阵
|
||||
|
||||
## 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` 确认已有排车查询散落、三栏错位,上轮不构成视觉验收通过。
|
||||
@@ -13,6 +25,10 @@
|
||||
|
||||
本轮追加:用户要求打开时默认第一个起运仓并删除标签;无已有选择、草稿或订单指定仓时采用目录首项并读取候选,空目录保持空值。已有排车按新截图 `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 智能排车截图复核与样式纠偏
|
||||
@@ -27,15 +43,15 @@
|
||||
|
||||
本轮定向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 装车完成直接进入派送中
|
||||
## 2026-10-21 装车后资料与自动派送
|
||||
|
||||
依据用户本轮明确确认“PDA装完货后车就开走”,覆盖早期截图中的装车后等待阶段;沿用原装车完成居中弹框及实载字段,不新增操作入口。
|
||||
依据用户最新提供的排车五阶段截图和“管理资料/装车资料”五张照片截图,替代同日此前装完即派送的确认。规则以排车正式契约为准。
|
||||
|
||||
| 页面/操作 | 当前调整 | 验收 |
|
||||
| 页面/操作 | 截图承载与当前实现 | 验证 |
|
||||
|---|---|---|
|
||||
| 排车列表/阶段与动作 | 显示装车前、装车中、派送中、派送完成;移除人工安排派送、确认发车,路线在派送中可读 | 定向回归通过;1920×1080人工截图待确认 |
|
||||
| 排车详情/装车完成 | 继续使用原装车完成弹框;真实拣货与实载条件保留,单次成功直接进入派送中 | 交互、原请求恢复与阶段校验通过;人工截图待确认 |
|
||||
| 装车完成/异常恢复 | 仅接受最终DELIVERING结果;读详情失败只刷新,不重复创建单据 | 定向回归及类型检查通过 |
|
||||
| 排车列表/装车后页签 | 在装车中与派送中之间显示;计数与分页按服务端AFTER_LOADING查询,行内管理资料进入原居中弹框 | 定向自动检查;待1920×1080、100%人工截图确认 |
|
||||
| 管理资料/装车资料 | 原居中弹框内两列共五个装车照片框;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
|
||||
| 装车完成/原请求恢复 | 装车结果为AFTER_LOADING;已确认结果重读失败不重复POST,当前派送状态另读 | 定向恢复检查 |
|
||||
|
||||
## 2026-10-21 智能排车恢复10月18日布局
|
||||
|
||||
@@ -405,3 +421,11 @@
|
||||
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
|
||||
|
||||
本轮自动验证: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)
|
||||
|
||||
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;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
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
|
||||
import type { MarkDispatchException, DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
|
||||
import { requireVersion, requireBatchVersions, type AddCargo, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
|
||||
import { 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'
|
||||
@@ -37,6 +37,7 @@ export const submitDispatchReview = (no: string, body: VersionCommand & { remark
|
||||
export const reviewDispatch = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/dispatch-review`, versioned(body))
|
||||
export const 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 getDispatchBolPdf = (no: string) => http.get<Blob, Blob>(`${path(no)}/prints/bol`, { responseType: 'blob' })
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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')
|
||||
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,78 @@
|
||||
<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">
|
||||
<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'] : ['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,38 @@ 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('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(['预览'])
|
||||
|
||||
@@ -16,17 +16,30 @@ const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids
|
||||
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 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(() => props.kind === 'DRIVER_PHOTO' ? types.slice(1) : types)
|
||||
const uploadFormats = computed(() => props.kind === 'DRIVER_PHOTO' ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
||||
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 = [...allIds.value]
|
||||
closePreview(); metadata.value = {}; error.value = null
|
||||
@@ -55,6 +68,7 @@ function remove(id: string) { if (!props.readonly && !props.disabled && !uploadi
|
||||
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 }
|
||||
@@ -70,7 +84,7 @@ async function preview(id: string) {
|
||||
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 }
|
||||
}
|
||||
@@ -83,8 +97,11 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
<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 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="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||||
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
|
||||
<AppDialog 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>
|
||||
@@ -94,5 +111,6 @@ 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-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,73 @@
|
||||
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 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,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 { 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, '装车照片上传或保存失败,请核对原请求') }
|
||||
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>
|
||||
@@ -11,8 +11,8 @@ const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||
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(): 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()
|
||||
@@ -197,3 +197,21 @@ 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()
|
||||
})
|
||||
|
||||
@@ -10,10 +10,10 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import type { CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import type { 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, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
@@ -38,15 +38,16 @@ const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null,
|
||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||
const reviewAttachments = ref(emptyReviewAttachments())
|
||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
|
||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
|
||||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
|
||||
async function load() {
|
||||
@@ -62,6 +63,7 @@ async function load() {
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
reviewAttachments.value = emptyReviewAttachments()
|
||||
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()
|
||||
@@ -98,6 +100,9 @@ function payload(): object {
|
||||
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
|
||||
}
|
||||
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('驳回时必须填写原因')
|
||||
@@ -117,6 +122,7 @@ 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)
|
||||
@@ -141,7 +147,7 @@ async function submit() {
|
||||
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
|
||||
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
|
||||
if (!result) return
|
||||
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? '装车完成,已进入派送中' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
|
||||
ElMessage.success(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 ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
|
||||
@@ -179,7 +185,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
||||
</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'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit">
|
||||
<div class="density-dialog-sections">
|
||||
<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 />
|
||||
@@ -192,6 +198,12 @@ 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 === 'MARK_EXCEPTION'">
|
||||
<el-form-item label="异常类型" required><el-select v-model="exception.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in dispatchExceptionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="退货去向"><el-select v-model="exception.returnDirection" clearable placeholder="请选择退货去向"><el-option v-for="(label, value) in dispatchReturnDirectionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
|
||||
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'EDIT'">
|
||||
<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>
|
||||
@@ -218,7 +230,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" v-model="reviewAttachments[field.key]" :reference-ids="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" save-hint="上传后提交审核,附件与本次审核结果一起保存。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy[field.kind] = $event" />
|
||||
</template>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="currentAction !== 'MARK_EXCEPTION'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -77,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()
|
||||
@@ -106,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
|
||||
|
||||
@@ -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,7 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
<template>
|
||||
<div class="dispatch-cargo-board" aria-label="智能排车三栏选货">
|
||||
<section class="cargo-board-panel cargo-board-orders" aria-label="待排订单">
|
||||
<header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span></header>
|
||||
<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">
|
||||
@@ -189,24 +190,22 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
<thead><tr><th>托盘号</th><th class="cargo-board-pallet-count">箱数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-if="!selectedOrder"><td colspan="2" class="cargo-board-empty">请选择左侧订单查看托盘明细</td></tr>
|
||||
<tr v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-assigned': assignedVehicle(source), 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
|
||||
<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>
|
||||
<strong>{{ palletLabel(source) }}</strong>
|
||||
<div class="cargo-board-muted">{{ destinationLabel(source) }}</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header>
|
||||
<header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击托盘号。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected || 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">
|
||||
@@ -216,11 +215,11 @@ 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>
|
||||
@@ -252,7 +251,10 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
.cargo-board-table tr.is-selected td { background: var(--admin-action-primary-hover); }
|
||||
.cargo-board-business-no { font-weight: 600; }
|
||||
.cargo-board-pagination { display: flex; align-items: center; padding-top: var(--admin-space-5); overflow-x: auto; }
|
||||
.cargo-board-order-caption { margin-left: auto; max-width: 65%; overflow-wrap: anywhere; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.cargo-board-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: min(500px, 48vh); overflow-y: auto; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); }
|
||||
@@ -261,7 +263,8 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
.cargo-board-pallet-table td { vertical-align: top; overflow-wrap: anywhere; }
|
||||
.cargo-board-pallet-table .cargo-board-empty { text-align: center; padding: var(--admin-space-5); }
|
||||
.cargo-board-pallet.is-draggable { cursor: grab; }
|
||||
.cargo-board-pallet.is-assigned td { background: var(--admin-label-bg); }
|
||||
.cargo-board-pallet-move { max-width: 100%; height: auto; white-space: normal; text-align: left; justify-content: flex-start; }
|
||||
.cargo-board-pallet.is-draggable { user-select: none; }
|
||||
.cargo-board-group-details { margin-top: var(--admin-space-2); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
.cargo-board-group-details summary { cursor: pointer; }
|
||||
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
|
||||
|
||||
@@ -2,6 +2,7 @@ import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { 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,31 +47,54 @@ beforeEach(() => {
|
||||
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
|
||||
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
|
||||
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
async function selectWarehouse(dialog: VueWrapper) { const select = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!; select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
|
||||
async function selectWarehouse(dialog: VueWrapper) {
|
||||
const select = 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('selects the first warehouse and loads its cargo immediately without creating a draft', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }])
|
||||
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()
|
||||
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
|
||||
expect(picker.props('modelValue')).toBe('321')
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '321', page: 1 }))
|
||||
expect(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('keeps the warehouse empty and skips cargo loading when no warehouse is available', async () => {
|
||||
it('reads regional candidates independently of the optional empty-dispatch warehouse directory', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
|
||||
const { dialog } = await render()
|
||||
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(view(dialog).text()).toContain('请先选择起运仓')
|
||||
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' }))
|
||||
})
|
||||
@@ -80,7 +104,7 @@ describe('new dispatch native layout', () => {
|
||||
const content = view(dialog)
|
||||
const searchPanel = content.get('[aria-label="候选货物查询"]')
|
||||
expect(searchPanel.find('.el-form-item__label').exists()).toBe(false)
|
||||
expect(searchPanel.find('[aria-label="起运仓"]').exists()).toBe(true)
|
||||
expect(searchPanel.find('[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)
|
||||
@@ -107,6 +131,34 @@ describe('new dispatch native layout', () => {
|
||||
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: [] }] }
|
||||
@@ -124,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)
|
||||
@@ -132,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: [] }
|
||||
@@ -140,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()
|
||||
@@ -171,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('空单暂不能核平、装车或发车')
|
||||
@@ -195,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)
|
||||
@@ -213,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()
|
||||
@@ -245,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')
|
||||
@@ -260,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 = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
|
||||
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)
|
||||
|
||||
@@ -167,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,12 +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: '' })
|
||||
@@ -49,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))
|
||||
@@ -67,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('所选车辆已变化,请重新选择')
|
||||
@@ -81,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 {
|
||||
@@ -90,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() }
|
||||
|
||||
@@ -112,14 +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 }
|
||||
}
|
||||
@@ -132,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))
|
||||
@@ -147,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) {
|
||||
@@ -160,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 => {
|
||||
@@ -211,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() {
|
||||
@@ -234,7 +261,7 @@ async function submit() {
|
||||
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-density admin-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>
|
||||
@@ -243,28 +270,29 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
<template v-else-if="workspace === 'create'">
|
||||
<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>
|
||||
<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">
|
||||
<el-select class="dispatch-warehouse-picker" aria-label="起运仓" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select>
|
||||
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button><el-button :disabled="frozen || loading || !warehouseId" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
|
||||
<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>
|
||||
<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>
|
||||
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击托盘号。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
|
||||
</div>
|
||||
</section>
|
||||
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
|
||||
<template v-if="mode === 'cargo'">
|
||||
<div class="warehouse-tabs density-actions" role="tablist" aria-label="候选仓库展示"><el-button v-for="tab in ([{ value: 'ALL', label: '全美' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :class="{ active: warehouseTab === tab.value }" :type="warehouseTab === tab.value ? 'primary' : undefined" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
|
||||
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
|
||||
<SmartDispatchCargoBoard :aria-busy="candidatePage.loading.value || saving || preparing" :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="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>
|
||||
@@ -284,6 +312,20 @@ 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-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
|
||||
|
||||
@@ -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() {} })
|
||||
@@ -91,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 () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { listVehicles, listDrivers, pickupStatusLabels, type VehicleDetail, type
|
||||
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
|
||||
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const props = defineProps<{ dispatchNo: string; pickupApproved?: 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)
|
||||
@@ -109,13 +109,13 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核
|
||||
<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.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId || (detail.taskType === 'PICKUP' && !props.pickupApproved)" @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>
|
||||
<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>
|
||||
<p v-if="detail.taskType === 'PICKUP'">{{ props.pickupApproved ? '两级审核已通过,可发车取货。' : '请维护车辆信息并提交调度审核,经调度和理货员审核通过后发车取货。' }}送仓后由仓库独立清点收货,取货数量保留为运输事实。</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>
|
||||
|
||||
@@ -3,11 +3,11 @@ import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useDispatchCommandRecovery } from './dispatchCommandRecovery'
|
||||
import { command } from './dispatchTestFixtures'
|
||||
beforeEach(() => sessionStorage.clear())
|
||||
it('does not accept an intermediate loading stage as completion of the original command', async () => {
|
||||
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: 'AFTER_LOADING' }), read, vi.fn())).rejects.toThrow('派送中')
|
||||
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')
|
||||
@@ -27,7 +27,7 @@ it('keeps a proven result across GET failure and does not send the command a sec
|
||||
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
|
||||
const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn()
|
||||
await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed')
|
||||
expect(recovery.pending.value?.result?.stage).toBe('DELIVERING')
|
||||
expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING')
|
||||
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!).result).toEqual(command('LOADING_COMPLETE'))
|
||||
const reopened = useDispatchCommandRecovery(() => owner)
|
||||
expect(reopened.pending.value?.result).toEqual(command('LOADING_COMPLETE'))
|
||||
@@ -143,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()
|
||||
})
|
||||
|
||||
@@ -15,7 +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 === 'LOADING_COMPLETE' && result.stage !== 'DELIVERING') 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
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 }
|
||||
@@ -31,7 +31,11 @@ export interface OutboundReference { customerId: string; customerName: string; o
|
||||
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
||||
export interface 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 }
|
||||
@@ -53,7 +57,7 @@ export interface DispatchItems { dispatchNo: string; recordVersion: Version; sta
|
||||
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
|
||||
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
|
||||
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
|
||||
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
|
||||
export type DispatchEvidenceKind = '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[] }
|
||||
@@ -81,6 +85,7 @@ 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: '派送完成' },
|
||||
]
|
||||
export const stageLabel = (stage: DispatchStage) => dispatchStages.find(row => row.value === stage)?.label || '—'
|
||||
@@ -109,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('选货组实托事实缺失或重复,请重新读取候选')
|
||||
|
||||
@@ -16,12 +16,12 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
|
||||
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',
|
||||
@@ -36,3 +36,6 @@ export function dispatchRejectionReason(reviews: Reviews | undefined): string {
|
||||
.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,12 +8,12 @@ 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 }] } } }
|
||||
export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
|
||||
const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'DELIVERING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
|
||||
const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
|
||||
return { ...base, result: action === 'DEPART' ? { departedAt: '2026-10-19T10:00:00Z', shippedPackages: 2, outbounds: [], deliveries: [] } : action === 'LOADING_COMPLETE' ? { loadingCompletionHash: 'load-hash', loadingCompletedAt: '2026-10-19T10:00:00Z', confirmedLoad: { manifestHash: 'manifest', pallets: [], operatorId: 'operator', confirmedAt: '2026-10-19T10:00:00Z' } } : { dispatchNo: no, changedItemIds: [] } } as CommandResult
|
||||
}
|
||||
export function loadingPreview(): LoadingPreview { return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', manifestHash: 'manifest', pickingReconciled: true, resources: resources(), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 2, palletSpaces: 1, weightKg: null, volumeCbm: null, deliveryIndex: 1, missingFields: ['weightKg', 'volumeCbm'], evidence: [{ field: 'weightKg', sourceType: 'ORDER_SNAPSHOT_ALLOCATION', sourceBusinessNo: 'IN-1', observedAt: null, value: 100, actual: false }] }], confirmedLoad: null, loadingComplete: { allowed: true, blockCode: null, blockReason: null }, missingFields: ['weightKg', 'volumeCbm'] } }
|
||||
|
||||
@@ -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,6 +19,28 @@ 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.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: [] }
|
||||
@@ -61,6 +83,31 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
|
||||
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('keeps return-to-review in the footer across tabs and opens the existing workflow', 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()
|
||||
const footer = wrapper.get('footer[aria-label="排车操作"]')
|
||||
expect(footer.isVisible()).toBe(true)
|
||||
expect(wrapper.get('.el-tabs').element.compareDocumentPosition(footer.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
await footer.get('button').trigger('click')
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'RETURN_TO_REVIEW' })
|
||||
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()
|
||||
@@ -87,6 +134,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()
|
||||
@@ -203,3 +252,16 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows saved dispatch exceptions and their own images in the existing dispatch tab', async () => {
|
||||
const value = detail({ stage: 'DELIVERING' })
|
||||
value.dispatchInfo.exceptions = [{ exceptionType: 'REFUSED', returnDirection: 'OVERSEAS_WAREHOUSE', description: '现场拒收待处理', imageFileIds: ['901'], occurredAt: '2026-10-21T10:00:00Z' }]
|
||||
vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
vi.mocked(getDispatchEvidence).mockResolvedValue({ fileId: '901', fileName: '异常现场.png', contentType: 'image/png', sizeBytes: 3 })
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('拒收'); expect(wrapper.text()).toContain('退回海外仓'); expect(wrapper.text()).toContain('现场拒收待处理')
|
||||
expect(getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'EXCEPTION', '901')
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4); expect(wrapper.findAll('input[type="file"]')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||
import { stageLabel } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||||
@@ -29,7 +29,7 @@ const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
|
||||
const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
|
||||
const visibleActions = computed(() => detailActions.filter(value => canDispatchAction(detail.value?.actions, value, access.can)))
|
||||
const visibleActions = computed(() => detailActions.filter(value => !(detail.value?.taskType === 'PICKUP' && value === 'EDIT') && canDispatchAction(detail.value?.actions, value, access.can)))
|
||||
const vehicleStatus = computed(() => {
|
||||
const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus
|
||||
return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—'
|
||||
@@ -84,18 +84,29 @@ onBeforeUnmount(() => { generation++ })
|
||||
<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>
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" @saved="load" />
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></el-tab-pane>
|
||||
<el-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>
|
||||
<footer v-if="detail && visibleActions.length" class="dispatch-actions density-actions" aria-label="排车操作">
|
||||
<el-button v-for="value in visibleActions" :key="value" :type="value === 'CANCEL' ? 'danger' : undefined" plain @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button>
|
||||
<el-button v-if="(!detail.taskType || detail.taskType === 'DELIVERY') && canDispatchAction(detail.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button>
|
||||
</footer>
|
||||
</div>
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
</OverseasDetailShell>
|
||||
@@ -104,7 +115,10 @@ onBeforeUnmount(() => { generation++ })
|
||||
<style scoped>
|
||||
.detail-section { min-width: 0; padding: var(--admin-space-5); }
|
||||
.dispatch-detail { min-width: 0; min-height: 0; }
|
||||
.dispatch-actions { justify-content: flex-end; flex-wrap: wrap; padding: var(--admin-space-5); }
|
||||
.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,9 +1,10 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { 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'
|
||||
@@ -24,7 +25,7 @@ it('never offers a second arrangement or departure after loading completion', as
|
||||
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ stage: 'DELIVERING', actions: { ARRANGE_DELIVERY: gate, DEPART: gate } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
expect(wrapper.findAll('[role="tab"]').some(tab => tab.text().startsWith('装车后'))).toBe(false)
|
||||
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')
|
||||
@@ -101,15 +102,126 @@ it.each([1, 2])('keeps only details in audit tab %s without its review permissio
|
||||
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['detail'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows four working phases and uses server stage counts instead of current page length', async () => {
|
||||
it('shows 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)
|
||||
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '派送中 1', '派送完成 1'])
|
||||
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
|
||||
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
|
||||
await tabs[2]!.trigger('click'); await flushPromises()
|
||||
await tabs[3]!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it.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()
|
||||
@@ -154,11 +266,24 @@ 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([expect.objectContaining({ key: 'detail', label: taskType === 'PICKUP' ? '详情' : '运输操作' })])
|
||||
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 () => {
|
||||
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()
|
||||
})
|
||||
@@ -204,3 +329,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()
|
||||
})
|
||||
|
||||
@@ -22,9 +22,12 @@ import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, ty
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions, dispatchRejectionReason } from '../dispatchPresentation'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import { 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,27 +36,42 @@ 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('')
|
||||
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' })
|
||||
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: '全部单据' }] },
|
||||
{ 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: 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' }] : []),
|
||||
@@ -74,30 +92,47 @@ async function load() {
|
||||
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search(value: QueryModel) { model.value = value; page.value = 1; void load() }
|
||||
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; page.value = 1; void load() }
|
||||
function 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', '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 rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
||||
const review = section.value
|
||||
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||
}
|
||||
if (row.taskType && row.taskType !== 'DELIVERY') return [{ key: 'detail', label: '运输操作', priority: 500 }]
|
||||
if (row.taskType === 'LOCAL_TRUCK') return [{ key: 'detail', label: '运输操作', priority: 500 }]
|
||||
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
|
||||
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
||||
return [...available.map((value, index) => ({
|
||||
return [...(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: value === 'CANCEL' ? 'danger' as const : undefined,
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
|
||||
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? 'danger' as const : undefined,
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(row.taskType === 'PICKUP' ? [] : [...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }])]
|
||||
}
|
||||
function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'detail') return openDetail(row)
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
|
||||
if (value === '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 }
|
||||
@@ -105,6 +140,7 @@ function handleAction(value: string, row: DispatchListRow) {
|
||||
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 === 'EDIT') { transportEditNo.value = row.dispatchNo; transportEditOpen.value = true; return }
|
||||
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
|
||||
}
|
||||
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
|
||||
@@ -139,18 +175,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)" :max-visible="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" title="维护取货车辆信息" width="1000px"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :pickup-approved="rows.find(row => row.dispatchNo === transportEditNo)?.reviewState === 'APPROVED'" @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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -4,23 +4,24 @@ import { listDispatchCandidates } from './api/dispatches'
|
||||
import { DispatchInputError, assertSelectionGroupVersions, type Candidate, type CandidateQuery } from './dispatchContracts'
|
||||
|
||||
/** One visible server page; selected cargo remains owned by the caller or server draft. */
|
||||
export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates) {
|
||||
export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates, allowAllWarehouses = false) {
|
||||
const candidates = ref<Candidate[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
|
||||
let generation = 0, warehouse = ''
|
||||
let generation = 0, warehouse = '', current = false
|
||||
let pending: { key: string; promise: Promise<void> } | undefined
|
||||
const groupVersions = new Map<string, string>()
|
||||
const sourceGroups = new Map<string, { groupKey: string; sourceVersion: string }>()
|
||||
|
||||
function reset() {
|
||||
generation++; pending = undefined; warehouse = ''
|
||||
generation++; pending = undefined; warehouse = ''; current = false
|
||||
candidates.value = []; page.value = 1; total.value = 0; loading.value = false
|
||||
groupVersions.clear(); sourceGroups.clear()
|
||||
}
|
||||
function assertCurrent(rows: Candidate[]) {
|
||||
if (rows.length && (loading.value || !current)) throw new DispatchInputError('货物候选尚未读取完成,请刷新后重试')
|
||||
assertSelectionGroupVersions(rows, candidates.value)
|
||||
for (const row of rows) {
|
||||
const group = row.selectionGroup
|
||||
if (row.warehouseId !== warehouse || (groupVersions.has(group.groupKey) && groupVersions.get(group.groupKey) !== group.groupVersion)
|
||||
if ((warehouse && row.warehouseId !== warehouse) || (groupVersions.has(group.groupKey) && groupVersions.get(group.groupKey) !== group.groupVersion)
|
||||
|| group.sources.some(source => {
|
||||
const latest = sourceGroups.get(source.sourceKey)
|
||||
return latest && (latest.groupKey !== group.groupKey || latest.sourceVersion !== source.selection.sourceVersion)
|
||||
@@ -28,27 +29,32 @@ export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchC
|
||||
}
|
||||
}
|
||||
function load(query: CandidateQuery): Promise<void> {
|
||||
if (!query.warehouseId) { reset(); return Promise.resolve() }
|
||||
if (warehouse !== query.warehouseId) { reset(); warehouse = query.warehouseId }
|
||||
if (!query.warehouseId && !allowAllWarehouses) { reset(); return Promise.resolve() }
|
||||
if (warehouse !== (query.warehouseId || '')) { reset(); warehouse = query.warehouseId || '' }
|
||||
const request = { ...query, page: query.page ?? page.value, size: query.size ?? size.value, warehouseTab: query.warehouseTab ?? 'ALL' }
|
||||
const key = JSON.stringify(request)
|
||||
if (pending?.key === key) return pending.promise
|
||||
const sequence = ++generation
|
||||
// Preserve the last total while loading: zero would make the pager emit page 1.
|
||||
loading.value = true; error.value = null; candidates.value = []
|
||||
// Keep the previous page visible but non-actionable until its replacement is validated.
|
||||
// Preserve the total too: zero would make the pager emit page 1.
|
||||
loading.value = true; current = false; error.value = null
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await fetchCandidates(request)
|
||||
if (sequence !== generation) return
|
||||
if (result.page !== request.page || result.size !== request.size) throw new DispatchInputError('候选响应页码不一致,请重新读取')
|
||||
if (result.candidates.some(row => row.warehouseId !== request.warehouseId || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
|
||||
if (result.candidates.some(row => !row.warehouseId || (request.warehouseId && row.warehouseId !== request.warehouseId) || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
|
||||
for (const row of result.candidates) {
|
||||
groupVersions.set(row.selectionGroup.groupKey, row.selectionGroup.groupVersion)
|
||||
for (const source of row.selectionGroup.sources) sourceGroups.set(source.sourceKey, { groupKey: row.selectionGroup.groupKey, sourceVersion: source.selection.sourceVersion })
|
||||
}
|
||||
current = true
|
||||
candidates.value = result.candidates; page.value = result.page; size.value = result.size; total.value = result.total
|
||||
} catch (cause) {
|
||||
if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败')
|
||||
if (sequence === generation) {
|
||||
candidates.value = []; current = false
|
||||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败')
|
||||
}
|
||||
} finally {
|
||||
if (sequence === generation) { loading.value = false; pending = undefined }
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
.admin-density .el-empty__description { margin-top: 0; }
|
||||
.admin-density .el-empty__description p { font-size: var(--admin-font-body, 13px); }
|
||||
.admin-density-dialog .el-form-item__label { line-height: 22px; }
|
||||
.admin-density-dialog .el-form--inline .el-form-item__label { align-items: center; }
|
||||
.admin-density-dialog .el-form-item:not(.el-form-item--label-top) .el-form-item__label { align-items: center; }
|
||||
:is(.admin-density, .admin-density-dialog, .admin-density-drawer) .el-table th.el-table__cell {
|
||||
background: var(--cc-list-table-header-bg);
|
||||
color: var(--cc-list-table-header-text);
|
||||
@@ -243,6 +243,7 @@
|
||||
|
||||
|
||||
.admin-density .density-contained-tabs > .el-tabs__content { padding: var(--admin-space-5); }
|
||||
.admin-density .density-contained-tabs.density-contained-tabs--flush > .el-tabs__content { padding: 0; }
|
||||
|
||||
/* Explicit page descriptions share the teleported detail table scale. */
|
||||
:is(.admin-density, .admin-density-dialog) .density-descriptions .el-descriptions__label.el-descriptions__cell { width: var(--admin-label-width); font-size: var(--admin-font-caption); background: var(--admin-label-bg); }
|
||||
|
||||
@@ -835,7 +835,7 @@ describe('approved Admin density ownership', () => {
|
||||
|
||||
it('keeps contained overseas tab content and external cargo tab bodies separate', () => {
|
||||
expect(shared).toContain('.density-element-tabs:not(.density-contained-tabs) > .el-tabs__content { display: none; }')
|
||||
for (const file of ['LastMileOrderDetailView', 'DeliveryOrderDetailView']) {
|
||||
for (const file of ['LastMileOrderDetailView', 'DeliveryOrderDetailView', 'DispatchDetailView']) {
|
||||
const source = read('modules/overseas-warehouse/pages/' + file + '.vue')
|
||||
const ast = parse(source).descriptor.template!.ast!
|
||||
let panes = 0
|
||||
@@ -854,6 +854,24 @@ describe('approved Admin density ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fits the five dispatch vehicle fields in one desktop row and keeps actions below the tabs', () => {
|
||||
const source = read('modules/overseas-warehouse/pages/DispatchDetailView.vue')
|
||||
const css = parse(source).descriptor.styles.map(style => style.content).join('\n')
|
||||
const declarations = (source: string, selector: string) => {
|
||||
const values: Record<string, string> = {}
|
||||
cssRoot(source).walkRules(rule => {
|
||||
if (rule.selector === selector && rule.parent?.type !== 'atrule') rule.walkDecls(decl => { values[decl.prop] = decl.value })
|
||||
})
|
||||
return values
|
||||
}
|
||||
expect(declarations(css, '.dispatch-detail .vehicle-fields')['grid-template-columns']).toBe('minmax(0, 1.3fr) minmax(0, 1.6fr) minmax(0, 1.2fr) minmax(0, 1.5fr) minmax(0, 0.8fr)')
|
||||
expect(declarations(css, '.dispatch-detail .vehicle-fields > div')['grid-template-columns']).toBe('max-content minmax(0, 1fr)')
|
||||
expect(declarations(css, '.dispatch-actions')['justify-content']).toBe('flex-end')
|
||||
expect(declarations(shared, '.admin-density .density-contained-tabs > .el-tabs__content').padding).toBe('var(--admin-space-5)')
|
||||
expect(declarations(shared, '.admin-density .density-contained-tabs.density-contained-tabs--flush > .el-tabs__content').padding).toBe('0')
|
||||
expect(elementsOf(source, 'el-tabs').every(node => classesOf(node).includes('density-contained-tabs--flush'))).toBe(true)
|
||||
})
|
||||
|
||||
it('opts shared overseas detail and relationship wrappers in only at approved callers', () => {
|
||||
for (const file of ['modules/overseas-warehouse/components/OverseasDetailShell.vue', 'modules/overseas-warehouse/components/OverseasReadOnlyDetail.vue', 'shared/components/InboundCodeRelationshipDialog.vue']) {
|
||||
const source = read(file)
|
||||
@@ -1098,7 +1116,17 @@ describe('approved Admin density ownership', () => {
|
||||
cell.innerHTML = '<table class="el-table__body"><tbody><tr><td class="el-table__cell"></td></tr></tbody></table>'
|
||||
expect(cell.querySelector('td')!.matches(stripeSelector)).toBe(false)
|
||||
expect(rules.get('.admin-density-dialog .el-descriptions__label.el-descriptions__cell')?.background).toBe('var(--admin-label-bg)')
|
||||
expect(rules.get('.admin-density-dialog .el-form--inline .el-form-item__label')?.['align-items']).toBe('center')
|
||||
const horizontalLabelSelector = '.admin-density-dialog .el-form-item:not(.el-form-item--label-top) .el-form-item__label'
|
||||
expect(rules.get(horizontalLabelSelector)?.['align-items']).toBe('center')
|
||||
const labelFixture = document.createElement('div')
|
||||
labelFixture.innerHTML = '<div class="admin-density-dialog"><form class="el-form"><div class="el-form-item el-form-item--label-left"><label class="el-form-item__label">计划送仓日期</label></div></form></div>'
|
||||
const formItem = labelFixture.querySelector('.el-form-item')!
|
||||
const label = labelFixture.querySelector('label')!
|
||||
expect(label.matches(horizontalLabelSelector)).toBe(true)
|
||||
formItem.className = 'el-form-item el-form-item--label-right'
|
||||
expect(label.matches(horizontalLabelSelector)).toBe(true)
|
||||
formItem.className = 'el-form-item el-form-item--label-top'
|
||||
expect(label.matches(horizontalLabelSelector)).toBe(false)
|
||||
expect(rules.get('.admin-density-dialog.el-dialog.admin-density-workspace > .el-dialog__body')?.flex).toBe('1 1 auto')
|
||||
for (const tone of ['warning', 'purple', 'primary', 'success', 'danger']) {
|
||||
expect(rules.get(`:is(.admin-density, .admin-density-dialog) .density-action[data-tone="${tone}"]`)?.['--action-hover-bg']).toBe(`var(--admin-action-${tone}-hover)`)
|
||||
@@ -1301,7 +1329,8 @@ describe('approved Admin density ownership', () => {
|
||||
expect(source).not.toContain('class="density-dialog-section dispatch-draft-toolbar"')
|
||||
expect(source).toContain('<template #vehicle-controls>')
|
||||
expect(source).toContain('class="density-form dispatch-setup-fields"')
|
||||
expect(source).toContain('class="dispatch-warehouse-picker" aria-label="起运仓"')
|
||||
expect(source).toContain('<el-form-item v-if="mode === \'empty\'" label="起运仓">')
|
||||
expect(source).toContain('<template #order-controls>')
|
||||
expect(source).not.toContain('class="dispatch-warehouse-field" label="起运仓"')
|
||||
expect(source).not.toContain('data-tone="work"')
|
||||
expect(source).not.toContain('data-tone="summary"')
|
||||
@@ -1351,7 +1380,10 @@ describe('approved Admin density ownership', () => {
|
||||
}
|
||||
expect(workflow).not.toMatch(/max-height="(?:380|420)"/)
|
||||
expect(evidence).toContain('class="density-table dispatch-evidence-table"')
|
||||
expect(evidence).toContain('fullscreenable :loading="previewLoading"')
|
||||
expect(evidence).toContain('fullscreenable :loading="previewBusy"')
|
||||
expect(evidence).toContain('class="evidence-preview" :aria-busy="previewBusy"')
|
||||
expect(evidence).not.toContain('aria-label="本车附件预览信息"')
|
||||
expect(evidence).not.toContain('正在读取本车附件…')
|
||||
expect(evidence).toContain('<LoadErrorState')
|
||||
expect(evidence).toContain('<LineClampText')
|
||||
for (const source of [workflow, evidence]) {
|
||||
|
||||
Reference in New Issue
Block a user