Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ACTUAL/ADD/MULTIPLY`;未启用默认调整规则时按实测原尺寸计算,仓库修正值替换对应实测边。测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的箱数、毛重汇总当前 `actualMeasurementRows`,计费体积默认取已保存的 `outerMeasurementRows` 汇总,最终按用户确认值提交,体积来源标记为 `OUTER`;不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ACTUAL/ADD/MULTIPLY`;未启用默认调整规则时按实测原尺寸计算,仓库修正值替换对应实测边。测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的箱数、毛重汇总当前 `actualMeasurementRows`,计费体积默认取服务端已保存的 `outerMeasurementRows` 汇总;通过只读 `/fees/preview` 刷新当前重量、体积和人工单价的计费量与金额,预览未完成或失败时不得发送;最终按用户确认值提交,体积来源标记为 `OUTER`;不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('ContainerCustomsPanel', () => {
|
||||
expect(wrapper.text()).toContain('覆盖预录单')
|
||||
expect(wrapper.text()).toContain('报关行回执.pdf')
|
||||
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
|
||||
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
||||
expect(wrapper.text()).not.toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
||||
wrapper.unmount()
|
||||
|
||||
wrapper = await render('DECLARING', false, true)
|
||||
@@ -237,10 +237,12 @@ describe('ContainerCustomsPanel', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('隐藏资料版本但保留预录单历史、原文件名和上传并发版本', async () => {
|
||||
it('隐藏预录单历史和资料版本,保留当前确认状态、原文件名和上传并发版本', async () => {
|
||||
const value = customs('DECLARING', true)
|
||||
const document = value.documents[0]!
|
||||
document.receipt!.version = 7
|
||||
document.receiptStatus = 'CONFIRMED'
|
||||
document.confirmationHistory.unshift({ id: '2092438843630391413', receiptVersion: 6, action: 'REJECT', reason: '请更新预录单', operatorName: '客户一', createdAt: '2026-08-29T10:55:00' })
|
||||
document.receiptHistory = [{ version: 6, file: { ...document.receipt!, version: 6 }, operatorName: '报关员', createdAt: '2026-08-29T10:50:00' }]
|
||||
api.get.mockResolvedValue(value)
|
||||
const wrapper = mount(ContainerCustomsPanel, {
|
||||
@@ -250,8 +252,11 @@ describe('ContainerCustomsPanel', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '关联入仓码', '报关行状态', '客户退税资料', '预录单', '真实报关完税文件', '客户', '预录单情况'])
|
||||
expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/)
|
||||
expect(wrapper.text()).toContain('预录单历史:2026/8/29 10:50:00 · 报关员')
|
||||
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
||||
expect(wrapper.findAll('.confirmation-history-note, .receipt-history-note')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('预录单历史')
|
||||
expect(wrapper.text()).not.toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
||||
expect(wrapper.text()).not.toContain('请更新预录单')
|
||||
expect(wrapper.text()).toContain('客户已确认预录单')
|
||||
expect(wrapper.find('[aria-label="下载预录单 报关行回执.pdf"]').exists()).toBe(true)
|
||||
const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' })
|
||||
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
|
||||
|
||||
@@ -79,9 +79,6 @@ const statusText: Record<ContainerCustomsStatus, string> = {
|
||||
function documentCustomers(document: ContainerCustomsDocument) {
|
||||
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
|
||||
}
|
||||
function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) {
|
||||
return action === 'CONFIRM' ? '确认' : '打回'
|
||||
}
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
@@ -327,7 +324,7 @@ onMounted(load)
|
||||
<td>{{ document.inboundCodes.join('、') || '—' }}</td>
|
||||
<td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td>
|
||||
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
|
||||
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td><td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td><td>{{ documentCustomers(document) }}</td><td><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small><small v-for="history in document.receiptHistory" :key="history.version" class="receipt-history-note">预录单历史:{{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}</small>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
|
||||
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td><td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
|
||||
</tr>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
@@ -363,5 +360,5 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--workbench-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createFeeActionIdempotencyKey,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
previewInboundFee,
|
||||
sendInboundFee,
|
||||
} from './api'
|
||||
|
||||
@@ -32,6 +33,15 @@ describe('inbound fee API', () => {
|
||||
expect(http.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews selected outer billing values without a write idempotency header', async () => {
|
||||
const preview = { actualWeightKg: 570.5, actualVolumeCbm: 3.894, chargeableQuantity: 650.298, priceMatched: true }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: preview })
|
||||
const payload = { selectedWeightKg: 570.5, selectedVolumeCbm: 3.894, selectedUnitPrice: 13.6, weightSource: 'ACTUAL', volumeSource: 'OUTER' }
|
||||
|
||||
await expect(previewInboundFee('10001', '88', payload)).resolves.toEqual(preview)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/preview', payload, { params: { tenantId: '10001' } })
|
||||
})
|
||||
|
||||
it('loads the latest immutable fee snapshot with string IDs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot })
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeePreview, InboundFeePreviewRequest, InboundFeeSelectionRequest, InboundFeeSnapshot } from './types'
|
||||
|
||||
function feeBasePath(inboundId: string) {
|
||||
const identifier = String(inboundId ?? '').trim()
|
||||
@@ -15,7 +15,14 @@ export async function getLatestInboundFee(tenantId: string, inboundId: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function calculateInboundFee(tenantId: string, inboundId: string, payload: { selectedWeightKg: number; selectedVolumeCbm: number; weightSource: string; volumeSource: string }, idempotencyKey: string) {
|
||||
export async function previewInboundFee(tenantId: string, inboundId: string, payload: InboundFeePreviewRequest) {
|
||||
const response = await http.post<unknown, ApiEnvelope<InboundFeePreview>>(
|
||||
`${feeBasePath(inboundId)}/preview`, payload, { params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function calculateInboundFee(tenantId: string, inboundId: string, payload: InboundFeeSelectionRequest, idempotencyKey: string) {
|
||||
const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||
`${feeBasePath(inboundId)}/calculate`, payload,
|
||||
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
export type InboundFeeStatus = 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'SUPERSEDED' | string
|
||||
export type InboundChargeMode = 'KG' | 'CBM' | string
|
||||
|
||||
export interface InboundFeeSelectionRequest {
|
||||
selectedWeightKg: number
|
||||
selectedVolumeCbm: number
|
||||
weightSource: string
|
||||
volumeSource: string
|
||||
}
|
||||
|
||||
export interface InboundFeePreview {
|
||||
actualWeightKg: number
|
||||
actualVolumeCbm: number
|
||||
chargeableQuantity: number
|
||||
chargeUnit: string
|
||||
conversionFactor: number
|
||||
roundingScale: number
|
||||
roundingMode: string
|
||||
unitPrice: number | null
|
||||
additionalAmount: number
|
||||
totalAmount: number | null
|
||||
currency: string
|
||||
priceMatched: boolean
|
||||
}
|
||||
|
||||
export interface InboundFeePreviewRequest extends InboundFeeSelectionRequest {
|
||||
selectedUnitPrice: number | null
|
||||
}
|
||||
|
||||
export interface InboundFeeSnapshot {
|
||||
feeId: string
|
||||
feeNo: string
|
||||
|
||||
@@ -14,9 +14,10 @@ export {
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
createFeeActionIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
previewInboundFee,
|
||||
sendInboundFee,
|
||||
} from './fee/api'
|
||||
export type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './fee/types'
|
||||
export type { AdjustPublishedInboundFeeRequest, InboundFeePreview, InboundFeeSnapshot } from './fee/types'
|
||||
export { downloadWeightImage } from './weight/api/weightOcr'
|
||||
export type { InboundMeasurementDetail } from './measurement/types'
|
||||
export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types'
|
||||
|
||||
@@ -213,7 +213,7 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
|
||||
<tbody>
|
||||
<tr><th scope="row">箱数</th><td>{{ formatNumber(detail.originalPackageCount, 0) }}</td><td>{{ formatNumber(detail.actualPackageCount, 0) }}</td><td>{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</td></tr>
|
||||
<tr><th scope="row">毛重(kg)</th><td>{{ formatNumber(detail.originalGrossWeightKg) }}</td><td>{{ formatNumber(detail.actualGrossWeightKg) }}</td><td>{{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</td></tr>
|
||||
<tr><th scope="row">体积(m³)</th><td>{{ formatNumber(detail.originalVolumeCbm, 4) }}</td><td>{{ formatNumber(detail.actualVolumeCbm, 4) }}</td><td>{{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³') }}</td></tr>
|
||||
<tr><th scope="row">体积(m³)</th><td>{{ formatNumber(detail.originalVolumeCbm, 3) }}</td><td>{{ formatNumber(detail.actualVolumeCbm, 3) }}</td><td>{{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 3, ' m³') }}</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="review-extra-facts"><span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span><span>体积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span></div>
|
||||
@@ -249,10 +249,10 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
|
||||
<template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单箱体积(m³)" width="106" align="right">
|
||||
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 4) }}</template>
|
||||
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 3) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总体积(m³)" width="96" align="right">
|
||||
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 4) }}</template>
|
||||
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 3) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="体积重(kg)" width="96" align="right">
|
||||
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchDocumentsDialog from './DispatchDocumentsDialog.vue'
|
||||
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
|
||||
import { detail } from '../dispatchTestFixtures'
|
||||
import { destination, detail } from '../dispatchTestFixtures'
|
||||
import { getDispatch } from '../api/dispatches'
|
||||
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, type DeliveryDocuments } from '../api/deliveryDocuments'
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
@@ -37,8 +37,13 @@ it.each(['LOADING', 'AFTER_LOADING'] as const)('opens loading documents in %s be
|
||||
expect(wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.attributes('aria-selected')).toBe('true')
|
||||
expect(getDeliveryDocuments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('reads existing delivery files in one dialog and saves receipt without requiring loading or BOL files', async () => {
|
||||
it('hides the single delivery selector and saves receipt files to that delivery without requiring loading or BOL files', async () => {
|
||||
const value = detail({ stage: 'DELIVERING' })
|
||||
value.documents.deliveries = [{ deliveryNo: 'DL-1', customerId: '101', customerName: '客户甲', groupKey: 'A', outboundNo: 'OB-1', destination: destination(), status: 'DELIVERING', packageCount: 1, palletCount: 1 }]
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'ElSelect' }).exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="选择派送单"]').exists()).toBe(false)
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101')
|
||||
expect(wrapper.text()).toContain('existing-pod.pdf')
|
||||
expect(wrapper.text()).not.toContain('资料核对')
|
||||
@@ -52,6 +57,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 () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'ElSelect' }).exists()).toBe(true)
|
||||
const receipt = wrapper.getComponent(DeliveryDocumentsDialog)
|
||||
await receipt.findAll('button').find(button => button.text() === '删除')!.trigger('click')
|
||||
expect(receipt.text()).not.toContain('existing-pod.pdf')
|
||||
|
||||
@@ -63,7 +63,7 @@ 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: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
|
||||
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
|
||||
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
|
||||
<el-form v-if="deliveries.length > 1" 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="['RECEIPT']" @saved="saved" />
|
||||
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('dispatch workflow native layout', () => {
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').text()).toContain('联系电话')
|
||||
expect(dialog.get('[aria-label="排车与预约"]').findAll('input')).toHaveLength(0)
|
||||
const photos = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(photos.map(photo => photo.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(photos.map(photo => photo.props('label'))).toEqual(['车牌图片', '司机照片', '驾驶证照片'])
|
||||
expect(photos.every(photo => photo.props('readonly'))).toBe(true)
|
||||
expect(dialog.get('[aria-label="操作原因与意见"] h3').text()).toBe('审核意见')
|
||||
expect(dialog.text()).not.toContain('请核对本车资源与本趟证据')
|
||||
@@ -246,7 +246,7 @@ describe('dispatch workflow native layout', () => {
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('unknown edit')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
const { wrapper, dialog } = await render('EDIT', undefined, true)
|
||||
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['车牌图片', '司机照片', '驾驶证照片'])
|
||||
expect(fields[0]!.props('modelValue')).toEqual(['301'])
|
||||
expect(dialog.get('.workflow-photo-grid').findAll('figure')).toHaveLength(3)
|
||||
for (const [index, files] of fields.entries()) {
|
||||
@@ -280,7 +280,7 @@ describe('dispatch workflow native layout', () => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(value)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['车牌图片', '司机照片', '驾驶证照片'])
|
||||
expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true)
|
||||
expect(fields[1]!.props('modelValue')).toEqual(['402', '403'])
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
|
||||
|
||||
@@ -137,7 +137,7 @@ function payload(): object {
|
||||
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
|
||||
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
|
||||
case 'EDIT':
|
||||
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车辆图片;当前图片尚未保存')
|
||||
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车牌图片;当前图片尚未保存')
|
||||
if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存')
|
||||
return { ...common, vehicle: missingVehicle.value ? null : vehicle.value, driver: missingDriver.value ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
@@ -268,7 +268,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
||||
<template #attachments>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车牌图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driver.mode === 'MASTER' && driver.driverId ? { kind: 'DRIVER', id: driver.driverId } : null" :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="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DispatchAction, DispatchActions, DispatchDetail, ReviewAttachments, Reviews } from './dispatchContracts'
|
||||
export const dispatchReviewAttachmentFields = [
|
||||
{ key: 'vehicleFileIds', kind: 'VEHICLE', label: '车辆图片' },
|
||||
{ key: 'vehicleFileIds', kind: 'VEHICLE', label: '车牌图片' },
|
||||
{ key: 'driverPhotoFileIds', kind: 'DRIVER_PHOTO', label: '司机照片' },
|
||||
{ key: 'licenseFileIds', kind: 'DRIVER', label: '驾驶证照片' },
|
||||
] as const
|
||||
|
||||
@@ -402,6 +402,7 @@ it.each(['NOT_SUBMITTED', 'PENDING', 'REJECTED', 'APPROVED'] as const)('offers B
|
||||
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')
|
||||
expect(actions.findAll('button').some(button => button.text() === '下载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)
|
||||
@@ -449,10 +450,12 @@ it('clears an earlier input rejection after the corrected workflow succeeds', as
|
||||
|
||||
it('opens the exception form from the dispatching row and hides it without permission', async () => {
|
||||
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'DELIVERING', actions: { MARK_EXCEPTION: gate } })]))
|
||||
const current = reviews()
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'DELIVERING', reviews: { ...current, dispatchReview: { ...current.dispatchReview, status: 'APPROVED' } }, actions: { MARK_EXCEPTION: gate } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions')).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'MARK_EXCEPTION', label: '标记异常', tone: 'danger' })]))
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['管理资料', '标记异常', '下载BOL', '更多'])
|
||||
row.vm.$emit('action', 'MARK_EXCEPTION'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'MARK_EXCEPTION', dispatchNo: 'PC/1' })
|
||||
wrapper.unmount(); can.mockReturnValue(false)
|
||||
|
||||
@@ -72,7 +72,7 @@ const availableFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
const fields = computed(() => availableFields.value.filter(field => showAdvancedFilters.value || field.key !== 'status'))
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : rows.value.some(canDownloadBol) ? 290 : 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||
{ key: 'vehicleStatus', label: '车辆状态', minWidth: 110, slotName: 'vehicleStatus', afterColumn: 'dispatchNo' }, { key: 'stage', label: '作业阶段', minWidth: 110, slotName: 'stage', afterColumn: 'dispatchNo' },
|
||||
@@ -152,7 +152,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
priority: value === primaryAction ? 600 : 400 - index * 10,
|
||||
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? 'danger' as const : undefined,
|
||||
loading: value === 'CANCEL' && cancellingPickup.value === row.dispatchNo,
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDownloadBol(row) ? [{ key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }] : [])]
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDownloadBol(row) ? [{ key: 'bol', label: '下载BOL', priority: 550, loading: bolLoadingNo.value === row.dispatchNo }] : [])]
|
||||
}
|
||||
/** 已送达仅保留详情,取货和送达照片在对应作业阶段维护。 */
|
||||
function pickupExecutionLabel(row: DispatchListRow) {
|
||||
@@ -235,7 +235,7 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template>
|
||||
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="dispatch-query-filters" @click="showAdvancedFilters = !showAdvancedFilters">{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}{{ model.status === 'CANCELLED' ? ' · 已取消' : model.status === 'ALL' ? ' · 全部单据' : '' }}</el-button></template></QueryToolbar></div></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' || canDownloadBol(row as DispatchListRow) ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}</template>
|
||||
<template #driverPhone="{ row }">{{ row.resources.driver?.phone || '—' }}</template>
|
||||
|
||||
@@ -688,7 +688,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
|
||||
it.each([
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-25T09:30:00', plannedDate: '2026-10-25' }, '2026-10-25 09:30'],
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, plannedDate: '2026-11-01', isDefault: true }, '2026-11-01(默认推算)'],
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, plannedDate: '2026-11-01', isDefault: true }, '2026-11-01'],
|
||||
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02' }, '2026-11-02'],
|
||||
])('reads the latest cabinet arrival on opening and permits planning with %s', async (arrival, expectedText) => {
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([arrival])
|
||||
@@ -698,6 +698,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId])
|
||||
const source = wrapper.get('[aria-label="入库来源"]')
|
||||
expect(source.text()).toContain(expectedText)
|
||||
expect(source.get('.inbound-plan-arrival').text()).not.toContain('默认推算')
|
||||
if (!arrival.estimatedWarehouseArrivalAt) expect(source.text()).not.toContain('00:00')
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
|
||||
@@ -125,8 +125,8 @@ const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value
|
||||
|| inboundPlanSourcePalletCount.value <= 0)))
|
||||
const inboundPlanForm = reactive({ inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
const inboundPlanExpectedArrival = computed(() => {
|
||||
const { date, time, isDefault } = inboundPlanArrivalProjection.value
|
||||
return date ? `${date}${time ? ` ${time}` : ''}${isDefault ? '(默认推算)' : ''}` : '—'
|
||||
const { date, time } = inboundPlanArrivalProjection.value
|
||||
return date ? `${date}${time ? ` ${time}` : ''}` : '—'
|
||||
})
|
||||
const holdOpen = ref(false)
|
||||
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
|
||||
import { downloadPlatformProductImage, getPlatformUnifiedOrder, listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { calculateInboundFee, confirmInboundFee, downloadPlatformInboundHoldEvidence, getLatestInboundFee, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound, sendInboundFee, uploadPlatformInboundHoldEvidence } from '@/modules/inbounds'
|
||||
import { adjustPublishedInboundFee, calculateInboundFee, confirmInboundFee, downloadPlatformInboundHoldEvidence, getInboundMeasurement, getLatestInboundFee, getPlatformInboundHold, holdPlatformInbound, previewInboundFee, releasePlatformInbound, sendInboundFee, updateMeasurementOuterDimensions, uploadPlatformInboundHoldEvidence, type InboundFeePreview } from '@/modules/inbounds'
|
||||
import UnifiedOrdersView from './UnifiedOrdersView.vue'
|
||||
|
||||
const { routeQuery, routerPush } = vi.hoisted(() => ({ routeQuery: { tenantId: '6001', from: '' }, routerPush: vi.fn() }))
|
||||
@@ -18,7 +18,9 @@ vi.mock('@/modules/iam', async importOriginal => ({
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
|
||||
vi.mock('@/modules/inbounds', () => ({
|
||||
downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(),
|
||||
calculateInboundFee: vi.fn(), confirmInboundFee: vi.fn(), sendInboundFee: vi.fn(), getLatestInboundFee: vi.fn(),
|
||||
updateMeasurementOuterDimensions: vi.fn(),
|
||||
adjustPublishedInboundFee: vi.fn(),
|
||||
calculateInboundFee: vi.fn(), confirmInboundFee: vi.fn(), sendInboundFee: vi.fn(), getLatestInboundFee: vi.fn(), previewInboundFee: vi.fn(),
|
||||
createFeeActionIdempotencyKey: vi.fn((action: string) => `fee-${action}`),
|
||||
createFeeAdjustmentIdempotencyKey: vi.fn(() => 'fee-adjust'),
|
||||
downloadPlatformInboundHoldEvidence: vi.fn(), getPlatformInboundHold: vi.fn(), holdPlatformInbound: vi.fn(), releasePlatformInbound: vi.fn(), uploadPlatformInboundHoldEvidence: vi.fn(),
|
||||
@@ -61,6 +63,7 @@ function mountDetail() {
|
||||
directives: { loading: () => undefined },
|
||||
stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<section v-if="modelValue"><slot /></section>' },
|
||||
DetailPageLayout: { template: '<section><slot /></section>' },
|
||||
OrderFulfillmentTreePanel: { template: '<div data-testid="fulfillment-tree" />' },
|
||||
LoadErrorState: { props: ['title', 'message'], template: '<div>{{ title }} {{ message }}<slot name="details" /></div>' },
|
||||
@@ -77,9 +80,44 @@ function mountDetail() {
|
||||
})
|
||||
}
|
||||
|
||||
function feePreview(overrides: Partial<InboundFeePreview> = {}): InboundFeePreview {
|
||||
return { actualWeightKg: 570.5, actualVolumeCbm: 3.894, chargeableQuantity: 650.298, chargeUnit: 'KG',
|
||||
conversionFactor: 167, roundingScale: 6, roundingMode: 'HALF_UP', unitPrice: 13.6,
|
||||
additionalAmount: 0, totalAmount: 8844.05, currency: 'CNY', priceMatched: true, ...overrides }
|
||||
}
|
||||
|
||||
function savedOuterMeasurement() {
|
||||
return { actualPackageCount: 22, actualGrossWeightKg: 570.5, actualVolumeCbm: 3.6894,
|
||||
items: [{ id: '9001', itemNo: 'B1', packageCount: 22, lengthCm: 62, widthCm: 52, heightCm: 52,
|
||||
outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53,
|
||||
calculatedOuterLengthCm: 63, calculatedOuterWidthCm: 53, calculatedOuterHeightCm: 53,
|
||||
unitVolumeCbm: 0.177, totalVolumeCbm: 3.894, volumeWeightKg: 650.298,
|
||||
coefficientMode: 'ACTUAL', calculationRuleVersion: 'WAREHOUSE_OUTER_OVERRIDE_V1' }] }
|
||||
}
|
||||
|
||||
type FeeConfirmVm = {
|
||||
measurementDetail: Record<string, unknown>
|
||||
measurementDimensionConfig: Record<string, unknown>
|
||||
measurementSectionModes: { outer: string; actual: string }
|
||||
feeConfirmVolume: number
|
||||
feeConfirmWeight: number
|
||||
feeConfirmUnitPrice: number | null
|
||||
feeConfirmPriceReason: string
|
||||
feeConfirmPriceChanged: boolean
|
||||
feeConfirmReference: InboundFeePreview | null
|
||||
feeConfirmReferenceLoading: boolean
|
||||
feeConfirmEstimatedTotal: number | null
|
||||
feeConfirmRows: { source: string; volume: number }[]
|
||||
openFeeConfirmDialog(): Promise<void>
|
||||
refreshFeeConfirmPreview(initializePrice?: boolean): Promise<void>
|
||||
executeConfirmAndSendFee(): Promise<void>
|
||||
}
|
||||
|
||||
describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(previewInboundFee).mockReset().mockResolvedValue(feePreview())
|
||||
routeQuery.from = ''
|
||||
canPermission.mockImplementation(() => true)
|
||||
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue(detail as never)
|
||||
@@ -122,7 +160,7 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps unchanged actual and outer volumes on the same server snapshot with three decimal places', async () => {
|
||||
it('shows actual and outer unit volumes and totals consistently with four decimal places', async () => {
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
@@ -138,8 +176,9 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
{ lengthCm: 60, widthCm: 50, heightCm: 50, packageCount: 10, unitVolumeCbm: 0.15, totalVolumeCbm: 1.5 },
|
||||
{ lengthCm: 61, widthCm: 51, heightCm: 51, packageCount: 9, unitVolumeCbm: 0.159, totalVolumeCbm: 1.431 },
|
||||
{ lengthCm: 50, widthCm: 40, heightCm: 40, packageCount: 1, unitVolumeCbm: 0.08, totalVolumeCbm: 0.08 },
|
||||
{ lengthCm: 62, widthCm: 52, heightCm: 52, packageCount: 22, unitVolumeCbm: 0.1677, totalVolumeCbm: 3.6894 },
|
||||
].map((row, index) => ({
|
||||
...row, itemNo: index + 1, coefficientMode: 'ACTUAL',
|
||||
...row, itemNo: index + 1, coefficientMode: 'ACTUAL', calculationRuleVersion: 'ACTUAL_DIMENSION_V1',
|
||||
calculatedOuterLengthCm: row.lengthCm, calculatedOuterWidthCm: row.widthCm, calculatedOuterHeightCm: row.heightCm,
|
||||
}))
|
||||
vm.measurementDetail = { items }
|
||||
@@ -148,13 +187,13 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
await flushPromises()
|
||||
|
||||
for (const rows of [vm.outerMeasurementRows, vm.actualMeasurementRows]) {
|
||||
expect(rows.map(row => vm.measurementValue(row, 'totalVolumeCbm'))).toEqual(['1.500', '1.431', '0.080'])
|
||||
expect(rows.map(row => vm.measurementValue(row, 'unitVolumeCbm'))).toEqual(['0.150', '0.159', '0.080'])
|
||||
expect(vm.formatMeasurementVolume(vm.measurementTotals(rows).volume)).toBe('3.011')
|
||||
expect(vm.formatMeasurementVolume(vm.measurementTotals(rows).weightVolume)).toBe('3.011')
|
||||
expect(rows.map(row => vm.measurementValue(row, 'totalVolumeCbm'))).toEqual(['1.5000', '1.4310', '0.0800', '3.6894'])
|
||||
expect(rows.map(row => vm.measurementValue(row, 'unitVolumeCbm'))).toEqual(['0.1500', '0.1590', '0.0800', '0.1677'])
|
||||
expect(vm.formatMeasurementVolume(vm.measurementTotals(rows).volume)).toBe('6.7004')
|
||||
expect(vm.formatMeasurementVolume(vm.measurementTotals(rows).weightVolume)).toBe('6.7004')
|
||||
}
|
||||
expect(vm.formatMeasurementVolume(3)).toBe('3.000')
|
||||
expect(vm.formatMeasurementVolume(0)).toBe('0.000')
|
||||
expect(vm.formatMeasurementVolume(3)).toBe('3.0000')
|
||||
expect(vm.formatMeasurementVolume(0)).toBe('0.0000')
|
||||
expect(vm.formatMeasurementVolume(null)).toBe('—')
|
||||
|
||||
// 仓库修正的外箱仍与实测尺寸分开,不能把外箱计费快照当成原始实测体积。
|
||||
@@ -162,46 +201,26 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
calculatedOuterLengthCm: 62, unitVolumeCbm: 0.1613, totalVolumeCbm: 1.4517,
|
||||
calculationRuleVersion: 'WAREHOUSE_OUTER_OVERRIDE_V1' }] }
|
||||
await flushPromises()
|
||||
expect(vm.measurementValue(vm.outerMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.452')
|
||||
expect(vm.measurementValue(vm.actualMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.428')
|
||||
expect(vm.measurementValue(vm.outerMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.4517')
|
||||
expect(vm.measurementValue(vm.actualMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.4283')
|
||||
|
||||
vm.measurementDetail = { items: [{ ...items[1], coefficientMode: 'ADD', calculatedOuterLengthCm: 62,
|
||||
calculatedOuterWidthCm: 52, calculatedOuterHeightCm: 52, unitVolumeCbm: 0.168, totalVolumeCbm: 1.512 }] }
|
||||
await flushPromises()
|
||||
expect(vm.measurementValue(vm.actualMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.428')
|
||||
expect(vm.measurementValue(vm.actualMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.4283')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ confirmedVolume: 3.894, hasReference: true },
|
||||
{ confirmedVolume: 4.25, hasReference: true },
|
||||
{ confirmedVolume: 4.25, hasReference: false },
|
||||
])('defaults billing to the saved outer volume and sends $confirmedVolume (reference: $hasReference)', async ({ confirmedVolume, hasReference }) => {
|
||||
it.each([3.894, 4.25])('defaults billing to the saved outer volume and sends the confirmed value %s', async (confirmedVolume) => {
|
||||
const fee = { unitPrice: 13.6, chargeableQuantity: 650.298, chargeUnit: 'KG', roundingScale: 4, rateCode: 'RATE', feeStatus: 'CALCULATED' }
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue(fee as never)
|
||||
if (!hasReference) vi.mocked(getLatestInboundFee).mockRejectedValueOnce(new ApiResponseError('暂无费用快照', 'A0404', 200))
|
||||
vi.mocked(calculateInboundFee).mockResolvedValue(fee as never)
|
||||
vi.mocked(confirmInboundFee).mockResolvedValue({ ...fee, feeStatus: 'CONFIRMED' } as never)
|
||||
vi.mocked(sendInboundFee).mockResolvedValue({ ...fee, feeStatus: 'SENT' } as never)
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
measurementDetail: Record<string, unknown>
|
||||
measurementSectionModes: { outer: string; actual: string }
|
||||
feeConfirmVolume: number
|
||||
feeConfirmWeight: number
|
||||
feeConfirmRows: { source: string; volume: number }[]
|
||||
openFeeConfirmDialog(): Promise<void>
|
||||
executeConfirmAndSendFee(): Promise<void>
|
||||
}
|
||||
vm.measurementDetail = {
|
||||
actualPackageCount: 22, actualGrossWeightKg: 570.5, actualVolumeCbm: 3.6894,
|
||||
items: [{ id: '9001', itemNo: 'B1', packageCount: 22, lengthCm: 62, widthCm: 52, heightCm: 52,
|
||||
outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53,
|
||||
calculatedOuterLengthCm: 63, calculatedOuterWidthCm: 53, calculatedOuterHeightCm: 53,
|
||||
unitVolumeCbm: 0.177, totalVolumeCbm: 3.894, volumeWeightKg: 650.298,
|
||||
coefficientMode: 'ACTUAL', calculationRuleVersion: 'WAREHOUSE_OUTER_OVERRIDE_V1' }],
|
||||
}
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = savedOuterMeasurement()
|
||||
vm.measurementSectionModes.outer = 'ACTUAL'
|
||||
vm.measurementSectionModes.actual = 'ACTUAL'
|
||||
await vm.openFeeConfirmDialog()
|
||||
@@ -210,10 +229,13 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
expect(vm.feeConfirmRows.find(row => row.source === '外箱规数据')?.volume).toBeCloseTo(3.894)
|
||||
expect(vm.feeConfirmVolume).toBeCloseTo(3.894)
|
||||
expect(vm.feeConfirmWeight).toBeCloseTo(570.5)
|
||||
if (!hasReference) expect(calculateInboundFee).toHaveBeenCalledWith('6001', '7001', {
|
||||
selectedWeightKg: 570.5, selectedVolumeCbm: 3.894, weightSource: 'ACTUAL', volumeSource: 'OUTER',
|
||||
}, 'fee-calculate')
|
||||
expect(previewInboundFee).toHaveBeenCalledWith('6001', '7001', {
|
||||
selectedWeightKg: 570.5, selectedVolumeCbm: 3.894, selectedUnitPrice: null, weightSource: 'ACTUAL', volumeSource: 'OUTER',
|
||||
})
|
||||
expect(calculateInboundFee).not.toHaveBeenCalled()
|
||||
expect(getLatestInboundFee).not.toHaveBeenCalled()
|
||||
vm.feeConfirmVolume = confirmedVolume
|
||||
await vm.refreshFeeConfirmPreview()
|
||||
await vm.executeConfirmAndSendFee()
|
||||
|
||||
expect(calculateInboundFee).toHaveBeenCalledWith('6001', '7001', {
|
||||
@@ -224,6 +246,245 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('refreshes the amount from current outer values instead of an older fee snapshot', async () => {
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({ unitPrice: 13.6, chargeableQuantity: 616.1298, totalAmount: 8379.37 } as never)
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = savedOuterMeasurement()
|
||||
await vm.openFeeConfirmDialog()
|
||||
|
||||
expect(vm.feeConfirmVolume).toBe(3.894)
|
||||
expect(vm.feeConfirmReference?.chargeableQuantity).toBe(650.298)
|
||||
expect(vm.feeConfirmEstimatedTotal).toBe(8844.05)
|
||||
expect(getLatestInboundFee).not.toHaveBeenCalled()
|
||||
expect(calculateInboundFee).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps saved outer totals when customer rules change without resaving the measurement', async () => {
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = {
|
||||
actualPackageCount: 10, actualGrossWeightKg: 100, actualVolumeCbm: 0.81,
|
||||
items: [{ id: '9001', itemNo: 'B1', packageCount: 10, lengthCm: 50, widthCm: 40, heightCm: 30,
|
||||
outerLengthCm: null, outerWidthCm: null, outerHeightCm: null,
|
||||
calculatedOuterLengthCm: 54, calculatedOuterWidthCm: 44, calculatedOuterHeightCm: 34,
|
||||
unitVolumeCbm: 0.081, totalVolumeCbm: 0.81, volumeWeightKg: 135.27,
|
||||
coefficientMode: 'ADD', calculationRuleVersion: 'CUSTOMER_DIMENSION_LARGE_V1' }],
|
||||
}
|
||||
vm.measurementDimensionConfig = { defaultCalculationMode: 'ADD', rules: [
|
||||
[30, 20, 20], [40, 30, 30], [50, 40, 40], [60, 50, 50],
|
||||
].map(([innerLengthCm, innerWidthCm, innerHeightCm]) => ({ innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm: 3 })) }
|
||||
vm.measurementSectionModes.outer = 'ADD'
|
||||
await vm.openFeeConfirmDialog()
|
||||
|
||||
expect(vm.feeConfirmVolume).toBeCloseTo(0.81)
|
||||
expect(previewInboundFee).toHaveBeenCalledWith('6001', '7001', {
|
||||
selectedWeightKg: 100, selectedVolumeCbm: 0.81, selectedUnitPrice: null, weightSource: 'ACTUAL', volumeSource: 'OUTER',
|
||||
})
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the price-table reference visible when manual pricing refreshes the amount and requires a reason before sending', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = savedOuterMeasurement()
|
||||
await vm.openFeeConfirmDialog()
|
||||
await flushPromises()
|
||||
const referencePriceText = () => wrapper.findAll('tr')
|
||||
.find(row => row.find('th').text() === '价格表参考单价')!.get('td').text()
|
||||
expect(referencePriceText()).toBe('13.6 CNY / KG')
|
||||
|
||||
vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ totalAmount: 9104.17 }))
|
||||
vm.feeConfirmUnitPrice = 14
|
||||
await flushPromises()
|
||||
expect(referencePriceText()).toBe('13.6 CNY / KG')
|
||||
expect(vm.feeConfirmEstimatedTotal).toBeNull()
|
||||
expect(vm.feeConfirmPriceChanged).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(referencePriceText()).toBe('13.6 CNY / KG')
|
||||
expect(vm.feeConfirmUnitPrice).toBe(14)
|
||||
expect(vm.feeConfirmEstimatedTotal).toBe(9104.17)
|
||||
expect(previewInboundFee).toHaveBeenLastCalledWith('6001', '7001', {
|
||||
selectedWeightKg: 570.5, selectedVolumeCbm: 3.894, selectedUnitPrice: 14, weightSource: 'ACTUAL', volumeSource: 'OUTER',
|
||||
})
|
||||
|
||||
await vm.executeConfirmAndSendFee()
|
||||
expect(calculateInboundFee).not.toHaveBeenCalled()
|
||||
const fee = { unitPrice: 13.6, chargeableQuantity: 650.298, additionalAmount: 0,
|
||||
rateCode: 'RATE', feeVersion: 2, feeStatus: 'CALCULATED' }
|
||||
vi.mocked(calculateInboundFee).mockResolvedValue(fee as never)
|
||||
vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...fee, unitPrice: 14, feeVersion: 3 } as never)
|
||||
vi.mocked(confirmInboundFee).mockResolvedValue({ ...fee, unitPrice: 14, feeStatus: 'CONFIRMED' } as never)
|
||||
vi.mocked(sendInboundFee).mockResolvedValue({ ...fee, unitPrice: 14, feeStatus: 'SENT' } as never)
|
||||
vm.feeConfirmPriceReason = '客户协商调价'
|
||||
await vm.executeConfirmAndSendFee()
|
||||
expect(adjustPublishedInboundFee).toHaveBeenCalledWith('6001', '7001', {
|
||||
expectedFeeVersion: 2, chargeableQuantity: 650.298, unitPrice: 14, additionalAmount: 0,
|
||||
reason: '客户协商调价', toleranceConfirmed: true,
|
||||
}, 'fee-adjust')
|
||||
expect(sendInboundFee).toHaveBeenCalledWith('6001', '7001', 'fee-send')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('stops requiring an adjustment reason immediately when the manual price is restored to the price-table reference', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = savedOuterMeasurement()
|
||||
await vm.openFeeConfirmDialog()
|
||||
vm.feeConfirmUnitPrice = 14
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(vm.feeConfirmPriceChanged).toBe(true)
|
||||
vm.feeConfirmUnitPrice = 13.6
|
||||
expect(vm.feeConfirmPriceChanged).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(vm.feeConfirmPriceChanged).toBe(false)
|
||||
expect(vm.feeConfirmEstimatedTotal).toBe(8844.05)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores stale preview responses and retains the manually entered unit price', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = savedOuterMeasurement()
|
||||
await vm.openFeeConfirmDialog()
|
||||
vm.feeConfirmUnitPrice = 16
|
||||
let resolveOld!: (value: InboundFeePreview) => void
|
||||
vi.mocked(previewInboundFee).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
vm.feeConfirmVolume = 4
|
||||
expect(vm.feeConfirmEstimatedTotal).toBeNull()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ actualVolumeCbm: 4.25, chargeableQuantity: 709.75, totalAmount: 11356 }))
|
||||
vm.feeConfirmVolume = 4.25
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(vm.feeConfirmReference?.actualVolumeCbm).toBe(4.25)
|
||||
expect(vm.feeConfirmUnitPrice).toBe(16)
|
||||
expect(vm.feeConfirmEstimatedTotal).toBe(11356)
|
||||
expect(previewInboundFee).toHaveBeenLastCalledWith('6001', '7001', {
|
||||
selectedWeightKg: 570.5, selectedVolumeCbm: 4.25, selectedUnitPrice: 16, weightSource: 'ACTUAL', volumeSource: 'OUTER',
|
||||
})
|
||||
resolveOld(feePreview({ actualVolumeCbm: 4, chargeableQuantity: 668, totalAmount: 9084.8 }))
|
||||
await flushPromises()
|
||||
expect(vm.feeConfirmReference?.actualVolumeCbm).toBe(4.25)
|
||||
expect(vm.feeConfirmEstimatedTotal).toBe(11356)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes billing preview and ignores its result after switching orders', async () => {
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm & { detail: Record<string, unknown>; feeConfirmOpen: boolean }
|
||||
vm.measurementDetail = savedOuterMeasurement()
|
||||
let resolvePreview!: (value: InboundFeePreview) => void
|
||||
vi.mocked(previewInboundFee).mockImplementationOnce(() => new Promise(resolve => { resolvePreview = resolve }))
|
||||
const pending = vm.openFeeConfirmDialog()
|
||||
vm.detail = { ...detail, summary: { ...detail.summary, inboundId: '7002' } }
|
||||
expect(vm.feeConfirmOpen).toBe(false)
|
||||
resolvePreview(feePreview())
|
||||
await pending
|
||||
expect(vm.feeConfirmReference).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not send when current fee preview fails', async () => {
|
||||
vi.mocked(previewInboundFee).mockRejectedValueOnce(new ApiResponseError('预览服务暂不可用', 'A0500', 200))
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = savedOuterMeasurement()
|
||||
await vm.openFeeConfirmDialog()
|
||||
vm.feeConfirmUnitPrice = 13.6
|
||||
await vm.executeConfirmAndSendFee()
|
||||
|
||||
expect(vm.feeConfirmReference).toBeNull()
|
||||
expect(vm.feeConfirmEstimatedTotal).toBeNull()
|
||||
expect(calculateInboundFee).not.toHaveBeenCalled()
|
||||
expect(sendInboundFee).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('previews manual pricing with server quantity when no price is matched', async () => {
|
||||
vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ priceMatched: false, unitPrice: null, totalAmount: null }))
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = savedOuterMeasurement()
|
||||
await vm.openFeeConfirmDialog()
|
||||
expect(vm.feeConfirmEstimatedTotal).toBeNull()
|
||||
vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ priceMatched: false, unitPrice: null, totalAmount: 6502.98 }))
|
||||
vm.feeConfirmUnitPrice = 10
|
||||
await vm.refreshFeeConfirmPreview()
|
||||
expect(vm.feeConfirmEstimatedTotal).toBe(6502.98)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses server money rounding after a manual unit price change', async () => {
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = savedOuterMeasurement()
|
||||
await vm.openFeeConfirmDialog()
|
||||
vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ chargeableQuantity: 1, unitPrice: 2, totalAmount: 1.01 }))
|
||||
vm.feeConfirmUnitPrice = 1.005
|
||||
expect(vm.feeConfirmEstimatedTotal).toBeNull()
|
||||
await vm.refreshFeeConfirmPreview()
|
||||
expect(vm.feeConfirmUnitPrice).toBe(1.005)
|
||||
expect(vm.feeConfirmEstimatedTotal).toBe(1.01)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('rejects a partially missing outer snapshot instead of billing the remaining rows', async () => {
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
const saved = savedOuterMeasurement()
|
||||
vm.measurementDetail = { ...saved, items: [saved.items[0], { ...saved.items[0], id: '9002', calculatedOuterLengthCm: null }] }
|
||||
await vm.openFeeConfirmDialog()
|
||||
expect(previewInboundFee).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('saves cleared warehouse dimensions as null and keeps them blank after server readback', async () => {
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm & {
|
||||
actualMeasurementRows: Record<string, unknown>[]
|
||||
outerMeasurementRows: Record<string, unknown>[]
|
||||
saveWarehouseOuterDimensions(row: Record<string, unknown>): Promise<void>
|
||||
}
|
||||
const previous = { ...savedOuterMeasurement(), measurementId: '8001', version: 3, customerFeeConfirmed: false }
|
||||
vm.measurementDetail = previous
|
||||
const cleared = { ...previous, version: 4, items: previous.items.map(row => ({ ...row,
|
||||
outerLengthCm: null, outerWidthCm: null, outerHeightCm: null,
|
||||
calculatedOuterLengthCm: 62, calculatedOuterWidthCm: 52, calculatedOuterHeightCm: 52,
|
||||
unitVolumeCbm: 0.1677, totalVolumeCbm: 3.6894, volumeWeightKg: 616.13,
|
||||
calculationRuleVersion: 'ACTUAL_DIMENSION_V1',
|
||||
})) }
|
||||
vi.mocked(updateMeasurementOuterDimensions).mockResolvedValue({ measurementId: '8001', version: 4, status: 'SUBMITTED', itemId: '9001' })
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValue(cleared as never)
|
||||
const row = vm.actualMeasurementRows[0]!
|
||||
row.outerLengthCm = null
|
||||
row.outerWidthCm = undefined
|
||||
row.outerHeightCm = ''
|
||||
|
||||
await vm.saveWarehouseOuterDimensions(row)
|
||||
|
||||
expect(updateMeasurementOuterDimensions).toHaveBeenCalledWith('6001', '7001', '9001', {
|
||||
measurementId: '8001', version: 3, outerLengthCm: null, outerWidthCm: null, outerHeightCm: null,
|
||||
})
|
||||
expect(vm.actualMeasurementRows[0]).toMatchObject({ outerLengthCm: null, outerWidthCm: null, outerHeightCm: null })
|
||||
expect(vm.outerMeasurementRows[0]).toMatchObject({ lengthCm: 62, widthCm: 52, heightCm: 52, totalVolumeCbm: 3.6894 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('downloads one preview for cargo rows sharing the same product image', async () => {
|
||||
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue({
|
||||
...detail,
|
||||
|
||||
@@ -29,12 +29,13 @@ describe('UnifiedOrdersView contract', () => {
|
||||
it('loads the reference unit price and lets the backend enforce the combined final lock', () => {
|
||||
const openDialogSource = source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()'))
|
||||
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
|
||||
expect(openDialogSource).toContain('getLatestInboundFee')
|
||||
expect(openDialogSource).toContain('previewInboundFee')
|
||||
expect(openDialogSource).not.toContain('calculateInboundFee(')
|
||||
expect(sendFeeSource).not.toContain('getLatestInboundFee')
|
||||
expect(sendFeeSource).toContain('adjustPublishedInboundFee')
|
||||
expect(source).toContain('价格表参考单价')
|
||||
expect(source).toContain('本次对客单价')
|
||||
expect(openDialogSource).toContain("feeConfirmPriceMissing.value = true")
|
||||
expect(openDialogSource).toContain('feeConfirmPriceMissing.value = !snapshot.priceMatched')
|
||||
expect(sendFeeSource).toContain('createManualInboundQuote')
|
||||
expect(source).toContain("feeConfirmPriceMissing ? '未命中'")
|
||||
expect(source).toContain("feeConfirmPriceMissing ? '人工定价原因'")
|
||||
@@ -486,7 +487,8 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).not.toContain('handleOuterModeDropdownVisibleChange')
|
||||
expect(source).toContain('const saved = await updateMeasurementCalculationMode(null, mode, ruleId)')
|
||||
expect(source).toContain('calculatedMeasurementRow(row, measurementSectionModes.customer, true)')
|
||||
expect(source).toContain('calculatedMeasurementRow(source, measurementSectionModes.outer)')
|
||||
expect(source).not.toContain('calculatedMeasurementRow(source, measurementSectionModes.outer)')
|
||||
expect(source).toContain('items.every(hasSavedOuterMeasurement)')
|
||||
expect(source).toContain('calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual, false, false)')
|
||||
expect(source).toContain("return Number(hasOuterOverride && override != null && override !== '' ? override : value)")
|
||||
expect(source).toContain("const hasOuterOverride = useWarehouseOuterOverrides && !customerSource")
|
||||
@@ -535,7 +537,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
|
||||
expect(sendFeeSource).not.toContain('saveInboundMeasurementDraft')
|
||||
expect(sendFeeSource).not.toContain('updateMeasurementCalculationMode')
|
||||
expect(source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()'))).toContain('getLatestInboundFee')
|
||||
expect(source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()'))).toContain('previewInboundFee')
|
||||
})
|
||||
|
||||
it('clears an invalidated measurement loading state before refreshing order details', () => {
|
||||
@@ -549,11 +551,10 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('? savedModes[0]!')
|
||||
expect(source).toContain("configuredMode === 'ADD' ? 'ADD' : 'ACTUAL'")
|
||||
expect(source).toContain('row.calculatedOuterLengthCm')
|
||||
expect(source).toContain('measurementCalculationModeForRow(source) === measurementSectionModes.outer')
|
||||
expect(source).toContain("String(row.calculationRuleVersion || '').startsWith('WAREHOUSE_OUTER_OVERRIDE_')")
|
||||
expect(source).toContain('warehouseOuterOverride')
|
||||
expect(source).toContain('Math.abs(Number(value) - Number(recalculatedDimensions[index])) < 0.001')
|
||||
expect(source).toContain('lengthCm: Number(savedDimensions[0])')
|
||||
expect(source).toContain('const saved = hasSavedOuterMeasurement(row)')
|
||||
expect(source).toContain('lengthCm: saved ? row.calculatedOuterLengthCm : null')
|
||||
expect(source).toContain('totalVolumeCbm: saved ? row.totalVolumeCbm : null')
|
||||
expect(source).not.toContain('warehouseOuterOverride')
|
||||
})
|
||||
|
||||
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
|
||||
|
||||
@@ -76,7 +76,7 @@ import {
|
||||
confirmInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
createFeeActionIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
previewInboundFee,
|
||||
getInboundMeasurement,
|
||||
getPlatformInboundHold,
|
||||
downloadInboundCartonLabelsBatch,
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
DropshipPalletPrintPanel,
|
||||
sendInboundFee,
|
||||
type InboundFeeSnapshot,
|
||||
type InboundFeePreview,
|
||||
type InboundMeasurementDetail,
|
||||
type InboundHoldDetail,
|
||||
} from '@/modules/inbounds'
|
||||
@@ -270,10 +271,16 @@ const feeConfirmOpen = ref(false)
|
||||
const feeConfirmWeight = ref<number | null>(null)
|
||||
const feeConfirmVolume = ref<number | null>(null)
|
||||
const feeConfirmReferenceLoading = ref(false)
|
||||
const feeConfirmReference = ref<InboundFeeSnapshot | null>(null)
|
||||
const feeConfirmReference = ref<InboundFeePreview | null>(null)
|
||||
const feeConfirmPriceReference = ref<Pick<InboundFeePreview, 'unitPrice' | 'currency' | 'chargeUnit'> | null>(null)
|
||||
const feeConfirmPriceMissing = ref(false)
|
||||
const feeConfirmUnitPrice = ref<number | null>(null)
|
||||
const feeConfirmPriceReason = ref('')
|
||||
let feeConfirmPreviewSerial = 0
|
||||
let feeConfirmPreviewTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const feeConfirmPreviewKey = ref('')
|
||||
let feeConfirmSettingInitialPrice = false
|
||||
const feeConfirmSelectionKey = computed(() => JSON.stringify([feeConfirmWeight.value, feeConfirmVolume.value, feeConfirmUnitPrice.value]))
|
||||
type FeeConfirmAttempt = {
|
||||
fingerprint: string
|
||||
calculateKey: string
|
||||
@@ -285,19 +292,20 @@ type FeeConfirmAttempt = {
|
||||
let feeConfirmAttempt: FeeConfirmAttempt | null = null
|
||||
const feeConfirmPriceChanged = computed(() => {
|
||||
if (feeConfirmPriceMissing.value) return true
|
||||
const reference = Number(feeConfirmReference.value?.unitPrice)
|
||||
const reference = feeConfirmPriceReference.value?.unitPrice
|
||||
const current = Number(feeConfirmUnitPrice.value)
|
||||
return reference >= 0 && current >= 0 && Math.abs(reference - current) > 0.000001
|
||||
return reference != null && reference >= 0 && current >= 0 && Math.abs(reference - current) > 0.000001
|
||||
})
|
||||
const feeConfirmPriceUnit = computed(() => {
|
||||
const snapshot = feeConfirmReference.value
|
||||
const snapshot = feeConfirmPriceReference.value
|
||||
return snapshot ? `${snapshot.currency} / ${snapshot.chargeUnit}` : 'CNY / 计费单位'
|
||||
})
|
||||
const feeConfirmEstimatedTotal = computed(() => {
|
||||
const snapshot = feeConfirmReference.value
|
||||
const unitPrice = Number(feeConfirmUnitPrice.value)
|
||||
if (!snapshot || !(unitPrice >= 0)) return null
|
||||
return Number(snapshot.chargeableQuantity || 0) * unitPrice + Number(snapshot.additionalAmount || 0)
|
||||
if (!snapshot || feeConfirmReferenceLoading.value || feeConfirmPreviewKey.value !== feeConfirmSelectionKey.value
|
||||
|| feeConfirmUnitPrice.value == null || !(unitPrice > 0)) return null
|
||||
return snapshot.totalAmount
|
||||
})
|
||||
const feeConfirmRows = computed(() => {
|
||||
const summary = detail.value?.summary
|
||||
@@ -537,34 +545,37 @@ function calculatedMeasurementRow(
|
||||
const customerMeasurementRows = computed(() => (detail.value?.order.cargoes || []).map((row) => (
|
||||
calculatedMeasurementRow(row, measurementSectionModes.customer, true)
|
||||
)))
|
||||
const outerMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => {
|
||||
function hasSavedOuterMeasurement(row: InboundMeasurementDetail['items'][number]) {
|
||||
return [row.calculatedOuterLengthCm, row.calculatedOuterWidthCm, row.calculatedOuterHeightCm,
|
||||
row.unitVolumeCbm, row.totalVolumeCbm].every(value => value != null && Number.isFinite(Number(value)) && Number(value) > 0)
|
||||
&& Boolean(row.calculationRuleVersion)
|
||||
}
|
||||
|
||||
const outerMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row): Record<string, unknown> => {
|
||||
const source = row as unknown as Record<string, unknown>
|
||||
const recalculated = calculatedMeasurementRow(source, measurementSectionModes.outer)
|
||||
const savedDimensions = [row.calculatedOuterLengthCm, row.calculatedOuterWidthCm, row.calculatedOuterHeightCm]
|
||||
const recalculatedDimensions = [recalculated.lengthCm, recalculated.widthCm, recalculated.heightCm]
|
||||
const warehouseOuterOverride = String(row.calculationRuleVersion || '').startsWith('WAREHOUSE_OUTER_OVERRIDE_')
|
||||
if (savedDimensions.every((value) => Number.isFinite(Number(value)) && Number(value) > 0)
|
||||
&& (warehouseOuterOverride
|
||||
|| savedDimensions.every((value, index) => Math.abs(Number(value) - Number(recalculatedDimensions[index])) < 0.001))
|
||||
&& measurementCalculationModeForRow(source) === measurementSectionModes.outer) {
|
||||
const packageCount = Number(row.packageCount)
|
||||
const unitWeightKg = Number(row.unitWeightKg) > 0
|
||||
? Number(row.unitWeightKg)
|
||||
: Number.isFinite(Number(measurementDetail.value?.actualGrossWeightKg))
|
||||
&& Number(measurementDetail.value?.actualPackageCount) > 0
|
||||
? Number(measurementDetail.value?.actualGrossWeightKg) / Number(measurementDetail.value?.actualPackageCount)
|
||||
: null
|
||||
return {
|
||||
...source,
|
||||
lengthCm: Number(savedDimensions[0]),
|
||||
widthCm: Number(savedDimensions[1]),
|
||||
heightCm: Number(savedDimensions[2]),
|
||||
unitWeightKg,
|
||||
totalWeightKg: unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null,
|
||||
}
|
||||
const saved = hasSavedOuterMeasurement(row)
|
||||
const packageCount = Number(row.packageCount)
|
||||
const unitWeightKg = Number(row.unitWeightKg) > 0
|
||||
? Number(row.unitWeightKg)
|
||||
: Number.isFinite(Number(measurementDetail.value?.actualGrossWeightKg))
|
||||
&& Number(measurementDetail.value?.actualPackageCount) > 0
|
||||
? Number(measurementDetail.value?.actualGrossWeightKg) / Number(measurementDetail.value?.actualPackageCount)
|
||||
: null
|
||||
// 外箱规和对客计费只认已保存快照,当前客户配置的变化不得在前端重写历史测量。
|
||||
return {
|
||||
...source,
|
||||
lengthCm: saved ? row.calculatedOuterLengthCm : null,
|
||||
widthCm: saved ? row.calculatedOuterWidthCm : null,
|
||||
heightCm: saved ? row.calculatedOuterHeightCm : null,
|
||||
unitVolumeCbm: saved ? row.unitVolumeCbm : null,
|
||||
totalVolumeCbm: saved ? row.totalVolumeCbm : null,
|
||||
volumeWeightKg: saved ? row.volumeWeightKg : null,
|
||||
unitWeightKg,
|
||||
totalWeightKg: unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null,
|
||||
}
|
||||
return recalculated
|
||||
}))
|
||||
const hasSavedOuterMeasurementData = computed(() => hasActualMeasurementData.value
|
||||
&& measurementDetail.value!.items.every(hasSavedOuterMeasurement))
|
||||
const actualMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => (
|
||||
calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual, false, false)
|
||||
)))
|
||||
@@ -878,7 +889,7 @@ function formatNumber(value: number | null | undefined, digits = 2) {
|
||||
|
||||
function formatMeasurementVolume(value: number | null | undefined) {
|
||||
return value == null || !Number.isFinite(value) ? '—'
|
||||
: value.toLocaleString('zh-CN', { minimumFractionDigits: 3, maximumFractionDigits: 3 })
|
||||
: value.toLocaleString('zh-CN', { minimumFractionDigits: 4, maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
function partyValue(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'RECEIVER', key: string) {
|
||||
@@ -1163,9 +1174,10 @@ async function saveWarehouseOuterDimensions(row: Record<string, unknown>) {
|
||||
outerLengthCm: dimensions[0],
|
||||
outerWidthCm: dimensions[1],
|
||||
outerHeightCm: dimensions[2],
|
||||
calculatedOuterLengthCm: dimensions[0],
|
||||
calculatedOuterWidthCm: dimensions[1],
|
||||
calculatedOuterHeightCm: dimensions[2],
|
||||
// 保存完成后等待服务端返回完整计算快照,不用修正输入值伪装计算结果。
|
||||
calculatedOuterLengthCm: null,
|
||||
calculatedOuterWidthCm: null,
|
||||
calculatedOuterHeightCm: null,
|
||||
} : item),
|
||||
}
|
||||
const refreshed = await waitForSavedMeasurementVersion(
|
||||
@@ -1737,12 +1749,12 @@ async function openFeeConfirmDialog() {
|
||||
if (confirmFeeSending.value) {
|
||||
return
|
||||
}
|
||||
if (measurementStrategySavingId.value) {
|
||||
if (measurementStrategySavingId.value || measurementOuterSavingId.value) {
|
||||
ElMessage.warning('外箱规计算规则正在保存,请稍后再发送费用')
|
||||
return
|
||||
}
|
||||
const outerTotals = measurementTotals(outerMeasurementRows.value)
|
||||
if (!outerMeasurementRows.value.length || !(Number(outerTotals.volume) > 0)) {
|
||||
if (!hasSavedOuterMeasurementData.value || !(Number(outerTotals.volume) > 0)) {
|
||||
ElMessage.warning('外箱规数据不完整,请先保存外箱规尺寸和计算规则')
|
||||
return
|
||||
}
|
||||
@@ -1754,44 +1766,79 @@ async function openFeeConfirmDialog() {
|
||||
: measuredGrossWeight > 0 ? measuredGrossWeight : measuredNetWeight > 0 ? measuredNetWeight : null
|
||||
feeConfirmVolume.value = Number(outerTotals.volume)
|
||||
feeConfirmReference.value = null
|
||||
feeConfirmPriceReference.value = null
|
||||
feeConfirmPriceMissing.value = false
|
||||
feeConfirmUnitPrice.value = null
|
||||
feeConfirmPriceReason.value = ''
|
||||
feeConfirmAttempt = null
|
||||
feeConfirmOpen.value = true
|
||||
await refreshFeeConfirmPreview(true)
|
||||
}
|
||||
|
||||
function cancelFeeConfirmPreview() {
|
||||
feeConfirmPreviewSerial += 1
|
||||
if (feeConfirmPreviewTimer) clearTimeout(feeConfirmPreviewTimer)
|
||||
feeConfirmPreviewTimer = null
|
||||
feeConfirmReferenceLoading.value = false
|
||||
feeConfirmPreviewKey.value = ''
|
||||
}
|
||||
|
||||
async function refreshFeeConfirmPreview(initializePrice = false) {
|
||||
cancelFeeConfirmPreview()
|
||||
const summary = detail.value?.summary
|
||||
const selectedWeightKg = Number(feeConfirmWeight.value)
|
||||
const selectedVolumeCbm = Number(feeConfirmVolume.value)
|
||||
const selectedUnitPrice = feeConfirmUnitPrice.value
|
||||
if (!feeConfirmOpen.value || !summary?.inboundId || !(selectedWeightKg > 0) || !(selectedVolumeCbm > 0)
|
||||
|| (selectedUnitPrice != null && !(selectedUnitPrice > 0))) {
|
||||
feeConfirmReference.value = null
|
||||
return
|
||||
}
|
||||
const serial = feeConfirmPreviewSerial
|
||||
feeConfirmReferenceLoading.value = true
|
||||
try {
|
||||
const tenantId = String(summary.tenantId)
|
||||
const inboundId = String(summary.inboundId)
|
||||
let snapshot: InboundFeeSnapshot
|
||||
try {
|
||||
snapshot = await getLatestInboundFee(tenantId, inboundId)
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '价格表参考单价加载失败')
|
||||
if (details.code !== 'A0404') throw error
|
||||
snapshot = await calculateInboundFee(tenantId, inboundId, {
|
||||
selectedWeightKg: Number(feeConfirmWeight.value),
|
||||
selectedVolumeCbm: Number(feeConfirmVolume.value),
|
||||
weightSource: 'ACTUAL',
|
||||
volumeSource: 'OUTER',
|
||||
}, createFeeActionIdempotencyKey('calculate'))
|
||||
}
|
||||
const snapshot = await previewInboundFee(String(summary.tenantId), String(summary.inboundId), {
|
||||
selectedWeightKg, selectedVolumeCbm, selectedUnitPrice, weightSource: 'ACTUAL', volumeSource: 'OUTER',
|
||||
})
|
||||
if (serial !== feeConfirmPreviewSerial || !feeConfirmOpen.value) return
|
||||
feeConfirmReference.value = snapshot
|
||||
feeConfirmUnitPrice.value = Number(snapshot.unitPrice)
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '价格表参考单价加载失败')
|
||||
if (details.code === 'A0404') {
|
||||
feeConfirmPriceMissing.value = true
|
||||
ElMessage.warning('当前FBA价格未命中,请填写本次对客单价和人工定价原因')
|
||||
} else {
|
||||
ElMessage.error(details.message)
|
||||
feeConfirmOpen.value = false
|
||||
// unitPrice 是价格表匹配价;人工单价只参与服务端金额预览,不覆盖该只读参考。
|
||||
feeConfirmPriceReference.value = { unitPrice: snapshot.unitPrice, currency: snapshot.currency, chargeUnit: snapshot.chargeUnit }
|
||||
feeConfirmPriceMissing.value = !snapshot.priceMatched
|
||||
if (initializePrice) {
|
||||
feeConfirmSettingInitialPrice = true
|
||||
feeConfirmUnitPrice.value = snapshot.unitPrice
|
||||
feeConfirmSettingInitialPrice = false
|
||||
}
|
||||
feeConfirmPreviewKey.value = feeConfirmSelectionKey.value
|
||||
if (initializePrice && !snapshot.priceMatched) ElMessage.warning('当前FBA价格未命中,请填写本次对客单价和人工定价原因')
|
||||
} catch (error) {
|
||||
if (serial !== feeConfirmPreviewSerial || !feeConfirmOpen.value) return
|
||||
feeConfirmReference.value = null
|
||||
ElMessage.error(getApiErrorDetails(error, '计费预览加载失败,请稍后重试').message)
|
||||
} finally {
|
||||
feeConfirmReferenceLoading.value = false
|
||||
if (serial === feeConfirmPreviewSerial) feeConfirmReferenceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([feeConfirmWeight, feeConfirmVolume, feeConfirmUnitPrice], () => {
|
||||
if (!feeConfirmOpen.value || confirmFeeSending.value || feeConfirmSettingInitialPrice) return
|
||||
cancelFeeConfirmPreview()
|
||||
feeConfirmReference.value = null
|
||||
if (!(Number(feeConfirmWeight.value) > 0) || !(Number(feeConfirmVolume.value) > 0)
|
||||
|| (feeConfirmUnitPrice.value != null && !(feeConfirmUnitPrice.value > 0))) return
|
||||
feeConfirmReferenceLoading.value = true
|
||||
feeConfirmPreviewTimer = setTimeout(() => { void refreshFeeConfirmPreview() }, 250)
|
||||
}, { flush: 'sync' })
|
||||
|
||||
watch(feeConfirmOpen, open => {
|
||||
if (!open) cancelFeeConfirmPreview()
|
||||
}, { flush: 'sync' })
|
||||
|
||||
watch(() => `${detail.value?.summary.tenantId}:${detail.value?.summary.inboundId}`, () => {
|
||||
feeConfirmOpen.value = false
|
||||
}, { flush: 'sync' })
|
||||
|
||||
function currentFeeConfirmAttempt(
|
||||
selectedWeightKg: number,
|
||||
selectedVolumeCbm: number,
|
||||
@@ -1831,6 +1878,11 @@ async function executeConfirmAndSendFee() {
|
||||
ElMessage.warning('对客单价必须大于 0')
|
||||
return
|
||||
}
|
||||
if (feeConfirmReferenceLoading.value || !feeConfirmReference.value
|
||||
|| feeConfirmPreviewKey.value !== feeConfirmSelectionKey.value) {
|
||||
ElMessage.warning('请等待当前计费重量和体积的预览更新后再发送')
|
||||
return
|
||||
}
|
||||
if (feeConfirmPriceChanged.value && feeConfirmPriceReason.value.trim().length < 2) {
|
||||
ElMessage.warning('人工修改参考单价后,请填写至少 2 个字符的调整原因')
|
||||
return
|
||||
@@ -2402,6 +2454,7 @@ onMounted(() => {
|
||||
if (!isDetailRoute.value) void loadOrders()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
cancelFeeConfirmPreview()
|
||||
ordersRequestSerial += 1
|
||||
statusCountsRequestSerial += 1
|
||||
detailAssociationRequestSerial += 1
|
||||
@@ -2418,7 +2471,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
<template>
|
||||
<AdminPage class="admin-workbench admin-density">
|
||||
<section class="business-page unified-orders-page admin-density">
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="feeConfirmOpen" :title="orderDialogTitle('编辑计费数据', detail?.summary.inboundCode)" confirm-text="确认并发送" :confirm-loading="confirmFeeSending || feeConfirmReferenceLoading" width="min(920px, 94vw)" fullscreenable :teleported="false" @confirm="executeConfirmAndSendFee">
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="feeConfirmOpen" :title="orderDialogTitle('编辑计费数据', detail?.summary.inboundCode)" confirm-text="确认并发送" :confirm-loading="confirmFeeSending" :confirm-disabled="feeConfirmReferenceLoading || !feeConfirmReference" width="min(920px, 94vw)" fullscreenable :teleported="false" @confirm="executeConfirmAndSendFee">
|
||||
<el-alert class="fee-confirm-price-alert" title="价格表单价仅作为参考;本次对客单价可人工修改,客户将一次确认计费量、单价和费用合计。" type="info" :closable="false" show-icon />
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<h3 class="density-dialog-section__title">本次计费数据 <small>毛重、体积和对客单价可修改</small></h3>
|
||||
@@ -2431,15 +2484,15 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="fee-confirm-weight">计费毛重</label></th>
|
||||
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-weight" v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" placeholder="请输入计费毛重" /><span>kg</span></div></td>
|
||||
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-weight" v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" :disabled="confirmFeeSending" placeholder="请输入计费毛重" /><span>kg</span></div></td>
|
||||
<th scope="row"><label for="fee-confirm-volume">计费体积</label></th>
|
||||
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-volume" v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" placeholder="请输入计费体积" /><span>m³</span></div></td>
|
||||
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-volume" v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" :disabled="confirmFeeSending" placeholder="请输入计费体积" /><span>m³</span></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">价格表参考单价</th>
|
||||
<td>{{ feeConfirmPriceMissing ? '未命中' : feeConfirmReference?.unitPrice == null ? '正在读取参考单价' : `${formatNumber(Number(feeConfirmReference.unitPrice), 4)} ${feeConfirmPriceUnit}` }}</td>
|
||||
<td>{{ feeConfirmPriceMissing ? '未命中' : feeConfirmPriceReference?.unitPrice == null ? '正在读取参考单价' : `${formatNumber(feeConfirmPriceReference.unitPrice, 4)} ${feeConfirmPriceUnit}` }}</td>
|
||||
<th scope="row"><label for="fee-confirm-unit-price">本次对客单价</label></th>
|
||||
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-unit-price" v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading" placeholder="请输入对客单价" /><span>{{ feeConfirmPriceUnit }}</span></div></td>
|
||||
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-unit-price" v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading || confirmFeeSending" placeholder="请输入对客单价" /><span>{{ feeConfirmPriceUnit }}</span></div></td>
|
||||
</tr>
|
||||
<tr v-if="feeConfirmReference">
|
||||
<th scope="row">参考计费量</th><td>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</td>
|
||||
|
||||
@@ -1855,7 +1855,8 @@ describe('approved Admin density ownership', () => {
|
||||
expect(overview).not.toContain('aspect-ratio:4/3')
|
||||
expect(customs).toContain('class="density-fields customs-summary"')
|
||||
expect(customs).not.toContain('class="broker-submission-fact"')
|
||||
expect(customs).toContain('class="confirmation-history-note"')
|
||||
expect(customs).not.toContain('confirmation-history-note')
|
||||
expect(customs).toContain("document.receiptStatus === 'CONFIRMED'")
|
||||
})
|
||||
|
||||
it('keeps container detail spacing compact and uses shared table styling for dense fulfillment records', () => {
|
||||
|
||||
Reference in New Issue
Block a user