Merge branch 'release/test' of http://8.163.30.128:10082/html/client-web into release/test

This commit is contained in:
2026-08-13 17:12:08 +08:00
14 changed files with 120 additions and 67 deletions
+4 -3
View File
@@ -91,9 +91,10 @@ pnpm build
## 全量验收脚本 ## 全量验收脚本
- 本地 Client 自动回归由后端仓库的 `../../baiyue-firstleg/scripts/frontend-full-regression.ps1` 统一编排;先执行 `-DryRun`,再运行 `pnpm test` 和 `pnpm typecheck`。 - Client 全量验收唯一入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `client-phase2-test-env-acceptance.mjs`,不替代完整入口。
- 测试环境接口验收入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;二期兼容链路再使用 `../../baiyue-firstleg/scripts/client-phase2-test-env-acceptance.mjs`。这些脚本会创建或修改测试数据,必须在当前对话获得明确测试环境授权后执行。 - 入口通过测试环境 HTTP 验证注册/审核、客户隔离、订单、入库计费、文件、财务和权限链路,会创建或修改测试数据,必须获得当前对话明确授权。
- 脚本通过只证明自动化契约和接口检查通过;样式验收仍使用开发环境验收账号,通过浏览器逐页检查原型、客户可见字段、权限、错误态、空态和窄屏布局。 - `pnpm test/typecheck` 和 `frontend-full-regression.ps1` 仅是本地质量检查,不代表 Client 服务已完成全量验收。
- HTTP 验收通过后,再使用开发验收账号通过浏览器逐页检查真实接口调用、客户可见字段、原型、权限、错误态、空态和窄屏布局。
## Client 全量验收快速索引 ## Client 全量验收快速索引
-1
View File
@@ -86,7 +86,6 @@ export const FIRST_LEG_ORDER_STATUSES = [
{ code: 'DRAFT', label: '草稿' }, { code: 'DRAFT', label: '草稿' },
{ code: 'PENDING_REVIEW', label: '待审核' }, { code: 'PENDING_REVIEW', label: '待审核' },
{ code: 'WAIT_MEASURE', label: '待测量数据' }, { code: 'WAIT_MEASURE', label: '待测量数据' },
{ code: 'INBOUNDED', label: '已入库' },
{ code: 'WAIT_SPLIT', label: '待分柜' }, { code: 'WAIT_SPLIT', label: '待分柜' },
{ code: 'LOADING', label: '装柜中' }, { code: 'LOADING', label: '装柜中' },
{ code: 'LOADED', label: '装柜完成' }, { code: 'LOADED', label: '装柜完成' },
+1 -1
View File
@@ -95,7 +95,7 @@ transport.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
const request = error.config as RetryableRequest | undefined const request = error.config as RetryableRequest | undefined
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh'] const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path)) const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path))
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) { if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) {
throw mapError(error) throw mapError(error)
@@ -92,6 +92,13 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
assert.doesNotMatch(authStoreSource, /mock-client:/) assert.doesNotMatch(authStoreSource, /mock-client:/)
}) })
test('handles impersonation exchange as public authentication and exposes failures', () => {
assert.match(httpSource, /publicAuthPaths = \[[^\]]*'\/auth\/impersonations\/exchange'/)
assert.match(authStoreSource, /\/login\?impersonation=failed/)
assert.match(loginSource, /route\.query\.impersonation === 'failed'/)
assert.match(loginSource, /代登录失败或授权已失效,请返回管理后台重新发起/)
})
test('treats only body code 200 as success', () => { test('treats only body code 200 as success', () => {
assert.match(httpSource, /if \(envelope\.code !== '200'\)/) assert.match(httpSource, /if \(envelope\.code !== '200'\)/)
assert.doesNotMatch(httpSource, /if \(envelope\.code !== '0'\)/) assert.doesNotMatch(httpSource, /if \(envelope\.code !== '0'\)/)
+8
View File
@@ -17,6 +17,7 @@ const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const loading = ref(false) const loading = ref(false)
const impersonationFailed = route.query.impersonation === 'failed'
const form = reactive<LoginForm>({ const form = reactive<LoginForm>({
account: typeof route.query.account === 'string' account: typeof route.query.account === 'string'
? route.query.account ? route.query.account
@@ -77,6 +78,13 @@ function showResetHint() {
<h2 id="login-title">登录客户工作台</h2> <h2 id="login-title">登录客户工作台</h2>
<p>使用企业账号登录,继续管理您的跨境物流业务。</p> <p>使用企业账号登录,继续管理您的跨境物流业务。</p>
</div> </div>
<el-alert
v-if="impersonationFailed"
title="代登录失败或授权已失效,请返回管理后台重新发起"
type="error"
:closable="false"
show-icon
/>
<div class="auth-account-note">企业注册成功后即可登录;提交订单前需完成企业资料审核。</div> <div class="auth-account-note">企业注册成功后即可登录;提交订单前需完成企业资料审核。</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit"> <el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="用户名" prop="account"> <el-form-item label="用户名" prop="account">
+57 -44
View File
@@ -10,37 +10,51 @@ type WireScalar = string | number
export interface FinanceBillWire { export interface FinanceBillWire {
id: WireScalar id: WireScalar
bill_no: string customerId: WireScalar
customer_name_snapshot: string billNo: string
currency_code: string customerName: string
bill_date: string | null currency: string
due_date: string | null billDate: string | null
dueDate: string | null
status: FinanceBillStatus status: FinanceBillStatus
total_amount: WireScalar totalAmount: WireScalar
received_amount: WireScalar receivedAmount: WireScalar
outstanding_amount: WireScalar outstandingAmount: WireScalar
lines?: FinanceBillLineWire[]
} }
export interface FinanceBillLineWire { export interface FinanceBillLineWire {
id: WireScalar id: WireScalar
source_no: string | null sourceType: string
order_id: WireScalar | null sourceId: WireScalar
fee_item_name: string sourceVersion: number
orderId: WireScalar | null
inboundId: WireScalar | null
feeItemCode: string
feeItemName: string
amount: WireScalar amount: WireScalar
currency_code: string currency: string
}
export interface FinanceBillDetailWire {
statement: FinanceBillWire
lines: FinanceBillLineWire[]
}
export interface FinanceReceiptAllocationWire {
billId: WireScalar
billNo: string
appliedAmount: WireScalar
} }
export interface FinanceReceiptWire { export interface FinanceReceiptWire {
id: WireScalar id: WireScalar
receipt_no: string receiptNo: string
received_amount: WireScalar receivedAmount: WireScalar
received_at: string receivedAt: string
payment_method: string | null paymentMethod: string | null
voucher_no: string | null currency: string
status: string status: string
bill_id: WireScalar | null allocations: FinanceReceiptAllocationWire[]
applied_amount: WireScalar | null
} }
function text(value: WireScalar) { function text(value: WireScalar) {
@@ -50,14 +64,14 @@ function text(value: WireScalar) {
function mapBillSummary(bill: FinanceBillWire): FinanceBillSummary { function mapBillSummary(bill: FinanceBillWire): FinanceBillSummary {
return { return {
id: text(bill.id), id: text(bill.id),
billNo: bill.bill_no, billNo: bill.billNo,
customerName: bill.customer_name_snapshot, customerName: bill.customerName,
currencyCode: 'CNY', currencyCode: 'CNY',
totalAmount: text(bill.total_amount), totalAmount: text(bill.totalAmount),
receivedAmount: text(bill.received_amount), receivedAmount: text(bill.receivedAmount),
outstandingAmount: text(bill.outstanding_amount), outstandingAmount: text(bill.outstandingAmount),
issueDate: bill.bill_date, issueDate: bill.billDate,
dueDate: bill.due_date, dueDate: bill.dueDate,
status: bill.status, status: bill.status,
} }
} }
@@ -65,9 +79,9 @@ function mapBillSummary(bill: FinanceBillWire): FinanceBillSummary {
function mapBillLine(line: FinanceBillLineWire): FinanceBillLine { function mapBillLine(line: FinanceBillLineWire): FinanceBillLine {
return { return {
id: text(line.id), id: text(line.id),
orderId: line.order_id == null ? null : text(line.order_id), orderId: line.orderId == null ? null : text(line.orderId),
feeNo: line.source_no, feeNo: `${line.sourceType}:${text(line.sourceId)}/v${line.sourceVersion}`,
feeName: line.fee_item_name, feeName: line.feeItemName,
amount: text(line.amount), amount: text(line.amount),
currencyCode: 'CNY', currencyCode: 'CNY',
} }
@@ -79,27 +93,26 @@ export function mapFinanceBills(bills: FinanceBillWire[]) {
.map(mapBillSummary) .map(mapBillSummary)
} }
export function mapFinanceBillDetail(bill: FinanceBillWire): FinanceBillDetail { export function mapFinanceBillDetail(detail: FinanceBillDetailWire): FinanceBillDetail {
return { return {
...mapBillSummary(bill), ...mapBillSummary(detail.statement),
lines: (bill.lines ?? []).map(mapBillLine), lines: (detail.lines ?? []).map(mapBillLine),
receipts: [], receipts: [],
} }
} }
export function mapFinanceReceipts(receipts: FinanceReceiptWire[], billId: string): FinanceReceiptRecord[] { export function mapFinanceReceipts(receipts: FinanceReceiptWire[], billId: string): FinanceReceiptRecord[] {
return receipts return receipts.flatMap((receipt) => receipt.allocations
.filter((receipt) => receipt.bill_id != null && text(receipt.bill_id) === billId) .filter((allocation) => text(allocation.billId) === billId)
.map((receipt) => ({ .map((allocation) => ({
id: text(receipt.id), id: text(receipt.id),
receiptNo: receipt.receipt_no, receiptNo: receipt.receiptNo,
receivedAt: receipt.received_at, receivedAt: receipt.receivedAt,
amount: text(receipt.received_amount), amount: text(receipt.receivedAmount),
currencyCode: 'CNY', currencyCode: 'CNY' as const,
method: receipt.payment_method, method: receipt.paymentMethod,
voucherNo: receipt.voucher_no, allocatedAmount: text(allocation.appliedAmount),
allocatedAmount: receipt.applied_amount == null ? '0' : text(receipt.applied_amount), })))
}))
} }
export function sumDecimalStrings(values: string[]) { export function sumDecimalStrings(values: string[]) {
+2 -1
View File
@@ -5,6 +5,7 @@ import {
mapFinanceBills, mapFinanceBills,
mapFinanceReceipts, mapFinanceReceipts,
sumDecimalStrings, sumDecimalStrings,
type FinanceBillDetailWire,
type FinanceBillWire, type FinanceBillWire,
type FinanceReceiptWire, type FinanceReceiptWire,
} from './adapters' } from './adapters'
@@ -39,7 +40,7 @@ export async function getMerchantFinanceBills(params: FinanceBillQuery = {}): Pr
} }
export async function getMerchantFinanceBill(id: string): Promise<FinanceBillDetail> { export async function getMerchantFinanceBill(id: string): Promise<FinanceBillDetail> {
const response = await apiRequest<FinanceBillWire>({ const response = await apiRequest<FinanceBillDetailWire>({
method: 'GET', method: 'GET',
url: `/client/finance/v1/bills/${encodeURIComponent(id)}`, url: `/client/finance/v1/bills/${encodeURIComponent(id)}`,
}) })
-1
View File
@@ -38,7 +38,6 @@ export interface FinanceReceiptRecord {
amount: string amount: string
currencyCode: 'CNY' currencyCode: 'CNY'
method: string | null method: string | null
voucherNo: string | null
allocatedAmount: string allocatedAmount: string
} }
@@ -38,11 +38,16 @@ test('uses client finance read APIs without browser-selected customer scope', ()
assert.doesNotMatch(apiSource, /params:\s*\{\s*billId/) assert.doesNotMatch(apiSource, /params:\s*\{\s*billId/)
}) })
test('adapts the finance snake-case wire contract and filters receipts locally by bill id', () => { test('adapts the Finance Java DTO contract and filters nested receipt allocations by bill id', () => {
for (const field of ['bill_no', 'customer_name_snapshot', 'bill_date', 'total_amount', 'outstanding_amount', 'fee_item_name', 'order_id', 'receipt_no', 'bill_id', 'applied_amount']) { for (const field of ['billNo', 'customerName', 'billDate', 'totalAmount', 'outstandingAmount', 'feeItemName', 'orderId', 'receiptNo', 'allocations', 'billId', 'appliedAmount']) {
assert.match(adapterSource, new RegExp(field)) assert.match(adapterSource, new RegExp(field))
} }
assert.match(adapterSource, /filter\(\(receipt\).*receipt\.bill_id/s) assert.match(adapterSource, /flatMap\(\(receipt\).*receipt\.allocations/s)
assert.match(adapterSource, /detail\.statement/)
assert.match(adapterSource, /detail\.lines/)
assert.doesNotMatch(adapterSource, /bill_no|customer_name|bill_date|total_amount|outstanding_amount|receipt_no|bill_id|applied_amount/)
assert.doesNotMatch(adapterSource, /receipt\.billId|receipt\.appliedAmount/)
assert.doesNotMatch(adapterSource, /voucherNo|voucher_no/)
assert.match(viewSource, /loadDetail\(row\.id\)/) assert.match(viewSource, /loadDetail\(row\.id\)/)
assert.doesNotMatch(viewSource, /loadDetail\(row\.billNo\)/) assert.doesNotMatch(viewSource, /loadDetail\(row\.billNo\)/)
}) })
@@ -50,15 +55,16 @@ test('adapts the finance snake-case wire contract and filters receipts locally b
test('does not expose draft or voided bills returned by the server', () => { test('does not expose draft or voided bills returned by the server', () => {
const bill = (id, status) => ({ const bill = (id, status) => ({
id, id,
bill_no: `B-${id}`, customerId: 10001,
customer_name_snapshot: '测试客户', billNo: `B-${id}`,
currency_code: 'CNY', customerName: '测试客户',
bill_date: '2026-08-11', currency: 'CNY',
due_date: '2026-09-10', billDate: '2026-08-11',
dueDate: '2026-09-10',
status, status,
total_amount: '100.00', totalAmount: '100.00',
received_amount: '0.00', receivedAmount: '0.00',
outstanding_amount: '100.00', outstandingAmount: '100.00',
}) })
const visible = financeAdapters.mapFinanceBills([ const visible = financeAdapters.mapFinanceBills([
bill(1, 'DRAFT'), bill(1, 'DRAFT'),
@@ -70,6 +76,26 @@ test('does not expose draft or voided bills returned by the server', () => {
assert.deepEqual(visible.map((item) => item.status), ['ISSUED', 'PARTIALLY_PAID', 'PAID']) assert.deepEqual(visible.map((item) => item.status), ['ISSUED', 'PARTIALLY_PAID', 'PAID'])
}) })
test('maps nested bill detail and customer-safe receipt allocations', () => {
const detail = financeAdapters.mapFinanceBillDetail({
statement: {
id: 2, customerId: 10001, billNo: 'B-2', customerName: '测试客户', currency: 'CNY',
billDate: '2026-08-11', dueDate: '2026-09-10', status: 'ISSUED', totalAmount: '100.00',
receivedAmount: '40.00', outstandingAmount: '60.00',
},
lines: [{ id: 21, sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, orderId: 31, inboundId: 41, feeItemCode: 'FREIGHT', feeItemName: '运费', amount: '100.00', currency: 'CNY' }],
})
const receipts = financeAdapters.mapFinanceReceipts([{
id: 51, receiptNo: 'RCPT-51', receivedAmount: '40.00', receivedAt: '2026-08-12T10:00:00',
paymentMethod: 'BANK_TRANSFER', currency: 'CNY', status: 'CONFIRMED',
allocations: [{ billId: 2, billNo: 'B-2', appliedAmount: '40.00' }],
}], '2')
assert.equal(detail.billNo, 'B-2')
assert.deepEqual(detail.lines[0], { id: '21', orderId: '31', feeNo: 'INBOUND_FEE:81/v3', feeName: '运费', amount: '100.00', currencyCode: 'CNY' })
assert.deepEqual(receipts[0], { id: '51', receiptNo: 'RCPT-51', receivedAt: '2026-08-12T10:00:00', amount: '40.00', currencyCode: 'CNY', method: 'BANK_TRANSFER', allocatedAmount: '40.00' })
})
test('requires both finance statement authorities for the page permission', () => { test('requires both finance statement authorities for the page permission', () => {
const allowed = (codes) => permissionContracts.hasMappedMerchantPermission('finance:bill:view', new Set(codes)) const allowed = (codes) => permissionContracts.hasMappedMerchantPermission('finance:bill:view', new Set(codes))
assert.equal(allowed([]), false) assert.equal(allowed([]), false)
@@ -4,7 +4,6 @@ import type { ApiFirstLegStatus, MerchantFirstLegOrderDetail, PageResult } from
export type ApiUnifiedOrderMainStatus = export type ApiUnifiedOrderMainStatus =
| 'PENDING_REVIEW' | 'PENDING_REVIEW'
| 'WAIT_MEASURE' | 'WAIT_MEASURE'
| 'INBOUNDED'
| 'WAIT_SPLIT' | 'WAIT_SPLIT'
| 'LOADING' | 'LOADING'
| 'LOADED' | 'LOADED'
@@ -10,7 +10,7 @@ test('renders loading, pending, isolated error, and published states', () => {
assert.match(source, /role="alert"/) assert.match(source, /role="alert"/)
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/) assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
assert.match(source, /role="status"/) assert.match(source, /role="status"/)
assert.match(source, /已入库 · 费用已发布/) assert.match(source, /待分柜 · 费用已发布/)
assert.match(source, /\$emit\('retry'\)/) assert.match(source, /\$emit\('retry'\)/)
}) })
@@ -34,7 +34,7 @@ defineEmits<{
<section class="inbound-billing-panel" aria-labelledby="inbound-billing-title"> <section class="inbound-billing-panel" aria-labelledby="inbound-billing-title">
<header class="inbound-billing-heading"> <header class="inbound-billing-heading">
<h3 id="inbound-billing-title">入库与费用</h3> <h3 id="inbound-billing-title">入库与费用</h3>
<el-tag v-if="props.data" type="success" effect="light">已入库 · 费用已发布</el-tag> <el-tag v-if="props.data" type="success" effect="light">待分柜 · 费用已发布</el-tag>
</header> </header>
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true"> <div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
@@ -6,7 +6,7 @@ export type MerchantInboundProcessStatus =
| 'WAIT_PRICING' | 'WAIT_PRICING'
| 'FEE_CONFIRMING' | 'FEE_CONFIRMING'
| 'INBOUNDING' | 'INBOUNDING'
| 'INBOUNDED' | 'WAIT_SPLIT'
| 'VOIDED' | 'VOIDED'
export type MerchantInboundFeeStatus = 'UNPRICED' | 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'VOIDED' export type MerchantInboundFeeStatus = 'UNPRICED' | 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'VOIDED'
@@ -58,7 +58,7 @@ function validDraft() {
test('uses the confirmed status and transport labels', () => { test('uses the confirmed status and transport labels', () => {
assert.equal(getFirstLegStatusLabel('WAIT_MEASURE'), '待测量数据') assert.equal(getFirstLegStatusLabel('WAIT_MEASURE'), '待测量数据')
assert.equal(getFirstLegStatusLabel('INBOUNDED'), '已入库') assert.equal(getFirstLegStatusLabel('WAIT_SPLIT'), '待分柜')
assert.equal(getFirstLegStatusLabel('WAIT_SPLIT'), '待分柜') assert.equal(getFirstLegStatusLabel('WAIT_SPLIT'), '待分柜')
assert.equal(getFirstLegStatusLabel('LOADING'), '装柜中') assert.equal(getFirstLegStatusLabel('LOADING'), '装柜中')
assert.equal(getFirstLegStatusLabel('LOADED'), '装柜完成') assert.equal(getFirstLegStatusLabel('LOADED'), '装柜完成')