Merge pull request 'Release/test' (#10) from release/test into main

Reviewed-on: https://git.buggoglobal.com/html/admin-web/pulls/10
This commit was merged in pull request #10.
This commit is contained in:
2026-09-21 11:36:04 +08:00
9 changed files with 98 additions and 23 deletions
+1
View File
@@ -41,6 +41,7 @@
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
@@ -381,4 +381,5 @@ describe('InboundFeePanel', () => {
expect(document.body.textContent).toContain('系统已刷新最新快照')
expect(document.body.textContent).toContain('当前输入仍保留')
})
})
+29 -6
View File
@@ -47,6 +47,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
const adjustmentError = ref<ApiErrorDetails | null>(null)
const conflictRefreshed = ref(false)
const snapshot = ref<InboundFeeSnapshot | null>(null)
const hasLoadedOnce = ref(false)
const adjustOpen = ref(false)
const trialOpen = ref(false)
const trialLoading = ref(false)
@@ -183,12 +184,25 @@ async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
if (!options.preserveAdjustment && adjustOpen.value) fillAdjustmentForm(result)
} catch (error) {
if (sequence !== loadSequence) return
snapshot.value = null
emit('loaded', null)
if (error instanceof ApiResponseError && error.code === 'A0404') notFound.value = true
else loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试')
const hadSnapshot = Boolean(snapshot.value)
if (error instanceof ApiResponseError && error.code === 'A0404') {
notFound.value = !hadSnapshot
if (!hadSnapshot) {
snapshot.value = null
emit('loaded', null)
}
} else {
loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试')
if (!hadSnapshot) {
snapshot.value = null
emit('loaded', null)
}
}
} finally {
if (sequence === loadSequence) loading.value = false
if (sequence === loadSequence) {
loading.value = false
hasLoadedOnce.value = true
}
}
}
@@ -353,6 +367,7 @@ watch(
() => {
adjustOpen.value = false
snapshot.value = null
hasLoadedOnce.value = false
void loadFee()
},
{ immediate: true },
@@ -435,11 +450,18 @@ defineExpose({ reload: loadFee })
<p>需要权限:firstleg:fba-pricing:view</p>
</el-alert>
<div v-else-if="loading && !snapshot" class="fee-loading" aria-live="polite">
<div v-else-if="loading && !snapshot && !hasLoadedOnce" class="fee-loading" aria-live="polite">
<el-skeleton :rows="6" animated />
<span class="sr-only">正在加载费用快照</span>
</div>
<el-alert v-if="loadError && snapshot" class="fee-refresh-error" type="error" :closable="false" show-icon role="status">
<template #title>最新费用快照暂时无法刷新,当前仍显示上一版快照
</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
</el-alert>
<el-alert v-else-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title>费用快照加载失败</template>
<p>{{ loadError.message }}</p>
@@ -597,6 +619,7 @@ defineExpose({ reload: loadFee })
.fee-heading-actions .el-button { margin-left: 0; }
.fee-refresh-button .is-spinning { animation: fee-refresh-spin 0.8s linear infinite; }
.fee-loading { padding: 20px 0; }
.fee-refresh-error { margin-bottom: 10px; }
.inbound-fee-panel :deep(.el-alert p) { margin: 5px 0 0; line-height: 1.55; }
.inbound-fee-panel :deep(.el-alert .el-button) { margin-top: 8px; }
.empty-note { margin: 0; color: var(--cc-muted); font-size: 12px; }
+12
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
adjustPublishedInboundFee,
createManualInboundQuote,
confirmInboundFee,
createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey,
@@ -69,6 +70,17 @@ describe('inbound fee API', () => {
})
})
it('creates a manual FBA quote through the dedicated fallback endpoint', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, rateCode: 'MANUAL_QUOTE' } })
const payload = { selectedWeightKg: 62.5, selectedVolumeCbm: 0.384, unitPrice: 12.2, reason: 'FBA价格未维护' }
await expect(createManualInboundQuote('10001', '88', payload, 'fee-manual-1'))
.resolves.toMatchObject({ rateCode: 'MANUAL_QUOTE' })
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/manual-quote', payload, {
params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-manual-1' },
})
})
it('preserves backend conflict metadata for stale-version recovery', async () => {
vi.mocked(http.put).mockResolvedValue({
code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null,
+11 -1
View File
@@ -23,6 +23,16 @@ export async function calculateInboundFee(tenantId: string, inboundId: string, p
return unwrapApiResponse(response)
}
export async function createManualInboundQuote(tenantId: string, inboundId: string, payload: {
selectedWeightKg: number; selectedVolumeCbm: number; unitPrice: number; reason: string
}, idempotencyKey: string) {
const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>(
`${feeBasePath(inboundId)}/manual-quote`, payload,
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
async function transitionInboundFee(
tenantId: string,
inboundId: string,
@@ -63,7 +73,7 @@ export function createFeeAdjustmentIdempotencyKey() {
return createFeeActionIdempotencyKey('adjust')
}
export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'confirm' | 'send') {
export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'manual' | 'confirm' | 'send') {
const uuid = globalThis.crypto?.randomUUID?.()
if (uuid) return `fee-${action}-${uuid}`
return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
+3 -3
View File
@@ -13,9 +13,9 @@ export interface InboundFeeSnapshot {
chargeRuleId: string
ruleCode: string
chargeRuleVersion: number
carrierRateId: string
carrierRateId: string | null
rateCode: string
carrierRateVersion: number
carrierRateVersion: number | null
customerId: string
customerName: string
originWarehouseId: string
@@ -45,7 +45,7 @@ export interface InboundFeeSnapshot {
additionalAmount: number
totalAmount: number
currency: string
rateEffectiveFrom: string
rateEffectiveFrom: string | null
rateEffectiveTo: string | null
calculatedAt: string
confirmedAt: string | null
+1
View File
@@ -8,6 +8,7 @@ export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
export {
adjustPublishedInboundFee,
calculateInboundFee,
createManualInboundQuote,
confirmInboundFee,
createFeeAdjustmentIdempotencyKey,
createFeeActionIdempotencyKey,
@@ -34,6 +34,11 @@ describe('UnifiedOrdersView contract', () => {
expect(sendFeeSource).toContain('adjustPublishedInboundFee')
expect(source).toContain('价格表参考单价')
expect(source).toContain('本次对客单价')
expect(openDialogSource).toContain("feeConfirmPriceMissing.value = true")
expect(sendFeeSource).toContain('createManualInboundQuote')
expect(source).toContain("feeConfirmPriceMissing ? '未命中'")
expect(source).toContain("feeConfirmPriceMissing ? '人工定价原因'")
expect(source).not.toContain('<dt>固定附加费</dt>')
expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)")
})
@@ -66,6 +66,7 @@ import {
adjustPublishedInboundFee,
downloadWeightImage,
calculateInboundFee,
createManualInboundQuote,
confirmInboundFee,
createFeeAdjustmentIdempotencyKey,
createFeeActionIdempotencyKey,
@@ -258,24 +259,27 @@ const feeConfirmWeight = ref<number | null>(null)
const feeConfirmVolume = ref<number | null>(null)
const feeConfirmReferenceLoading = ref(false)
const feeConfirmReference = ref<InboundFeeSnapshot | null>(null)
const feeConfirmPriceMissing = ref(false)
const feeConfirmUnitPrice = ref<number | null>(null)
const feeConfirmPriceReason = ref('')
type FeeConfirmAttempt = {
fingerprint: string
calculateKey: string
manualKey: string
adjustKey: string
confirmKey: string
sendKey: string
}
let feeConfirmAttempt: FeeConfirmAttempt | null = null
const feeConfirmPriceChanged = computed(() => {
if (feeConfirmPriceMissing.value) return true
const reference = Number(feeConfirmReference.value?.unitPrice)
const current = Number(feeConfirmUnitPrice.value)
return reference >= 0 && current >= 0 && Math.abs(reference - current) > 0.000001
})
const feeConfirmPriceUnit = computed(() => {
const snapshot = feeConfirmReference.value
return snapshot ? `${snapshot.currency} / ${snapshot.chargeUnit}` : '—'
return snapshot ? `${snapshot.currency} / ${snapshot.chargeUnit}` : 'CNY / 计费单位'
})
const feeConfirmEstimatedTotal = computed(() => {
const snapshot = feeConfirmReference.value
@@ -1669,6 +1673,7 @@ async function openFeeConfirmDialog() {
: measuredGrossWeight > 0 ? measuredGrossWeight : measuredNetWeight > 0 ? measuredNetWeight : null
feeConfirmVolume.value = Number(actualTotals.volume) > 0 ? Number(actualTotals.volume) : null
feeConfirmReference.value = null
feeConfirmPriceMissing.value = false
feeConfirmUnitPrice.value = null
feeConfirmPriceReason.value = ''
feeConfirmAttempt = null
@@ -1693,8 +1698,14 @@ async function openFeeConfirmDialog() {
feeConfirmReference.value = snapshot
feeConfirmUnitPrice.value = Number(snapshot.unitPrice)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '价格表参考单价加载失败,暂不能推送费用').message)
feeConfirmOpen.value = false
const details = getApiErrorDetails(error, '价格表参考单价加载失败')
if (details.code === 'A0404') {
feeConfirmPriceMissing.value = true
ElMessage.warning('当前FBA价格未命中,请填写本次对客单价和人工定价原因')
} else {
ElMessage.error(details.message)
feeConfirmOpen.value = false
}
} finally {
feeConfirmReferenceLoading.value = false
}
@@ -1715,6 +1726,7 @@ function currentFeeConfirmAttempt(
feeConfirmAttempt = {
fingerprint,
calculateKey: createFeeActionIdempotencyKey('calculate'),
manualKey: createFeeActionIdempotencyKey('manual'),
adjustKey: createFeeAdjustmentIdempotencyKey(),
confirmKey: createFeeActionIdempotencyKey('confirm'),
sendKey: createFeeActionIdempotencyKey('send'),
@@ -1747,14 +1759,25 @@ async function executeConfirmAndSendFee() {
try {
const tenantId = String(summary.tenantId)
const inboundId = String(summary.inboundId)
let snapshot = await calculateInboundFee(tenantId, inboundId, {
selectedWeightKg,
selectedVolumeCbm,
weightSource: 'ACTUAL',
volumeSource: 'ACTUAL',
}, attempt.calculateKey)
let snapshot: InboundFeeSnapshot
try {
snapshot = await calculateInboundFee(tenantId, inboundId, {
selectedWeightKg, selectedVolumeCbm, weightSource: 'ACTUAL', volumeSource: 'ACTUAL',
}, attempt.calculateKey)
} catch (error) {
if (getApiErrorDetails(error, '费用试算失败').code !== 'A0404') throw error
feeConfirmPriceMissing.value = true
if (feeConfirmPriceReason.value.trim().length < 2) {
ElMessage.warning('价格未命中,请填写至少 2 个字符的人工定价原因')
return
}
snapshot = await createManualInboundQuote(tenantId, inboundId, {
selectedWeightKg, selectedVolumeCbm, unitPrice: selectedUnitPrice,
reason: feeConfirmPriceReason.value.trim(),
}, attempt.manualKey)
}
const calculatedUnitPrice = Number(snapshot.unitPrice)
if (Math.abs(selectedUnitPrice - calculatedUnitPrice) > 0.000001) {
if (snapshot.rateCode !== 'MANUAL_QUOTE' && Math.abs(selectedUnitPrice - calculatedUnitPrice) > 0.000001) {
snapshot = await adjustPublishedInboundFee(tenantId, inboundId, {
expectedFeeVersion: snapshot.feeVersion,
chargeableQuantity: Number(snapshot.chargeableQuantity),
@@ -2311,13 +2334,12 @@ onBeforeUnmount(() => {
<el-form-item label="渠道"><el-input :model-value="detail?.summary.channelName || detail?.summary.channelCode || '—'" disabled /></el-form-item>
<el-form-item label="计费毛重"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" class="fee-confirm-number" placeholder="请输入计费毛重" /><span class="fee-confirm-unit">kg</span></div></el-form-item>
<el-form-item label="计费体积"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" class="fee-confirm-number" placeholder="请输入计费体积" /><span class="fee-confirm-unit">m³</span></div></el-form-item>
<el-form-item label="价格表参考单价"><div class="fee-confirm-number-wrap"><el-input-number :model-value="feeConfirmReference?.unitPrice == null ? undefined : Number(feeConfirmReference.unitPrice)" :precision="4" :controls="false" disabled class="fee-confirm-number" placeholder="正在读取参考单价" /><span class="fee-confirm-unit">{{ feeConfirmPriceUnit }}</span></div></el-form-item>
<el-form-item label="价格表参考单价"><div class="fee-confirm-number-wrap"><el-input-number :model-value="feeConfirmPriceMissing || feeConfirmReference?.unitPrice == null ? undefined : Number(feeConfirmReference.unitPrice)" :precision="4" :controls="false" disabled class="fee-confirm-number" :placeholder="feeConfirmPriceMissing ? '未命中' : '正在读取参考单价'" /><span class="fee-confirm-unit">{{ feeConfirmPriceUnit }}</span></div></el-form-item>
<el-form-item label="本次对客单价"><div class="fee-confirm-number-wrap"><el-input-number v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading" class="fee-confirm-number" placeholder="请输入对客单价" /><span class="fee-confirm-unit">{{ feeConfirmPriceUnit }}</span></div></el-form-item>
<el-form-item v-if="feeConfirmPriceChanged" label="单价调整原因" class="fee-confirm-price-reason"><el-input v-model="feeConfirmPriceReason" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请说明本次人工改单价的原因" /></el-form-item>
<el-form-item v-if="feeConfirmPriceChanged" :label="feeConfirmPriceMissing ? '人工定价原因' : '单价调整原因'" class="fee-confirm-price-reason"><el-input v-model="feeConfirmPriceReason" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请说明本次人工定价的原因" /></el-form-item>
</el-form>
<dl v-if="feeConfirmReference" class="fee-confirm-price-summary">
<div><dt>参考计费量</dt><dd>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</dd></div>
<div><dt>固定附加费</dt><dd>{{ feeConfirmMoney(Number(feeConfirmReference.additionalAmount || 0), feeConfirmReference.currency) }}</dd></div>
<div><dt>按当前单价预计合计</dt><dd>{{ feeConfirmEstimatedTotal == null ? '—' : feeConfirmMoney(feeConfirmEstimatedTotal, feeConfirmReference.currency) }}</dd></div>
</dl>
<h4 class="fee-confirm-reference-title">参考数据</h4>