This commit is contained in:
2026-08-04 21:01:34 +08:00
parent 161bf580c7
commit 2313fd238c
12 changed files with 716 additions and 24 deletions
+1
View File
@@ -36,6 +36,7 @@ declare module 'vue' {
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./inboundBilling.ts', import.meta.url), 'utf8')
const typesSource = readFileSync(new URL('../types/inboundBilling.ts', import.meta.url), 'utf8')
test('queries the merchant order inbound-billing endpoint without tenant parameters', () => {
assert.match(source, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/)
assert.match(source, /method: 'GET'/)
assert.doesNotMatch(source, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/)
})
test('sanitizes the platform-shaped response into a merchant whitelist', () => {
assert.match(source, /function sanitizeInboundBilling/)
assert.match(source, /summary: sanitizeSummary\(response\.inbound\.summary\)/)
assert.match(source, /palletLocations: response\.inbound\.palletLocations\.map\(sanitizePalletLocation\)/)
assert.match(source, /latestFee: sanitizeFee\(response\.latestFee\)/)
assert.doesNotMatch(source, /\.\.\.response|\.\.\.summary|\.\.\.fee/)
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
})
test('keeps all backend Long identifiers as strings', () => {
for (const field of ['id', 'orderId', 'warehouseId', 'palletId', 'locationId', 'feeId', 'inboundId', 'chargeRuleId', 'carrierRateId', 'customerId', 'carrierId']) {
assert.match(typesSource, new RegExp(`${field}: string`))
}
assert.doesNotMatch(typesSource, /(?:id|Id): number/)
assert.doesNotMatch(typesSource, /internalNotes|statusHistory|files/)
})
+121
View File
@@ -0,0 +1,121 @@
import { apiRequest } from '@/api/http'
import type {
MerchantInboundBilling,
MerchantInboundFeeSnapshot,
MerchantInboundResult,
MerchantInboundSummary,
MerchantPalletLocation,
} from '@/modules/first-leg/types/inboundBilling'
interface MerchantInboundBillingWireResponse {
inbound: MerchantInboundResult
latestFee: MerchantInboundFeeSnapshot
}
function sanitizeSummary(summary: MerchantInboundSummary): MerchantInboundSummary {
return {
id: summary.id,
orderId: summary.orderId,
orderNo: summary.orderNo,
inboundCode: summary.inboundCode,
processStatus: summary.processStatus,
feeStatus: summary.feeStatus,
notificationStatus: summary.notificationStatus,
warehouseName: summary.warehouseName,
deliveryWarehouseName: summary.deliveryWarehouseName,
destinationCode: summary.destinationCode,
destinationName: summary.destinationName,
totalPackageCount: summary.totalPackageCount,
actualGrossWeightKg: summary.actualGrossWeightKg,
actualNetWeightKg: summary.actualNetWeightKg,
actualVolumeCbm: summary.actualVolumeCbm,
volumeWeightKg: summary.volumeWeightKg,
chargeableWeightKg: summary.chargeableWeightKg,
chargeableVolumeCbm: summary.chargeableVolumeCbm,
palletCount: summary.palletCount,
locationCodes: summary.locationCodes,
measureCompletedAt: summary.measureCompletedAt,
inboundedAt: summary.inboundedAt,
}
}
function sanitizePalletLocation(item: MerchantPalletLocation): MerchantPalletLocation {
return {
allocationNo: item.allocationNo,
warehouseId: item.warehouseId,
palletId: item.palletId,
palletCode: item.palletCode,
palletCapacityCbm: item.palletCapacityCbm,
allocatedVolumeCbm: item.allocatedVolumeCbm,
allocatedPackageCount: item.allocatedPackageCount,
locationId: item.locationId,
locationCode: item.locationCode,
allocationStatus: item.allocationStatus,
}
}
function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapshot {
return {
feeId: fee.feeId,
feeNo: fee.feeNo,
feeVersion: fee.feeVersion,
feeStatus: fee.feeStatus,
inboundId: fee.inboundId,
inboundCode: fee.inboundCode,
chargeRuleId: fee.chargeRuleId,
ruleCode: fee.ruleCode,
chargeRuleVersion: fee.chargeRuleVersion,
carrierRateId: fee.carrierRateId,
rateCode: fee.rateCode,
carrierRateVersion: fee.carrierRateVersion,
customerId: fee.customerId,
customerName: fee.customerName,
originWarehouseId: fee.originWarehouseId,
originWarehouseName: fee.originWarehouseName,
businessType: fee.businessType,
channelCode: fee.channelCode,
carrierId: fee.carrierId,
carrierName: fee.carrierName,
destinationCode: fee.destinationCode,
taxMode: fee.taxMode,
chargeMode: fee.chargeMode,
conversionFactor: fee.conversionFactor,
roundingScale: fee.roundingScale,
roundingMode: fee.roundingMode,
actualWeightKg: fee.actualWeightKg,
actualVolumeCbm: fee.actualVolumeCbm,
convertedQuantity: fee.convertedQuantity,
chargeableQuantity: fee.chargeableQuantity,
chargeUnit: fee.chargeUnit,
unitPrice: fee.unitPrice,
subtotalAmount: fee.subtotalAmount,
additionalAmount: fee.additionalAmount,
totalAmount: fee.totalAmount,
currency: fee.currency,
rateEffectiveFrom: fee.rateEffectiveFrom,
rateEffectiveTo: fee.rateEffectiveTo,
calculatedAt: fee.calculatedAt,
confirmedAt: fee.confirmedAt,
sentAt: fee.sentAt,
nextProcessStatus: fee.nextProcessStatus,
}
}
function sanitizeInboundBilling(response: MerchantInboundBillingWireResponse): MerchantInboundBilling {
return {
inbound: {
summary: sanitizeSummary(response.inbound.summary),
publicRemark: response.inbound.publicRemark,
palletLocations: response.inbound.palletLocations.map(sanitizePalletLocation),
},
latestFee: sanitizeFee(response.latestFee),
}
}
export async function getMerchantInboundBilling(orderId: string): Promise<MerchantInboundBilling> {
const response = await apiRequest<MerchantInboundBillingWireResponse>({
method: 'GET',
url: `/merchant/v1/first-leg/orders/${orderId}/inbound-billing`,
})
return sanitizeInboundBilling(response)
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./InboundBillingPanel.vue', import.meta.url), 'utf8')
test('renders loading, pending, isolated error, and published states', () => {
assert.match(source, /v-if="props\.loading"/)
assert.match(source, /v-else-if="props\.error"/)
assert.match(source, /role="alert"/)
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
assert.match(source, /role="status"/)
assert.match(source, /已入库 · 费用已发布/)
assert.match(source, /\$emit\('retry'\)/)
})
test('shows the confirmed merchant inbound and fee fields without write actions', () => {
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
assert.match(source, /getRoundingModeLabel\(props\.data\.latestFee\.roundingMode\)/)
assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/)
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
assert.match(source, /latestFee\.ruleCode/)
assert.match(source, /latestFee\.rateCode/)
assert.match(source, /latestFee\.chargeRuleVersion/)
assert.match(source, /latestFee\.carrierRateVersion/)
})
test('stacks core information for 720 and 360 pixel layouts', () => {
assert.match(source, /@media \(max-width: 720px\)/)
assert.match(source, /\.inbound-overview, \.inbound-measurements, \.fee-calculation-grid \{ grid-template-columns: 1fr;/)
assert.match(source, /\.inbound-billing-state \.el-button \{[^}]*min-height: 44px;/)
assert.match(source, /@media \(max-width: 360px\)/)
})
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
import {
formatInboundDateTime,
formatInboundMeasure,
formatInboundMoney,
formatInboundNumber,
getChargeModeLabel,
getRoundingModeLabel,
getTaxModeLabel,
} from '@/modules/first-leg/utils/inboundBilling'
const props = withDefaults(defineProps<{
data?: MerchantInboundBilling | null
loading?: boolean
unavailable?: boolean
unavailableMessage?: string
error?: string
}>(), {
data: null,
loading: false,
unavailable: false,
unavailableMessage: '入库及费用结果尚未发布',
error: '',
})
defineEmits<{
retry: []
}>()
</script>
<template>
<section class="inbound-billing-panel" aria-labelledby="inbound-billing-title">
<header class="inbound-billing-heading">
<h3 id="inbound-billing-title">入库与费用</h3>
<el-tag v-if="props.data" type="success" effect="light">已入库 · 费用已发布</el-tag>
</header>
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
<el-skeleton :rows="4" animated />
<span class="sr-only">正在加载入库与费用结果</span>
</div>
<div v-else-if="props.error" class="inbound-billing-state is-error" role="alert">
<TriangleAlert :size="22" />
<div>
<strong>入库与费用加载失败</strong>
<span>{{ props.error }}</span>
</div>
<el-button @click="$emit('retry')"><RefreshCw :size="15" />重新加载</el-button>
</div>
<div v-else-if="props.unavailable || !props.data" class="inbound-billing-state is-pending" role="status">
<Clock3 :size="22" />
<div>
<strong>{{ props.unavailableMessage }}</strong>
<span>客服完成测量审核、入库并发布费用后,将在这里展示最终结果。</span>
</div>
</div>
<template v-else>
<dl class="inbound-overview">
<div><dt>入仓码</dt><dd>{{ props.data.inbound.summary.inboundCode }}</dd></div>
<div><dt>入库完成</dt><dd>{{ formatInboundDateTime(props.data.inbound.summary.inboundedAt) }}</dd></div>
<div><dt>平台收货仓</dt><dd>{{ props.data.inbound.summary.warehouseName || '-' }}</dd></div>
<div><dt>目的仓</dt><dd>{{ props.data.inbound.summary.destinationCode || props.data.inbound.summary.destinationName || '-' }}</dd></div>
</dl>
<div class="inbound-result-section">
<h4>实测结果</h4>
<dl class="inbound-measurements">
<div><dt>实际箱数</dt><dd>{{ formatInboundNumber(props.data.inbound.summary.totalPackageCount, 0) }}</dd></div>
<div><dt>实际毛重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualGrossWeightKg, 'kg') }}</dd></div>
<div><dt>实际净重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualNetWeightKg, 'kg') }}</dd></div>
<div><dt>实测体积</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualVolumeCbm, 'm³') }}</dd></div>
<div><dt>材积重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.volumeWeightKg, 'kg') }}</dd></div>
<div><dt>测量完成</dt><dd>{{ formatInboundDateTime(props.data.inbound.summary.measureCompletedAt) }}</dd></div>
</dl>
</div>
<div class="inbound-result-section">
<div class="inbound-section-title">
<h4>托盘与库位</h4>
<span>{{ props.data.inbound.palletLocations.length }} 个托盘</span>
</div>
<div v-if="props.data.inbound.palletLocations.length" class="pallet-location-list">
<article v-for="item in props.data.inbound.palletLocations" :key="`${item.palletId}-${item.locationId}`">
<PackageCheck :size="18" />
<div class="pallet-location-main">
<strong>{{ item.palletCode }}</strong>
<span>库位 {{ item.locationCode }}</span>
</div>
<div class="pallet-location-quantity">
<span>{{ formatInboundMeasure(item.allocatedVolumeCbm, 'm³') }}</span>
<span>{{ formatInboundNumber(item.allocatedPackageCount, 0) }} 箱</span>
</div>
</article>
</div>
<p v-else class="inbound-inline-empty">暂无托盘和库位明细</p>
</div>
<div class="inbound-result-section fee-result">
<div class="inbound-section-title">
<h4>费用结果</h4>
<span>{{ props.data.latestFee.feeNo }} · 第 {{ props.data.latestFee.feeVersion }} 版</span>
</div>
<dl class="fee-calculation-grid">
<div><dt>计费方式</dt><dd>{{ getChargeModeLabel(props.data.latestFee.chargeMode) }} · {{ getTaxModeLabel(props.data.latestFee.taxMode) }}</dd></div>
<div>
<dt>换算与精度</dt>
<dd>
系数 {{ formatInboundNumber(props.data.latestFee.conversionFactor) }} ·
保留 {{ props.data.latestFee.roundingScale }} 位 {{ getRoundingModeLabel(props.data.latestFee.roundingMode) }}
</dd>
</div>
<div><dt>实际重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}</dd></div>
<div><dt>实际体积</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}</dd></div>
<div><dt>折算量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.convertedQuantity, props.data.latestFee.chargeUnit) }}</dd></div>
<div><dt>最终计费量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.chargeableQuantity, props.data.latestFee.chargeUnit) }}</dd></div>
<div><dt>单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit }}</dd></div>
<div><dt>基础费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.subtotalAmount, props.data.latestFee.currency) }}</dd></div>
<div><dt>附加费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.additionalAmount, props.data.latestFee.currency) }}</dd></div>
</dl>
<div class="fee-total">
<div>
<span>费用合计</span>
<small>{{ props.data.latestFee.ruleCode }} v{{ props.data.latestFee.chargeRuleVersion }} · {{ props.data.latestFee.rateCode }} v{{ props.data.latestFee.carrierRateVersion }}</small>
</div>
<strong>{{ formatInboundMoney(props.data.latestFee.totalAmount, props.data.latestFee.currency) }}</strong>
</div>
<p class="fee-sent-time">发布于 {{ formatInboundDateTime(props.data.latestFee.sentAt) }}</p>
</div>
<div v-if="props.data.inbound.publicRemark" class="inbound-public-remark">
<strong>平台备注</strong>
<p>{{ props.data.inbound.publicRemark }}</p>
</div>
</template>
</section>
</template>
<style scoped>
.inbound-billing-panel { margin-top: 22px; }
.inbound-billing-heading, .inbound-section-title, .fee-total { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.inbound-billing-heading h3, .inbound-result-section h4 { margin: 0; color: var(--navy); }
.inbound-billing-heading h3 { font-size: 15px; }
.inbound-result-section h4 { font-size: 13px; }
.inbound-billing-loading { min-height: 180px; padding: 18px 0; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.inbound-billing-state { display: grid; min-height: 112px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; margin-top: 12px; padding: 18px; border: 1px dashed #cbd5da; }
.inbound-billing-state > svg { color: var(--primary); }
.inbound-billing-state > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.inbound-billing-state strong { color: var(--navy); font-size: 13px; }
.inbound-billing-state span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; }
.inbound-billing-state.is-error { border-style: solid; border-color: #e8c5bd; background: #fff8f6; }
.inbound-billing-state.is-error > svg { color: #b5472f; }
.inbound-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 12px 0 0; border: 1px solid var(--border); }
.inbound-overview > div { min-width: 0; padding: 13px; border-bottom: 1px solid var(--border); }
.inbound-overview > div:nth-child(odd) { border-right: 1px solid var(--border); }
.inbound-overview > div:nth-last-child(-n + 2) { border-bottom: 0; }
.inbound-overview dt, .inbound-measurements dt, .fee-calculation-grid dt { color: var(--muted); font-size: 10px; }
.inbound-overview dd, .inbound-measurements dd, .fee-calculation-grid dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
.inbound-result-section { margin-top: 18px; }
.inbound-section-title > span { color: var(--muted); font-size: 10px; text-align: right; }
.inbound-measurements { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 10px 0 0; background: #f6f8f8; }
.inbound-measurements > div { min-width: 0; padding: 12px; }
.pallet-location-list { margin-top: 9px; border-top: 1px solid var(--border); }
.pallet-location-list article { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border); }
.pallet-location-list article > svg { color: var(--primary); }
.pallet-location-main, .pallet-location-quantity { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.pallet-location-main strong { overflow-wrap: anywhere; color: var(--navy); font-size: 12px; }
.pallet-location-main span, .pallet-location-quantity span { color: var(--muted); font-size: 10px; }
.pallet-location-quantity { align-items: flex-end; }
.inbound-inline-empty { margin: 9px 0 0; padding: 14px; background: #f6f8f8; color: var(--muted); font-size: 11px; text-align: center; }
.fee-result { padding-top: 18px; border-top: 1px solid var(--border); }
.fee-calculation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 10px 0 0; border: 1px solid var(--border); }
.fee-calculation-grid > div { min-width: 0; padding: 11px 12px; border-bottom: 1px solid var(--border); }
.fee-calculation-grid > div:not(:nth-child(3n)) { border-right: 1px solid var(--border); }
.fee-calculation-grid > div:nth-last-child(-n + 3) { border-bottom: 0; }
.fee-total { padding: 15px 0 0; }
.fee-total > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.fee-total span { color: var(--navy); font-size: 12px; font-weight: 650; }
.fee-total small, .fee-sent-time { color: var(--muted); font-size: 10px; }
.fee-total strong { flex: 0 0 auto; color: var(--primary); font-size: 21px; }
.fee-sent-time { margin: 5px 0 0; text-align: right; }
.inbound-public-remark { margin-top: 16px; padding: 13px 14px; background: #f2f7f6; }
.inbound-public-remark strong { color: var(--navy); font-size: 11px; }
.inbound-public-remark p { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; white-space: pre-wrap; }
@media (max-width: 720px) {
.inbound-billing-state { grid-template-columns: auto minmax(0, 1fr); }
.inbound-billing-state .el-button { grid-column: 1 / -1; width: 100%; min-height: 44px; }
.inbound-overview, .inbound-measurements, .fee-calculation-grid { grid-template-columns: 1fr; }
.inbound-overview > div, .inbound-overview > div:nth-child(odd), .fee-calculation-grid > div, .fee-calculation-grid > div:not(:nth-child(3n)) { border-right: 0; border-bottom: 1px solid var(--border); }
.inbound-overview > div:last-child, .fee-calculation-grid > div:last-child { border-bottom: 0; }
.pallet-location-list article { grid-template-columns: auto minmax(0, 1fr); }
.pallet-location-quantity { grid-column: 2; align-items: flex-start; flex-direction: row; flex-wrap: wrap; gap: 8px; }
}
@media (max-width: 360px) {
.inbound-billing-heading { align-items: flex-start; flex-direction: column; }
.fee-total { align-items: flex-start; flex-direction: column; }
.fee-total strong { font-size: 19px; }
.fee-sent-time { text-align: left; }
}
</style>
@@ -0,0 +1,106 @@
export type MerchantInboundProcessStatus =
| 'WAIT_MEASURE'
| 'MEASURING'
| 'MEASURE_PENDING'
| 'MEASURE_REJECTED'
| 'WAIT_PRICING'
| 'FEE_CONFIRMING'
| 'INBOUNDING'
| 'INBOUNDED'
| 'VOIDED'
export type MerchantInboundFeeStatus = 'UNPRICED' | 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'VOIDED'
export interface MerchantInboundSummary {
id: string
orderId: string
orderNo: string | null
inboundCode: string
processStatus: MerchantInboundProcessStatus
feeStatus: MerchantInboundFeeStatus
notificationStatus: string | null
warehouseName: string | null
deliveryWarehouseName: string | null
destinationCode: string | null
destinationName: string | null
totalPackageCount: number | null
actualGrossWeightKg: number | null
actualNetWeightKg: number | null
actualVolumeCbm: number | null
volumeWeightKg: number | null
chargeableWeightKg: number | null
chargeableVolumeCbm: number | null
palletCount: number | null
locationCodes: string | null
measureCompletedAt: string | null
inboundedAt: string | null
}
export interface MerchantPalletLocation {
allocationNo: number
warehouseId: string
palletId: string
palletCode: string
palletCapacityCbm: number | null
allocatedVolumeCbm: number | null
allocatedPackageCount: number | null
locationId: string
locationCode: string
allocationStatus: string
}
export interface MerchantInboundResult {
summary: MerchantInboundSummary
publicRemark: string | null
palletLocations: MerchantPalletLocation[]
}
export interface MerchantInboundFeeSnapshot {
feeId: string
feeNo: string
feeVersion: number
feeStatus: MerchantInboundFeeStatus
inboundId: string
inboundCode: string
chargeRuleId: string
ruleCode: string
chargeRuleVersion: number
carrierRateId: string
rateCode: string
carrierRateVersion: number
customerId: string
customerName: string
originWarehouseId: string
originWarehouseName: string
businessType: string
channelCode: string
carrierId: string
carrierName: string
destinationCode: string
taxMode: string
chargeMode: 'KG' | 'CBM'
conversionFactor: number
roundingScale: number
roundingMode: string
actualWeightKg: number
actualVolumeCbm: number
convertedQuantity: number
chargeableQuantity: number
chargeUnit: 'KG' | 'CBM'
unitPrice: number
subtotalAmount: number
additionalAmount: number
totalAmount: number
currency: string
rateEffectiveFrom: string | null
rateEffectiveTo: string | null
calculatedAt: string | null
confirmedAt: string | null
sentAt: string | null
nextProcessStatus: MerchantInboundProcessStatus | null
}
export interface MerchantInboundBilling {
inbound: MerchantInboundResult
latestFee: MerchantInboundFeeSnapshot
}
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./inboundBilling.ts', import.meta.url), 'utf8')
test('formats zero values, currencies, measurements, and backend dates without hardcoded business factors', () => {
assert.match(source, /value === null \|\| value === undefined/)
assert.match(source, /Intl\.NumberFormat\('zh-CN'/)
assert.match(source, /style: 'currency'/)
assert.match(source, /Intl\.DateTimeFormat\('zh-CN'/)
assert.doesNotMatch(source, /\b167\b|\b363\b/)
})
test('translates backend rounding modes into customer-facing calculation labels', () => {
assert.match(source, /export function getRoundingModeLabel/)
assert.match(source, /CEILING: '向上取整'/)
assert.match(source, /HALF_UP: '四舍五入'/)
assert.match(source, /return labels\[value\] \|\| value \|\| '-'/)
})
@@ -0,0 +1,60 @@
export function formatInboundNumber(value: number | null | undefined, maximumFractionDigits = 3) {
if (value === null || value === undefined || !Number.isFinite(value)) return '-'
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
}
export function formatInboundMeasure(value: number | null | undefined, unit: string, maximumFractionDigits = 3) {
const formatted = formatInboundNumber(value, maximumFractionDigits)
return formatted === '-' ? formatted : `${formatted} ${unit}`
}
export function formatInboundMoney(value: number | null | undefined, currency: string) {
if (value === null || value === undefined || !Number.isFinite(value)) return '-'
try {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
} catch {
return `${currency} ${value.toFixed(2)}`
}
}
export function formatInboundDateTime(value: string | null | undefined) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}
export function getChargeModeLabel(value: 'KG' | 'CBM') {
return value === 'KG' ? '按重量计费' : '按体积计费'
}
export function getTaxModeLabel(value: string) {
if (value === 'TAX_INCLUDED') return '包税'
if (value === 'TAX_EXCLUDED') return '不包税'
return value || '-'
}
export function getRoundingModeLabel(value: string) {
const labels: Record<string, string> = {
UP: '向上舍入',
DOWN: '向下舍入',
CEILING: '向上取整',
FLOOR: '向下取整',
HALF_UP: '四舍五入',
HALF_DOWN: '五舍六入',
HALF_EVEN: '银行家舍入',
}
return labels[value] || value || '-'
}
@@ -181,6 +181,30 @@ test('shows fulfillment courier information as read-only client detail', () => {
assert.doesNotMatch(source, /v-model="form\.trackingNumber"/)
})
test('loads merchant inbound billing as an isolated read-only detail section', () => {
assert.match(source, /import InboundBillingPanel/)
assert.match(source, /import \{ getMerchantInboundBilling \}/)
assert.match(source, /void loadInboundBilling\(order\)/)
assert.match(source, /<InboundBillingPanel/)
assert.match(source, /@retry="retryInboundBilling"/)
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
assert.match(source, /order\.status === 'DRAFT'/)
assert.match(source, /订单提交后可查看入库结果/)
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
})
test('keeps platform enterprise scope and tenant terminology out of the customer order UI', () => {
const templateSource = source.slice(source.indexOf('<template>'), source.lastIndexOf('</template>'))
assert.doesNotMatch(templateSource, /租户|tenantId|所属企业|企业筛选/)
})
test('discards stale inbound billing responses when the drawer closes or the user changes orders', () => {
assert.match(source, /let inboundBillingRequestSequence = 0/)
assert.match(source, /const sequence = \+\+inboundBillingRequestSequence/)
assert.match(source, /sequence === inboundBillingRequestSequence[\s\S]*detailOpen\.value[\s\S]*detailId\.value === orderId/)
assert.match(source, /watch\(detailOpen,[\s\S]*resetInboundBillingState\(\)/)
})
test('protects unsaved drafts and exposes recoverable load failures', () => {
assert.match(source, /onBeforeRouteLeave/)
assert.match(source, /beforeunload/)
@@ -6,12 +6,16 @@ import { AlertTriangle, Box, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus,
import { FIRST_LEG_ORDER_STATUSES, FIRST_LEG_TRANSPORT_MODES, type FirstLegOrderStatus } from '@chongchong/shared'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
import { useAuthStore } from '@/modules/auth/public'
import type { FirstLegPartyRole, FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties'
import type { FirstLegOrder, FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
import {
canClientEditFirstLegOrder,
createEmptyCargoItem,
@@ -70,10 +74,16 @@ const detailId = ref<string>()
const saving = ref(false)
const detailLoading = ref(false)
const detailError = ref('')
const inboundBilling = ref<MerchantInboundBilling | null>(null)
const inboundBillingLoading = ref(false)
const inboundBillingUnavailable = ref(false)
const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布')
const inboundBillingError = ref('')
const supportingDataLoading = ref(false)
const supportingDataErrors = ref<string[]>([])
const editorBaseline = ref('')
let cargoSequence = 0
let inboundBillingRequestSequence = 0
interface CachedPrivateAddress {
id: string
@@ -293,7 +303,14 @@ watch(isCreatePage, (isCreate, wasCreate) => {
resetEditorValidation()
}, { immediate: true })
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
watch(detailOpen, (open) => {
if (!open) resetInboundBillingState()
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
resetInboundBillingState()
})
onBeforeRouteLeave(async () => {
if (!editorDirty.value) return true
@@ -536,6 +553,7 @@ async function openDetail(order: FirstLegOrder) {
detailOpen.value = true
detailLoading.value = true
detailError.value = ''
void loadInboundBilling(order)
try {
await store.loadDetail(order.id)
} catch (error) {
@@ -551,6 +569,58 @@ async function retryDetail() {
if (order) await openDetail(order)
}
function resetInboundBillingState() {
inboundBillingRequestSequence += 1
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
inboundBillingError.value = ''
}
function isCurrentInboundBillingRequest(sequence: number, orderId: string) {
return sequence === inboundBillingRequestSequence
&& detailOpen.value
&& detailId.value === orderId
}
async function loadInboundBilling(order: FirstLegOrder) {
const sequence = ++inboundBillingRequestSequence
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
inboundBillingError.value = ''
if (order.status === 'DRAFT') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = '订单提交后可查看入库结果'
return
}
inboundBillingLoading.value = true
try {
const result = await getMerchantInboundBilling(order.id)
if (!isCurrentInboundBillingRequest(sequence, order.id)) return
inboundBilling.value = result
} catch (error) {
if (!isCurrentInboundBillingRequest(sequence, order.id)) return
if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
} else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
}
} finally {
if (isCurrentInboundBillingRequest(sequence, order.id)) inboundBillingLoading.value = false
}
}
async function retryInboundBilling() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await loadInboundBilling(order)
}
async function confirmDiscardChanges() {
try {
await ElMessageBox.confirm('当前订单还有未保存的修改。离开后这些内容将丢失。', '放弃未保存的修改?', {
@@ -865,6 +935,14 @@ async function searchOrders(model: QueryModel) {
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
</section>
<section class="detail-section"><h3>货物汇总</h3><div v-if="detailSummary" class="cargo-summary"><div><Box :size="18" /><span>明细</span><strong>{{ detailOrder.items.length }} 项</strong></div><div><span>件数</span><strong>{{ detailSummary.pieces }}</strong></div><div><span>毛重</span><strong>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</strong></div><div><span>体积</span><strong>{{ detailSummary.volumeCbm }} m³</strong></div></div><ul class="cargo-detail-list"><li v-for="item in detailOrder.items" :key="item.id"><strong>{{ item.cargoNameCn }}</strong><span>{{ item.sku || '无 SKU' }} · {{ item.pieces }} 件 · {{ item.grossWeightKg }} kg</span></li></ul></section>
<InboundBillingPanel
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"
:unavailable-message="inboundBillingUnavailableMessage"
:error="inboundBillingError"
@retry="retryInboundBilling"
/>
<section class="detail-section"><h3>处理时间线</h3><el-timeline><el-timeline-item v-for="item in [...detailOrder.timeline].reverse()" :key="item.id" :timestamp="item.occurredAt" placement="top"><strong>{{ item.title }}</strong><p>{{ item.description || '-' }}</p><span>{{ item.operatorName || '未提供' }}</span></el-timeline-item></el-timeline></section>
</template>
</div>
@@ -78,14 +78,13 @@ const qualificationCertificateNo = ref('')
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const companyCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(store.company.creditCode))
const normalizedQualificationCertificateNo = computed(() => qualificationCertificateNo.value.trim().toUpperCase())
const businessLicenseFileSelected = computed(() => Boolean(
qualificationDrafts.find((item) => item.value === 'BUSINESS_LICENSE')?.file,
))
const qualificationCreditCodeReady = computed(() => !businessLicenseFileSelected.value
|| companyCreditCodeValid.value
|| unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
const draftCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
const businessLicenseFileSelected = computed(() => Boolean(qualificationDraftItem('BUSINESS_LICENSE')?.file))
const qualificationCreditCodeReady = computed(() => companyCreditCodeValid.value
|| (businessLicenseFileSelected.value && draftCreditCodeValid.value))
const qualificationCreditCodeError = computed(() => {
if (qualificationCreditCodeReady.value) return ''
if (draftCreditCodeValid.value && !businessLicenseFileSelected.value) return '统一社会信用代码必须随营业执照文件共同保存'
return '请输入18位字母或数字组成的统一社会信用代码'
})
const canUploadQualification = computed(() => ['PROFILE_REQUIRED', 'REJECTED'].includes(store.company.auditStatus))
@@ -93,6 +92,17 @@ const uploadedQualificationTypes = computed(() => new Set(store.qualifications
.filter((item) => item.status === 'PENDING')
.map((item) => qualificationTypes.find((type) => type.label === item.type)?.value)
.filter((type): type is MerchantQualificationType => Boolean(type))))
const requiredPackageFilesReady = computed(() => requiredQualificationTypes.every((type) => (
uploadedQualificationTypes.value.has(type.value) || Boolean(qualificationDraftItem(type.value)?.file)
)))
const requiredPackageComplete = computed(() => qualificationCreditCodeReady.value && requiredPackageFilesReady.value)
const missingRequiredPackageItems = computed(() => {
const missing = requiredQualificationTypes
.filter((type) => !uploadedQualificationTypes.value.has(type.value) && !qualificationDraftItem(type.value)?.file)
.map((type) => type.label)
if (!qualificationCreditCodeReady.value) missing.splice(1, 0, '18位统一社会信用代码')
return missing
})
const qualificationUploadDisabledReason = computed(() => {
if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。'
if (store.company.auditStatus === 'ACTIVE') return '当前企业已审核通过,资质替换请联系平台管理员处理。'
@@ -102,11 +112,11 @@ const qualificationUploadDisabledReason = computed(() => {
const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
const canSaveQualificationBatch = computed(() => canUploadQualification.value
&& selectedQualificationCount.value > 0
&& qualificationCreditCodeReady.value)
&& requiredPackageComplete.value)
const qualificationSaveDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
if (!qualificationCreditCodeReady.value) return qualificationCreditCodeError.value
if (!selectedQualificationCount.value) return '请选择需要新增或替换的资质文件'
if (!requiredPackageComplete.value) return `请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`
if (!selectedQualificationCount.value) return '当前没有需要保存的变更'
return ''
})
const qualificationDialogHint = computed(() => qualificationSaveDisabledReason.value === qualificationCreditCodeError.value
@@ -314,17 +324,13 @@ async function submitQualification() {
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
if (!qualificationCreditCodeReady.value) {
if (!requiredPackageComplete.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
if (businessLicense) businessLicense.error = qualificationCreditCodeError.value
ElMessage.warning(qualificationCreditCodeError.value)
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
return
}
const pendingItems = qualificationDrafts.filter((item) => item.file)
if (!pendingItems.length) {
ElMessage.warning('请选择需要新增或替换的资质文件')
return
}
qualificationSubmitting.value = true
let uploadedCount = 0
const failures: string[] = []
@@ -364,7 +370,7 @@ async function submitQualification() {
return
}
qualificationOpen.value = false
if (refreshed) ElMessage.success(`已保存 ${uploadedCount} 项资质文件,尚未提交审核`)
if (refreshed) ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核')
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
} finally {
qualificationSubmitting.value = false
@@ -526,8 +532,8 @@ function closeQualificationPreview() {
@closed="resetQualificationDraft"
>
<div class="qualification-dialog-intro">
<strong>必填资质与选填资料</strong>
<span>营业执照、法人身份证必填;其余两项可按需上传。文件保存不会自动提交企业审核。</span>
<strong>企业认证必填业务项</strong>
<span>营业执照、18位统一社会信用代码、法人身份证需共同完整保存;其余两项可按需上传。</span>
</div>
<div class="qualification-upload-grid">
<article v-for="item in qualificationDrafts" :key="item.value" class="qualification-upload-item">
@@ -563,8 +569,9 @@ function closeQualificationPreview() {
<p v-if="item.error" class="qualification-upload-error" role="alert">{{ item.error }}</p>
</article>
</div>
<el-form-item label="统一社会信用代码" class="qualification-credit-code" :required="businessLicenseFileSelected && !companyCreditCodeValid" :error="qualificationCreditCodeReady ? '' : qualificationCreditCodeError">
<el-input v-model="qualificationCertificateNo" maxlength="18" placeholder="请输入18位统一社会信用代码" @input="qualificationCertificateNo = String($event).toUpperCase()" />
<el-form-item label="统一社会信用代码" class="qualification-credit-code" required :error="qualificationCreditCodeReady ? '' : qualificationCreditCodeError">
<el-input v-model="qualificationCertificateNo" maxlength="18" :disabled="companyCreditCodeValid" placeholder="请输入18位统一社会信用代码" @input="qualificationCertificateNo = String($event).toUpperCase()" />
<small class="qualification-credit-code-hint">{{ companyCreditCodeValid ? '已登记的统一社会信用代码不可直接修改。' : '与营业执照一致,保存后不可直接修改。' }}</small>
</el-form-item>
<p v-if="qualificationDialogHint" class="qualification-dialog-hint">{{ qualificationDialogHint }}</p>
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" :disabled="!canSaveQualificationBatch" @click="submitQualification">保存全部资质</el-button></template>
@@ -649,6 +656,7 @@ function closeQualificationPreview() {
.selected-qualification-file span { color: var(--muted); font-size: 10px; }
.qualification-upload-error { margin: 9px 0 0; color: #b42318; font-size: 11px; line-height: 1.5; }
.qualification-credit-code { max-width: 420px; margin: 18px 0 0; }
.qualification-credit-code-hint { display:block; margin-top:6px; color:var(--muted); font-size:11px; line-height:1.5; }
.qualification-dialog-hint { margin: 10px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
.document-preview { display: grid; min-height: 260px; place-items: center; align-content: center; gap: 12px; border: 1px dashed #b8c8c5; background: #f6faf9; color: var(--primary); }
.document-preview > strong { max-width: min(420px, calc(100% - 32px)); overflow-wrap: anywhere; color: var(--navy); text-align: center; }
@@ -47,7 +47,9 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /:close-on-click-modal="!qualificationSubmitting"/)
assert.match(profileSource, /:close-on-press-escape="!qualificationSubmitting"/)
assert.match(profileSource, /:show-close="!qualificationSubmitting"/)
assert.match(profileSource, /const qualificationCreditCodeReady = computed\(\(\) => !businessLicenseFileSelected\.value/)
assert.match(profileSource, /const requiredPackageComplete = computed/)
assert.match(profileSource, /营业执照、18位统一社会信用代码、法人身份证需共同完整保存/)
assert.match(profileSource, /已登记的统一社会信用代码不可直接修改/)
assert.match(profileSource, /companyCreditCodeValid\.value/)
assert.doesNotMatch(profileSource, /<el-select v-model="qualificationDraft\.type"/)
assert.match(profileSource, /class="mobile-qualification-list"/)
@@ -69,7 +71,7 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/)
assert.match(profileSource, /submitTenantQualificationReview/)
assert.match(profileSource, /提交企业审核/)
assert.match(profileSource, /已保存.*项资质文件,尚未提交审核/)
assert.match(profileSource, /营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核/)
assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/)
assert.match(profileSource, /企业资料已提交审核,但最新状态刷新失败/)
assert.match(profileSource, /qualificationStatusMeta/)