优化
This commit is contained in:
Vendored
+1
@@ -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/)
|
||||
})
|
||||
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user