发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #21
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, useDispatchCommandRecovery, type CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
@@ -35,3 +35,46 @@ 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()
|
||||
})
|
||||
it.each([false, true])('refreshes the original add-cargo gate after a rejection and preserves its message when GET fails: %s', async readFails => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '选择同仓未安排货物').trigger('click')
|
||||
if (readFails) vi.mocked(getDispatch).mockRejectedValueOnce(new Error('read unavailable'))
|
||||
else vi.mocked(getDispatch).mockResolvedValueOnce(detail('DS-60', false))
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '来源已变化,请重新核对', code: 'A0409' })
|
||||
dialog.vm.$emit('update:modelValue', false); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('来源已变化,请重新核对')
|
||||
expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id)
|
||||
recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] })
|
||||
recovery.pending.value!.attempted = true
|
||||
const completed: CommandResult = {
|
||||
dispatchNo: 'DS-60', action: 'ADD_CARGO', idempotencyKey: 'known-key', recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING',
|
||||
occurredAt: '2026-10-20T00:00:00Z', effect: 'APPLIED', counts: { itemCount: 1, palletCount: 1, packageCount: 60, pickedPackageCount: 0, shippedPackageCount: 0 },
|
||||
replayed: false, alreadyArranged: false, result: { dispatchNo: 'DS-60', changedItemIds: [] },
|
||||
}
|
||||
recovery.pending.value!.result = completed
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
|
||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||
await button(wrapper, '刷新加货资料').trigger('click')
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
||||
expect(recovery.pending.value!.body.idempotencyKey).toBe('known-key')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '来源版本需重新核对', code: 'A0409' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('来源版本需重新核对')
|
||||
dialog.vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('来源版本需重新核对')
|
||||
expect(wrapper.emitted('updated')).toHaveLength(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = defineProps<{ dispatchNo: string; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; busy: [value: boolean]; external: [path: string] }>()
|
||||
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 rejectionError = 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))
|
||||
@@ -29,11 +30,12 @@ async function load() {
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本车加货资格读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) open.value = true }
|
||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; 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() }
|
||||
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
|
||||
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
|
||||
watch(() => props.dispatchNo, load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
</script>
|
||||
@@ -42,10 +44,11 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
<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>
|
||||
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></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>
|
||||
<p v-if="recovery.pending.value">{{ recovery.pending.value.result ? '原排车操作已完成,页面资料待刷新,不能另发加货命令。' : '原排车请求尚未核对,不能另发加货命令。' }}<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">{{ recovery.pending.value.result ? '刷新加货资料' : '核对原加货请求' }}</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" />
|
||||
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 费用项目列表、启停与排序 | `pages/FeeItemsView.vue`;`master-data/fee-items` | `api/platformFeeItems.ts`;`firstleg/controller/PlatformFeeItemController.java` |
|
||||
| 新增/编辑费用项目弹框 | `pages/FeeItemsView.vue` 内弹框 | `api/platformFeeItems.ts` 的 `FeeItemSaveRequest`(`feeCode` 可改) |
|
||||
| 新增/编辑费用项目弹框 | `pages/FeeItemsView.vue` 内弹框 | `api/platformFeeItems.ts` 的 `SavePlatformFeeItemRequest`;当前表单不提交 `feeCode` |
|
||||
| 默认价格(CNY,可空)与类别/贸易范围 | 列表与新增/编辑弹框 | `api/platformFeeItems.ts`;带价与校验规则见详细索引 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- `feeCode` 允许在编辑时修改,OpenAPI 只约束 `^[A-Za-z0-9_-]+$` 与 50 字符;不要新增“编码不可变”的前端禁用、改名拦截或唯一性推断,以服务端校验为准。
|
||||
- 服务端支持修改 `feeCode`,省略时保留原编码;当前 Admin 表单未暴露该字段,不因本指引扩展界面。编码格式、长度与唯一性以服务端契约和校验为准。
|
||||
- `defaultPrice` 只是带入初值:仅当用户主动选择该项目时带入(入库同时带单价与金额、柜次带金额),**均允许人工修改**;为空表示不自动带价,`0` 表示明确的零元。修改或清空默认价不追改已有费用;打开已有费用、切换供应商或保存业务费用时不重新套用默认价,也不改变审核、发布与结算流程。
|
||||
- 停用费用项目不回溯已出账费用;历史账单继续显示原项目名称快照。
|
||||
- 供应商类别仅对需要供应商的费用类型有效,留空表示任意启用供应商,不由前端补默认值。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- 本文件适用于 `src/modules/inbounds/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,字段与计费细则见 `../../../docs/business-index.md`。
|
||||
- 模块按子域分目录:`measurement/`(测量)、`review/`(测量审核)、`weight/`(称重与 OCR)、`fee/`(费用与人工定价)、`core/`(入库单公共)。先定子域再进文件。
|
||||
- 后端只查 `firstleg/`(测量、审核、计价、扣货、称重库位)与 `warehouse/`(库位);纯页面与文案不读后端。
|
||||
- 后端入口为 `firstleg/`(测量、审核、计价、扣货、称重库位)与 `warehouse/`(库位);仅涉及展示、文案且不涉及接口、权限、状态、金额或数据归属时,默认不读后端;涉及上述边界时沿实际 API 进入对应服务。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
|
||||
@@ -885,7 +885,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog class="admin-density-dialog" v-model="manualEntryOpen" :title="detail?.inboundCode ? `手动录入箱规 · ${detail.inboundCode}` : '手动录入箱规'" width="min(680px, 92vw)" destroy-on-close append-to-body>
|
||||
<el-dialog class="admin-density-dialog" v-model="manualEntryOpen" :title="detail?.inboundCode ? `手动录入箱规 · ${detail.inboundCode}` : '手动录入箱规'" width="min(880px, 92vw)" destroy-on-close append-to-body>
|
||||
<el-alert v-if="manualEntryErrors.length" type="error" :closable="false" show-icon class="manual-entry-alert" title="请修正以下问题">
|
||||
<ul><li v-for="error in manualEntryErrors" :key="error">{{ error }}</li></ul>
|
||||
</el-alert>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
|
||||
import { FileUp } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadOceanBookingDocument, invalidateOceanBookingDocument, uploadOceanBookingDocument } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDocument, OceanBookingDocumentType, OceanBookingStatus } from '../types'
|
||||
@@ -109,13 +110,13 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
|
||||
<template>
|
||||
<section class="surface-panel booking-documents admin-density" aria-label="订舱资料">
|
||||
<div class="booking-documents__heading"><div><h2>订舱资料</h2><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></div><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
|
||||
<div class="booking-documents__heading"><h2>订舱资料<InlineHelp title="订舱资料说明"><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></InlineHelp></h2><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
|
||||
<el-alert v-if="bookingStatus === 'DRAFT' && !editable && manageReason" type="warning" :closable="false" show-icon :title="manageReason" />
|
||||
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
|
||||
<div class="booking-document-types">
|
||||
<article v-for="type in types" :key="type.value" class="booking-document-card">
|
||||
<div class="booking-document-card__heading"><div><h3>{{ type.label }}</h3><p>{{ type.description }}</p><small class="booking-document-limit">任意格式,单文件不超过 50MB</small></div><label v-if="editable" class="booking-document-upload" :class="{ 'is-disabled': Boolean(busyType || busyDocumentId) }"><FileUp :size="15" /><span>{{ busyType === type.value ? '上传处理中' : (documentsOfType(type.value).length ? '替换文件' : '上传文件') }}</span><input type="file" :disabled="Boolean(busyType || busyDocumentId)" @change="selectFile($event, type.value)" /></label></div>
|
||||
<el-empty v-if="!documentsOfType(type.value).length" description="暂无有效文件" :image-size="48" />
|
||||
<div class="booking-document-card__heading"><h3>{{ type.label }}<InlineHelp :title="`${type.label}说明`"><p>{{ type.description }}</p><p>任意格式,单文件不超过 50MB</p></InlineHelp></h3><label v-if="editable" class="booking-document-upload" :class="{ 'is-disabled': Boolean(busyType || busyDocumentId) }"><FileUp :size="15" /><span>{{ busyType === type.value ? '上传处理中' : (documentsOfType(type.value).length ? '替换文件' : '上传文件') }}</span><input type="file" :disabled="Boolean(busyType || busyDocumentId)" @change="selectFile($event, type.value)" /></label></div>
|
||||
<p v-if="!documentsOfType(type.value).length" class="booking-document-empty" role="status">暂无有效文件</p>
|
||||
<ul v-else class="booking-document-list"><li v-for="item in documentsOfType(type.value)" :key="item.id"><div><strong :title="item.fileName">{{ item.fileName }}</strong><small>{{ fileSize(item.sizeBytes) }} · {{ item.uploadedByName || '—' }} · {{ item.uploadedAt }}</small></div><div class="booking-document-actions density-actions"><el-button class="density-action" size="small" plain :loading="busyDocumentId === item.id" @click="download(item)">下载</el-button><el-button data-tone="danger" class="density-action" v-if="editable" size="small" type="danger" plain :loading="busyDocumentId === item.id" @click="remove(item)">作废</el-button></div></li></ul>
|
||||
</article>
|
||||
</div>
|
||||
@@ -124,6 +125,8 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-documents{padding:var(--admin-space-5)}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:var(--admin-space-5)}.booking-documents__heading{margin-bottom:var(--admin-space-5)}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:var(--admin-font-section)}.booking-documents__heading p,.booking-document-card__heading p{margin:var(--admin-space-1) 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-limit{display:block;margin-top:5px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-types{display:grid;grid-template-columns:1fr;gap:0}.booking-document-card{min-width:0;border:0;border-top:1px solid var(--cc-border);border-radius:0;padding:var(--admin-space-3) 0}.booking-document-card h3{font-size:var(--admin-font-section)}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:var(--admin-space-2);min-height:var(--admin-control-height);padding:0 var(--admin-space-5);border:1px solid var(--cc-primary);border-radius:var(--admin-radius);color:var(--cc-primary);font-size:var(--admin-font-caption);cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:var(--admin-space-3);margin:var(--admin-space-5) 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;border-radius:0;background:transparent;border-bottom:1px solid var(--cc-border);padding:var(--admin-space-2) 0}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{white-space:normal;overflow-wrap:anywhere}.booking-document-list small,.booking-document-history small{color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-actions{flex:0 0 auto}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:var(--admin-space-5);color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:var(--admin-space-5);padding:7px var(--admin-space-4);border-bottom:1px solid var(--cc-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr;gap:0}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
|
||||
.booking-documents{padding:var(--admin-space-5)}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:var(--admin-space-5)}.booking-documents__heading{margin-bottom:var(--admin-space-5)}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:var(--admin-font-section)}.booking-document-types{display:grid;grid-template-columns:1fr;gap:0}.booking-document-card{min-width:0;border:0;border-top:1px solid var(--cc-border);border-radius:0;padding:var(--admin-space-3) 0}.booking-document-card h3{font-size:var(--admin-font-section)}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:var(--admin-space-2);min-height:var(--admin-control-height);padding:0 var(--admin-space-5);border:1px solid var(--cc-primary);border-radius:var(--admin-radius);color:var(--cc-primary);font-size:var(--admin-font-caption);cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:var(--admin-space-3);margin:var(--admin-space-5) 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;border-radius:0;background:transparent;border-bottom:1px solid var(--cc-border);padding:var(--admin-space-2) 0}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{white-space:normal;overflow-wrap:anywhere}.booking-document-list small,.booking-document-history small{color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-actions{flex:0 0 auto}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:var(--admin-space-5);color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:var(--admin-space-5);padding:7px var(--admin-space-4);border-bottom:1px solid var(--cc-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr;gap:0}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
|
||||
.booking-document-history li span{min-width:0;overflow-wrap:anywhere}
|
||||
.booking-documents__heading h2,.booking-document-card__heading h3{display:flex;align-items:center;gap:var(--admin-space-1)}
|
||||
.booking-document-empty{margin:var(--admin-space-3) 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}
|
||||
</style>
|
||||
|
||||
@@ -387,7 +387,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<template v-else>
|
||||
<section class="surface-panel">
|
||||
<div class="section-title">
|
||||
<div><h2>订舱信息</h2></div>
|
||||
<h2>订舱信息</h2>
|
||||
<div class="actions">
|
||||
<el-tooltip :content="canPrint ? '按当前已保存资料打印出口货物托运单或提单确认件' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
|
||||
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
|
||||
|
||||
@@ -7,12 +7,13 @@ import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { candidate, command, detail, destination, loadingPreview, page, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
||||
interface State { submit(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail }
|
||||
interface State { submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||
it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
|
||||
@@ -155,3 +156,81 @@ it('refuses already picked item removal and a route that drops an authoritative
|
||||
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
|
||||
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
|
||||
})
|
||||
it('refreshes current actions and notifies its parent after a fresh A0409, without presenting an unknown result or replay request', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } }))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'review-request', traceId: 'review-trace' })]])
|
||||
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains edited values and the original validation response when the action is still allowed', async () => {
|
||||
const { wrapper, state } = await open('EDIT'); state.form.remark = '用户未保存的本轮备注'
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('预计到达时间须在发车之后', 'A0400', 400, undefined, 'validation-request'))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 7, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(state.form.remark).toBe('用户未保存的本轮备注')
|
||||
expect(state.detail.recordVersion).toBe(7)
|
||||
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
||||
expect(wrapper.text()).toContain('A0400')
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await state.retryError()
|
||||
expect(state.form.remark).toBe('用户未保存的本轮备注')
|
||||
expect(api.updateDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports a definite rejection to its parent even if the follow-up GET fails and blocks a new write until refreshed', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW'); state.form.remark = '保留本次备注'
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('审核已提交', 'A0409', 409, undefined, 'reject-request'))
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('GET network failure'))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'reject-request' })]])
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
expect(state.form.remark).toBe('保留本次备注')
|
||||
await state.submit()
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps an unknown original command when its later replay is rejected instead of treating it as a fresh rejection', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('unknown transport outcome')).mockRejectedValueOnce(new ApiResponseError('当前已提交', 'A0409', 409))
|
||||
await state.submit()
|
||||
const original = structuredClone(vi.mocked(api.submitDispatchReview).mock.calls[0]![1])
|
||||
expect(wrapper.text()).toContain('原操作结果尚待核对')
|
||||
vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('未找到命令', 'A0404', 404))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(api.submitDispatchReview).toHaveBeenNthCalledWith(2, 'PC/1', original)
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.body).toEqual(original)
|
||||
expect(wrapper.emitted('rejected')).toBeUndefined()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(api.getDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('原操作结果尚待核对')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('describes a proven success with a failed GET as completed and retries only its read', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
||||
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('current detail unavailable'))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.result).toMatchObject({ action: 'SUBMIT_REVIEW', recordVersion: 1 })
|
||||
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
expect(wrapper.text()).not.toContain('核对并重试原请求')
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 }))
|
||||
await state.submit()
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -14,14 +14,14 @@ import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candi
|
||||
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
|
||||
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import DispatchArrangeFields from './DispatchArrangeFields.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ dispatchNo: string; action: DispatchAction; operation?: 'ROUTE' }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const emit = defineEmits<{ saved: []; rejected: [details: ApiErrorDetails] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const { pending, busy } = recovery
|
||||
@@ -30,6 +30,7 @@ const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
|
||||
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
|
||||
const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
|
||||
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const factsStale = ref(false)
|
||||
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
|
||||
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
|
||||
const candidates = ref<Candidate[]>([]), selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string }>>([]), candidateKeyword = ref('')
|
||||
@@ -40,7 +41,7 @@ const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver()
|
||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
const allowed = computed(() => !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const allowed = computed(() => !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
|
||||
@@ -49,7 +50,7 @@ const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DR
|
||||
async function load() {
|
||||
const sequence = ++generation, no = currentNo.value, action = currentAction.value
|
||||
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { loading.value = false; return }
|
||||
loading.value = true; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
|
||||
loading.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
|
||||
items.value = null; itemId.value = ''; route.value = null; stops.value = []
|
||||
try {
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
@@ -207,17 +208,46 @@ async function submit() {
|
||||
if (!result) return
|
||||
ElMessage.success(routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
|
||||
emit('saved'); open.value = false
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '操作结果尚未确认,请保留原请求核对') }
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : pending.value?.result ? '操作已完成,页面资料待刷新' : pending.value ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
|
||||
// Recovery clears only a fresh, definite rejection. A later refusal must not erase an unknown write.
|
||||
if (!pending.value && isDefiniteDispatchRejection(cause)) {
|
||||
editorRequestKey = ''
|
||||
emit('rejected', error.value)
|
||||
await refreshFacts()
|
||||
}
|
||||
}
|
||||
}
|
||||
async function refreshFacts() {
|
||||
const sequence = ++generation, no = currentNo.value
|
||||
loading.value = true; factsStale.value = true
|
||||
try {
|
||||
const value = await api.getDispatch(no)
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
if (currentOperation.value === 'ROUTE') {
|
||||
const data = await api.getDispatchRoute(no)
|
||||
if (sequence !== generation) return
|
||||
if (data.dispatchNo !== no) throw new DispatchInputError('路线与原排车单不一致')
|
||||
route.value = data
|
||||
}
|
||||
factsStale.value = false
|
||||
if (!pending.value && !allowed.value) open.value = false
|
||||
} catch (cause) {
|
||||
// Retain the original rejection and every editor value if its follow-up read also fails.
|
||||
if (sequence === generation && !error.value) error.value = getApiErrorDetails(cause, '当前操作资格读取失败,请重试刷新')
|
||||
} finally { if (sequence === generation) loading.value = false }
|
||||
}
|
||||
async function retryError() { if (pending.value) await submit(); else if (detail.value) await refreshFacts(); else await load() }
|
||||
function moveStop(index: number, offset: number) { const target = index + offset; if (frozen.value || target < 0 || target >= stops.value.length) return; [stops.value[index], stops.value[target]] = [stops.value[target]!, stops.value[index]!] }
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else generation++ }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert v-if="pending" role="status" title="原操作结果尚待核对,当前使用原版本和原请求内容。" type="warning" :closable="false" show-icon />
|
||||
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending?.rejected" :disabled="busy || loading || uploadingEvidence" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
|
||||
<el-alert v-if="pending" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
|
||||
<LoadErrorState v-if="error" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
|
||||
<el-form v-if="detail" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
|
||||
|
||||
@@ -19,8 +19,12 @@ it('keeps a proven result across GET failure and does not send the command a sec
|
||||
const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn()
|
||||
await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed')
|
||||
expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING')
|
||||
await recovery.execute(send, read, find)
|
||||
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!).result).toEqual(command('LOADING_COMPLETE'))
|
||||
const reopened = useDispatchCommandRecovery(() => owner)
|
||||
expect(reopened.pending.value?.result).toEqual(command('LOADING_COMPLETE'))
|
||||
await reopened.execute(send, read, find)
|
||||
expect(send).toHaveBeenCalledTimes(1); expect(find).not.toHaveBeenCalled()
|
||||
expect(reopened.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
|
||||
})
|
||||
it('shares one pending command between empty-create and workflow dialogs and refuses a new command', () => {
|
||||
const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner), second = useDispatchCommandRecovery(() => owner)
|
||||
@@ -38,6 +42,12 @@ it('does not discard an earlier unknown write on a later business rejection', as
|
||||
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('unknown')
|
||||
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('version conflict')
|
||||
stable.discardRejected(); expect(stable.pending.value).not.toBeNull()
|
||||
expect(find).toHaveBeenCalledWith('PC/1', 'key')
|
||||
expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key' })
|
||||
expect(stable.pending.value?.rejected).toBe(false)
|
||||
const saved = JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!)
|
||||
expect(saved).toMatchObject({ attempted: true, rejected: false, result: null, body: { recordVersion: 0, idempotencyKey: 'key' } })
|
||||
expect(useDispatchCommandRecovery(() => owner).pending.value).toEqual(saved)
|
||||
})
|
||||
it('rejects a mismatched business number/action/key instead of treating it as successful', async () => {
|
||||
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
|
||||
@@ -61,3 +71,66 @@ it('treats an internal server error as unknown instead of authorizing a new comm
|
||||
await expect(recovery.execute(vi.fn().mockRejectedValue(new ApiResponseError('server failure', 'A0500', 500)), vi.fn(), vi.fn())).rejects.toThrow('server failure')
|
||||
recovery.discardRejected(); expect(recovery.pending.value?.rejected).toBe(false)
|
||||
})
|
||||
it.each(['400', '401', '403', '404', '409', '410', '422'].flatMap(code => [code, `A0${code}`, `HTTP_${code}`]))(
|
||||
'clears the first definite rejection %s from memory and storage and preserves its original error', async code => {
|
||||
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
|
||||
recovery.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'rejected-key' })
|
||||
const rejection = new ApiResponseError('明确拒绝', code)
|
||||
const send = vi.fn().mockRejectedValue(rejection), read = vi.fn(), find = vi.fn()
|
||||
await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
expect(read).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled()
|
||||
expect(recovery.pending.value).toBeNull(); expect(recovery.busy.value).toBe(false)
|
||||
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
|
||||
const reopened = useDispatchCommandRecovery(() => owner)
|
||||
expect(reopened.pending.value).toBeNull()
|
||||
expect(() => reopened.begin('PC/1', 'EDIT', { recordVersion: 2, idempotencyKey: 'fresh-key' })).not.toThrow()
|
||||
},
|
||||
)
|
||||
it('cleans an explicitly rejected legacy browser record when its scope is restored', () => {
|
||||
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
|
||||
sessionStorage.setItem(key, JSON.stringify({ dispatchNo: 'PC/1', action: 'SUBMIT_REVIEW',
|
||||
body: { recordVersion: 0, idempotencyKey: 'terminal-key' }, attempted: true, rejected: true, result: null }))
|
||||
const recovery = useDispatchCommandRecovery(() => owner)
|
||||
expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
|
||||
expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 1, idempotencyKey: 'new-key' })).not.toThrow()
|
||||
})
|
||||
it('cleans a terminal rejected record already shared by dialogs in the same scope', () => {
|
||||
const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner)
|
||||
first.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'terminal-key' })
|
||||
first.pending.value!.attempted = true; first.pending.value!.rejected = true
|
||||
sessionStorage.setItem(`dispatch-command:${owner}`, JSON.stringify(first.pending.value))
|
||||
const second = useDispatchCommandRecovery(() => owner)
|
||||
expect(second.pending).toBe(first.pending)
|
||||
expect(first.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
|
||||
})
|
||||
it('retains a restored unknown record without explicit rejection proof and reconciles its original key', async () => {
|
||||
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
|
||||
const original = { dispatchNo: 'PC/1', action: 'EDIT', body: { recordVersion: 0, idempotencyKey: 'unknown-key', remark: 'original' },
|
||||
attempted: true, rejected: false, result: null }
|
||||
sessionStorage.setItem(key, JSON.stringify(original))
|
||||
const recovery = useDispatchCommandRecovery(() => owner)
|
||||
expect(recovery.pending.value).toEqual(original)
|
||||
const rejection = new ApiResponseError('later rejection', 'A0409')
|
||||
const send = vi.fn().mockRejectedValue(rejection), read = vi.fn()
|
||||
const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
|
||||
await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
|
||||
expect(find).toHaveBeenCalledWith('PC/1', 'unknown-key')
|
||||
expect(send.mock.calls[0]![0].body).toEqual(original.body)
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
|
||||
})
|
||||
it('only rereads a restored proven result and keeps it when the detail read still fails', async () => {
|
||||
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
|
||||
const original = { dispatchNo: 'PC/1', action: 'LOADING_COMPLETE', body: { recordVersion: 0, idempotencyKey: 'key' },
|
||||
attempted: true, rejected: false, result: command('LOADING_COMPLETE') }
|
||||
sessionStorage.setItem(key, JSON.stringify(original))
|
||||
const recovery = useDispatchCommandRecovery(() => owner)
|
||||
const send = vi.fn(), find = vi.fn(), failure = new Error('detail unavailable')
|
||||
const read = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 })
|
||||
await expect(recovery.execute(send, read, find)).rejects.toBe(failure)
|
||||
expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
|
||||
await recovery.execute(send, read, find)
|
||||
expect(send).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled(); expect(read).toHaveBeenCalledTimes(2)
|
||||
expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -64,7 +64,9 @@ export function useDispatchCommandRecovery(scope: () => string) {
|
||||
try { const sent = await send(snapshot(command)); validateCommandResult(sent, command); command.result = sent }
|
||||
catch (error) {
|
||||
// A later rejection cannot erase an earlier unknown write result.
|
||||
if (!previouslyAttempted && isDefiniteDispatchRejection(error)) command.rejected = true
|
||||
if (!previouslyAttempted && isDefiniteDispatchRejection(error)) {
|
||||
pending.value = null
|
||||
}
|
||||
persist(); throw error
|
||||
}
|
||||
}
|
||||
@@ -75,8 +77,11 @@ export function useDispatchCommandRecovery(scope: () => string) {
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
function discardRejected() {
|
||||
if (busy.value || !pending.value?.rejected) return
|
||||
if (busy.value || pending.value?.rejected !== true) return
|
||||
pending.value = null; persist()
|
||||
}
|
||||
// Older browser records explicitly marked a first-attempt rejection as terminal.
|
||||
// Only that recorded proof permits cleanup; unknown attempts and proven results remain.
|
||||
discardRejected()
|
||||
return { pending, busy, begin, execute, discardRejected }
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock('@/modules/overseas-master-data', async () => ({
|
||||
inquiryReturnTarget: (value: Record<string, string>) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null,
|
||||
}))
|
||||
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } }
|
||||
beforeEach(() => { vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
||||
describe('whole-dispatch four-tab detail', () => {
|
||||
it('uses only four accessible colored tabs and switches without another network read', async () => {
|
||||
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||
@@ -104,4 +104,45 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click')
|
||||
expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount()
|
||||
})
|
||||
it.each(['saved', 'rejected'] as const)('refreshes detail actions after the workflow %s event and retains a real rejection after closure', async event => {
|
||||
can.mockReturnValue(true)
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '提交调度审核')!.trigger('click')
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '已提交' } } }))
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'detail-reject-request' })
|
||||
else dialog.vm.$emit('saved')
|
||||
dialog.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '提交调度审核')).toBe(false)
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4)
|
||||
if (event === 'rejected') {
|
||||
expect(wrapper.text()).toContain('本轮审核已经提交')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('detail-reject-request')
|
||||
}
|
||||
expect(wrapper.text()).not.toContain('结果尚待核对')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains a workflow rejection when the parent detail refresh also fails', async () => {
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('detail read failed'))
|
||||
wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('rejected', { message: '已提交,请刷新', code: 'A0409' })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('已提交,请刷新')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('排车详情加载失败')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('请补齐本趟资料')
|
||||
dialog.vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('请补齐本趟资料')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
|
||||
const detail = ref<DispatchDetail | null>(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref<ApiErrorDetails | null>(null)
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
|
||||
const detailActions: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
|
||||
@@ -65,13 +66,16 @@ async function load() {
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function back() { void router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
|
||||
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (!canDispatchAction(detail.value?.actions, value, access.can)) return; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
|
||||
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
|
||||
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
|
||||
async function handleSaved() { rejectionError.value = null; await load() }
|
||||
watch(dispatchNo, load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
@@ -85,7 +89,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
</OverseasDetailShell>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import DispatchesView from './DispatchesView.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import RowActions from '@/shared/components/RowActions.vue'
|
||||
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
||||
import { detail, listRow, page } from '../dispatchTestFixtures'
|
||||
import { command, detail, listRow, page } from '../dispatchTestFixtures'
|
||||
import type { DispatchPage } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
@@ -69,3 +69,45 @@ it('navigates by the business number without arranging a separate trip', async (
|
||||
it('reads one BOL on demand and preserves popup failure handling', async () => {
|
||||
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %s without retaining a stale submit button', async event => {
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions')).toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
|
||||
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })]))
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'list-reject-request' })
|
||||
else dialog.vm.$emit('saved')
|
||||
dialog.vm.$emit('update:modelValue', false); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenCalledTimes(2)
|
||||
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
|
||||
if (event === 'rejected') {
|
||||
expect(wrapper.text()).toContain('本轮审核已经提交')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('list-reject-request')
|
||||
}
|
||||
expect(wrapper.text()).not.toContain('结果尚待核对')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('describes a proven command awaiting its GET as completed instead of unknown', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id)
|
||||
recovery.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'known-key' })
|
||||
recovery.pending.value!.attempted = true
|
||||
recovery.pending.value!.result = command('SUBMIT_REVIEW', 'known-key')
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
|
||||
expect(wrapper.text()).not.toContain('结果尚待核对')
|
||||
await wrapper.findAll('button').find(button => button.text() === '刷新页面资料')!.trigger('click')
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('请补齐本趟资料')
|
||||
dialog.vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('请补齐本趟资料')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | null>(null)
|
||||
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
@@ -78,12 +79,15 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'detail') return openDetail(row)
|
||||
if (value === 'bol') return void openBol(row)
|
||||
rejectionError.value = null
|
||||
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
|
||||
operation.value = undefined
|
||||
const requested = value as DispatchAction
|
||||
if (!canDispatchAction(row.actions, requested, access.can)) return
|
||||
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
|
||||
}
|
||||
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
|
||||
async function handleSaved() { rejectionError.value = null; await load() }
|
||||
function recoverPending() {
|
||||
const command = recovery.pending.value
|
||||
if (!command) return
|
||||
@@ -109,7 +113,8 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}结果尚待核对。<el-button link :disabled="recovery.busy.value" @click="recoverPending">核对原请求</el-button></p>
|
||||
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}<el-button link :disabled="recovery.busy.value" @click="recoverPending">{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}</el-button></p>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template>
|
||||
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
|
||||
@@ -122,6 +127,6 @@ onBeforeUnmount(() => { sequence++ })
|
||||
</DataTable>
|
||||
</ListPageLayout></AdminPage>
|
||||
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
|
||||
</template>
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('InboundFeeDetailManager', () => {
|
||||
const dialog = document.querySelector<HTMLElement>('.el-dialog.admin-density-dialog')!
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.querySelector('.density-form')).not.toBeNull()
|
||||
expect(dialog.style.getPropertyValue('--el-dialog-width')).toBe(action === '编辑' ? 'min(680px, calc(100vw - 28px))' : 'min(560px, calc(100vw - 28px))')
|
||||
expect(dialog.style.getPropertyValue('--el-dialog-width')).toBe(action === '编辑' ? 'min(880px, calc(100vw - 28px))' : 'min(720px, calc(100vw - 28px))')
|
||||
if (action === '驳回') {
|
||||
const confirm = [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '确认驳回')!
|
||||
confirm.click()
|
||||
|
||||
@@ -336,7 +336,7 @@ onMounted(() => void load())
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
|
||||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(880px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
|
||||
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="selectedFeeItem?.feeCode === privateDeliveryFeeCode" title="私人仓尾程派送费仅适用于目的仓类型为“私人仓”的订单,最终资格由服务端校验。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
|
||||
@@ -369,7 +369,7 @@ onMounted(() => void load())
|
||||
</el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" @confirm="reject">
|
||||
<AppDialog class="admin-density-dialog" v-model="rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" width="min(720px, calc(100vw - 28px))" @confirm="reject">
|
||||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="驳回原因" required><el-input v-model="rejectReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -63,6 +63,20 @@ describe('OrderDeclarationEditorDialog', () => {
|
||||
expect([...parent.querySelectorAll('.density-dialog-section__title')].map(title => title.textContent)).toEqual(['原订单资料', '订单申报资料', '货品申报明细 不允许新增、删除或调整明细序号'])
|
||||
expect(parent.style.getPropertyValue('--el-dialog-width')).toBe('min(1180px, calc(100vw - 36px))')
|
||||
expect(parent.querySelector('.density-descriptions')).not.toBeNull()
|
||||
const fields = parent.querySelector<HTMLTableElement>('table[aria-label="订单申报资料"]')!
|
||||
expect(fields.rows).toHaveLength(2)
|
||||
expect([...fields.querySelectorAll('.el-form-item__label')].map(label => label.textContent?.trim())).toEqual(['渠道名称', '渠道编码', '预估体积(m³)', '预计到货时间', '客户备注'])
|
||||
expect(fields.rows[1]!.cells[1]!.colSpan).toBe(2)
|
||||
const remark = fields.querySelector<HTMLInputElement>('input[aria-label="客户备注"]')!
|
||||
remark.value = '待保存的客户备注'
|
||||
remark.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
for (const label of ['展开全屏', '退出全屏']) {
|
||||
parent.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!.click()
|
||||
await flushPromises()
|
||||
expect(parent.querySelector<HTMLInputElement>('input[aria-label="客户备注"]')!.value).toBe('待保存的客户备注')
|
||||
expect(fields.querySelector('[aria-label="渠道编码"]')!.textContent).toBe('OLD')
|
||||
}
|
||||
parent.querySelector<HTMLButtonElement>('.cargo-table .density-action')!.click()
|
||||
await flushPromises()
|
||||
const dialogs = document.querySelectorAll<HTMLElement>('.el-dialog.admin-density-dialog')
|
||||
@@ -97,10 +111,20 @@ describe('OrderDeclarationEditorDialog', () => {
|
||||
expect(listWarehouseLogisticsChannels).toHaveBeenCalledWith('700000000000000001')
|
||||
expect(wrapper.text()).toContain('国内收货仓和中转仓不可修改,如需变更请重新下单')
|
||||
expect(wrapper.text()).toContain('RK-001')
|
||||
const volume = wrapper.findComponent({ name: 'ElInputNumber' })
|
||||
expect(volume.props()).toMatchObject({ min: 0.0001, precision: 4, controls: false, modelValue: 0.625 })
|
||||
volume.vm.$emit('update:modelValue', 0.875)
|
||||
const arrival = wrapper.findComponent({ name: 'ElDatePicker' })
|
||||
expect(arrival.props()).toMatchObject({ type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm:ss', format: 'YYYY-MM-DD HH:mm', clearable: true, modelValue: '2026-09-06T10:00:00' })
|
||||
arrival.vm.$emit('update:modelValue', '2026-09-07T11:30:00')
|
||||
const remark = wrapper.get('input[aria-label="客户备注"]')
|
||||
expect(remark.attributes('maxlength')).toBe('1000')
|
||||
await remark.setValue('调整后的客户备注')
|
||||
const channelSelect = wrapper.findAllComponents({ name: 'ElSelect' })[0]!
|
||||
channelSelect.vm.$emit('update:modelValue', 'NEW')
|
||||
channelSelect.vm.$emit('change', 'NEW')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.get('span[aria-label="渠道编码"]').text()).toBe('NEW')
|
||||
await wrapper.get('[data-testid="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -108,7 +132,9 @@ describe('OrderDeclarationEditorDialog', () => {
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('纸面上的旧渠道文字不会自动变化'), '确认变更渠道', expect.any(Object))
|
||||
expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({
|
||||
expectedVersion: 7,
|
||||
estimatedVolumeCbm: 0.625,
|
||||
estimatedVolumeCbm: 0.875,
|
||||
expectedArrivalAt: '2026-09-07T11:30:00',
|
||||
customerRemark: '调整后的客户备注',
|
||||
channelCode: 'NEW',
|
||||
channelName: '新渠道',
|
||||
cargoes: [expect.objectContaining({ itemNo: 1, usage: '照明' })],
|
||||
@@ -122,7 +148,8 @@ describe('OrderDeclarationEditorDialog', () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('input[aria-label="渠道编码"]').attributes('readonly')).toBeDefined()
|
||||
expect(wrapper.find('input[aria-label="渠道编码"]').exists()).toBe(false)
|
||||
expect(wrapper.get('span[aria-label="渠道编码"]').text()).toBe('OLD')
|
||||
expect(wrapper.findAllComponents({ name: 'ElSelect' })).toHaveLength(1)
|
||||
expect(wrapper.find('.cargo-table').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('箱数 / 数量')
|
||||
|
||||
@@ -253,14 +253,21 @@ async function save() {
|
||||
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<h3 class="density-dialog-section__title">订单申报资料</h3>
|
||||
<el-form label-position="top" class="order-fields density-form">
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="8"><el-form-item label="渠道名称" required><el-select v-model="form.channelCode" aria-label="渠道名称" filterable clearable :loading="channelsLoading" placeholder="请选择正式渠道" @change="selectChannel"><el-option v-for="channel in channels" :key="channel.channelCode" :label="`${channel.channelName} · ${channel.channelCode}`" :value="channel.channelCode" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="渠道编码"><el-input :model-value="form.channelCode" readonly aria-label="渠道编码" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="预估体积(m³)"><el-input-number v-model="form.estimatedVolumeCbm" :min="0.0001" :precision="4" :controls="false" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="预计到货时间"><el-date-picker v-model="form.expectedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" clearable /></el-form-item></el-col>
|
||||
<el-col :span="16"><el-form-item label="客户备注"><el-input v-model="form.customerRemark" maxlength="1000" show-word-limit /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form label-position="left" label-width="110px" class="order-fields density-form">
|
||||
<table class="density-table density-table--editable order-declaration-fields" aria-label="订单申报资料">
|
||||
<colgroup><col style="width: 40%"><col style="width: 27%"><col style="width: 33%"></colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><el-form-item label="渠道名称" required><el-select v-model="form.channelCode" aria-label="渠道名称" filterable clearable :loading="channelsLoading" placeholder="请选择正式渠道" @change="selectChannel"><el-option v-for="channel in channels" :key="channel.channelCode" :label="`${channel.channelName} · ${channel.channelCode}`" :value="channel.channelCode" /></el-select></el-form-item></td>
|
||||
<td><el-form-item label="渠道编码"><span aria-label="渠道编码">{{ form.channelCode || '—' }}</span></el-form-item></td>
|
||||
<td><el-form-item label="预估体积(m³)"><el-input-number v-model="form.estimatedVolumeCbm" :min="0.0001" :precision="4" :controls="false" aria-label="预估体积(m³)" placeholder="请输入预估体积(选填)" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="预计到货时间"><el-date-picker v-model="form.expectedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" aria-label="预计到货时间" clearable /></el-form-item></td>
|
||||
<td colspan="2"><el-form-item label="客户备注"><el-input v-model="form.customerRemark" maxlength="1000" show-word-limit aria-label="客户备注" placeholder="请输入客户备注(选填)" /></el-form-item></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</el-form>
|
||||
</section>
|
||||
|
||||
@@ -314,13 +321,13 @@ async function save() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-fields :deep(.el-date-editor), .order-fields :deep(.el-input) { width: 100%; }
|
||||
.order-declaration-fields { table-layout: fixed; }
|
||||
.cargo-table { width: 100%; }
|
||||
.cargo-table small, .cargo-table .cell-primary { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cargo-table small { margin-top: 3px; color: #98a2b3; }
|
||||
.cargo-editor-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-5) var(--admin-space-5); }
|
||||
.cargo-editor-grid label { display: grid; min-width: 0; gap: var(--admin-space-1); color: #667085; font-size: var(--admin-font-caption); }
|
||||
.cargo-editor-grid label.wide { grid-column: span 3; }
|
||||
.cargo-editor-grid :deep(.el-input-number), .cargo-editor-grid :deep(.el-input), .order-fields :deep(.el-select) { width: 100%; }
|
||||
.cargo-editor-grid :deep(.el-input-number), .cargo-editor-grid :deep(.el-input) { width: 100%; }
|
||||
@media (max-width: 900px) { .cargo-editor-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .cargo-editor-grid label.wide { grid-column: span 2; } }
|
||||
</style>
|
||||
|
||||
@@ -2981,7 +2981,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
</section>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="coLoadCreateOpen" title="合柜报关" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="!canCreateCoLoadGroup" @confirm="createCoLoadGroup">
|
||||
<AppDialog class="admin-density-dialog" v-model="coLoadCreateOpen" title="合柜报关" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="!canCreateCoLoadGroup" width="min(720px, calc(100vw - 28px))" @confirm="createCoLoadGroup">
|
||||
<el-alert type="warning" :closable="false" show-icon :title="`所选 ${selectedOrders.length} 票须属于同一国内仓、同一渠道,并全量进入同一柜;目的仓或目的港可以不同。资格由服务端按完整入仓片段校验。`" />
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">所选入仓码</h3>
|
||||
@@ -3019,7 +3019,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
/>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="noteOpen" :title="orderDialogTitle('编辑订单备注', noteOrder?.inboundCode)" width="min(680px, calc(100vw - 28px))" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
|
||||
<AppDialog class="admin-density-dialog" v-model="noteOpen" :title="orderDialogTitle('编辑订单备注', noteOrder?.inboundCode)" width="min(880px, calc(100vw - 28px))" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
|
||||
<el-form label-position="top" class="order-remarks-form">
|
||||
<el-form-item label="客户备注(只读)"><el-input :model-value="noteOrder?.customerRemark || '—'" disabled /></el-form-item>
|
||||
<el-form-item label="内部备注"><el-input v-model="noteForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
@@ -3028,12 +3028,12 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="holdDialogOpen" :title="orderDialogTitle(holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货', detail?.summary.inboundCode)" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
|
||||
<AppDialog class="admin-density-dialog" v-model="holdDialogOpen" :title="orderDialogTitle(holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货', detail?.summary.inboundCode)" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(720px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
|
||||
<el-alert :title="holdAction === 'HOLD' ? '扣货后服务端将阻止后续实物履约操作。' : '解除后是否可继续履约仍以服务端状态为准。'" :type="holdAction === 'HOLD' ? 'warning' : 'info'" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="hold-command-form"><el-form-item :label="holdAction === 'HOLD' ? '扣货原因' : '解除原因'" required><el-input v-model="holdReason" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="holdAction === 'HOLD' ? '请输入扣货原因' : '请输入解除扣货原因'" aria-label="扣货操作原因" /></el-form-item><el-form-item label="关联文件(选填)"><el-upload :key="holdEvidenceUploadKey" :auto-upload="false" :limit="1" accept=".txt,.pdf,.jpg,.jpeg,.png,text/plain,application/pdf,image/jpeg,image/png" :disabled="holdSaving" :on-change="selectHoldEvidenceFile" :on-remove="removeHoldEvidenceFile"><el-button :disabled="holdSaving"><FileUp :size="15" />选择文件</el-button><template #tip><div class="el-upload__tip">仅支持 TXT、PDF、JPG、PNG,单文件不超过 10MB。</div></template></el-upload></el-form-item><LoadErrorState v-if="holdEvidenceError" compact title="关联文件处理失败" :message="holdEvidenceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="holdEvidenceError" /></template></LoadErrorState></el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="approvalOpen" :title="orderDialogTitle('审核订单', approvalOrder?.inboundCode)" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||||
<AppDialog class="admin-density-dialog" v-model="approvalOpen" :title="orderDialogTitle('审核订单', approvalOrder?.inboundCode)" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" width="min(720px, calc(100vw - 28px))" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||||
<section v-if="approvalOrder" class="density-dialog-section" data-tone="summary">
|
||||
<h3 class="density-dialog-section__title">订单审核资料</h3>
|
||||
<dl class="approval-facts">
|
||||
|
||||
@@ -162,7 +162,7 @@ describe('LocationsView', () => {
|
||||
it('uses the prototype-sized centered editor without a detail drawer or section cards', () => {
|
||||
expect(locationsSource).not.toContain('AppDrawer')
|
||||
expect(locationsSource).not.toContain('FormSection')
|
||||
expect(locationsSource).toContain('width="min(560px, calc(100vw - 28px))"')
|
||||
expect(locationsSource).toContain('width="min(880px, calc(100vw - 28px))"')
|
||||
expect(densitySource).toContain('.admin-density-dialog.el-dialog .el-dialog__body { min-height: 0; overflow-y: auto;')
|
||||
expect(locationsSource).toContain('.editor-field-grid { grid-template-columns: minmax(0, 1fr); }')
|
||||
})
|
||||
|
||||
@@ -600,7 +600,7 @@ onMounted(() => {
|
||||
<AppDialog
|
||||
v-model="editorOpen"
|
||||
:title="editorMode === 'edit' && form.locationCode ? `${editorTitle} · ${form.locationCode}` : editorTitle"
|
||||
width="min(560px, calc(100vw - 28px))"
|
||||
width="min(880px, calc(100vw - 28px))"
|
||||
class="location-editor-dialog admin-density-dialog"
|
||||
:confirm-loading="saving"
|
||||
:close-on-click-modal="false"
|
||||
|
||||
@@ -178,6 +178,15 @@ const cssRoot = (css: string) => {
|
||||
}
|
||||
|
||||
describe('approved Admin density ownership', () => {
|
||||
it('keeps declaration order fields on the continuous cell-editor baseline', () => {
|
||||
const source = read('modules/unified-orders/components/OrderDeclarationEditorDialog.vue')
|
||||
const orderFields = source.slice(source.indexOf('<el-form label-position="left"'), source.indexOf('</el-form>') + '</el-form>'.length)
|
||||
expect(orderFields).toContain('class="order-fields density-form"')
|
||||
expect(orderFields).toContain('density-table density-table--editable')
|
||||
expect(orderFields).not.toMatch(/<el-(?:row|col)\b/)
|
||||
expect(orderFields).toContain('<span aria-label="渠道编码">')
|
||||
expect(orderFields).not.toContain('<el-input :model-value="form.channelCode"')
|
||||
})
|
||||
it('shares the measurement palette through explicit dialog sections without changing other dialog bodies', () => {
|
||||
const tokenValues: Record<string, string> = {}
|
||||
cssRoot(tokens).walkDecls(decl => { tokenValues[decl.prop] = decl.value })
|
||||
|
||||
Reference in New Issue
Block a user