优化
This commit is contained in:
Vendored
+1
@@ -36,6 +36,7 @@ declare module 'vue' {
|
|||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
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/)
|
||||||
|
})
|
||||||
@@ -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"/)
|
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', () => {
|
test('protects unsaved drafts and exposes recoverable load failures', () => {
|
||||||
assert.match(source, /onBeforeRouteLeave/)
|
assert.match(source, /onBeforeRouteLeave/)
|
||||||
assert.match(source, /beforeunload/)
|
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 { 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 QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.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 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 { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
|
||||||
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
|
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
|
||||||
import { useAuthStore } from '@/modules/auth/public'
|
import { useAuthStore } from '@/modules/auth/public'
|
||||||
import type { FirstLegPartyRole, FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties'
|
import type { FirstLegPartyRole, FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties'
|
||||||
import type { FirstLegOrder, FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
|
import type { FirstLegOrder, FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
|
||||||
|
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
|
||||||
import {
|
import {
|
||||||
canClientEditFirstLegOrder,
|
canClientEditFirstLegOrder,
|
||||||
createEmptyCargoItem,
|
createEmptyCargoItem,
|
||||||
@@ -70,10 +74,16 @@ const detailId = ref<string>()
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailError = ref('')
|
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 supportingDataLoading = ref(false)
|
||||||
const supportingDataErrors = ref<string[]>([])
|
const supportingDataErrors = ref<string[]>([])
|
||||||
const editorBaseline = ref('')
|
const editorBaseline = ref('')
|
||||||
let cargoSequence = 0
|
let cargoSequence = 0
|
||||||
|
let inboundBillingRequestSequence = 0
|
||||||
|
|
||||||
interface CachedPrivateAddress {
|
interface CachedPrivateAddress {
|
||||||
id: string
|
id: string
|
||||||
@@ -293,7 +303,14 @@ watch(isCreatePage, (isCreate, wasCreate) => {
|
|||||||
resetEditorValidation()
|
resetEditorValidation()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
|
watch(detailOpen, (open) => {
|
||||||
|
if (!open) resetInboundBillingState()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
resetInboundBillingState()
|
||||||
|
})
|
||||||
|
|
||||||
onBeforeRouteLeave(async () => {
|
onBeforeRouteLeave(async () => {
|
||||||
if (!editorDirty.value) return true
|
if (!editorDirty.value) return true
|
||||||
@@ -536,6 +553,7 @@ async function openDetail(order: FirstLegOrder) {
|
|||||||
detailOpen.value = true
|
detailOpen.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
detailError.value = ''
|
detailError.value = ''
|
||||||
|
void loadInboundBilling(order)
|
||||||
try {
|
try {
|
||||||
await store.loadDetail(order.id)
|
await store.loadDetail(order.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -551,6 +569,58 @@ async function retryDetail() {
|
|||||||
if (order) await openDetail(order)
|
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() {
|
async function confirmDiscardChanges() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('当前订单还有未保存的修改。离开后这些内容将丢失。', '放弃未保存的修改?', {
|
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>
|
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
|
||||||
</section>
|
</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>
|
<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>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,14 +78,13 @@ const qualificationCertificateNo = ref('')
|
|||||||
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
|
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
|
||||||
const companyCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(store.company.creditCode))
|
const companyCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(store.company.creditCode))
|
||||||
const normalizedQualificationCertificateNo = computed(() => qualificationCertificateNo.value.trim().toUpperCase())
|
const normalizedQualificationCertificateNo = computed(() => qualificationCertificateNo.value.trim().toUpperCase())
|
||||||
const businessLicenseFileSelected = computed(() => Boolean(
|
const draftCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
|
||||||
qualificationDrafts.find((item) => item.value === 'BUSINESS_LICENSE')?.file,
|
const businessLicenseFileSelected = computed(() => Boolean(qualificationDraftItem('BUSINESS_LICENSE')?.file))
|
||||||
))
|
const qualificationCreditCodeReady = computed(() => companyCreditCodeValid.value
|
||||||
const qualificationCreditCodeReady = computed(() => !businessLicenseFileSelected.value
|
|| (businessLicenseFileSelected.value && draftCreditCodeValid.value))
|
||||||
|| companyCreditCodeValid.value
|
|
||||||
|| unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
|
|
||||||
const qualificationCreditCodeError = computed(() => {
|
const qualificationCreditCodeError = computed(() => {
|
||||||
if (qualificationCreditCodeReady.value) return ''
|
if (qualificationCreditCodeReady.value) return ''
|
||||||
|
if (draftCreditCodeValid.value && !businessLicenseFileSelected.value) return '统一社会信用代码必须随营业执照文件共同保存'
|
||||||
return '请输入18位字母或数字组成的统一社会信用代码'
|
return '请输入18位字母或数字组成的统一社会信用代码'
|
||||||
})
|
})
|
||||||
const canUploadQualification = computed(() => ['PROFILE_REQUIRED', 'REJECTED'].includes(store.company.auditStatus))
|
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')
|
.filter((item) => item.status === 'PENDING')
|
||||||
.map((item) => qualificationTypes.find((type) => type.label === item.type)?.value)
|
.map((item) => qualificationTypes.find((type) => type.label === item.type)?.value)
|
||||||
.filter((type): type is MerchantQualificationType => Boolean(type))))
|
.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(() => {
|
const qualificationUploadDisabledReason = computed(() => {
|
||||||
if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。'
|
if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。'
|
||||||
if (store.company.auditStatus === 'ACTIVE') 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 selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
|
||||||
const canSaveQualificationBatch = computed(() => canUploadQualification.value
|
const canSaveQualificationBatch = computed(() => canUploadQualification.value
|
||||||
&& selectedQualificationCount.value > 0
|
&& selectedQualificationCount.value > 0
|
||||||
&& qualificationCreditCodeReady.value)
|
&& requiredPackageComplete.value)
|
||||||
const qualificationSaveDisabledReason = computed(() => {
|
const qualificationSaveDisabledReason = computed(() => {
|
||||||
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
|
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
|
||||||
if (!qualificationCreditCodeReady.value) return qualificationCreditCodeError.value
|
if (!requiredPackageComplete.value) return `请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`
|
||||||
if (!selectedQualificationCount.value) return '请选择需要新增或替换的资质文件'
|
if (!selectedQualificationCount.value) return '当前没有需要保存的变更'
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
const qualificationDialogHint = computed(() => qualificationSaveDisabledReason.value === qualificationCreditCodeError.value
|
const qualificationDialogHint = computed(() => qualificationSaveDisabledReason.value === qualificationCreditCodeError.value
|
||||||
@@ -314,17 +324,13 @@ async function submitQualification() {
|
|||||||
ElMessage.warning(qualificationUploadDisabledReason.value)
|
ElMessage.warning(qualificationUploadDisabledReason.value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!qualificationCreditCodeReady.value) {
|
if (!requiredPackageComplete.value) {
|
||||||
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
|
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
|
||||||
if (businessLicense) businessLicense.error = qualificationCreditCodeError.value
|
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
|
||||||
ElMessage.warning(qualificationCreditCodeError.value)
|
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const pendingItems = qualificationDrafts.filter((item) => item.file)
|
const pendingItems = qualificationDrafts.filter((item) => item.file)
|
||||||
if (!pendingItems.length) {
|
|
||||||
ElMessage.warning('请选择需要新增或替换的资质文件')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
qualificationSubmitting.value = true
|
qualificationSubmitting.value = true
|
||||||
let uploadedCount = 0
|
let uploadedCount = 0
|
||||||
const failures: string[] = []
|
const failures: string[] = []
|
||||||
@@ -364,7 +370,7 @@ async function submitQualification() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
qualificationOpen.value = false
|
qualificationOpen.value = false
|
||||||
if (refreshed) ElMessage.success(`已保存 ${uploadedCount} 项资质文件,尚未提交审核`)
|
if (refreshed) ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核')
|
||||||
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
|
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
|
||||||
} finally {
|
} finally {
|
||||||
qualificationSubmitting.value = false
|
qualificationSubmitting.value = false
|
||||||
@@ -526,8 +532,8 @@ function closeQualificationPreview() {
|
|||||||
@closed="resetQualificationDraft"
|
@closed="resetQualificationDraft"
|
||||||
>
|
>
|
||||||
<div class="qualification-dialog-intro">
|
<div class="qualification-dialog-intro">
|
||||||
<strong>必填资质与选填资料</strong>
|
<strong>企业认证必填业务项</strong>
|
||||||
<span>营业执照、法人身份证必填;其余两项可按需上传。文件保存不会自动提交企业审核。</span>
|
<span>营业执照、18位统一社会信用代码、法人身份证需共同完整保存;其余两项可按需上传。</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="qualification-upload-grid">
|
<div class="qualification-upload-grid">
|
||||||
<article v-for="item in qualificationDrafts" :key="item.value" class="qualification-upload-item">
|
<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>
|
<p v-if="item.error" class="qualification-upload-error" role="alert">{{ item.error }}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="统一社会信用代码" class="qualification-credit-code" :required="businessLicenseFileSelected && !companyCreditCodeValid" :error="qualificationCreditCodeReady ? '' : qualificationCreditCodeError">
|
<el-form-item label="统一社会信用代码" class="qualification-credit-code" required :error="qualificationCreditCodeReady ? '' : qualificationCreditCodeError">
|
||||||
<el-input v-model="qualificationCertificateNo" maxlength="18" placeholder="请输入18位统一社会信用代码" @input="qualificationCertificateNo = String($event).toUpperCase()" />
|
<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>
|
</el-form-item>
|
||||||
<p v-if="qualificationDialogHint" class="qualification-dialog-hint">{{ qualificationDialogHint }}</p>
|
<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>
|
<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; }
|
.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-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 { 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; }
|
.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 { 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; }
|
.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-click-modal="!qualificationSubmitting"/)
|
||||||
assert.match(profileSource, /:close-on-press-escape="!qualificationSubmitting"/)
|
assert.match(profileSource, /:close-on-press-escape="!qualificationSubmitting"/)
|
||||||
assert.match(profileSource, /:show-close="!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.match(profileSource, /companyCreditCodeValid\.value/)
|
||||||
assert.doesNotMatch(profileSource, /<el-select v-model="qualificationDraft\.type"/)
|
assert.doesNotMatch(profileSource, /<el-select v-model="qualificationDraft\.type"/)
|
||||||
assert.match(profileSource, /class="mobile-qualification-list"/)
|
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, /qualificationUploadErrorMessage\(error\)/)
|
||||||
assert.match(profileSource, /submitTenantQualificationReview/)
|
assert.match(profileSource, /submitTenantQualificationReview/)
|
||||||
assert.match(profileSource, /提交企业审核/)
|
assert.match(profileSource, /提交企业审核/)
|
||||||
assert.match(profileSource, /已保存.*项资质文件,尚未提交审核/)
|
assert.match(profileSource, /营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核/)
|
||||||
assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/)
|
assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/)
|
||||||
assert.match(profileSource, /企业资料已提交审核,但最新状态刷新失败/)
|
assert.match(profileSource, /企业资料已提交审核,但最新状态刷新失败/)
|
||||||
assert.match(profileSource, /qualificationStatusMeta/)
|
assert.match(profileSource, /qualificationStatusMeta/)
|
||||||
|
|||||||
Reference in New Issue
Block a user