From 2313fd238cc334de882cbf4a4d1dbb91b1ff3b69 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 4 Aug 2026 21:01:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components.d.ts | 1 + .../first-leg/api/inboundBilling.test.mjs | 29 +++ src/modules/first-leg/api/inboundBilling.ts | 121 ++++++++++ .../components/InboundBillingPanel.test.mjs | 36 +++ .../components/InboundBillingPanel.vue | 207 ++++++++++++++++++ src/modules/first-leg/types/inboundBilling.ts | 106 +++++++++ .../first-leg/utils/inboundBilling.test.mjs | 20 ++ src/modules/first-leg/utils/inboundBilling.ts | 60 +++++ .../views/FirstLegOrdersView.test.mjs | 24 ++ .../first-leg/views/FirstLegOrdersView.vue | 80 ++++++- .../views/CompanyProfileView.vue | 50 +++-- .../views/TenantAdministrationViews.test.mjs | 6 +- 12 files changed, 716 insertions(+), 24 deletions(-) create mode 100644 src/modules/first-leg/api/inboundBilling.test.mjs create mode 100644 src/modules/first-leg/api/inboundBilling.ts create mode 100644 src/modules/first-leg/components/InboundBillingPanel.test.mjs create mode 100644 src/modules/first-leg/components/InboundBillingPanel.vue create mode 100644 src/modules/first-leg/types/inboundBilling.ts create mode 100644 src/modules/first-leg/utils/inboundBilling.test.mjs create mode 100644 src/modules/first-leg/utils/inboundBilling.ts diff --git a/src/components.d.ts b/src/components.d.ts index 905aff4..34c807c 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -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'] diff --git a/src/modules/first-leg/api/inboundBilling.test.mjs b/src/modules/first-leg/api/inboundBilling.test.mjs new file mode 100644 index 0000000..2e8e05a --- /dev/null +++ b/src/modules/first-leg/api/inboundBilling.test.mjs @@ -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/) +}) diff --git a/src/modules/first-leg/api/inboundBilling.ts b/src/modules/first-leg/api/inboundBilling.ts new file mode 100644 index 0000000..fef4283 --- /dev/null +++ b/src/modules/first-leg/api/inboundBilling.ts @@ -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 { + const response = await apiRequest({ + method: 'GET', + url: `/merchant/v1/first-leg/orders/${orderId}/inbound-billing`, + }) + return sanitizeInboundBilling(response) +} diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs new file mode 100644 index 0000000..e45257b --- /dev/null +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -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\)/) +}) diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue new file mode 100644 index 0000000..b745b51 --- /dev/null +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/src/modules/first-leg/types/inboundBilling.ts b/src/modules/first-leg/types/inboundBilling.ts new file mode 100644 index 0000000..bf52f8a --- /dev/null +++ b/src/modules/first-leg/types/inboundBilling.ts @@ -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 +} diff --git a/src/modules/first-leg/utils/inboundBilling.test.mjs b/src/modules/first-leg/utils/inboundBilling.test.mjs new file mode 100644 index 0000000..bb093f5 --- /dev/null +++ b/src/modules/first-leg/utils/inboundBilling.test.mjs @@ -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 \|\| '-'/) +}) diff --git a/src/modules/first-leg/utils/inboundBilling.ts b/src/modules/first-leg/utils/inboundBilling.ts new file mode 100644 index 0000000..842cd58 --- /dev/null +++ b/src/modules/first-leg/utils/inboundBilling.ts @@ -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 = { + UP: '向上舍入', + DOWN: '向下舍入', + CEILING: '向上取整', + FLOOR: '向下取整', + HALF_UP: '四舍五入', + HALF_DOWN: '五舍六入', + HALF_EVEN: '银行家舍入', + } + return labels[value] || value || '-' +} diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 6fe32a9..e66496c 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -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, / { + const templateSource = source.slice(source.indexOf('')) + 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/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 41767a1..30878e9 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -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() const saving = ref(false) const detailLoading = ref(false) const detailError = ref('') +const inboundBilling = ref(null) +const inboundBillingLoading = ref(false) +const inboundBillingUnavailable = ref(false) +const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布') +const inboundBillingError = ref('') const supportingDataLoading = ref(false) const supportingDataErrors = ref([]) 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) {
平台尚未维护快递信息快递公司和快递单号由平台后台维护,客户端仅只读展示。

货物汇总

明细{{ detailOrder.items.length }} 项
件数{{ detailSummary.pieces }}
毛重{{ detailSummary.grossWeightKg.toLocaleString() }} kg
体积{{ detailSummary.volumeCbm }} m³
  • {{ item.cargoNameCn }}{{ item.sku || '无 SKU' }} · {{ item.pieces }} 件 · {{ item.grossWeightKg }} kg
+

处理时间线

{{ item.title }}

{{ item.description || '-' }}

{{ item.operatorName || '未提供' }}
diff --git a/src/modules/tenant-management/views/CompanyProfileView.vue b/src/modules/tenant-management/views/CompanyProfileView.vue index bc84525..74f9535 100644 --- a/src/modules/tenant-management/views/CompanyProfileView.vue +++ b/src/modules/tenant-management/views/CompanyProfileView.vue @@ -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" >
- 必填资质与选填资料 - 营业执照、法人身份证必填;其余两项可按需上传。文件保存不会自动提交企业审核。 + 企业认证必填业务项 + 营业执照、18位统一社会信用代码、法人身份证需共同完整保存;其余两项可按需上传。
@@ -563,8 +569,9 @@ function closeQualificationPreview() {
- - + + + {{ companyCreditCodeValid ? '已登记的统一社会信用代码不可直接修改。' : '与营业执照一致,保存后不可直接修改。' }}

{{ qualificationDialogHint }}

@@ -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; } diff --git a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs index 3df9593..e92de85 100644 --- a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs +++ b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs @@ -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, /