Merge origin/release/test and preserve pickup review photos
This commit is contained in:
@@ -1086,7 +1086,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
|
|
||||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
|
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
|
||||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
|
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">基础资料</h3>
|
||||||
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
|
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
|
||||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
|
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
|
<tr><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
|
||||||
@@ -1095,7 +1095,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
</tbody></table>
|
</tbody></table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
||||||
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
|
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
|
||||||
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td></tr>
|
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
|
<tr><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
|
||||||
@@ -1103,7 +1103,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
</tbody></table>
|
</tbody></table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="density-dialog-section qualification-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
<section class="density-dialog-section qualification-section workbench-evidence"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||||
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
|
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
|
||||||
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
||||||
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
||||||
|
|||||||
@@ -93,9 +93,9 @@ onMounted(() => { void load() })
|
|||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
<AppDialog class="admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
<AppDialog class="admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||||
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
|
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
|
||||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
|
<section class="density-dialog-section workbench-evidence"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||||
<div class="customer-fee-tier-groups">
|
<div class="customer-fee-tier-groups">
|
||||||
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
|
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section">
|
||||||
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
|
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
|
||||||
<div class="customer-fee-tier-table-wrap workbench-evidence"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
|
<div class="customer-fee-tier-table-wrap workbench-evidence"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
|
||||||
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
|
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-work-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-work-bg); }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
|
||||||
|
|
||||||
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert { background: var(--admin-surface, #fff); }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 海外通用资料原型核对矩阵
|
# 海外通用资料原型核对矩阵
|
||||||
|
|
||||||
|
## 散板需求只读展示(2026-10-21)
|
||||||
|
|
||||||
|
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
|
||||||
|
|
||||||
|
| 页面/操作 | 当前承载与调整 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| 散板需求列表/查询、分页、详情 | 保持独立列表与原余位来源,默认展示车次余位、线路、时间、联系信息;隐藏待匹配散板、我的登记、登记及待办入口,原代码保留 | 43项定向测试及页面、测试源码定向类型检查通过;待人工1920×1080、100%截图确认 |
|
||||||
|
| 散板需求详情/查看、重新核对 | 保持独立详情,仅展示运输摘要、联系方式和分段余位;隐藏登记、意向列表、来货及履约窗口,旧登记参数不再打开窗口 | 同上 |
|
||||||
|
|
||||||
## 2026-10-20 列表顶部紧凑收尾
|
## 2026-10-20 列表顶部紧凑收尾
|
||||||
|
|
||||||
依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。
|
依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import ElementPlus, { ElMessageBox } from 'element-plus'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { defineComponent } from 'vue'
|
import { defineComponent, ref } from 'vue'
|
||||||
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
||||||
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
|
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
|
||||||
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
|
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
|
||||||
import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue'
|
|
||||||
import type { CapacityOffer } from '../capacityOfferTypes'
|
import type { CapacityOffer } from '../capacityOfferTypes'
|
||||||
import type { FleetTrip } from '../tripTypes'
|
import type { FleetTrip } from '../tripTypes'
|
||||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
@@ -64,11 +63,13 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
|
|||||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => {
|
it('preserves route-leave protection in the retained native inquiry dialog without duplicating the global prompt', async () => {
|
||||||
const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm')
|
const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm')
|
||||||
|
// 登记页面入口已隐藏;保留组件自身与真实路由的未保存保护回归。
|
||||||
|
const InquiryHost = defineComponent({ components: { CapacityInquiryDialog }, setup: () => ({ offer, open: ref(false) }), template: '<button @click="open = true">打开保留的登记组件</button><CapacityInquiryDialog v-model="open" :offer="offer" :inquiry="null" :readonly="false" />' })
|
||||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||||
{ path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '<p data-testid="pool-list">运力列表</p>' }, meta: { requiresAuth: true } },
|
{ path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '<p data-testid="pool-list">运力列表</p>' }, meta: { requiresAuth: true } },
|
||||||
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } },
|
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: InquiryHost, meta: { requiresAuth: true } },
|
||||||
{ path: '/outside', name: 'outside', component: { template: '<p>外部页面</p>' }, meta: { workspaceTab: false } },
|
{ path: '/outside', name: 'outside', component: { template: '<p>外部页面</p>' }, meta: { workspaceTab: false } },
|
||||||
] })
|
] })
|
||||||
// Match the application's intentional policy: cross-workspace switching skips the global prompt.
|
// Match the application's intentional policy: cross-workspace switching skips the global prompt.
|
||||||
@@ -81,7 +82,7 @@ it('blocks browser back with the real router, cached detail and native dialog, w
|
|||||||
await router.push('/pool'); await router.isReady()
|
await router.push('/pool'); await router.isReady()
|
||||||
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
|
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
|
||||||
await router.push('/pool/CO-CLOSE'); await flushPromises()
|
await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||||
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
|
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
|
||||||
await wrapper.get('details summary').trigger('click')
|
await wrapper.get('details summary').trigger('click')
|
||||||
const input = wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]')
|
const input = wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]')
|
||||||
await input.setValue('UI-修复后保留输入'); await flushPromises()
|
await input.setValue('UI-修复后保留输入'); await flushPromises()
|
||||||
@@ -92,15 +93,15 @@ it('blocks browser back with the real router, cached detail and native dialog, w
|
|||||||
expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true)
|
expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true)
|
||||||
await continueEditing()
|
await continueEditing()
|
||||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
||||||
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).not.toHaveBeenCalled()
|
||||||
confirms.mockClear(); router.back(); await flushPromises()
|
confirms.mockClear(); router.back(); await flushPromises()
|
||||||
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
||||||
const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement
|
const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement
|
||||||
expect(leave).toBeTruthy(); leave.click(); await flushPromises()
|
expect(leave).toBeTruthy(); leave.click(); await flushPromises()
|
||||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
|
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
|
||||||
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).not.toHaveBeenCalled()
|
||||||
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
|
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||||
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
|
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
|
||||||
await wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
|
await wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
|
||||||
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
|
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
|
||||||
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
|
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
|
||||||
|
|||||||
@@ -53,11 +53,14 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const value = await getCapacityOffer(String(route.params.offerNo))
|
const value = await getCapacityOffer(String(route.params.offerNo))
|
||||||
let checked: CapacityInquiry | null = null
|
let checked: CapacityInquiry | null = null
|
||||||
|
// 当前只读展示余位,旧登记深链不再打开登记窗口;原流程保留。
|
||||||
|
/*
|
||||||
if (typeof route.query?.inquiry === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(route.query.inquiry)) {
|
if (typeof route.query?.inquiry === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(route.query.inquiry)) {
|
||||||
const returned = await getCapacityInquiry(route.query.inquiry)
|
const returned = await getCapacityInquiry(route.query.inquiry)
|
||||||
if (returned.offerNo !== value.offer.offerNo) throw new Error('登记不属于当前发布')
|
if (returned.offerNo !== value.offer.offerNo) throw new Error('登记不属于当前发布')
|
||||||
if (current === sequence) { checked = returned; readonly.value = true; confirmMode.value = false; editorOpen.value = true }
|
if (current === sequence) { checked = returned; readonly.value = true; confirmMode.value = false; editorOpen.value = true }
|
||||||
} else if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
|
} else if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
|
||||||
|
*/
|
||||||
if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) }
|
if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) }
|
||||||
}
|
}
|
||||||
catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } }
|
catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } }
|
||||||
@@ -90,18 +93,29 @@ watch([page, size], loadInquiries)
|
|||||||
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
|
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
|
||||||
</script>
|
</script>
|
||||||
<template><AdminPage class="admin-density admin-workbench"><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="运力发布详情">
|
<template><AdminPage class="admin-density admin-workbench"><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="运力发布详情">
|
||||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
|
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
|
<el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button>
|
||||||
|
--></div></div></template>
|
||||||
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
|
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
|
||||||
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||||
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
|
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
|
<template><p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
|
||||||
|
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p></template>
|
||||||
|
--><p v-if="detail.offer.blockReason" role="status">{{ detail.offer.blockReason }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
|
||||||
<h2 class="capacity-section-title">分段运力</h2><div class="workbench-table"><DataTable :data="detail.offer.segments" :columns="columns" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable></div>
|
<h2 class="capacity-section-title">分段运力</h2><div class="workbench-table"><DataTable :data="detail.offer.segments" :columns="columns" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable></div>
|
||||||
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
|
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
|
<template><h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
|
||||||
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
|
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template>
|
||||||
|
</LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
|
||||||
|
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" /></template>
|
||||||
|
-->
|
||||||
</template></div>
|
</template></div>
|
||||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
|
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||||||
|
-->
|
||||||
|
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
|
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
|
||||||
|
-->
|
||||||
</DetailPageLayout></AdminPage></template>
|
</DetailPageLayout></AdminPage></template>
|
||||||
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
|
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function mount(component: object, props: Record<string, unknown> = {}): { wrappe
|
|||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
|
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
|
||||||
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
|
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
|
||||||
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="loads" :row="row"/></div><slot/></div>' },
|
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total', 'columns'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="toolbar-end"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="actions" :row="row"/><slot name="loads" :row="row"/><slot name="status" :row="row"/><slot name="contact" :row="row"/><slot name="destinations" :row="row"/></div><slot/></div>' },
|
||||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], template: '<div/>' },
|
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], template: '<div/>' },
|
||||||
CapacityInquiryTable: { name: 'CapacityInquiryTable', props: ['rows', 'total', 'failed'], emits: ['action', 'update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' },
|
CapacityInquiryTable: { name: 'CapacityInquiryTable', props: ['rows', 'total', 'failed'], emits: ['action', 'update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' },
|
||||||
CapacityInquiryDialog: component === CapacityInquiryDialog ? false : true,
|
CapacityInquiryDialog: component === CapacityInquiryDialog ? false : true,
|
||||||
@@ -59,35 +59,46 @@ it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labe
|
|||||||
expect(detail.find('[label="已核实实托数"]').exists()).toBe(false)
|
expect(detail.find('[label="已核实实托数"]').exists()).toBe(false)
|
||||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
|
it('keeps the pool read-only and sends one query after resetting from a later page', async () => {
|
||||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||||
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
|
||||||
|
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||||
|
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
|
||||||
expect(wrapper.text()).toContain('待调度核对')
|
expect(wrapper.text()).toContain('待调度核对')
|
||||||
|
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||||
|
expect(wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).exists()).toBe(false)
|
||||||
|
for (const label of ['我的登记', '待匹配散板', '登记客户货物', '待我处理']) expect(button(wrapper, label)).toBeUndefined()
|
||||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||||
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||||
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
|
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3)
|
||||||
await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
|
||||||
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'MINE' })); expect(api.getCapacityOffer).not.toHaveBeenCalled()
|
|
||||||
const query = wrapper.findComponent({ name: 'QueryToolbar' }); query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
|
||||||
expect(api.listCapacityInquiries).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'MINE' }))
|
|
||||||
})
|
})
|
||||||
it('ignores an older page response after changing tabs and retains an explicit error state', async () => {
|
|
||||||
|
it('ignores an older pool query response and retains errors with retry', async () => {
|
||||||
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
|
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
|
||||||
vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done }))
|
vi.mocked(api.listCapacityOffers).mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||||
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
const { wrapper } = mount(CapacityOffersView)
|
||||||
|
vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([offer], 7))
|
||||||
|
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||||
resolve(pageOf([offer], 900)); await flushPromises()
|
resolve(pageOf([offer], 900)); await flushPromises()
|
||||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('total')).toBe(1)
|
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(7)
|
||||||
vi.mocked(api.listCapacityInquiries).mockRejectedValue(new Error('读取失败')); wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
vi.mocked(api.listCapacityOffers).mockRejectedValueOnce(new Error('读取失败'))
|
||||||
|
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
||||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('failed')).toBe(true)
|
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
||||||
|
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
|
||||||
})
|
})
|
||||||
it('reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes', async () => {
|
|
||||||
|
it('reads only the offer detail and hides registration even with write permission', async () => {
|
||||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
|
||||||
await button(wrapper, '登记客户货物').trigger('click'); await flushPromises()
|
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(true)
|
expect(button(wrapper, '登记客户货物')).toBeUndefined()
|
||||||
|
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||||
|
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).exists()).toBe(false)
|
||||||
expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled()
|
expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => {
|
it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => {
|
||||||
vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true })
|
vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true })
|
||||||
const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
|
const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
|
||||||
@@ -120,12 +131,13 @@ it('retains an uncertain request and route protection after closing and reopenin
|
|||||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||||
expect(api.saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, inquiry.bookingNo, first)
|
expect(api.saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, inquiry.bookingNo, first)
|
||||||
})
|
})
|
||||||
it('keeps sales from confirming even if a stale row advertises the action', async () => {
|
it('retains pool route details as the only cargo row action for sales', async () => {
|
||||||
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
|
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).exists()).toBe(false)
|
||||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
|
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled(); expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads publishing settings only when explicitly opened, not with the trip page', async () => {
|
it('loads publishing settings only when explicitly opened, not with the trip page', async () => {
|
||||||
state.manage = true
|
state.manage = true
|
||||||
const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises()
|
const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises()
|
||||||
@@ -148,24 +160,22 @@ it('retries an uncertain combined save and confirmation with the original payloa
|
|||||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the create button disabled with a reason after cutoff, while hiding it without write permission', async () => {
|
it('keeps capacity blockers visible without registration prompts after cutoff', async () => {
|
||||||
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
|
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
|
||||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||||
expect(button(wrapper, '登记客户货物').attributes('disabled')).toBeDefined()
|
expect(button(wrapper, '登记客户货物')).toBeUndefined()
|
||||||
expect(wrapper.text()).toContain('无法登记客户货物:交货截止已过')
|
expect(wrapper.text()).toContain('交货截止已过')
|
||||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
|
expect(wrapper.text()).not.toContain('无法登记客户货物')
|
||||||
state.write = false
|
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||||
const readonly = mount(CapacityOfferDetailView); await flushPromises()
|
|
||||||
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
|
|
||||||
})
|
})
|
||||||
it('defaults minimal managers to their TODO inquiries without loading offers, and explicitly switches to available offers', async () => {
|
|
||||||
|
it('defaults managers to the same read-only pool and preserves explicit historical queries', async () => {
|
||||||
state.manage = true
|
state.manage = true
|
||||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||||
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'TODO' }))
|
|
||||||
expect(api.listCapacityOffers).not.toHaveBeenCalled()
|
|
||||||
expect(state.links.size).toBe(0)
|
|
||||||
await button(wrapper, '可拼车次').trigger('click'); await flushPromises()
|
|
||||||
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
|
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
|
||||||
|
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||||
|
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
|
||||||
|
for (const label of ['我的登记', '待匹配散板', '待我处理', '登记客户货物']) expect(button(wrapper, label)).toBeUndefined()
|
||||||
const query = wrapper.findComponent({ name: 'QueryToolbar' })
|
const query = wrapper.findComponent({ name: 'QueryToolbar' })
|
||||||
query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||||
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' }))
|
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' }))
|
||||||
@@ -188,21 +198,6 @@ it('requires an explicit choice for multiple destinations', async () => {
|
|||||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigation must retain the exact bound business references and independent read permissions.
|
|
||||||
it.each([CapacityOffersView, CapacityOfferDetailView])('opens the bound trip separately from read-only dispatch detail', async component => {
|
|
||||||
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
|
|
||||||
state.links = new Set(['platform:fleet:trip:list', 'platform:overseas-warehouse:dispatch:list'])
|
|
||||||
const { wrapper } = mount(component); await flushPromises()
|
|
||||||
const dialog = wrapper.findComponent(CapacityInquiryDialog)
|
|
||||||
dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
|
|
||||||
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
|
|
||||||
dialog.vm.$emit('action', 'trip', row); await flushPromises()
|
|
||||||
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
|
|
||||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
|
||||||
state.links.clear(); state.push.mockClear()
|
|
||||||
dialog.vm.$emit('action', 'trip', row); dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
|
|
||||||
expect(state.push).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
it('shows follow-up destinations only with the matching read permission', async () => {
|
it('shows follow-up destinations only with the matching read permission', async () => {
|
||||||
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
|
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
|
||||||
state.links.add('platform:overseas-warehouse:dispatch:list')
|
state.links.add('platform:overseas-warehouse:dispatch:list')
|
||||||
@@ -318,69 +313,47 @@ it('shows the business identity and exposes the order link only with its list pe
|
|||||||
await button(wrapper, 'ORDER-1').trigger('click')
|
await button(wrapper, 'ORDER-1').trigger('click')
|
||||||
expect(wrapper.emitted('action')?.[0]).toEqual(['business-order', row])
|
expect(wrapper.emitted('action')?.[0]).toEqual(['business-order', row])
|
||||||
})
|
})
|
||||||
it('reopens a returned inquiry through an authorized fetch and refuses a different offer', async () => {
|
it('ignores old inquiry deep links and renders only the offer summary', async () => {
|
||||||
state.route.query = { inquiry: inquiry.bookingNo }
|
|
||||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
|
|
||||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
|
||||||
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
|
|
||||||
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
|
|
||||||
expect(editor.props('inquiry').bookingNo).toBe(inquiry.bookingNo)
|
|
||||||
wrapper.unmount()
|
|
||||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, offerNo: 'FO-OTHER' })
|
|
||||||
const rejected = mount(CapacityOfferDetailView).wrapper; await flushPromises()
|
|
||||||
expect(rejected.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
|
|
||||||
expect(rejected.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
|
||||||
})
|
|
||||||
it('opens refreshed server guidance after fulfillment succeeds without replaying a write', async () => {
|
|
||||||
state.manage = true
|
state.manage = true
|
||||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
|
||||||
const updated = { ...inquiry, status: 'BOUND' as const, progress: { ...inquiry.progress, primaryAction: 'VIEW', message: '服务端下一待办', owner: '审核 / 仓库' } }
|
|
||||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue(updated)
|
|
||||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'bind', inquiry); await flushPromises()
|
|
||||||
wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).vm.$emit('saved'); await flushPromises()
|
|
||||||
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
|
|
||||||
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
|
|
||||||
expect(editor.props('inquiry').progress.message).toBe('服务端下一待办')
|
|
||||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
it('fetches an off-page returned inquiry once per refresh and retries without falling back to stale data', async () => {
|
|
||||||
state.route.query = { inquiry: inquiry.bookingNo }
|
state.route.query = { inquiry: inquiry.bookingNo }
|
||||||
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer, inquiries: pageOf<CapacityInquiry>([], 30) })
|
|
||||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
|
|
||||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||||
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
|
expect(api.getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-1')
|
||||||
expect(editor.props('modelValue')).toBe(true)
|
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
|
||||||
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
|
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 7 })
|
expect(wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).exists()).toBe(false)
|
||||||
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
expect(wrapper.text()).toContain(offer.warehouseName)
|
||||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
|
expect(wrapper.text()).toContain(offer.contactPhone)
|
||||||
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
|
})
|
||||||
expect(editor.props('inquiry').recordVersion).toBe(7)
|
|
||||||
vi.mocked(api.getCapacityInquiry).mockRejectedValueOnce(new Error('读取失败'))
|
it('keeps transport, destination, remaining capacity and contact columns for manual follow-up', async () => {
|
||||||
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||||
const error = wrapper.findComponent({ name: 'LoadErrorState' })
|
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as { key: string }[]
|
||||||
expect(error.exists()).toBe(true)
|
for (const key of ['warehouseName', 'destinations', 'plannedDepartureAt', 'contactName']) expect(columns.some(column => column.key === key)).toBe(true)
|
||||||
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
|
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 8 })
|
|
||||||
error.vm.$emit('retry'); await flushPromises()
|
|
||||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
|
|
||||||
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
|
|
||||||
expect(editor.props('inquiry').recordVersion).toBe(8)
|
|
||||||
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
|
||||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows customer looseboard demand in the same pool and passes the original to the selected trip inquiry', async () => {
|
it('refreshes and retries read-only offer detail without loading an old inquiry', async () => {
|
||||||
const source = { orderNo: 'LB-SOURCE', customerId: '19', version: 2, origin: 'LA', destination: 'ONT8', productName: '货物', estimatedPalletCount: 3, packageCount: 12, totalWeightKg: 100, totalVolumeCbm: 2, currency: 'USD', originContactName: 'Alice', originContactPhone: '555', remark: null }
|
state.route.query = { inquiry: inquiry.bookingNo }
|
||||||
vi.mocked(api.listLooseboardDemands).mockResolvedValue(pageOf([source]))
|
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||||
|
vi.mocked(api.getCapacityOffer).mockClear()
|
||||||
|
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
||||||
|
expect(api.getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-1')
|
||||||
|
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
|
||||||
|
vi.mocked(api.getCapacityOffer).mockRejectedValueOnce(new Error('读取失败'))
|
||||||
|
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
||||||
|
const error = wrapper.findComponent({ name: 'LoadErrorState' })
|
||||||
|
expect(error.exists()).toBe(true)
|
||||||
|
error.vm.$emit('retry'); await flushPromises()
|
||||||
|
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
|
||||||
|
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
|
||||||
|
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not load customer looseboard demands or expose matching from the pool', async () => {
|
||||||
|
state.manage = true
|
||||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||||
await button(wrapper, '待匹配散板').trigger('click'); await flushPromises()
|
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
|
||||||
expect(api.listLooseboardDemands).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined })
|
expect(button(wrapper, '匹配车次')).toBeUndefined()
|
||||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([source])
|
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||||
expect(api.getCapacityOffer).not.toHaveBeenCalled()
|
|
||||||
// 用页面实际暴露的匹配动作选择来源,再沿已有登记入口选车。
|
|
||||||
const vm = wrapper.vm as unknown as { matchDemand: (row: typeof source) => void; create: (row: CapacityOffer) => Promise<void> }
|
|
||||||
vm.matchDemand(source); await flushPromises(); await vm.create(offer); await flushPromises()
|
|
||||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('sourceDemand')).toEqual(source)
|
|
||||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('offer').offerNo).toBe(offer.offerNo)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
|
|||||||
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||||||
const router = useRouter(), access = useAccessPreviewStore()
|
const router = useRouter(), access = useAccessPreviewStore()
|
||||||
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||||
const tab = ref<'offers' | 'inquiries' | 'demands'>(manage.value ? 'inquiries' : 'offers')
|
// 当前只展示余位需求池;登记、原单匹配及履约模板直接隐藏,原实现保留。
|
||||||
const filters = ref<QueryModel>(manage.value ? { scope: 'TODO' } : {}), applied = ref<QueryModel>({ ...filters.value })
|
const tab = ref<'offers' | 'inquiries' | 'demands'>('offers')
|
||||||
|
const filters = ref<QueryModel>({}), applied = ref<QueryModel>({ ...filters.value })
|
||||||
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
|
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
|
||||||
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
|
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
|
||||||
@@ -56,8 +57,8 @@ let sequence = 0, editorSequence = 0
|
|||||||
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
|
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
|
||||||
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
|
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
|
||||||
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
|
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
|
||||||
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
|
{ key: 'canConfirm', label: '余位状态', type: 'select', placeholder: '全部状态', width: 150, options: [{ value: 'true', label: '余位已核对' }, { value: 'false', label: '待核对或不可承运' }] },
|
||||||
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
|
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '当前发布', width: 180, options: [{ value: 'AVAILABLE', label: '当前发布' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
|
||||||
] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [
|
] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [
|
||||||
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
|
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
|
||||||
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
|
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||||
@@ -68,7 +69,7 @@ const columns = computed<DataTableColumn[]>(() => [
|
|||||||
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
|
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
|
||||||
{ key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' },
|
{ key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' },
|
||||||
...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []),
|
...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []),
|
||||||
{ key: 'status', label: '接单状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
|
{ key: 'status', label: '发布状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
|
||||||
])
|
])
|
||||||
const canCreate = computed(() => manage.value || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create'))
|
const canCreate = computed(() => manage.value || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create'))
|
||||||
async function create(row: CapacityOffer) {
|
async function create(row: CapacityOffer) {
|
||||||
@@ -77,7 +78,7 @@ async function create(row: CapacityOffer) {
|
|||||||
try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
|
try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||||
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
|
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
|
||||||
}
|
}
|
||||||
function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
|
function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (value !== 'offers' || tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
|
||||||
async function load() {
|
async function load() {
|
||||||
const current = ++sequence; loading.value = true; error.value = null
|
const current = ++sequence; loading.value = true; error.value = null
|
||||||
try {
|
try {
|
||||||
@@ -129,29 +130,40 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
|
|||||||
</script>
|
</script>
|
||||||
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求">
|
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求">
|
||||||
<p v-if="selectedDemand && tab === 'offers'">正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。<el-button link @click="selectedDemand = null">取消选择</el-button></p>
|
<p v-if="selectedDemand && tab === 'offers'">正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。<el-button link @click="selectedDemand = null">取消选择</el-button></p>
|
||||||
|
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
<div v-if="tab === 'demands'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-looseboard-demands" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="demands" :columns="demandColumns" :loading="loading" :total="total" row-key="orderNo" empty-text="暂无待匹配散板需求">
|
<div v-if="tab === 'demands'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-looseboard-demands" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="demands" :columns="demandColumns" :loading="loading" :total="total" row-key="orderNo" empty-text="暂无待匹配散板需求">
|
||||||
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="true" class="is-active" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></template>
|
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="true" class="is-active" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="待匹配散板查询" :loading="loading" @search="search" /></template>
|
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="待匹配散板查询" :loading="loading" @search="search" /></template>
|
||||||
<template #notices><LoadErrorState v-if="error" title="需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
<template #notices><LoadErrorState v-if="error" title="需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" @click="matchDemand(row as LooseboardDemand)">匹配车次</el-button></div></template>
|
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" @click="matchDemand(row as LooseboardDemand)">匹配车次</el-button></div></template>
|
||||||
</DataTable></div>
|
</DataTable></div>
|
||||||
|
-->
|
||||||
<div v-if="tab === 'offers'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
<div v-if="tab === 'offers'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||||||
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
<template #toolbar-start><h1>散板需求</h1><!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
|
<div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||||||
|
--></template><template #toolbar-end><!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
|
<el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button>
|
||||||
|
--><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<div class="integrated-query-group">
|
<div class="integrated-query-group">
|
||||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
|
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
|
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
|
<el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button>
|
||||||
|
--><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
|
||||||
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
|
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
|
||||||
<template #destinations="{ row }"><div v-for="stop in (row as CapacityOffer).destinations" :key="stop.destinationKey">{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}</div></template>
|
<template #destinations="{ row }"><div v-for="stop in (row as CapacityOffer).destinations" :key="stop.destinationKey">{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}</div></template>
|
||||||
<template #times="{ row }"><div>发车 {{ offerTime(row.plannedDepartureAt) }}</div><div>最晚送仓 {{ offerTime(row.cutoffAt) }}</div></template>
|
<template #times="{ row }"><div>发车 {{ offerTime(row.plannedDepartureAt) }}</div><div>最晚送仓 {{ offerTime(row.cutoffAt) }}</div></template>
|
||||||
<template #loads="{ row }"><span v-if="!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
|
<template #loads="{ row }"><span v-if="!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
|
||||||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }}<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
|
<template> · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}</template>
|
||||||
|
--><div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||||||
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
|
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
|
||||||
</DataTable></div>
|
</DataTable></div>
|
||||||
<CapacityInquiryTable v-else-if="tab === 'inquiries'" integrated class="is-page-surface" v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
|
<CapacityInquiryTable v-if="tab === 'inquiries'" integrated class="is-page-surface" v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
||||||
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div><span v-if="applied.scope === 'TODO' || applied.scope === 'ALL'" class="workbench-badge">{{ applied.scope === 'TODO' ? '待我处理' : '全部登记' }}</span></template>
|
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div><span v-if="applied.scope === 'TODO' || applied.scope === 'ALL'" class="workbench-badge">{{ applied.scope === 'TODO' ? '待我处理' : '全部登记' }}</span></template>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<div class="integrated-query-group">
|
<div class="integrated-query-group">
|
||||||
@@ -160,6 +172,11 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
|
|||||||
</template>
|
</template>
|
||||||
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
|
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
|
||||||
</CapacityInquiryTable>
|
</CapacityInquiryTable>
|
||||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
-->
|
||||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
|
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||||||
|
-->
|
||||||
|
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||||
|
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
||||||
|
-->
|
||||||
</ListPageLayout></AdminPage></template>
|
</ListPageLayout></AdminPage></template>
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ export const overseasMasterDataRoutes = [
|
|||||||
path: 'overseas-master-data/capacity-offers',
|
path: 'overseas-master-data/capacity-offers',
|
||||||
name: 'overseas-master-data-capacity-offers',
|
name: 'overseas-master-data-capacity-offers',
|
||||||
component: () => import('./pages/CapacityOffersView.vue'),
|
component: () => import('./pages/CapacityOffersView.vue'),
|
||||||
meta: { title: '散板需求', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
meta: { title: '散板需求', description: '查看车次余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'overseas-master-data/capacity-offers/:offerNo',
|
path: 'overseas-master-data/capacity-offers/:offerNo',
|
||||||
name: 'overseas-master-data-capacity-offer-detail',
|
name: 'overseas-master-data-capacity-offer-detail',
|
||||||
component: () => import('./pages/CapacityOfferDetailView.vue'),
|
component: () => import('./pages/CapacityOfferDetailView.vue'),
|
||||||
meta: { title: '散板需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
meta: { title: '散板需求详情', description: '查看车次分段余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'overseas-master-data/fleet-quotes',
|
path: 'overseas-master-data/fleet-quotes',
|
||||||
|
|||||||
@@ -436,3 +436,5 @@
|
|||||||
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 |
|
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 |
|
||||||
|
|
||||||
|
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ it('reads existing delivery files in one dialog and saves receipt without requir
|
|||||||
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
|
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
|
||||||
const wrapper = setup(); await flushPromises()
|
const wrapper = setup(); await flushPromises()
|
||||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.trigger('click')
|
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.trigger('click')
|
||||||
|
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['LOADING_PHOTO', 'BOL'])
|
||||||
await wrapper.getComponent(DeliveryDocumentsDialog).find('input').setValue('BOL-CHANGED')
|
await wrapper.getComponent(DeliveryDocumentsDialog).find('input').setValue('BOL-CHANGED')
|
||||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
|
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
|
||||||
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
|||||||
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||||
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
|
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
|
||||||
<div v-show="tab !== 'FUEL'" v-if="!beforeDeparture">
|
<div v-show="tab !== 'FUEL'" v-if="!beforeDeparture">
|
||||||
|
<h3 v-if="tab === 'LOADING'" class="density-dialog-section__title">关联派送单资料</h3>
|
||||||
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
|
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
|
||||||
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['BOL']" @saved="saved" />
|
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['LOADING_PHOTO', 'BOL']" @saved="saved" />
|
||||||
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
|
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ it('shows existing photos without exposing upload for a read-only account', asyn
|
|||||||
expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); wrapper.unmount()
|
expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('allows choosing another image after the server definitively rejects the file', async () => {
|
||||||
|
vi.mocked(api.uploadLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('图片内容无效', 'A0400'))
|
||||||
|
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||||
|
expect(wrapper.text()).toContain('图片内容无效')
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||||
|
expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
|
||||||
|
await upload(wrapper)
|
||||||
|
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2)
|
||||||
|
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('allows saving photos while loading and keeps the loading stage', async () => {
|
it('allows saving photos while loading and keeps the loading stage', async () => {
|
||||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING' }))
|
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING' }))
|
||||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'LOADING', recordVersion: '1', photos: [photo] }))
|
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'LOADING', recordVersion: '1', photos: [photo] }))
|
||||||
|
|||||||
@@ -69,7 +69,13 @@ async function upload(slot: LoadingPhotoSlot, event: Event) {
|
|||||||
try {
|
try {
|
||||||
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||||
pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求') }
|
} catch (cause) {
|
||||||
|
if (active) {
|
||||||
|
error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求')
|
||||||
|
// 文件校验明确拒绝时尚未创建上传意图,允许重新选择;未知结果仍保留原请求。
|
||||||
|
if (pending.value && !pending.value.photo && /^(?:A0|HTTP_)?400$/.test(String(error.value.code))) { pending.value = null; persist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
finally { if (active) busy.value = false }
|
finally { if (active) busy.value = false }
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -54,32 +54,43 @@ function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) {
|
|||||||
describe('dispatch workflow native layout', () => {
|
describe('dispatch workflow native layout', () => {
|
||||||
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
|
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
|
||||||
const { dialog } = await render('EDIT')
|
const { dialog } = await render('EDIT')
|
||||||
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
|
expect(dialog.props('title')).toBe('维护车辆 · PC/1')
|
||||||
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
|
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
|
||||||
await field(dialog, '车牌号').get('input').setValue('CA-NEW')
|
await field(dialog, '车牌号').get('input').setValue('CA-NEW')
|
||||||
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
|
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
|
||||||
await field(dialog, '司机姓名').get('input').setValue('新司机')
|
await field(dialog, '司机姓名').get('input').setValue('新司机')
|
||||||
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
|
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
|
||||||
await dialog.get('textarea[aria-label="本次操作备注"]').setValue('维护备注')
|
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||||
dialog.vm.$emit('confirm'); await flushPromises()
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
|
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
|
||||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '维护备注' })
|
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||||
|
})
|
||||||
|
it('saves all three resource photos before submitting review with the refreshed version', async () => {
|
||||||
|
const { dialog } = await render('EDIT')
|
||||||
|
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||||
|
fields.forEach((field, index) => field.vm.$emit('update:modelValue', [String(401 + index)]))
|
||||||
|
await flushPromises()
|
||||||
|
await dialog.findAll('button').find(button => button.text() === '提交审核')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.objectContaining({ evidenceFileIds: ['401'] }), driver: expect.objectContaining({ evidence: expect.objectContaining({ photoFileIds: ['402'], fileIds: ['403'] }) }) }))
|
||||||
|
expect(api.submitDispatchReview).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null }))
|
||||||
|
expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!)
|
||||||
})
|
})
|
||||||
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
|
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
|
||||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||||
const { wrapper, dialog } = await render('EDIT')
|
const { wrapper, dialog } = await render('EDIT')
|
||||||
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
|
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
|
||||||
await dialog.get('textarea[aria-label="本次操作备注"]').setValue('保留本次输入')
|
|
||||||
dialog.vm.$emit('confirm'); await flushPromises()
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
|
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
|
||||||
expect(dialog.text()).toContain('workflow-request')
|
expect(dialog.text()).toContain('workflow-request')
|
||||||
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
|
||||||
await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises()
|
await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises()
|
||||||
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
|
expect(dialog.props('title')).toBe('维护车辆 · PC/1')
|
||||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||||
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
|
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
|
||||||
expect((dialog.get('textarea[aria-label="本次操作备注"]').element as HTMLTextAreaElement).value).toBe('保留本次输入')
|
expect(original.remark).toBe('真实备注')
|
||||||
|
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||||
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||||
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
|
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
|
||||||
})
|
})
|
||||||
@@ -173,9 +184,10 @@ describe('dispatch workflow native layout', () => {
|
|||||||
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
|
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
|
||||||
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
|
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
|
||||||
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
|
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
|
||||||
await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接')
|
if (needsReason) await dialog.get('textarea').setValue(' 现场原因 ')
|
||||||
|
else expect(dialog.find('textarea').exists()).toBe(false)
|
||||||
dialog.vm.$emit('confirm'); await flushPromises()
|
dialog.vm.$emit('confirm'); await flushPromises()
|
||||||
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: '本次交接' }) })
|
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) })
|
||||||
})
|
})
|
||||||
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
|
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
|
||||||
const { dialog } = await render('EDIT', 'ROUTE')
|
const { dialog } = await render('EDIT', 'ROUTE')
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
|
|||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||||
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||||
async function open(action: DispatchAction, allowed = true) {
|
async function open(action: DispatchAction, allowed = true) {
|
||||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
||||||
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||||
@@ -215,3 +215,65 @@ it('does not submit an exception when the server disallows the action', async ()
|
|||||||
state.exception.exceptionType = 'REFUSED'; await state.submit()
|
state.exception.exceptionType = 'REFUSED'; await state.submit()
|
||||||
expect(api.markDispatchException).not.toHaveBeenCalled(); wrapper.unmount()
|
expect(api.markDispatchException).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('submits review from vehicle maintenance after saving the current inputs and reading the new version', async () => {
|
||||||
|
const { wrapper, state } = await open('EDIT')
|
||||||
|
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||||
|
expect(wrapper.find('[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.findAll('button').some(button => button.text() === '保存')).toBe(true)
|
||||||
|
expect(wrapper.findAll('button').some(button => button.text() === '提交审核')).toBe(true)
|
||||||
|
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||||
|
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 }))
|
||||||
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: gate, SUBMIT_REVIEW: gate } })).mockResolvedValueOnce(detail({ recordVersion: 2 }))
|
||||||
|
await state.submit(true)
|
||||||
|
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.any(Object), driver: expect.any(Object) }))
|
||||||
|
expect(api.submitDispatchReview).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null }))
|
||||||
|
expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!)
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('save alone never submits review and a failed save cannot advance review', async () => {
|
||||||
|
const first = await open('EDIT')
|
||||||
|
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||||
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 }))
|
||||||
|
await first.state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); first.wrapper.unmount()
|
||||||
|
const second = await open('EDIT')
|
||||||
|
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('车辆资料不完整', 'A0400'))
|
||||||
|
await second.state.submit(true); expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||||
|
expect(second.wrapper.text()).toContain('车辆资料不完整'); second.wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('rechecks review eligibility after save and leaves the saved editor open if blocked', async () => {
|
||||||
|
const { wrapper, state } = await open('EDIT')
|
||||||
|
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||||
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, SUBMIT_REVIEW: { allowed: false, blockCode: 'MISSING', blockReason: '预约资料未齐全' } } }))
|
||||||
|
await state.submit(true)
|
||||||
|
expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(state.detail.recordVersion).toBe(1)
|
||||||
|
expect(wrapper.text()).toContain('预约资料未齐全'); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('retains the follow-up review intent through an unknown save and does not save twice on recovery', async () => {
|
||||||
|
const { wrapper, state } = await open('EDIT')
|
||||||
|
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save outcome unknown'))
|
||||||
|
await state.submit(true)
|
||||||
|
const pending = useDispatchCommandRecovery(() => identity.id).pending.value!
|
||||||
|
expect(pending.submitReviewAfterSave).toBe(true)
|
||||||
|
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${identity.id}`)!).submitReviewAfterSave).toBe(true)
|
||||||
|
vi.mocked(api.getDispatchCommand).mockResolvedValueOnce(command('EDIT', String(pending.body.idempotencyKey)))
|
||||||
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })).mockResolvedValueOnce(detail({ recordVersion: 2 }))
|
||||||
|
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 }))
|
||||||
|
await state.submit()
|
||||||
|
expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||||
|
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull(); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('recovers an unknown review submission without saving the vehicle again', async () => {
|
||||||
|
const { wrapper, state } = await open('EDIT')
|
||||||
|
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||||
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||||
|
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('review outcome unknown'))
|
||||||
|
await state.submit(true)
|
||||||
|
const pending = useDispatchCommandRecovery(() => identity.id).pending.value!
|
||||||
|
expect(pending.action).toBe('SUBMIT_REVIEW'); expect(pending.body).toMatchObject({ recordVersion: 1, remark: null })
|
||||||
|
vi.mocked(api.getDispatchCommand).mockResolvedValueOnce({ ...command('SUBMIT_REVIEW', String(pending.body.idempotencyKey)), recordVersion: 2 })
|
||||||
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 2 }))
|
||||||
|
await state.submit()
|
||||||
|
expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ let editorRequestKey = ''
|
|||||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||||
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
|
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
|
||||||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
|
||||||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
const submitting = ref(false)
|
||||||
|
const width = computed(() => vehicleEditor.value || currentAction.value === 'LOADING_COMPLETE' ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
||||||
|
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
|
||||||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ function payload(): object {
|
|||||||
if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测')
|
if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测')
|
||||||
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete
|
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete
|
||||||
}
|
}
|
||||||
|
case 'SUBMIT_REVIEW': return { ...common, remark: null }
|
||||||
default: return { ...common, remark: form.remark || null }
|
default: return { ...common, remark: form.remark || null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,20 +141,35 @@ async function submitReview(decision: 'APPROVED' | 'REJECTED') {
|
|||||||
form.decision = decision
|
form.decision = decision
|
||||||
await submit()
|
await submit()
|
||||||
}
|
}
|
||||||
async function submit() {
|
async function submit(submitReviewAfterSave = false) {
|
||||||
if (busy.value || loading.value || uploadingEvidence.value) return
|
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
|
||||||
|
submitting.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
|
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
|
||||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||||
if (!pending.value) {
|
if (!pending.value) {
|
||||||
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value })
|
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(submitReviewAfterSave && vehicleEditor.value ? { submitReviewAfterSave: true } : {}) })
|
||||||
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
||||||
}
|
}
|
||||||
const routeCommand = pending.value?.operation === 'ROUTE'
|
const routeCommand = pending.value?.operation === 'ROUTE'
|
||||||
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
|
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
|
||||||
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
|
const continueReview = pending.value?.action === 'EDIT' && pending.value.submitReviewAfterSave === true
|
||||||
|
const readCurrent = async (no: string) => {
|
||||||
|
const value = await api.getDispatch(no)
|
||||||
|
if (value.dispatchNo === currentNo.value) detail.value = value
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
let result = await recovery.execute(send, readCurrent, api.getDispatchCommand)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
if (continueReview) {
|
||||||
|
emit('saved')
|
||||||
|
if (!canDispatchAction(detail.value?.actions, 'SUBMIT_REVIEW', access.can)) throw new DispatchInputError(detail.value?.actions.SUBMIT_REVIEW?.blockReason || '车辆信息已保存,当前不能提交审核,请核对资料')
|
||||||
|
recovery.begin(result.dispatchNo, 'SUBMIT_REVIEW', { recordVersion: requireVersion(detail.value?.recordVersion), idempotencyKey: crypto.randomUUID(), remark: null })
|
||||||
|
editorRequestKey = String(pending.value?.body.idempotencyKey || '')
|
||||||
|
result = await recovery.execute(send, readCurrent, api.getDispatchCommand)
|
||||||
|
if (!result) return
|
||||||
|
}
|
||||||
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? result.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成,请在装车后补齐装车资料' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
|
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? result.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成,请在装车后补齐装车资料' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
|
||||||
emit('saved'); open.value = false
|
emit('saved'); open.value = false
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
@@ -162,7 +180,7 @@ async function submit() {
|
|||||||
emit('rejected', error.value)
|
emit('rejected', error.value)
|
||||||
await refreshFacts()
|
await refreshFacts()
|
||||||
}
|
}
|
||||||
}
|
} finally { submitting.value = false }
|
||||||
}
|
}
|
||||||
async function refreshFacts() {
|
async function refreshFacts() {
|
||||||
const sequence = ++generation, no = currentNo.value
|
const sequence = ++generation, no = currentNo.value
|
||||||
@@ -190,7 +208,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
|
|||||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit">
|
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
|
||||||
<div class="density-dialog-sections">
|
<div class="density-dialog-sections">
|
||||||
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
||||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||||
@@ -209,7 +227,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
|||||||
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
|
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
|
||||||
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
|
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="currentAction === 'EDIT'">
|
<template v-else-if="vehicleEditor">
|
||||||
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
||||||
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
||||||
</template>
|
</template>
|
||||||
@@ -225,7 +243,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
|||||||
</template>
|
</template>
|
||||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||||
<div v-if="currentOperation !== 'ROUTE' && currentAction === 'EDIT'" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
<div v-if="vehicleEditor" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||||
@@ -234,10 +252,15 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
|||||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||||
</div>
|
</div>
|
||||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||||
<section v-else-if="currentAction !== 'MARK_EXCEPTION'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="isReview && !pending" #footer>
|
<template v-if="vehicleEditor" #footer="{ close }">
|
||||||
|
<el-button :disabled="submitting || busy || uploadingEvidence" @click="close">取消</el-button>
|
||||||
|
<el-button v-if="!pending" :disabled="submitting || loading || uploadingEvidence || !allowed" @click="submit()">保存</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting || busy" :disabled="loading || uploadingEvidence || !pending && !allowed" @click="submit(true)">{{ pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '提交审核' }}</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isReview && !pending" #footer>
|
||||||
<div class="workflow-review-actions">
|
<div class="workflow-review-actions">
|
||||||
<el-button type="danger" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('REJECTED')">驳回</el-button>
|
<el-button type="danger" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('REJECTED')">驳回</el-button>
|
||||||
<el-button type="primary" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('APPROVED')">审核通过</el-button>
|
<el-button type="primary" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('APPROVED')">审核通过</el-button>
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
|||||||
</template>
|
</template>
|
||||||
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
|
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
|
||||||
<section class="density-dialog-section" aria-label="排车计划时间与备注">
|
<section class="density-dialog-section" aria-label="排车计划时间与备注">
|
||||||
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆信息”补充。</p></InlineHelp></h3>
|
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆”补充。</p></InlineHelp></h3>
|
||||||
<table class="density-table density-table--editable dispatch-create-table"><tbody>
|
<table class="density-table density-table--editable dispatch-create-table"><tbody>
|
||||||
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
|
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
|
||||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
|
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ it.each([false, true])('confirms the %s photo stage with its own evidence and cu
|
|||||||
await input.trigger('change'); await flushPromises()
|
await input.trigger('change'); await flushPromises()
|
||||||
await wrapper.findAll('button').find(button => button.text() === (arriving ? '确认到仓' : '确认取货'))!.trigger('click'); await flushPromises()
|
await wrapper.findAll('button').find(button => button.text() === (arriving ? '确认到仓' : '确认取货'))!.trigger('click'); await flushPromises()
|
||||||
expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 3 }) }))
|
expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 3 }) }))
|
||||||
if (arriving) expect(commandTransport.mock.calls[0]![2]).not.toHaveProperty('actualPackages')
|
if (arriving) expect(vi.mocked(commandTransport).mock.calls[0]![2]).not.toHaveProperty('actualPackages')
|
||||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('keeps photo confirmation unavailable until both reviews are approved', async () => {
|
it('keeps photo confirmation unavailable until both reviews are approved', async () => {
|
||||||
|
|||||||
@@ -163,8 +163,8 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核
|
|||||||
<div v-if="detail.taskType === 'LOCAL_TRUCK'"><dt>路线</dt><dd>{{ trip.stops.map(stop => stop.name).join(' → ') }}</dd></div>
|
<div v-if="detail.taskType === 'LOCAL_TRUCK'"><dt>路线</dt><dd>{{ trip.stops.map(stop => stop.name).join(' → ') }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form">
|
<el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form">
|
||||||
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="value => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
|
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
|
||||||
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="value => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
|
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
|
||||||
<section v-if="detail.taskType === 'PICKUP'" class="transport-review-photos" aria-label="调度审核照片">
|
<section v-if="detail.taskType === 'PICKUP'" class="transport-review-photos" aria-label="调度审核照片">
|
||||||
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :disabled="locked" @busy="photoBusy.vehicle = $event" />
|
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :disabled="locked" @busy="photoBusy.vehicle = $event" />
|
||||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="locked" @busy="photoBusy.driver = $event" />
|
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="locked" @busy="photoBusy.driver = $event" />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ApiResponseError } from '@/shared/api/http'
|
|||||||
import type { CommandResult, DispatchAction } from './dispatchContracts'
|
import type { CommandResult, DispatchAction } from './dispatchContracts'
|
||||||
import { requireVersion } from './dispatchContracts'
|
import { requireVersion } from './dispatchContracts'
|
||||||
|
|
||||||
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; attempted: boolean; result: CommandResult | null; rejected: boolean; operation?: 'ROUTE'; itemId?: string }
|
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; attempted: boolean; result: CommandResult | null; rejected: boolean; operation?: 'ROUTE'; itemId?: string; submitReviewAfterSave?: boolean }
|
||||||
const storageKey = (scope: string) => `dispatch-command:${scope}`
|
const storageKey = (scope: string) => `dispatch-command:${scope}`
|
||||||
const snapshot = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
const snapshot = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||||
const sessions = new Map<string, { pending: ReturnType<typeof ref<PendingDispatchCommand | null>>; busy: ReturnType<typeof ref<boolean>> }>()
|
const sessions = new Map<string, { pending: ReturnType<typeof ref<PendingDispatchCommand | null>>; busy: ReturnType<typeof ref<boolean>> }>()
|
||||||
@@ -42,7 +42,7 @@ export function useDispatchCommandRecovery(scope: () => string) {
|
|||||||
if (pending.value) sessionStorage.setItem(storageKey(owner), JSON.stringify(pending.value))
|
if (pending.value) sessionStorage.setItem(storageKey(owner), JSON.stringify(pending.value))
|
||||||
else sessionStorage.removeItem(storageKey(owner))
|
else sessionStorage.removeItem(storageKey(owner))
|
||||||
}
|
}
|
||||||
function begin(dispatchNo: string, action: DispatchAction, body: object, target: Pick<PendingDispatchCommand, 'operation' | 'itemId'> = {}) {
|
function begin(dispatchNo: string, action: DispatchAction, body: object, target: Pick<PendingDispatchCommand, 'operation' | 'itemId' | 'submitReviewAfterSave'> = {}) {
|
||||||
if (pending.value) throw new Error('上次排车操作结果尚未核对,请先恢复原请求')
|
if (pending.value) throw new Error('上次排车操作结果尚未核对,请先恢复原请求')
|
||||||
pending.value = { dispatchNo, action, body: snapshot(body) as Record<string, unknown>, attempted: false, result: null, rejected: false, ...target }
|
pending.value = { dispatchNo, action, body: snapshot(body) as Record<string, unknown>, attempted: false, result: null, rejected: false, ...target }
|
||||||
persist()
|
persist()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
export const dispatchActionLabels: Record<DispatchAction, string> = {
|
export const dispatchActionLabels: Record<DispatchAction, string> = {
|
||||||
MARK_EXCEPTION: '标记异常', CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
MARK_EXCEPTION: '标记异常', CREATE: '新建排车', EDIT: '维护车辆', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
||||||
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
||||||
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
|
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe('whole-dispatch four-tab detail', () => {
|
|||||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } }))
|
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||||
const actions = wrapper.findAll('button').map(button => button.text())
|
const actions = wrapper.findAll('button').map(button => button.text())
|
||||||
expect(actions).not.toContain('维护车辆信息')
|
expect(actions).not.toContain('维护车辆')
|
||||||
expect(actions).not.toContain('添加货物')
|
expect(actions).not.toContain('添加货物')
|
||||||
expect(actions).not.toContain('移除货物')
|
expect(actions).not.toContain('移除货物')
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
@@ -181,7 +181,7 @@ describe('whole-dispatch four-tab detail', () => {
|
|||||||
expect(wrapper.text()).toContain('装车中')
|
expect(wrapper.text()).toContain('装车中')
|
||||||
expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件')
|
expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件')
|
||||||
expect(wrapper.text()).not.toContain('当前无此操作权限')
|
expect(wrapper.text()).not.toContain('当前无此操作权限')
|
||||||
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆信息')).toBe(false)
|
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆')).toBe(false)
|
||||||
expect(wrapper.findAll('button').some(button => button.text() === '确认装车完成')).toBe(false)
|
expect(wrapper.findAll('button').some(button => button.text() === '确认装车完成')).toBe(false)
|
||||||
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
|
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import DataTable from '@/shared/components/DataTable.vue'
|
|||||||
import RowActions from '@/shared/components/RowActions.vue'
|
import RowActions from '@/shared/components/RowActions.vue'
|
||||||
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
||||||
import { cancelTransport, getTransport } from '../api/transportTasks'
|
import { cancelTransport, getTransport } from '../api/transportTasks'
|
||||||
import { command, detail, listRow, page } from '../dispatchTestFixtures'
|
import { command, detail, listRow, page, reviews } from '../dispatchTestFixtures'
|
||||||
import type { DispatchPage } from '../dispatchContracts'
|
import type { DispatchPage } from '../dispatchContracts'
|
||||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||||
const identity = vi.hoisted(() => ({ id: '' }))
|
const identity = vi.hoisted(() => ({ id: '' }))
|
||||||
@@ -29,7 +29,7 @@ it('offers pickup detail, transport vehicle maintenance and the shared review wo
|
|||||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||||
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
|
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
|
||||||
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆信息', '提交调度审核', '详情', '更多'])
|
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆', '提交调度审核', '详情', '更多'])
|
||||||
row.vm.$emit('action', 'EDIT'); await flushPromises()
|
row.vm.$emit('action', 'EDIT'); await flushPromises()
|
||||||
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
|
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
|
||||||
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('editResources')).toBe(true)
|
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('editResources')).toBe(true)
|
||||||
@@ -42,7 +42,7 @@ it('confirms cancelling pickup and linked transfers using the transport version'
|
|||||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', recordVersion: 10, actions: { CANCEL: allowed } })]))
|
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', recordVersion: 10, actions: { CANCEL: allowed } })]))
|
||||||
vi.mocked(getTransport).mockResolvedValue({ execution: { trip: { recordVersion: 4 } }, pickups: [{ transferNo: 'WT-1' }] } as Awaited<ReturnType<typeof getTransport>>)
|
vi.mocked(getTransport).mockResolvedValue({ execution: { trip: { recordVersion: 4 } }, pickups: [{ transferNo: 'WT-1' }] } as Awaited<ReturnType<typeof getTransport>>)
|
||||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
|
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
|
||||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||||
row.vm.$emit('action', 'CANCEL'); await flushPromises()
|
row.vm.$emit('action', 'CANCEL'); await flushPromises()
|
||||||
@@ -125,17 +125,18 @@ it('centralizes cargo changes in smart dispatch and never exposes row add/remove
|
|||||||
expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('modelValue')).toBe(true)
|
expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('modelValue')).toBe(true)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('keeps vehicle maintenance and detail visible while submitting review remains available in the menu', async () => {
|
it('keeps vehicle maintenance but hides standalone review submission and route ordering', async () => {
|
||||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
|
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
|
||||||
const wrapper = mount(DispatchesView, { ...options, attachTo: document.body }); await flushPromises()
|
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||||
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆信息', '详情', '更多'])
|
const keys = (row.props('actions') as Array<{ key: string }>).map(item => item.key)
|
||||||
await row.get('button[aria-label="更多操作 · PC/1"]').trigger('click'); await flushPromises()
|
expect(keys).toContain('EDIT'); expect(keys).toContain('detail')
|
||||||
const submit = Array.from(document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')).find(item => item.textContent === '提交调度审核')!
|
expect(keys).not.toContain('SUBMIT_REVIEW'); expect(keys).not.toContain('route-order')
|
||||||
expect(submit).toBeDefined()
|
row.vm.$emit('action', 'SUBMIT_REVIEW'); row.vm.$emit('action', 'route-order'); await flushPromises()
|
||||||
submit.click(); await flushPromises()
|
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
|
row.vm.$emit('action', 'EDIT'); await flushPromises()
|
||||||
|
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'EDIT', dispatchNo: 'PC/1' })
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it.each([['DISPATCH_REVIEW', 1, '调度审核'], ['LOADING_REVIEW', 2, '理货员审核']] as const)('prioritizes the current %s queue action with a short label', async (action, index, label) => {
|
it.each([['DISPATCH_REVIEW', 1, '调度审核'], ['LOADING_REVIEW', 2, '理货员审核']] as const)('prioritizes the current %s queue action with a short label', async (action, index, label) => {
|
||||||
@@ -323,7 +324,7 @@ it('gates every mutation with both its literal service capability and independen
|
|||||||
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>
|
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>
|
||||||
expect(actions.map(row => row.key)).toContain('EDIT'); expect(actions.map(row => row.key)).not.toContain('DEPART')
|
expect(actions.map(row => row.key)).toContain('EDIT'); expect(actions.map(row => row.key)).not.toContain('DEPART')
|
||||||
can.mockReturnValue(false); wrapper.unmount()
|
can.mockReturnValue(false); wrapper.unmount()
|
||||||
const readonly = mount(DispatchesView, options); await flushPromises(); expect((readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>).map(row => row.key)).toEqual(['detail', 'bol']); readonly.unmount()
|
const readonly = mount(DispatchesView, options); await flushPromises(); expect((readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>).map(row => row.key)).toEqual(['detail']); readonly.unmount()
|
||||||
})
|
})
|
||||||
it('keeps server errors visible with the integrated query and retry context', async () => {
|
it('keeps server errors visible with the integrated query and retry context', async () => {
|
||||||
vi.mocked(listDispatches).mockRejectedValueOnce(new Error('service unavailable'))
|
vi.mocked(listDispatches).mockRejectedValueOnce(new Error('service unavailable'))
|
||||||
@@ -356,21 +357,38 @@ it('keeps pickup workflow actions but hides route ordering and BOL even when edi
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('reads one BOL on demand and preserves popup failure handling', async () => {
|
it('reads one BOL on demand and preserves popup failure handling', async () => {
|
||||||
|
const current = reviews()
|
||||||
|
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ reviews: { ...current, dispatchReview: { ...current.dispatchReview, status: 'APPROVED' } } })]))
|
||||||
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
|
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
it.each(['NOT_SUBMITTED', 'PENDING', 'REJECTED', 'APPROVED'] as const)('offers BOL only after current dispatch review is approved: %s', async status => {
|
||||||
|
const current = reviews()
|
||||||
|
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ reviews: { ...current, dispatchReview: { ...current.dispatchReview, status } } })]))
|
||||||
|
const popup = { close: vi.fn(), location: { href: '' } }
|
||||||
|
vi.stubGlobal('open', vi.fn().mockReturnValue(popup))
|
||||||
|
vi.mocked(getDispatchBolPdf).mockRejectedValue(new Error('print unavailable'))
|
||||||
|
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||||
|
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||||
|
expect((actions.props('actions') as Array<{ key: string }>).some(item => item.key === 'bol')).toBe(status === 'APPROVED')
|
||||||
|
actions.vm.$emit('action', 'bol'); await flushPromises()
|
||||||
|
expect(getDispatchBolPdf).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
|
||||||
|
expect(window.open).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
|
||||||
|
if (status === 'APPROVED') expect(popup.close).toHaveBeenCalledOnce()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %s without retaining a stale submit button', async event => {
|
it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %s without retaining a stale submit button', async event => {
|
||||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })]))
|
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } })]))
|
||||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||||
expect(row.props('actions')).toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
|
expect(row.props('actions')).toContainEqual(expect.objectContaining({ key: 'EDIT' }))
|
||||||
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
|
row.vm.$emit('action', 'EDIT'); await flushPromises()
|
||||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })]))
|
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ recordVersion: 1, actions: { EDIT: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })]))
|
||||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||||
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'list-reject-request' })
|
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'list-reject-request' })
|
||||||
else dialog.vm.$emit('saved')
|
else dialog.vm.$emit('saved')
|
||||||
dialog.vm.$emit('update:modelValue', false); await flushPromises()
|
dialog.vm.$emit('update:modelValue', false); await flushPromises()
|
||||||
expect(listDispatches).toHaveBeenCalledTimes(2)
|
expect(listDispatches).toHaveBeenCalledTimes(2)
|
||||||
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
|
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'EDIT' }))
|
||||||
if (event === 'rejected') {
|
if (event === 'rejected') {
|
||||||
expect(wrapper.text()).toContain('本轮审核已经提交')
|
expect(wrapper.text()).toContain('本轮审核已经提交')
|
||||||
expect(wrapper.text()).toContain('A0409')
|
expect(wrapper.text()).toContain('A0409')
|
||||||
|
|||||||
@@ -116,13 +116,15 @@ function switchSection(value: BeforeLoadingSection) { section.value = value; pag
|
|||||||
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
||||||
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
|
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
|
||||||
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(row.stage) }
|
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(row.stage) }
|
||||||
|
// 仅当前调度审核通过的仓库派送排车可下载,退回或审核失效后重新收起入口。
|
||||||
|
function canDownloadBol(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.reviews?.dispatchReview?.status === 'APPROVED' }
|
||||||
function rowActions(row: DispatchListRow): RowActionItem[] {
|
function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||||
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
||||||
const review = section.value
|
const review = section.value
|
||||||
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||||
}
|
}
|
||||||
if (row.taskType === 'LOCAL_TRUCK') return [...(row.status === 'ACTIVE' && (access.can(dispatchActionPermissions.EDIT) || access.can(dispatchActionPermissions.CANCEL) || access.can(dispatchActionPermissions.DEPART)) ? [{ key: 'transport', label: '运输操作', priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
if (row.taskType === 'LOCAL_TRUCK') return [...(row.status === 'ACTIVE' && (access.can(dispatchActionPermissions.EDIT) || access.can(dispatchActionPermissions.CANCEL) || access.can(dispatchActionPermissions.DEPART)) ? [{ key: 'transport', label: '运输操作', priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||||
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
|
const available = rowActionOrder.filter(value => (value !== 'SUBMIT_REVIEW' || row.taskType === 'PICKUP') && canDispatchAction(row.actions, value, access.can))
|
||||||
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
||||||
const execution = pickupExecutionLabel(row)
|
const execution = pickupExecutionLabel(row)
|
||||||
return [...(execution ? [{ key: 'transport', label: execution, priority: 650 }] : []), ...(canManageDocuments(row) ? [{ key: 'documents', label: '管理资料', priority: 700 }] : []), ...available.map((value, index) => ({
|
return [...(execution ? [{ key: 'transport', label: execution, priority: 650 }] : []), ...(canManageDocuments(row) ? [{ key: 'documents', label: '管理资料', priority: 700 }] : []), ...available.map((value, index) => ({
|
||||||
@@ -132,7 +134,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
|||||||
priority: value === primaryAction ? 600 : 400 - index * 10,
|
priority: value === primaryAction ? 600 : 400 - index * 10,
|
||||||
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? 'danger' as const : undefined,
|
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? 'danger' as const : undefined,
|
||||||
loading: value === 'CANCEL' && cancellingPickup.value === row.dispatchNo,
|
loading: value === 'CANCEL' && cancellingPickup.value === row.dispatchNo,
|
||||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(row.taskType === 'PICKUP' ? [] : [...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }])]
|
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDownloadBol(row) ? [{ key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }] : [])]
|
||||||
}
|
}
|
||||||
/** 详情只读,照片和资源结束操作从列表进入对应运输面板。 */
|
/** 详情只读,照片和资源结束操作从列表进入对应运输面板。 */
|
||||||
function pickupExecutionLabel(row: DispatchListRow) {
|
function pickupExecutionLabel(row: DispatchListRow) {
|
||||||
@@ -153,7 +155,7 @@ function handleAction(value: string, row: DispatchListRow) {
|
|||||||
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
|
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
|
||||||
if (value === 'bol') return void openBol(row)
|
if (value === 'bol') return void openBol(row)
|
||||||
rejectionError.value = null
|
rejectionError.value = null
|
||||||
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
|
if (value === 'route-order' || value === 'SUBMIT_REVIEW' && row.taskType !== 'PICKUP') return
|
||||||
operation.value = undefined
|
operation.value = undefined
|
||||||
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
|
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
|
||||||
const requested = value as DispatchAction
|
const requested = value as DispatchAction
|
||||||
@@ -183,7 +185,7 @@ function recoverPending() {
|
|||||||
operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
|
operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
|
||||||
}
|
}
|
||||||
async function openBol(row: DispatchListRow) {
|
async function openBol(row: DispatchListRow) {
|
||||||
if (bolLoadingNo.value) return
|
if (bolLoadingNo.value || !canDownloadBol(row)) return
|
||||||
const windowHandle = window.open('', '_blank')
|
const windowHandle = window.open('', '_blank')
|
||||||
if (!windowHandle) { ElMessage.warning('请允许本站打开BOL预览窗口'); return }
|
if (!windowHandle) { ElMessage.warning('请允许本站打开BOL预览窗口'); return }
|
||||||
bolLoadingNo.value = row.dispatchNo
|
bolLoadingNo.value = row.dispatchNo
|
||||||
|
|||||||
@@ -991,6 +991,13 @@ describe('approved Admin density ownership', () => {
|
|||||||
it('keeps customer dialogs on shared sections, editors and preview geometry', () => {
|
it('keeps customer dialogs on shared sections, editors and preview geometry', () => {
|
||||||
const customers = read('modules/customers/pages/CustomersView.vue')
|
const customers = read('modules/customers/pages/CustomersView.vue')
|
||||||
const detail = read('modules/customers/pages/CustomerDetailView.vue')
|
const detail = read('modules/customers/pages/CustomerDetailView.vue')
|
||||||
|
const editor = customers.split('<AppDialog v-model="editorOpen"')[1]!.split('</AppDialog>')[0]!
|
||||||
|
const sections = [...editor.matchAll(/<section\b[^>]*>/g)].map(match => match[0])
|
||||||
|
expect(sections).toHaveLength(3)
|
||||||
|
for (const section of sections) {
|
||||||
|
expect(section).toContain('density-dialog-section')
|
||||||
|
expect(section).not.toContain('data-tone=')
|
||||||
|
}
|
||||||
expect(customers).toContain('density-table density-table--editable customer-editor-table')
|
expect(customers).toContain('density-table density-table--editable customer-editor-table')
|
||||||
expect(customers).toContain('density-actions density-actions--footer')
|
expect(customers).toContain('density-actions density-actions--footer')
|
||||||
expect(customers).not.toContain('<FormSection')
|
expect(customers).not.toContain('<FormSection')
|
||||||
@@ -1006,7 +1013,7 @@ describe('approved Admin density ownership', () => {
|
|||||||
for (const file of ['customers/CustomerOverseasStorageFeePanel.vue', 'customers/pages/OverseasStorageFeesView.vue', 'customers/components/CustomerBillingRulesPanel.vue']) {
|
for (const file of ['customers/CustomerOverseasStorageFeePanel.vue', 'customers/pages/OverseasStorageFeesView.vue', 'customers/components/CustomerBillingRulesPanel.vue']) {
|
||||||
const source = read(`modules/${file}`)
|
const source = read(`modules/${file}`)
|
||||||
expect(source, file).toContain('density-table density-table--editable')
|
expect(source, file).toContain('density-table density-table--editable')
|
||||||
expect(source, file).toContain('class="density-dialog-section" data-tone="work"')
|
expect(source, file).toContain(file === 'customers/pages/OverseasStorageFeesView.vue' ? 'class="density-dialog-section"' : 'class="density-dialog-section" data-tone="work"')
|
||||||
expect(source, file).toContain('fullscreenable')
|
expect(source, file).toContain('fullscreenable')
|
||||||
expect(source, file).not.toContain('class="dialog-actions"')
|
expect(source, file).not.toContain('class="dialog-actions"')
|
||||||
}
|
}
|
||||||
@@ -1186,14 +1193,15 @@ describe('approved Admin density ownership', () => {
|
|||||||
sectionStyles.set(rule.selector, declarations)
|
sectionStyles.set(rule.selector, declarations)
|
||||||
})
|
})
|
||||||
for (const [section, background] of [
|
for (const [section, background] of [
|
||||||
['summary-section', 'var(--admin-dialog-summary-bg)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-dialog-work-bg)'],
|
['summary-section', 'var(--admin-dialog-summary-bg)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-surface, #fff)'],
|
||||||
]) {
|
]) {
|
||||||
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)).toMatchObject({
|
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)).toMatchObject({
|
||||||
padding: 'var(--admin-space-4, 10px) var(--admin-space-5, 12px)',
|
padding: 'var(--admin-space-4, 10px) var(--admin-space-5, 12px)',
|
||||||
background,
|
background,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Shared density must not reset the coloured sections' horizontal gutters.
|
expect(sectionStyles.get('.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert')?.background).toBe('var(--admin-surface, #fff)')
|
||||||
|
// Shared density must not reset the sections' horizontal gutters.
|
||||||
expect(sectionStyles.get('.inbound-density.admin-density-dialog .measurement-section')).toEqual({
|
expect(sectionStyles.get('.inbound-density.admin-density-dialog .measurement-section')).toEqual({
|
||||||
'padding-block': 'var(--admin-space-4, 10px)',
|
'padding-block': 'var(--admin-space-4, 10px)',
|
||||||
})
|
})
|
||||||
@@ -1371,7 +1379,7 @@ describe('approved Admin density ownership', () => {
|
|||||||
expect(workflow).toContain('<DispatchResourceFields continuous')
|
expect(workflow).toContain('<DispatchResourceFields continuous')
|
||||||
expect(workflow).toContain('density-table density-table--editable workflow-editor-table')
|
expect(workflow).toContain('density-table density-table--editable workflow-editor-table')
|
||||||
expect(workflow).toContain('label-position="left" label-width="130px"')
|
expect(workflow).toContain('label-position="left" label-width="130px"')
|
||||||
expect(workflow).toContain(':confirm-loading="busy || uploadingEvidence"')
|
expect(workflow).toContain(':confirm-loading="submitting || busy || uploadingEvidence"')
|
||||||
expect(workflow).not.toContain('aria-label="候选货物分页"')
|
expect(workflow).not.toContain('aria-label="候选货物分页"')
|
||||||
expect(cargo).toContain('class="existing-cargo density-dialog-sections"')
|
expect(cargo).toContain('class="existing-cargo density-dialog-sections"')
|
||||||
expect(cargo).toContain('<SmartDispatchCargoBoard')
|
expect(cargo).toContain('<SmartDispatchCargoBoard')
|
||||||
|
|||||||
Reference in New Issue
Block a user