优化
This commit is contained in:
@@ -30,10 +30,18 @@
|
||||
|
||||
本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。
|
||||
|
||||
2026-10-16 本次 UI 检查:用户重新要求检查后,运行 Admin 全量测试,397 文件 / 3292 项通过;发现并修正 QueryToolbar 必填 `ariaLabel` 属性的模板写法。补充真实 KeepAlive + Teleport 离开/恢复及点击提醒弹层回归后,最新定向检查 3 文件 / 66 项通过,最终 `pnpm typecheck`、`pnpm build` 和 `git diff --check` 通过。构建仍有既有 AnalyticsChart 约 588 kB 分块提示。此记录更新下文历史“未运行验证”的结论;本地自动检查不代表测试环境或人工视觉验收。所属客户显示缺项仍待后端合同补齐,INP 采集报错的脚本来源未确认,本次未部署。日志为 `.logs/appointment-ui-current-{test,focused,typecheck,build}.log`。
|
||||
|
||||
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。
|
||||
|
||||
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
|
||||
|
||||
2026-10-16 操作列样式核对:用户截图 `codex-clipboard-9d54345c-c9c1-4991-82ba-b319948f781b.png` 明确要求与外层表格统一。
|
||||
|
||||
| 页面 | 操作 | 截图依据与布局 | 当前实现 | 本轮结论 |
|
||||
|---|---|---|---|---|
|
||||
| 预约工作明细表格 | 查看详情或服务端提供的下一步操作 | 操作列内蓝色普通文字,无边框、底色或装饰图标 | `RowActions` 默认描边样式 | 使用公共 `text-actions` 模式,保留当前文案、权限、点击承载和长文案换行;待人工截图确认 |
|
||||
|
||||
2026-10-16 当前承载调整:用户反馈“点击外面关闭不了,也看不清”。日期/类型/全局明细、事项详情、原侧边编辑表单和到港参考统一改用带遮罩的居中 `AppDialog`,常规宽度 1120px、高度 88dvh,支持全屏;标题与分页固定、仅正文滚动。遮罩、Esc 和关闭按钮统一经过未保存/提交结果待确认保护。此结论优先于下表早期侧边承载描述;原业务独立页面跳转、确认流程和统计口径保持原样。改后待人工截图确认。
|
||||
|
||||
V13 日历布局:按已确认定高要求,月日格固定 146px,周日格固定 270px;只展示五类服务端计数和独立到港参考,不追加逐事项卡片、不依明细条数扩高。替代下表早期“随内容自然增高”的结论;不裁掉五类入口,仍需人工核对 1920×1080、100% 下完整内容。
|
||||
@@ -71,6 +79,7 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
||||
|
||||
| V13 场景 | 当前前端与直接检查 | 依赖/剩余验收 |
|
||||
|---|---|---|
|
||||
| 事项列表统一表格 | 用户截图 `codex-clipboard-1bde1991-b226-4956-8530-6de44074f818.png` 明确要求统一表格或卡片,不能混排 | 所有日期/类型/全局待办列表统一使用 DataTable 与 RowActions,替代早期多行卡片排版。操作固定最左;事项、类型、状态、作业时间、时区、流程、地点、负责人、下次跟进、提醒分列,时间保留实际/确认/目标口径。列表弹框加宽,表头与分页由共享表格承载;保留每页 8 项、筛选和返回上下文。当前调整覆盖旧卡片与独立分页结论;未运行验证或部署,待人工截图确认 |
|
||||
| 01 整客户 160 移出续办 | `WorkDetailPanel.test.ts` 保存后读取主要下一步;`WorkExistingDispatchForm.test.ts` 沿用真实排车单继续安排本批车辆 | 单客户移出命令、当前数量/车辆版本、原车 120 保留结果及后续目标;真实发车签收待集成 |
|
||||
| 02 100 先送、60 后送 | `AppointmentBatchForm.test.ts` 保留普通分批与来源版本;`WorkDetailPanel.test.ts` 区分未定作业日和跟进日 | 已确认预约/装载关系的部分移出与调整回复语义,不能以普通分批代替 |
|
||||
| 03 后送 60 加另一客户 40 共车 | 本批与另一客户按实际托盘各建排车单;结果页“与同仓其他排车单共车”沿业务号核对两单后复用 TripPreparationDialog/Fleet prepare,可加入已有计划车次;WorkDispatchForm、WorkSharedTripForm、TripPreparationDialog、trips API 测试覆盖传递与导航 | 实物须能对应本次数量,不能对应时先走原库存拆托;共车后的路线、容量、审核及发车须真实 HTTP 验收,非任意箱级拆托开发缺口 |
|
||||
@@ -172,8 +181,12 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
||||
|
||||
| 本次操作 | 证据与现状 | 调整结论 |
|
||||
|---|---|---|
|
||||
| 未定日期范围内搜索 | 用户截图 `codex-clipboard-07b5ab03-e467-4ce1-8410-13d05ac5bc51.png` 反馈大量数据缺少搜索,且测试环境仍显示旧卡片 | 全局待办弹框接入 QueryToolbar,支持柜号、批次号、车次号、地点关键词;沿现有 items API 同时传 view(含 UNDATED)与 keyword,服务端全范围筛选后分页,不过滤当前 8 条。搜索/清空回第 1 页,返回明细保留搜索词和分页,原业务跳转的 return URL 保留 workListKeyword。保留全局统计口径;表格与搜索均为当前工作区改动,本任务未部署,未运行验证 |
|
||||
| 所属客户缺失排查 | 用户截图 `codex-clipboard-645f5354-0dfd-4b90-b6f7-05ff9b1f2ffb.png`;`BusinessReference` 只有 kind/key/businessNo/label,客户引用存在 `CUSTOMER + 内部客户键 + null + 所属客户` 的生产路径,详情查询仅合并引用 | 前端按 `businessNo || '—'` 展示,缺少真实客户名称;不能据此认定没有关联客户,也不能回退内部客户键。待工作事项后端按权威客户目录补充名称/业务编号并明确展示合同;遵循开发协作契约,本任务不保留后端改动。未完成接口或环境验收 |
|
||||
| 提醒列与跳转遮罩 | 用户截图 `codex-clipboard-54e0b56d-448c-40da-a147-15798c932091.png` 和按钮反馈 | 提醒列固定 105px,仅显示去重后的条数,点击展开完整可滚动提醒,不撑高表格行。预约页被 KeepAlive 停用时卸载挂到 body 的弹框,恢复时保留父级事项/分页上下文。用户 startTime 截图实际是 INP 采集的空 entries 读取,脚本来源尚待确认,不能将该报错认定为已修复;本轮未运行验证或部署 |
|
||||
| 事项详情的信息层级 | 用户 2026-10-16 截图 `codex-clipboard-cad4abc6-51d1-42f2-8e7b-7914b264348e.png` 反馈排版不清楚、缺少重点 | 顶部突出事项名称与状态;作业时间、时区、地点、负责人和下次跟进复用共享描述表;原有提醒去重后集中展示,保留完成态与跟进日期语义;当前操作与关联资料/历史分区,保留所有动作资格、原业务跳转、默认历史展开条件和未保存保护。空原业务进度不渲染空标题;已知事件代码使用中文名称,未知代码保留在辅助信息中,时间保留原时区。本轮未运行验证或部署,待人工截图确认 |
|
||||
| 列表信息层级与日历视觉细化 | 用户 2026-10-16 截图 `codex-clipboard-6858cde3-eda3-4a02-a043-e1a47143f67c.png`、`codex-clipboard-5a68965d-cdbd-4ce9-b8b3-385d9d76b4f2.png`,并明确要求按建议优化 | 查看详情/历史使用普通描边按钮,办理动作保留主色且仍在左侧;完整标题、状态、作业时间、时区、地点、流程、负责人、跟进日期及异常分层显示。日历类型改浅色底与弱边框,保留语义色、今天/选中态;周末表头与到港参考保持辅助层级,零值弱化、超期数字提示。保留现有覆盖面板、字号、统计和动作链;改后待人工截图确认 |
|
||||
| 工作明细左侧长操作按钮 | Admin #621 真实 1920×1080 截图 `baiyue-firstleg/reports/appointment-browser-20261015/admin-621-follow-today-1920.png`;按钮与文本实际行高仅 13px | 保留完整文案和 108px 操作列,统一使用 20px 行高、自然换行和自适应高度;普通/展开面板及主操作/查看详情使用同一规则 |
|
||||
| 工作明细左侧长操作按钮 | 用户 2026-10-16 截图 `codex-clipboard-b36b905a-a223-4fa6-98ca-7ece08149a37.png` 反馈按钮过大;原 108px 列宽、全宽实心按钮导致长文案换行撑高 | 操作列调整为 184px,按钮按文案宽度使用共享 `density-action` 描边样式和 28px 最小高度;完整保留服务端动作标签及点击行为,超长文案仍可换行。业务编号使用深色标题,辅助信息使用说明字号,行间距收紧。适用全部工作明细弹框及全屏态,覆盖早期实心主按钮结论;改后待人工截图确认,本轮未运行验证或部署 |
|
||||
| 流程与工具栏分组 | 用户后续流程下拉、日期工具栏及上下操作区截图(`115df974`、`d217350b`、`80dd9fa1`) | 流程改为四个直接切换按钮;全局待办、流程、日期与类型筛选收进连续白底工作区,各行靠左,保留全局与区间统计边界 |
|
||||
| 日历浏览与日期选择 | 主页面截图;原型 `renderCalendar()`、`.day.selected`、`.todaymark` | 缩小日格空白,今天与选择态分别标识;零项日期只显示日期;显示实际查询起止日期,保留加载失败状态 |
|
||||
| 日期/类型/全局明细 | 展开截图中日历被挤窄;原型第 11 行 `.workspace`、`.panel` 为右侧覆盖布局 | 恢复覆盖式侧边工作面板,不压缩日历;增加展开/收起,标题和关闭固定,正文滚动,分页固定;仍保留左侧业务操作 |
|
||||
@@ -197,10 +210,8 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
||||
| 客户仓批次用车 | 原排车与车次安排入口 | 按 DISPATCH 引用读取原排车单,再进入原车次安排 | `WorkExistingDispatchForm.vue`;核对不可变批次键与真实业务号,保留原实物;不调用 FBA 候选,不新建排车单;原派送进度按明确业务引用进入 |
|
||||
| 本柜接仓计划与记录 | `executeForm()`、原入库单详情 | 单计划进入对应客户原单,多计划列出本柜实际关联清单;缺计划时先预览整柜分组 | `WorkReceiptPlans.vue`;复用 container-summaries、原整柜预览/生成 API 和原入库单路由,快照与原资源重试键保持一致;实际收货仍由原仓库作业流程登记 |
|
||||
| 失败、无权限、未保存 | 真实接口与应用验收规则 | 明确失败和重试;错误不改为0项,保留输入和原上下文 | 各组件直接回归;未知命令不执行,未确认保存结果禁止离开 |
|
||||
|
||||
| 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 |
|
||||
| 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 |
|
||||
|
||||
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
|
||||
|
||||
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getFbaAppointment: vi.fn() }))
|
||||
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
const reference = (batchKey = 'FBA_APPOINTMENT:1', batchNo: string | null = 'AP-001'): DispatchBatchReference => ({ batchKey, batchNo, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70 }] })
|
||||
const reference = (batchKey = 'FBA_APPOINTMENT:1', batchNo: string | null = 'AP-001'): DispatchBatchReference => ({ batchKey, batchNo, recordVersion: 0, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70 }] })
|
||||
function render(overrides: Partial<InstanceType<typeof TripAppointmentPanel>['$props']> = {}) { return mount(TripAppointmentPanel, { props: { dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }], previousDispatches: [], historyUnavailableReason: null, canWrite: true, ...overrides }, global: { stubs: {
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
|
||||
|
||||
@@ -21,6 +21,14 @@ import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
|
||||
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
|
||||
const attentionReasons = computed(() => [...new Set([
|
||||
...(detail.value?.item.blockReasons || []),
|
||||
...(detail.value?.item.missingFields || []),
|
||||
...(detail.value?.item.overdueReasons || []),
|
||||
].map(workReason))])
|
||||
const needsFollowup = computed(() => !!detail.value && detail.value.item.executionStatus !== 'COMPLETED' && (!detail.value.item.owner || !detail.value.item.followupDate))
|
||||
const historyLabels: Record<string, string> = { BASELINE_OBSERVED: '同步原业务状态', GENERATED: '创建工作事项', CONFIRM_ROUTE: '确认路线', SAVE_FOLLOWUP: '记录跟进', RESOLVE_REVIEW: '处理复核', SAVE_REQUIREMENT: '保存预约要求', HANDOFF_RETURN: '交接还空责任', ACCEPT_RETURN: '确认接手还空' }
|
||||
function historyTime(value: string) { return value.replace('T', ' ').replace(/\.\d+(?=Z|[+-]\d{2}:\d{2}$)/, '').replace(/Z$/, ' UTC') }
|
||||
const receivingOwners = computed(() => [detail.value?.item.owner, ...(detail.value?.relatedItems.map(item => item.owner) || [])].filter((person): person is { id: string; name: string } => !!person?.id))
|
||||
const actionNotice = ref('')
|
||||
const saveCompleted = ref(false)
|
||||
@@ -105,17 +113,33 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<div :aria-busy="loading">
|
||||
<div class="work-detail" :aria-busy="loading">
|
||||
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading" role="status">{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}</p>
|
||||
<template v-if="detail">
|
||||
<h3>{{ detail.item.title }}</h3><p>{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}</p>
|
||||
<p>{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}</p><p>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}</p>
|
||||
<p class="followup-summary">下次跟进:{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'"> · 作业日期未定,跟进日不作为作业日期</span></p>
|
||||
<p v-if="detail.item.executionStatus !== 'COMPLETED' && (!detail.item.owner || !detail.item.followupDate)" class="work-warning">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</p>
|
||||
<div class="container-links"><button v-for="container in detail.item.containers" :key="container.containerBookingId" type="button" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</button></div>
|
||||
<p v-for="reason in [...detail.item.blockReasons, ...detail.item.missingFields, ...detail.item.overdueReasons]" :key="reason" class="work-warning">{{ workReason(reason) }}</p>
|
||||
<header class="detail-overview">
|
||||
<div class="detail-title"><h3>{{ detail.item.title }}</h3><el-tag :type="detail.item.executionStatus === 'COMPLETED' ? 'success' : detail.item.reviewRequired ? 'warning' : 'info'" size="small">{{ workStatus(detail.item) }}</el-tag></div>
|
||||
<p class="detail-category">{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }}</p>
|
||||
</header>
|
||||
<dl class="density-fields detail-facts" aria-label="事项关键信息">
|
||||
<div><dt>作业时间</dt><dd>{{ workDate(detail.item) }}</dd></div>
|
||||
<div><dt>作业时区</dt><dd>{{ detail.item.timezone || '地点时区待核实' }}</dd></div>
|
||||
<div><dt>作业地点</dt><dd>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }}</dd></div>
|
||||
<div><dt>负责人</dt><dd>{{ detail.item.owner?.name || '待分派' }}</dd></div>
|
||||
<div class="followup-summary"><dt>下次跟进</dt><dd>{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'" class="field-note">作业日期未定,跟进日不作为作业日期</span></dd></div>
|
||||
</dl>
|
||||
<div v-if="detail.item.containers.length" class="container-links"><span>关联货柜</span><el-button v-for="container in detail.item.containers" :key="container.containerBookingId" class="density-action" data-tone="primary" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</el-button></div>
|
||||
<aside v-if="needsFollowup || attentionReasons.length" class="attention-panel" aria-label="事项提醒">
|
||||
<h4>事项提醒</h4>
|
||||
<ul>
|
||||
<li v-if="needsFollowup">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</li>
|
||||
<li v-for="reason in attentionReasons" :key="reason">{{ reason }}</li>
|
||||
</ul>
|
||||
</aside>
|
||||
<p v-if="detail.returnHandoff">还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}</p>
|
||||
<section class="current-work" aria-label="当前处理">
|
||||
<h4>{{ detail.item.executionStatus === 'COMPLETED' ? '事项结果' : '当前处理' }}</h4>
|
||||
<p v-if="detail.item.executionStatus === 'COMPLETED'" class="completion-summary">当前事项已完成,可查看关联工作和历史记录。</p>
|
||||
<template v-if="!action && !continuation">
|
||||
<p v-if="saveCompleted" role="status" class="save-result">本次保存已完成。{{ loading ? '正在读取当前下一步…' : error ? '下一步读取失败,请重试读取事项,无需重复提交已保存内容。' : detail.item.executionStatus === 'COMPLETED' ? '当前事项已完成,可返回查看其他待办。' : '请按当前下一步继续处理。' }}</p>
|
||||
<el-button v-if="detail.item.nextAction" class="primary-next-action" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
|
||||
@@ -133,15 +157,43 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" @batch="openBatch" />
|
||||
<WorkExistingDispatchForm v-else-if="existingDispatches.length" :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<WorkReceiptPlans v-else-if="action?.target.kind === 'WAREHOUSE_RECEIPT'" :key="action.target.containerBookingId" :container-booking-id="action.target.containerBookingId" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<section v-else-if="action?.target.kind === 'READ_ONLY'" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
|
||||
<section v-else-if="action?.target.kind === 'READ_ONLY' && action.target.references.length" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
|
||||
<details v-if="secondaryActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in secondaryActions" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
|
||||
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button @click="emit('open', related.workItemKey)">查看</el-button></div></details>
|
||||
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p></details>
|
||||
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><b>{{ entry.action }}</b><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }} · {{ entry.occurredAt }}</small></div><p v-if="!detail.history.length">暂无记录</p></details>
|
||||
</section>
|
||||
<section class="detail-records" aria-label="关联资料与历史">
|
||||
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button class="density-action" @click="emit('open', related.workItemKey)">查看</el-button></div><p v-if="!detail.relatedItems.length">暂无关联工作</p></details>
|
||||
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p><p v-if="!detail.references.length">暂无原业务记录</p></details>
|
||||
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录 · {{ detail.history.length }} 条</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><div class="history-heading"><b>{{ historyLabels[entry.action] || '事项记录' }}</b><time :datetime="entry.occurredAt">{{ historyTime(entry.occurredAt) }}</time></div><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }}<span v-if="!historyLabels[entry.action]"> · {{ entry.action }}</span></small></div><p v-if="!detail.history.length">暂无记录</p></details>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.container-links, .more-actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }.container-links button { background: transparent; border: 0; color: var(--el-color-primary); cursor: pointer; font: inherit; }
|
||||
.related-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }details { margin-top: var(--admin-space-5); }summary { cursor: pointer; padding-block: var(--admin-space-3); }.work-warning { color: var(--el-color-warning-dark-2); }
|
||||
.work-detail { font-size: var(--admin-font-body); line-height: 1.6; color: var(--el-text-color-primary); }
|
||||
.detail-overview { margin-bottom: var(--admin-space-5); }
|
||||
.detail-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.detail-title h3 { margin: 0; font-size: var(--admin-font-title); }
|
||||
.detail-category { margin: var(--admin-space-1) 0 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-detail .detail-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.followup-summary { grid-column: 1 / -1; }
|
||||
.field-note { margin-left: var(--admin-space-5); color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.container-links, .more-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.container-links { margin-top: var(--admin-space-3); }
|
||||
.container-links > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.attention-panel { margin-top: var(--admin-space-5); padding: var(--admin-space-3) var(--admin-space-5); border-left: 3px solid var(--el-color-warning); background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); }
|
||||
.attention-panel h4, .current-work > h4 { margin: 0 0 var(--admin-space-1); font-size: var(--admin-font-section); }
|
||||
.attention-panel ul { margin: 0; padding-left: var(--admin-space-6); font-size: var(--admin-font-caption); }
|
||||
.current-work { margin-top: var(--admin-space-5); padding-block: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.current-work > p { margin: var(--admin-space-2) 0; }
|
||||
.completion-summary { color: var(--el-color-success-dark-2); }
|
||||
.detail-records { border-top: 1px solid var(--cc-border); }
|
||||
.detail-records > details { border-bottom: 1px solid var(--cc-border); }
|
||||
summary { cursor: pointer; padding-block: var(--admin-space-3); font-weight: 600; }
|
||||
summary:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
|
||||
.detail-records p { margin: var(--admin-space-1) 0; }
|
||||
.related-row, .history-heading { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }
|
||||
.related-row:last-child, .history-entry:last-child { border-bottom: 0; }
|
||||
.related-row p, .history-entry small, .history-heading time { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.work-warning { color: var(--el-color-warning-dark-2); }
|
||||
</style>
|
||||
|
||||
@@ -1,146 +1,162 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkDispatchForm from './WorkDispatchForm.vue'
|
||||
import { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { getReceivingContext, type ReceivingContext } from './receivingApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
const access = vi.hoisted(() => ({ allowed: true, denied: [] as string[] }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
function receiving(batchKey: string, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', batchKey).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'v1', vehicles, blockReasons: [] }, executionScope: { version: 'e1', members: [] }, allowedActions: [], blockReasons: [] } }
|
||||
const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
const batch = 'FBA_APPOINTMENT:1'
|
||||
function receiving(key = batch, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', key).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'transport', vehicles, blockReasons: [] }, executionScope: { version: 'execution', members: [] }, allowedActions: [], blockReasons: [] } }
|
||||
function row(id = '9007199254740993', customerId = 'customer-a', key = batch): BatchDispatchCandidate { return { planPalletId: id, palletNo: `PALLET-${id.slice(-2)}`, planId: `plan-${id}`, planNo: 'IP-001', customerId, sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: id, batchKey: key, customerId, sourceKey: customerId === 'customer-a' ? 'ORDER:1' : 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12, recordVersion: 4 }], blockReasons: [] } }
|
||||
function candidate(value = row()): Candidate { return { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] } }
|
||||
function detail(no = 'DS/001', version = 1): DispatchDetail {
|
||||
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
|
||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
}
|
||||
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
|
||||
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
|
||||
function render(props: { batchKeys?: string[]; canWrite?: boolean } = {}) { return mount(WorkDispatchForm, { props: { batchKeys: [batch], canWrite: true, ...props }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true } } }) }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
||||
it.each([0, '0', '9007199254740993'])('preserves authoritative allocation version %s from the existing batch provider', async recordVersion => {
|
||||
const value = row(); value.allocations[0]!.recordVersion = recordVersion
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [value], blockReasons: [] })
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe(recordVersion)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => {
|
||||
const value = row(); value.allocations[0]!.recordVersion = recordVersion as unknown as number
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 0, candidates: [value], blockReasons: [] })
|
||||
const wrapper = render()
|
||||
try { await flushPromises(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选'); expect(createDispatchDraft).not.toHaveBeenCalled() }
|
||||
finally { wrapper.unmount() }
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); access.allowed = true
|
||||
vi.mocked(getReceivingContext).mockImplementation(async batchKey => receiving(batchKey))
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([])
|
||||
vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
|
||||
vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
|
||||
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
|
||||
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); access.allowed = true; access.denied = []
|
||||
natives = [candidate()]; draft = { draftToken: 'server-owned-token', recordVersion: 0, status: 'ACTIVE', warehouseId: 'origin-1', containers: [], sources: [], expiresAt: '2026-10-20T00:00:00Z' }
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
|
||||
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
|
||||
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
|
||||
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
|
||||
vi.mocked(convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: [{ draftContainerId: 'container-1', dispatch: proof(body.groups[0]!.dispatch.idempotencyKey) }] }))
|
||||
vi.mocked(getDispatch).mockImplementation(async no => detail(no)); vi.mocked(listDispatchDrafts).mockImplementation(async () => [clone(draft)])
|
||||
vi.mocked(releaseDispatchDraft).mockImplementation(async () => ({ ...clone(draft), status: 'RELEASED', containers: [], sources: [] }))
|
||||
})
|
||||
it('restores the original FBA dispatch after remount and opens trip preparation without any draft or creation', async () => {
|
||||
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', overseasWarehouseId: 'origin-1', packageCount: 4, appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
|
||||
for (let mountNumber = 0; mountNumber < 2; mountNumber++) {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('DS-EXISTING')
|
||||
expect(wrapper.text()).not.toContain('创建本客户排车单')
|
||||
await button(wrapper, '继续安排本批车辆').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-EXISTING'] })
|
||||
await button(wrapper, '查看原排车单').trigger('click')
|
||||
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches?dispatchNo=DS-EXISTING']])
|
||||
wrapper.unmount()
|
||||
it('creates the whole dispatch when native Long candidate quantities arrive as strings and batch quantities are Integers', async () => {
|
||||
const actual = await vi.importActual<typeof import('../overseas-warehouse/api/dispatches')>('../overseas-warehouse/api/dispatches')
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(actual.listDispatchCandidates)
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenCalledOnce(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations)
|
||||
expect(vi.mocked(lockDispatchDraftPallet).mock.calls[0]![0].source.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
} finally { wrapper.unmount(); get.mockRestore() }
|
||||
})
|
||||
it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS/EXISTING', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING')
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount()
|
||||
}
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(createDispatchDraftContainer).not.toHaveBeenCalled()
|
||||
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(convertDispatchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
it('fails closed when transport relations cannot be read', async () => {
|
||||
vi.mocked(getReceivingContext).mockRejectedValue(new Error('unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('创建本客户排车单')
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => {
|
||||
vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount()
|
||||
vi.mocked(getReceivingContext).mockResolvedValueOnce(receiving(batch, [{ dispatchNo: 'DS-OLD', batchKey: batch, customerId: 'customer-b', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
const mismatch = render(); await flushPromises(); expect(mismatch.text()).toContain('排车单与工作批次关联不一致'); expect(mismatch.text()).not.toContain('核对本批剩余可排实物'); expect(createDispatchDraft).not.toHaveBeenCalled(); mismatch.unmount()
|
||||
})
|
||||
it('does not offer remaining-cargo creation when the original dispatch no longer owns this batch', async () => {
|
||||
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
|
||||
expect(wrapper.text()).not.toContain('创建本客户排车单')
|
||||
expect(wrapper.text()).not.toContain('核对本批剩余可排实物')
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('loads remaining eligible cargo only after an explicit choice while retaining the existing dispatch', async () => {
|
||||
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises()
|
||||
expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith('FBA_APPOINTMENT:1')
|
||||
expect(wrapper.text()).toContain('DS-EXISTING')
|
||||
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
it('uses a server-owned draft, explicit zero versions, native source hashes and exact allocations for one whole dispatch', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatchDraft).toHaveBeenCalledWith({ warehouseId: 'origin-1', idempotencyKey: expect.any(String) })
|
||||
expect(createDispatchDraftContainer).toHaveBeenCalledWith({ draftToken: 'server-owned-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 })
|
||||
expect(lockDispatchDraftPallet).toHaveBeenCalledWith({ draftToken: 'server-owned-token', draftContainerId: 'container-1', recordVersion: 1, idempotencyKey: expect.any(String), source: natives[0]!.selection })
|
||||
const request = vi.mocked(convertDispatchDraft).mock.calls[0]![0]
|
||||
expect(request).toMatchObject({ draftToken: 'server-owned-token', recordVersion: 2, groups: [{ draftContainerId: 'container-1', dispatch: { warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null } }] })
|
||||
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
|
||||
})
|
||||
it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(preArrangeDraftDispatch).toHaveBeenCalledWith({ draftToken: expect.any(String), groups: [{ draftContainerId: 'container-1', planPalletIds: [candidate.planPalletId], returnReceiptIds: [], batchAllocations: candidate.allocations }] })
|
||||
expect(wrapper.text()).toContain('DS-001'); expect(wrapper.text()).not.toContain('internal-dispatch')
|
||||
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
|
||||
expect(prepare.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-001'] })
|
||||
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-001']])
|
||||
wrapper.unmount()
|
||||
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
|
||||
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
|
||||
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
|
||||
})
|
||||
it('opens quantity splitting for only the current batch without locking physical cargo or creating a new full-source appointment', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await button(wrapper, '按当前批次箱数分批').trigger('click')
|
||||
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']])
|
||||
expect(lockDispatchDraftPallet).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => {
|
||||
const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] }))
|
||||
const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
|
||||
})
|
||||
it('preselects only eligible physical cargo of the entered batch, never unrelated allocations or unknown quantity', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: 'other-batch', allocations: [{ ...candidate.allocations[0]!, batchKey: 'FBA_APPOINTMENT:999' }] }, { ...candidate, planPalletId: 'unknown', packageCount: null }] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
const inputs = wrapper.findAll('input[type=checkbox]')
|
||||
expect(inputs.map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false])
|
||||
expect(inputs[1]!.attributes('disabled')).toBeDefined()
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId])
|
||||
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(candidate.allocations)
|
||||
wrapper.unmount()
|
||||
it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => {
|
||||
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
|
||||
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
|
||||
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
|
||||
})
|
||||
it('replays a submitted draft without re-locking already dispatched pallets after an unknown response', async () => {
|
||||
vi.mocked(preArrangeDraftDispatch).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false)
|
||||
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
|
||||
expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, first); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
it('fails closed on incomplete native candidates, missing versions, and cross-warehouse batches', async () => {
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.text()).toContain('未完整返回'); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
|
||||
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.get('input').attributes('disabled')).toBeDefined(); missing.unmount()
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
|
||||
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
|
||||
})
|
||||
it('recovers a draft container from the same token when its creation response was lost', async () => {
|
||||
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('lost create response'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
const token = vi.mocked(createDispatchDraftContainer).mock.calls[0]![0]
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: token, containers: [{ id: 'container-1', capacity: 30, palletIds: [] }] }])
|
||||
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
|
||||
expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
it('retains the entire original conversion and skips all prior locks after an unknown response, even after remount with the work closed', async () => {
|
||||
vi.mocked(convertDispatchDraft).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(convertDispatchDraft).mock.calls[0]![0])
|
||||
expect(await guard()).toBe(false); wrapper.unmount()
|
||||
const restored = render({ canWrite: false }); await flushPromises(); expect(restored.text()).toContain('原请求已保留'); await button(restored, '核对并重试原请求').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenNthCalledWith(2, original); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(restored.emitted('updated')).toHaveLength(1); restored.unmount()
|
||||
})
|
||||
it('shows server blockers and prevents mixing customers or selecting blocked physical cargo', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b', allocations: [{ ...candidate.allocations[0]!, customerId: 'customer-b', planPalletId: '2' }] }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] })
|
||||
const wrapper = render(); await flushPromises(); const inputs = wrapper.findAll('input[type=checkbox]')
|
||||
expect(inputs[2]!.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('本托货量超过本批剩余可排量')
|
||||
await inputs[0]!.setValue(true); await inputs[1]!.setValue(true)
|
||||
expect((inputs[1]!.element as HTMLInputElement).checked).toBe(false)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId]); wrapper.unmount()
|
||||
it('retries the exact container command after a lost response instead of generating another container key', async () => {
|
||||
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('unknown container'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(createDispatchDraftContainer).mock.calls[0]![0])
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original); expect(createDispatchDraft).toHaveBeenCalledTimes(1); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('does not read or write scoped cargo without all original permissions', async () => {
|
||||
access.allowed = false; const wrapper = render(); await flushPromises()
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
it('keeps a proven CREATE across failed current-detail GET and only repeats the read', async () => {
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(getDispatch).toHaveBeenCalledTimes(2); expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('continues the actual later 60-box dispatch through the shared-trip entry without re-creating or reallocating cargo', async () => {
|
||||
const sixty = { ...candidate, packageCount: 60, allocations: [{ ...candidate.allocations[0]!, packageCount: 60 }] }
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [sixty], blockReasons: [] })
|
||||
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-later', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'origin-1', packageCount: 60 } }])
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '与同仓其他排车单共车').trigger('click'); await flushPromises()
|
||||
const shared = wrapper.getComponent({ name: 'WorkSharedTripForm' })
|
||||
expect(shared.props()).toMatchObject({ dispatchNo: 'DS-60', canWrite: true })
|
||||
expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(sixty.allocations)
|
||||
shared.vm.$emit('busy', true); await flushPromises(); expect(button(wrapper, '继续安排其他实物').attributes('disabled')).toBeDefined()
|
||||
shared.vm.$emit('external', '/overseas-master-data/trips/TR-100'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-100']])
|
||||
wrapper.unmount()
|
||||
it('never releases an unknown result for a later rejection, and releases only an explicitly rejected owned draft at its read-back version', async () => {
|
||||
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409))
|
||||
const unknown = render(); await flushPromises(); await button(unknown, '创建整车排车单').trigger('click'); await flushPromises(); await button(unknown, '核对并重试原请求').trigger('click'); await flushPromises(); expect(unknown.text()).not.toContain('按明确拒绝结果释放'); expect(await guard()).toBe(false); unknown.unmount()
|
||||
sessionStorage.clear(); vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)); draft = { ...draft, draftToken: 'another-owned-token', containers: [], recordVersion: 0 }
|
||||
const rejected = render(); await flushPromises(); await button(rejected, '创建整车排车单').trigger('click'); await flushPromises(); draft.recordVersion = 9
|
||||
await button(rejected, '按明确拒绝结果释放本草稿并重新核对').trigger('click'); await flushPromises(); expect(releaseDispatchDraft).toHaveBeenCalledWith({ draftToken: 'another-owned-token', recordVersion: 9, idempotencyKey: expect.any(String) }); rejected.unmount()
|
||||
})
|
||||
it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => {
|
||||
access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises()
|
||||
await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount()
|
||||
})
|
||||
it('does not read scoped cargo without original permissions or create while only read access remains', async () => {
|
||||
access.allowed = false; const denied = render(); await flushPromises(); expect(getReceivingContext).not.toHaveBeenCalled(); denied.unmount()
|
||||
access.allowed = true; access.denied = ['platform:overseas-warehouse:dispatch:create']; const readonly = render(); await flushPromises(); expect(button(readonly, '创建整车排车单').attributes('disabled')).toBeDefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); readonly.unmount()
|
||||
})
|
||||
it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] })
|
||||
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
|
||||
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
|
||||
})
|
||||
|
||||
@@ -1,125 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, dispatchVehicleSpecs, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import { getReceivingContext } from './receivingApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
import { useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
|
||||
const access = useAccessPreviewStore(), contexts = ref<BatchDispatchCandidates[]>([]), selected = ref<string[]>([])
|
||||
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
|
||||
type ReadyRow = BatchRow & { native: Candidate }
|
||||
type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record<string, unknown>; attempted: boolean; rejected: boolean }
|
||||
type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean }
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
||||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
|
||||
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const capacity = ref(30), result = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false), rejected = ref(false)
|
||||
const sharedTripOpen = ref(false), sharedTripBusy = ref(false)
|
||||
const existingDispatches = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), arrangingRemaining = ref(false)
|
||||
const existingReady = ref(false)
|
||||
const selectionBaseline = ref('[]')
|
||||
const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null)
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission)))
|
||||
const writable = computed(() => props.canWrite && readable.value && (!existingDispatches.value.length || existingReady.value) && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
|
||||
const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] }))))
|
||||
function reasons(row: BatchDispatchCandidate) {
|
||||
const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1
|
||||
return [...row.blockReasons, ...(row.packageCount == null || row.packageCount <= 0 ? ['实物箱数待核实'] : []), ...(!row.allocations.length ? ['缺少逐来源批次分配'] : []), ...(row.allocations.some(allocation => !props.batchKeys.includes(allocation.batchKey) || allocation.customerId !== row.customerId) ? ['候选不属于当前批次和客户范围'] : []), ...(duplicates ? ['同一实物出现在多个批次,请核对分批范围'] : [])]
|
||||
}
|
||||
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.planPalletId)))
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== selectionBaseline.value && !result.value), computed(() => saving.value || !!pending.value))
|
||||
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
|
||||
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
|
||||
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
||||
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
|
||||
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
|
||||
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.key)))
|
||||
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
let generation = 0
|
||||
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
|
||||
function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
||||
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
||||
function reasons(row: BatchRow) { return row.blockReasons }
|
||||
function physicalCount(values: BatchRow[]) { return new Set(values.map(row => row.native?.selection.warehousePalletId || row.native?.sourceKey || row.key)).size }
|
||||
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
async function load() {
|
||||
if (!readable.value || pending.value) return
|
||||
loading.value = true; error.value = null; existingReady.value = false
|
||||
if (!readable.value || pending.value || corrupted.value) return
|
||||
const current = ++generation, batches = [...new Set(props.batchKeys)]
|
||||
loading.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []
|
||||
try {
|
||||
// 从服务端当前运输关系恢复排车单;读取失败时禁止退回创建流程。
|
||||
const batches = [...new Set(props.batchKeys)]
|
||||
const receiving = await Promise.all(batches.map(batch => getReceivingContext(batch)))
|
||||
const references: typeof existingDispatches.value = []
|
||||
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
|
||||
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
|
||||
if (current !== generation) return
|
||||
const references: typeof existing.value = []
|
||||
receiving.forEach((context, index) => {
|
||||
const batchKey = batches[index]!
|
||||
if (context.batch.batchKey !== batchKey) throw new ApiResponseError('运输关系与当前预约批次不一致,请重新读取', 'WORK_DISPATCH_MISMATCH')
|
||||
if (context.batch.batchKey !== batchKey) throw new DispatchInputError('运输关系与当前预约批次不一致,请重新读取')
|
||||
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
|
||||
if (!vehicle.dispatchNo) throw new ApiResponseError('本批已有运输安排但缺少排车单号,请核对原车次', 'WORK_DISPATCH_MISMATCH')
|
||||
if (!vehicle.dispatchNo) throw new DispatchInputError('本批已有运输安排但缺少排车单号,请核对原记录')
|
||||
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
|
||||
}
|
||||
})
|
||||
existingDispatches.value = references
|
||||
contexts.value = []; selected.value = []; selectionBaseline.value = '[]'
|
||||
existing.value = references
|
||||
if (references.length && !arrangingRemaining.value) return
|
||||
contexts.value = await Promise.all(batches.map(batch => getBatchDispatchCandidates(batch)))
|
||||
const eligible = rows.value.filter(row => !reasons(row).length && row.packageCount != null && row.packageCount > 0)
|
||||
selected.value = props.batchKeys.length === 1 && eligible.length <= capacity.value && new Set(eligible.map(row => row.customerId)).size === 1 ? eligible.map(row => row.planPalletId) : []
|
||||
selectionBaseline.value = JSON.stringify(selected.value)
|
||||
}
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '批次可排实物读取失败') }
|
||||
finally { loading.value = false }
|
||||
const origins = [...new Set(receiving.map(context => context.batch.originWarehouseId).filter(Boolean))]
|
||||
if (origins.length !== 1 || receiving.some(context => !context.batch.originWarehouseId)) throw new DispatchInputError('所选批次须有明确且相同的起运仓,请从同仓批次继续')
|
||||
warehouseId.value = origins[0]!
|
||||
const batchCandidates = await Promise.all(batches.map(key => getBatchDispatchCandidates(key)))
|
||||
if (current !== generation) return
|
||||
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
|
||||
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
|
||||
contexts.value = batchCandidates
|
||||
const planIds = [...new Set(batchCandidates.flatMap(context => context.candidates.map(row => row.planId)))]
|
||||
const native = planIds.length ? await listDispatchCandidates({ warehouseId: warehouseId.value, planIds }) : { candidates: [], complete: true }
|
||||
if (current !== generation) return
|
||||
requireBatchVersions([...batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)), ...native.candidates.flatMap(row => row.appointmentBatches)])
|
||||
if (native.complete !== true) throw new DispatchInputError('本仓原生候选未完整返回,不能按部分结果建单')
|
||||
rows.value = batchCandidates.flatMap((context, index) => context.candidates.map(row => {
|
||||
const matches = native.candidates.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
|
||||
const candidate = matches.length === 1 ? matches[0]! : null
|
||||
const sources = receiving[index]!.batch.items
|
||||
const blocks = [...context.blockReasons, ...row.blockReasons]
|
||||
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
||||
if (candidate) blocks.push(...candidate.blockReasons)
|
||||
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
|
||||
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
|
||||
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
||||
return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] }
|
||||
}))
|
||||
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
|
||||
const eligible = rows.value.filter(row => !reasons(row).length)
|
||||
selected.value = batches.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
|
||||
baseline.value = JSON.stringify(selected.value)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function select(row: BatchDispatchCandidate, checked: boolean) {
|
||||
if (!writable.value || pending.value || saving.value || loading.value || error.value || reasons(row).length) return
|
||||
if (!checked) { selected.value = selected.value.filter(id => id !== row.planPalletId); return }
|
||||
if (selected.value.includes(row.planPalletId)) return
|
||||
if (chosen.value.some(current => current.customerId !== row.customerId)) { ElMessage.warning('一张排车单只选择同一客户实物;其余客户可继续分别安排'); return }
|
||||
if (selected.value.length >= capacity.value) { ElMessage.warning('已达到所选车型托位上限'); return }
|
||||
selected.value = [...selected.value, row.planPalletId]
|
||||
}
|
||||
function changeSelection(row: BatchDispatchCandidate, event: Event) {
|
||||
function select(row: BatchRow, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
select(row, input.checked); input.checked = selected.value.includes(row.planPalletId)
|
||||
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
|
||||
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
|
||||
else if (!selected.value.includes(row.key)) {
|
||||
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
|
||||
else selected.value.push(row.key)
|
||||
}
|
||||
input.checked = selected.value.includes(row.key)
|
||||
}
|
||||
function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record<string, unknown>, attempted: false, rejected: false }; persist() }
|
||||
function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } }
|
||||
async function executeStep() {
|
||||
const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted
|
||||
command.attempted = true; persist()
|
||||
try {
|
||||
let value: DispatchDraft | ConvertedDraft
|
||||
if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string })
|
||||
else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number })
|
||||
else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection)
|
||||
else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string })
|
||||
else value = await convertDispatchDraft(command.body as unknown as ConvertDraft)
|
||||
ensureOwner()
|
||||
if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求')
|
||||
if (command.action === 'CONVERT') {
|
||||
const converted = value as ConvertedDraft
|
||||
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
|
||||
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey }, attempted: true, rejected: false, result: null })
|
||||
request.converted = converted
|
||||
} else {
|
||||
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
|
||||
if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致')
|
||||
if (command.action === 'CONTAINER') {
|
||||
if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对')
|
||||
request.containerId = draft.containers[0]!.draftContainerId
|
||||
}
|
||||
if (command.action === 'LOCK') {
|
||||
const selectedSource = command.body.source as DraftSelection['source']
|
||||
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
||||
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
||||
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
||||
request.lockedKeys.push(source.sourceKey)
|
||||
}
|
||||
request.draft = draft
|
||||
}
|
||||
request.step = null; persist()
|
||||
} catch (cause) {
|
||||
ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true
|
||||
persist(); throw cause
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!writable.value || saving.value || loading.value || error.value || result.value) return
|
||||
if (!pending.value) {
|
||||
if (!chosen.value.length || chosen.value.length > capacity.value || chosen.value.some(row => reasons(row).length) || new Set(chosen.value.map(row => row.customerId)).size !== 1) { ElMessage.warning('请核对所选客户、实物资格和托位数量'); return }
|
||||
pending.value = { token: crypto.randomUUID(), capacity: capacity.value, rows: structuredClone(chosen.value.map(row => ({ ...row, allocations: row.allocations.map(allocation => ({ ...allocation })), blockReasons: [...row.blockReasons] }))), containerId: null, lockedIds: [], submitting: false }
|
||||
}
|
||||
saving.value = true; saveError.value = null; rejected.value = false
|
||||
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
|
||||
saveError.value = null
|
||||
try {
|
||||
const request = pending.value
|
||||
if (!request.containerId) {
|
||||
const drafts = await listDispatchDrafts(), existing = drafts.find(draft => draft.draftToken === request.token)?.containers || []
|
||||
if (existing.length > 1) throw new Error('本次草稿出现多组装车容器,请核对保存结果')
|
||||
request.containerId = existing[0]?.id || (await createDispatchDraftContainer(request.token, request.capacity)).id
|
||||
ensureOwner()
|
||||
if (!pending.value) {
|
||||
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||||
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
|
||||
}
|
||||
if (!request.submitting) for (const row of request.rows) if (!request.lockedIds.includes(row.planPalletId)) { await lockDispatchDraftPallet(row.planPalletId, request.token, request.containerId); request.lockedIds.push(row.planPalletId) }
|
||||
request.submitting = true
|
||||
const response = await preArrangeDraftDispatch({ draftToken: request.token, groups: [{ draftContainerId: request.containerId, planPalletIds: request.rows.map(row => row.planPalletId), returnReceiptIds: [], batchAllocations: request.rows.flatMap(row => row.allocations) }] })
|
||||
if (response.length !== 1 || response[0]?.draftContainerId !== request.containerId || !response[0].dispatch.dispatchNo) throw new Error('排车结果与本次分组不一致,请重试核对原请求')
|
||||
result.value = response[0].dispatch; pending.value = null; selected.value = []
|
||||
ElMessage.success(`排车单 ${result.value.dispatchNo} 已创建`); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '本次排车结果待确认,请重试原请求'); rejected.value = rejectedCommand(cause) }
|
||||
saving.value = true
|
||||
const request = pending.value
|
||||
while (!request.converted) {
|
||||
if (!request.step) {
|
||||
if (request.releasing) { step('RELEASE', common(request.draft!)) }
|
||||
else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId })
|
||||
else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity })
|
||||
else {
|
||||
const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey))
|
||||
if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection })
|
||||
else {
|
||||
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
||||
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: cargo.map(source => source!.selection), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||
}
|
||||
}
|
||||
}
|
||||
await executeStep()
|
||||
if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return }
|
||||
}
|
||||
// Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts.
|
||||
const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo)
|
||||
ensureOwner(); validateCurrentDispatch(latest, proof)
|
||||
result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]'
|
||||
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function refreshRejected() {
|
||||
if (!rejected.value || !pending.value || saving.value) return
|
||||
saving.value = true
|
||||
try { await releaseDispatchDraft(pending.value.token); pending.value = null; selected.value = []; rejected.value = false; await load() }
|
||||
catch (cause) { saveError.value = getApiErrorDetails(cause, '本次草稿尚未释放,请重试') }
|
||||
async function releaseRejected() {
|
||||
if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
ensureOwner(); const request = pending.value
|
||||
if (!request.draft) { pending.value = null; persist(); return }
|
||||
const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken)
|
||||
ensureOwner()
|
||||
if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对')
|
||||
request.draft = owned; request.releasing = true; request.step = null; persist()
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') }
|
||||
finally { saving.value = false }
|
||||
if (pending.value?.releasing) await save()
|
||||
else if (!pending.value) await load()
|
||||
}
|
||||
function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; arrangingRemaining.value = true; await load() } }
|
||||
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="按预约批次排车">
|
||||
<h3>{{ existingDispatches.length ? '沿用本批已有排车单' : '按批次选择真实托盘' }}</h3><p>已有排车单沿用原实物安排车辆;只有尚未分配的实物才需要创建排车单。</p>
|
||||
<el-button v-if="batchKeys.length === 1 && !result && !existingDispatches.length" :disabled="loading || saving || !!pending" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p v-if="batchKeys.length === 1 && !result && !existingDispatches.length">只安排部分箱数时,先核对本批逐来源数量并分批;新的当前批次继续使用原排车资格。若箱数无法对应完整实物,请先在原库存作业中完成拆托;不把整票重新选入。</p>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单和入仓单列表权限。</p>
|
||||
<section class="work-form" aria-label="按预约批次创建整车排车">
|
||||
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }}</h3><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、客户与实物来源…</p>
|
||||
<template v-if="existingDispatches.length && !loading && !error && !result">
|
||||
<WorkExistingDispatchForm :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<el-button v-if="!arrangingRemaining && writable" @click="continueArranging">核对本批剩余可排实物</el-button>
|
||||
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
||||
<template v-if="existing.length && !loading && !error && !result && !pending">
|
||||
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
|
||||
</template>
|
||||
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" :disabled="sharedTripBusy" @click="prepareOpen = true">继续安排本批车辆</el-button><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list') && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" @click="sharedTripOpen = true">与同仓其他排车单共车</el-button><WorkSharedTripForm v-if="sharedTripOpen" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="sharedTripBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button :disabled="sharedTripBusy" @click="continueArranging">继续安排其他实物</el-button></template>
|
||||
<template v-else-if="readable && !loading && !error && (!existingDispatches.length || arrangingRemaining && existingReady)"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<table class="density-table"><thead><tr><th>选择</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.planPalletId}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.planPalletId)" :disabled="!writable || loading || saving || !!pending || !!error || !!reasons(row).length" @change="changeSelection(row, $event)"></td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table>
|
||||
<p v-if="!loading && !error && !rows.length">当前批次尚无可排实物。</p><el-button type="primary" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次预排' : '创建本客户排车单' }}</el-button>
|
||||
<template v-if="result"><p>已创建整车排车单:{{ result.dispatchNo }}</p><el-button @click="emit('external', detailPath(result.dispatchNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
|
||||
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
||||
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
|
||||
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
|
||||
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<table class="density-table"><thead><tr><th>选择</th><th>客户</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.key}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.key)" :disabled="!writable || unresolved || reasons(row).length > 0" @change="select(row, $event)"></td><td>{{ row.native?.customerName || '客户待核实' }}</td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : '计划托盘' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table><p v-if="!rows.length">当前批次尚无可排实物。</p>
|
||||
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
|
||||
</template>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="rejected && pending" :loading="saving" @click="refreshRejected">释放本次草稿并重新核对</el-button>
|
||||
<TripPreparationDialog v-if="result?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="result.overseasWarehouseId" :warehouse-name="result.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[result.dispatchNo]" @saved="tripSaved" />
|
||||
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="pending?.step?.rejected && !pending.converted" :loading="saving" @click="releaseRejected">按明确拒绝结果释放本草稿并重新核对</el-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,43 +1,42 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import { getDispatch } from '@/modules/overseas-warehouse'
|
||||
const state = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.allowed }) }))
|
||||
import { getDispatch, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
|
||||
it.each(['customer-b', undefined])('rejects FBA dispatch references outside the exact batch customer: %s', async customerId => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
|
||||
const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: 'DS-ORIGINAL', customerId }]); await flushPromises()
|
||||
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
|
||||
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS/ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' } } } }) }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()); vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'DISPATCH:9007199254740999', customerId: 'customer-a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS/ORIGINAL' }) }), candidates: [] }) })
|
||||
it.each(['customer-b', undefined])('rejects a FBA reference outside the exact batch customer: %s', async customerId => {
|
||||
const value = detail(); value.dispatchInfo.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', recordVersion: 0, sources: [] }]; vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: value.dispatchNo, customerId }]); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('reads shared dispatch once and verifies every FBA batch reference', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: ['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, batchNo: `AP-${id}`, customerId: 'customer-a', sources: [] })) })
|
||||
const wrapper = render(['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, dispatchNo: 'DS-ORIGINAL', customerId: 'customer-a' }))); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
it('reads a shared whole dispatch once and verifies every nested batch and real customer', async () => {
|
||||
const value = detail(); value.dispatchInfo.appointmentBatches = ['a', 'b'].map((customerId, index) => ({ batchKey: `FBA_APPOINTMENT:${index + 1}`, batchNo: `AP-${index + 1}`, customerId, recordVersion: 0, sources: [] })); vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
const wrapper = render(value.dispatchInfo.appointmentBatches.map(row => ({ batchKey: row.batchKey, customerId: row.customerId, dispatchNo: value.dispatchNo }))); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.findAll('tbody tr')).toHaveLength(1); expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL')
|
||||
await wrapper.findAll('button').find(button => button.text() === '继续安排本批车辆')!.trigger('click')
|
||||
const dialog = wrapper.getComponent({ name: 'TripPreparationDialog' }); expect(dialog.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-ORIGINAL'] })
|
||||
dialog.vm.$emit('saved', { trip: { tripNo: 'TR/001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR%2F001']])
|
||||
expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
|
||||
it('resolves the opaque dispatch batch in the work domain and opens the original independent detail', async () => {
|
||||
const wrapper = render(); await flushPromises(); expect(resolveBatchWork).toHaveBeenCalledWith('DISPATCH:9007199254740999', undefined)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FORIGINAL']])
|
||||
expect(wrapper.text()).not.toContain('9007199254740999'); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
|
||||
})
|
||||
it('rejects mismatched dispatch identity instead of arranging unrelated cargo', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740998', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], status: 'PENDING_DEPARTURE' })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
|
||||
it('rejects mismatched authority or returned business identity instead of deriving an internal ID', async () => {
|
||||
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'DISPATCH:9007199254740999', customerId: 'a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS-OTHER' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.find('table').exists()).toBe(false); expect(wrapper.text()).toContain('关联不一致'); wrapper.unmount()
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.find('table').exists()).toBe(false); wrong.unmount()
|
||||
})
|
||||
it('opens an already bound real trip instead of preparing another one', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], workflow: { tripNo: 'TR-EXISTING', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).not.toContain('继续安排本批车辆')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'TR-EXISTING')!.trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-EXISTING']]); wrapper.unmount()
|
||||
it('opens same-dispatch adding only under the server gate, and keeps already scanned cargo out of the old merge flow', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '为本车添加同仓货物').trigger('click')
|
||||
expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props()).toMatchObject({ dispatchNo: 'DS/ORIGINAL', canWrite: true }); wrapper.unmount()
|
||||
const value = detail(); value.stage = 'LOADING'; value.counts.pickedPackageCount = 1; value.actions.ADD_CARGO!.allowed = false; vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const scanned = render(); await flushPromises(); expect(scanned.text()).not.toContain('为本车添加同仓货物'); expect(scanned.text()).not.toContain('共车'); scanned.unmount()
|
||||
})
|
||||
it('does not read the dispatch without its original list permission', async () => {
|
||||
state.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
it('does not read dispatch or batch authority without the actual list permissions', async () => {
|
||||
access.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,44 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getDispatch, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import { getDispatch, stageLabel, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }>; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; ready: [valid: boolean] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<DispatchSummaryDto[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit'))
|
||||
const access = useAccessPreviewStore(), rows = ref<DispatchDetail[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const addingNo = ref(''), addingBusy = ref(false)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list') && (!props.dispatches.some(row => row.batchKey.startsWith('DISPATCH:')) || access.can('platform:appointment:list')))
|
||||
function path(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
function canEdit(row: DispatchDetail) { return props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && row.status === 'ACTIVE' && row.actions.EDIT?.allowed === true }
|
||||
function canAdd(row: DispatchDetail) { return props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && row.status === 'ACTIVE' && row.stage === 'BEFORE_LOADING' && row.actions.ADD_CARGO?.allowed === true }
|
||||
let generation = 0
|
||||
async function load() {
|
||||
const current = ++generation; emit('ready', false); rows.value = []; error.value = null
|
||||
if (!readable.value) return
|
||||
loading.value = true; error.value = null; emit('ready', false)
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await Promise.all([...new Set(props.dispatches.map(reference => reference.dispatchNo))].map(async dispatchNo => {
|
||||
const row = await getDispatch(dispatchNo)
|
||||
// 预约批次必须仍由这张原排车单持有同一客户的分配,不能仅凭展示业务号续办。
|
||||
const matches = props.dispatches.filter(reference => reference.dispatchNo === dispatchNo).every(reference =>
|
||||
reference.batchKey.startsWith('FBA_APPOINTMENT:')
|
||||
? !!reference.customerId && row.status !== 'CANCELLED' && row.appointmentBatches.some(batch => batch.batchKey === reference.batchKey && batch.customerId === reference.customerId)
|
||||
: `DISPATCH:${row.id}` === reference.batchKey)
|
||||
if (!matches || row.dispatchNo !== dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
const references = props.dispatches.map(row => ({ ...row }))
|
||||
const details = await Promise.all([...new Set(references.map(row => row.dispatchNo))].map(async no => {
|
||||
const row = await getDispatch(no)
|
||||
if (row.dispatchNo !== no || row.status === 'CANCELLED') throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
for (const reference of references.filter(value => value.dispatchNo === no)) {
|
||||
let matches = false
|
||||
if (/^FBA_APPOINTMENT:\d+$/.test(reference.batchKey)) {
|
||||
matches = Boolean(reference.customerId && row.dispatchInfo.appointmentBatches.some(batch => batch.batchKey === reference.batchKey && batch.customerId === reference.customerId))
|
||||
} else if (/^DISPATCH:\d+$/.test(reference.batchKey)) {
|
||||
// The work domain resolves its opaque batch identity; Detail has no internal dispatch ID.
|
||||
const resolution = await resolveBatchWork(reference.batchKey, reference.customerId)
|
||||
matches = resolution.batchKey === reference.batchKey && (resolution.detail
|
||||
? resolution.detail.item.batchKey === reference.batchKey && resolution.detail.item.batchNo === no && (!reference.customerId || resolution.customerId === reference.customerId)
|
||||
: resolution.candidates.length > 0 && resolution.candidates.every(candidate => candidate.batchNo === no) && (!reference.customerId || resolution.candidates.some(candidate => candidate.customerId === reference.customerId)))
|
||||
}
|
||||
if (!matches) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
}
|
||||
return row
|
||||
}))
|
||||
emit('ready', true)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '原排车单读取失败') }
|
||||
finally { loading.value = false }
|
||||
if (current !== generation) return
|
||||
rows.value = details; emit('ready', details.length > 0)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '原排车单读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function prepare(row: DispatchSummaryDto) { if (writable.value && !loading.value && !error.value && row.status === 'PENDING_DEPARTURE' && !row.workflow && row.overseasWarehouseId) { selected.value = row; prepareOpen.value = true } }
|
||||
function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
onMounted(load)
|
||||
function add(row: DispatchDetail) { if (canAdd(row) && !loading.value && !error.value && !addingBusy.value) addingNo.value = row.dispatchNo }
|
||||
function updated() { emit('updated'); void load() }
|
||||
watch(() => props.dispatches, load, { immediate: true, deep: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>沿用本批已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
|
||||
<p v-if="!readable">查看原排车单需要排车列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||
<section aria-label="沿用原整车排车"><h3>沿用原整车排车</h3><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p>
|
||||
<p v-if="!readable">查看原排车单及批次关联需要相应列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>起运仓</th><th>箱数</th><th>车次</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="!row.workflow" :disabled="!writable || row.status !== 'PENDING_DEPARTURE' || !row.overseasWarehouseId" @click="prepare(row)">继续安排本批车辆</el-button><el-button @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(row.dispatchNo)}`)">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.overseasWarehouseName || '起运仓待核实' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td><el-button v-if="row.workflow?.tripNo && access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(row.workflow.tripNo)}`)">{{ row.workflow.tripNo }}</el-button><span v-else>尚未关联车次</span></td></tr></tbody></table>
|
||||
<TripPreparationDialog v-if="selected?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="selected.overseasWarehouseId" :warehouse-name="selected.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[selected.dispatchNo]" @saved="saved" />
|
||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>车牌号</th><th>箱数</th><th>阶段</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="canEdit(row)" :disabled="addingBusy" @click="emit('external', path(row.dispatchNo))">继续维护本车</el-button><el-button v-if="canAdd(row)" :disabled="addingBusy" @click="add(row)">为本车添加同仓货物</el-button><el-button @click="emit('external', path(row.dispatchNo))">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.vehicleInfo.vehicle?.plate || '—' }}</td><td>{{ row.counts.packageCount }}</td><td>{{ stageLabel(row.stage) }}</td></tr></tbody></table>
|
||||
<WorkSharedTripForm v-if="addingNo" :key="addingNo" :dispatch-no="addingNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="updated" @external="value => emit('external', value)" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,48 +1,37 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { getDispatch } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() } }))
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(WorkSharedTripForm, { props: { dispatchNo: 'DS-60', canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
TripPreparationDialog: { name: 'TripPreparationDialog', props: ['modelValue', 'warehouseId', 'dispatchNos'], template: '<div />' },
|
||||
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
} } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockImplementation(async number => ({ id: `internal-${number}`, dispatchNo: number, appointmentBatches: [], customerId: number === 'DS-60' ? 'customer-b' : 'customer-d', overseasWarehouseId: 'warehouse-real', overseasWarehouseName: '实际起运仓', packageCount: number === 'DS-60' ? 60 : 40, status: 'PENDING_DEPARTURE' })) })
|
||||
it('checks exact 60 and 40 dispatch business numbers and passes both to the existing Fleet preparation before navigating to its real trip', async () => {
|
||||
const wrapper = render()
|
||||
expect(getDispatch).not.toHaveBeenCalled()
|
||||
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(getDispatch).mock.calls.map(call => call[0])).toEqual(['DS-60', 'DS-40'])
|
||||
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
|
||||
expect(prepare.props()).toMatchObject({ modelValue: true, warehouseId: 'warehouse-real', dispatchNos: ['DS-60', 'DS-40'] })
|
||||
expect(wrapper.text()).toContain('60 箱'); expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).not.toContain('internal-')
|
||||
expect(await guard()).toBe(false)
|
||||
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-SHARED' } }); await flushPromises()
|
||||
expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-SHARED']]); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
function render() { return mount(WorkSharedTripForm, { props: { dispatchNo: 'DS-60', canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, DispatchWorkflowDialog: { name: 'DispatchWorkflowDialog', props: ['modelValue', 'dispatchNo', 'action'], template: '<div data-add-cargo />' }, LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}<button @click="$emit(\'retry\')">重试</button></p>' }, ApiErrorMeta: true } } }) }
|
||||
function detail(no = 'DS-60', allowed = true) { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, counts: { packageCount: 60 }, vehicleInfo: { vehicle: null, driver: null }, actions: { ADD_CARGO: { allowed, blockCode: allowed ? null : 'PICKED', blockReason: allowed ? null : '货物已开始真实拣货' } } } as DispatchDetail }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()) })
|
||||
it('opens the native add-cargo workflow for the same dispatch instead of asking for another customer dispatch', async () => {
|
||||
const wrapper = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-60'); expect(wrapper.find('input').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('60 箱'); await button(wrapper, '选择同仓未安排货物').trigger('click')
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }); expect(dialog.props()).toMatchObject({ modelValue: true, dispatchNo: 'DS-60', action: 'ADD_CARGO' }); expect(await guard()).toBe(false)
|
||||
dialog.vm.$emit('saved'); await flushPromises(); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true); wrapper.unmount()
|
||||
})
|
||||
it('rejects duplicate, cross-warehouse, already-bound and mismatched dispatches without opening Fleet preparation', async () => {
|
||||
const wrapper = render(); await wrapper.get('input').setValue('DS-60'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(getDispatch).not.toHaveBeenCalled()
|
||||
for (const change of [{ overseasWarehouseId: 'another-warehouse' }, { workflow: { tripNo: 'TR-OTHER', dispatchReview: { status: 'PENDING' as const, reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING' as const, reason: null, reviewedAt: null } } }, { status: 'SHIPPED' }, { dispatchNo: 'WRONG' }]) {
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce({ id: 'a', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'warehouse-real', status: 'PENDING_DEPARTURE' }).mockResolvedValueOnce({ id: 'b', dispatchNo: 'DS-40', appointmentBatches: [], overseasWarehouseId: 'warehouse-real', status: 'PENDING_DEPARTURE', ...change })
|
||||
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TripPreparationDialog' }).exists()).toBe(false); expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
}
|
||||
wrapper.unmount()
|
||||
it('honors native stage/gate and permissions and rejects a different returned business number', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail('DS-60', false), stage: 'LOADING' }); const scanned = render(); await flushPromises(); expect(button(scanned, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(scanned.text()).toContain('货物已开始真实拣货'); scanned.unmount()
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.get('[role="alert"]').text()).toContain('原排车单不一致'); wrong.unmount()
|
||||
access.allowed = false; const count = vi.mocked(getDispatch).mock.calls.length, denied = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(count); denied.unmount()
|
||||
})
|
||||
it('keeps a failed lookup retry read-only and respects original permissions', async () => {
|
||||
const wrapper = render(); vi.mocked(getDispatch).mockRejectedValueOnce(new Error('读取失败'))
|
||||
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TripPreparationDialog' }).exists()).toBe(false)
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(4); expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props('dispatchNos')).toEqual(['DS-60', 'DS-40'])
|
||||
wrapper.unmount(); access.allowed = false
|
||||
const readonly = render(); await readonly.get('button').trigger('click'); expect(getDispatch).toHaveBeenCalledTimes(4); readonly.unmount()
|
||||
it('retains an unknown original add command after the overlay closes and offers recovery without a new key', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id); recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'original-key', cargo: [], batchAllocations: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(await guard()).toBe(false)
|
||||
await button(wrapper, '核对原加货请求').trigger('click'); expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
||||
expect(recovery.pending.value?.body.idempotencyKey).toBe('original-key'); wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('update:modelValue', false); await flushPromises(); expect(await guard()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('retries lookup as a read and keeps the original detail navigation', async () => {
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
await button(wrapper, '查看原排车单').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS-60']]); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getDispatch, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { DispatchWorkflowDialog, DispatchInputError, getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ dispatchNo: string; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; busy: [value: boolean]; external: [path: string] }>()
|
||||
const access = useAccessPreviewStore(), otherNo = ref(''), rows = ref<DispatchSummaryDto[]>([])
|
||||
const loading = ref(false), error = ref<ApiErrorDetails | null>(null), prepareOpen = ref(false)
|
||||
const allowed = computed(() => props.canWrite && ['platform:overseas-warehouse:dispatch:list', 'platform:fleet:trip:list', 'platform:fleet:trip:edit'].every(permission => access.can(permission)))
|
||||
watch(prepareOpen, value => emit('busy', value))
|
||||
useWorkDraft(computed(() => false), prepareOpen)
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const detail = ref<DispatchDetail | null>(null), open = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
|
||||
const allowed = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.actions.ADD_CARGO?.allowed === true)
|
||||
const busy = computed(() => loading.value || open.value || Boolean(recovery.pending.value))
|
||||
watch(busy, value => emit('busy', value), { immediate: true })
|
||||
useWorkDraft(computed(() => false), busy)
|
||||
let generation = 0
|
||||
function changed() { ++generation; loading.value = false; rows.value = []; error.value = null }
|
||||
async function prepare() {
|
||||
if (!allowed.value || loading.value || prepareOpen.value) return
|
||||
rows.value = []; error.value = null
|
||||
const other = otherNo.value.trim()
|
||||
if (!other || other === props.dispatchNo) { error.value = { message: '请填写另一张已备好的排车单业务号,不能重复选择本单。' }; return }
|
||||
const current = ++generation; loading.value = true
|
||||
async function load() {
|
||||
const current = ++generation; detail.value = null; error.value = null
|
||||
if (!readable.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const numbers = [props.dispatchNo, other]
|
||||
const details = await Promise.all(numbers.map(getDispatch))
|
||||
const value = await getDispatch(props.dispatchNo)
|
||||
if (current !== generation) return
|
||||
if (details.some((row, index) => row.dispatchNo !== numbers[index])) throw new Error('返回的排车单与所选业务号不一致,请重新核对。')
|
||||
if (details.some(row => row.status !== 'PENDING_DEPARTURE' || row.workflow)) throw new Error('请选择尚未关联车次的待发运排车单;已关联的货物请从原车次继续处理。')
|
||||
if (!details[0]!.overseasWarehouseId || details.some(row => row.overseasWarehouseId !== details[0]!.overseasWarehouseId)) throw new Error('两张排车单必须有明确且相同的起运仓。')
|
||||
rows.value = details; prepareOpen.value = true
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '共车排车单核对失败') }
|
||||
if (value.dispatchNo !== props.dispatchNo) throw new DispatchInputError('读取结果与原排车单不一致,请重新核对')
|
||||
detail.value = value
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本车加货资格读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
onBeforeUnmount(() => { ++generation; emit('busy', false) })
|
||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) open.value = true }
|
||||
function recover() {
|
||||
if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true
|
||||
}
|
||||
function saved() { open.value = false; emit('updated'); void load() }
|
||||
watch(() => props.dispatchNo, load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="与同仓其他排车单共车">
|
||||
<h4>与同仓其他排车单共车</h4>
|
||||
<p>本批排车单:{{ dispatchNo }}。请先按各客户实际托盘分别备好排车单,再核对同仓两单并安排同一车次。</p>
|
||||
<p>所需箱数无法对应完整实物时,请先在原库存作业中完成拆托,再返回核对;分批计划不会代替实物拆托。</p>
|
||||
<label>另一张排车单号<input v-model="otherNo" aria-label="共车排车单号" maxlength="64" :disabled="loading || prepareOpen || !allowed" @input="changed" @keydown.enter.prevent="prepare"></label>
|
||||
<el-button :disabled="!allowed || prepareOpen" :loading="loading" @click="prepare">核对两单并安排共车</el-button>
|
||||
<LoadErrorState v-if="error" title="共车排车单核对失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-for="row in rows" :key="row.dispatchNo">{{ row.dispatchNo }} · {{ row.overseasWarehouseName || '起运仓名称待核实' }} · {{ row.packageCount ?? '箱数待核实' }}<span v-if="row.packageCount != null"> 箱</span></p>
|
||||
<TripPreparationDialog v-if="rows[0]?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="rows[0].overseasWarehouseId" :warehouse-name="rows[0].overseasWarehouseName || '起运仓名称待核实'" :dispatch-nos="rows.map(row => row.dispatchNo)" @saved="saved" />
|
||||
<section class="work-form" aria-label="为原排车添加同仓货物">
|
||||
<h4>为原排车添加同仓货物</h4><p>本车 {{ dispatchNo }}。只选择尚未安排的真实货物加入本车,不合并另一张排车单或搬迁已扫码货物。</p>
|
||||
<p>箱数无法对应完整实物时,先在原库存作业完成拆托;预约分批不代替实物拆托。</p>
|
||||
<LoadErrorState v-if="error" title="本车加货资格读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
|
||||
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
|
||||
<p v-if="recovery.pending.value">原排车请求尚未核对,不能另发加货命令。<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">核对原加货请求</el-button></p>
|
||||
<el-button :disabled="!allowed || busy" :loading="loading" @click="prepare">选择同仓未安排货物</el-button><el-button v-if="readable" :disabled="busy" @click="emit('external', `/overseas-warehouse/dispatches/${encodeURIComponent(dispatchNo)}`)">查看原排车单</el-button>
|
||||
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user