This commit is contained in:
2026-10-20 11:54:44 +08:00
parent 5036593195
commit 8abc058a65
29 changed files with 445 additions and 78 deletions
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
test('计费重确认保留大整数来源和原幂等键,只提交预期重量版本', async () => {
const calls = []
const source = readFileSync(new URL('./chargeableWeights.ts', import.meta.url), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
const exports = {}
new Function('require', 'exports', code)(() => ({ apiRequest: async request => { calls.push(request); return [] } }), exports)
const id = '9223372036854775806'
await exports.getChargeableWeights(id)
await exports.confirmChargeableWeight({ inboundId: id, fingerprint: 'abc' }, 'original-command')
assert.equal(calls[0].url, `/merchant/v1/first-leg/orders/${id}/chargeable-weights`)
assert.equal(calls[1].url, `/merchant/v1/first-leg/chargeable-weights/${id}/confirm`)
assert.deepEqual(calls[1].headers, { 'Idempotency-Key': 'original-command' })
assert.deepEqual(calls[1].data, { expectedFingerprint: 'abc' })
assert.equal(calls.some(call => call.url.includes('inbound-billing')), false)
})
@@ -0,0 +1,16 @@
import { apiRequest } from '@/api/http'
export interface ChargeableWeight {
inboundId: string
orderId: string
chargeableWeightKg: number
fingerprint: string
confirmed: boolean
confirmedAt: string | null
}
export function getChargeableWeights(orderId: string) {
return apiRequest<ChargeableWeight[]>({ method: 'GET', url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/chargeable-weights` })
}
export function confirmChargeableWeight(weight: ChargeableWeight, key: string) {
return apiRequest<ChargeableWeight>({ method: 'POST', url: `/merchant/v1/first-leg/chargeable-weights/${encodeURIComponent(weight.inboundId)}/confirm`,
headers: { 'Idempotency-Key': key }, data: { expectedFingerprint: weight.fingerprint } })
}
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import { ref } from 'vue'
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
function harness(api, props = { orderId: 'one', canConfirm: true }) {
let changed, disposed
const events = []
const source = readFileSync(new URL('./ChargeableWeightDialog.vue', import.meta.url), 'utf8').split('<script setup lang="ts">')[1].split('</script>')[0]
const code = ts.transpileModule(source + '\nexports.state = { rows, loading, saving, error, confirm, load };', {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText
const exports = {}
new Function('require', 'exports', 'defineProps', 'defineEmits', code)(name => {
if (name === 'vue') return { ref, watch: (_, callback) => { changed = callback; callback() }, onBeforeUnmount: callback => { disposed = callback } }
if (name === 'element-plus') return { ElMessage: { success() {} } }
return api
}, exports, () => props, () => name => events.push(name))
return { ...exports.state, props, change: () => changed(), dispose: () => disposed(), events }
}
test('确认结果未知时重试原命令,成功后回查并通知工作台刷新', async () => {
const row = { inboundId: '100', fingerprint: 'weight-v1', chargeableWeightKg: 50, confirmed: false }
const commands = []
let fail = true
const h = harness({ getChargeableWeights: async () => [row], confirmChargeableWeight: async (_, key) => {
commands.push(key); if (fail) throw new Error('响应丢失'); row.confirmed = true
} })
await flush(); await h.confirm(row)
assert.equal(h.error.value, '响应丢失'); assert.equal(h.saving.value, '')
fail = false; await h.confirm(row)
assert.equal(commands[0], commands[1]); assert.deepEqual(h.events, ['confirmed'])
assert.equal(h.rows.value[0].confirmed, true); assert.equal(h.saving.value, '')
})
test('切换订单或关闭后丢弃迟到的读取结果,无权限不能确认', async () => {
let resolveOld
const h = harness({ getChargeableWeights: order => order === 'one' ? new Promise(resolve => { resolveOld = resolve }) : Promise.resolve([]),
confirmChargeableWeight: () => { throw new Error('无权限不得写入') } }, { orderId: 'one', canConfirm: false })
h.props.orderId = 'two'; h.change(); await flush()
resolveOld([{ inboundId: 'old' }]); await flush()
assert.deepEqual(h.rows.value, [])
await h.confirm({ inboundId: 'old', fingerprint: 'old' })
assert.deepEqual(h.events, [])
h.dispose()
})
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getChargeableWeights, confirmChargeableWeight, type ChargeableWeight } from '../api/chargeableWeights'
const props = defineProps<{ orderId: string; canConfirm: boolean }>()
const emit = defineEmits<{ close: []; confirmed: [] }>()
const rows = ref<ChargeableWeight[]>([])
const loading = ref(false)
const saving = ref('')
const error = ref('')
const commands = new Map<string, string>()
let sequence = 0
let confirmationSequence = 0
async function load() {
const ticket = ++sequence
loading.value = true; error.value = ''; rows.value = []
try { const data = await getChargeableWeights(props.orderId); if (ticket === sequence) rows.value = data }
catch (e) { if (ticket === sequence) error.value = e instanceof Error ? e.message : '计费重加载失败' }
finally { if (ticket === sequence) loading.value = false }
}
async function confirm(row: ChargeableWeight) {
if (saving.value || !props.canConfirm) return
const ticket = sequence
const attempt = ++confirmationSequence
const fingerprint = `${row.inboundId}:${row.fingerprint}`
const key = commands.get(fingerprint) ?? `weight-${crypto.randomUUID()}`
commands.set(fingerprint, key); saving.value = row.inboundId; error.value = ''
try {
await confirmChargeableWeight(row, key)
if (ticket !== sequence) return
ElMessage.success('计费重已确认'); emit('confirmed'); await load()
} catch (e) { if (ticket === sequence) error.value = e instanceof Error ? e.message : '确认失败,请刷新后重试' }
finally { if (attempt === confirmationSequence) saving.value = '' }
}
watch(() => props.orderId, () => { confirmationSequence++; saving.value = ''; commands.clear(); void load() }, { immediate: true })
onBeforeUnmount(() => { sequence++; confirmationSequence++ })
</script>
<template>
<el-dialog :model-value="true" title="确认计费重" width="560px" :close-on-click-modal="false" @close="emit('close')">
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<el-button v-if="error" :disabled="!!saving" @click="load">重新加载</el-button>
<div v-loading="loading">
<el-empty v-if="!loading && !error && !rows.length" description="暂无已审核的计费重" />
<div v-for="row in rows" :key="row.inboundId" class="weight-row">
<span>来源入仓单:{{ row.inboundId }}<br>计费重:{{ row.chargeableWeightKg }} kg</span>
<el-tag v-if="row.confirmed" type="success">已确认</el-tag>
<el-button v-else-if="canConfirm" type="primary" :disabled="!!saving" :loading="saving === row.inboundId" @click="confirm(row)">确认计费重</el-button>
<span v-else>待确认</span>
</div>
</div>
</el-dialog>
</template>
<style scoped>
.weight-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px 0; }
</style>
@@ -20,7 +20,7 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v
assert.match(source, /watch\(\(\) => props\.autoOpen/)
assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/)
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
assert.match(source, /平台推送计费重后,可在这里核对并确认/)
assert.match(source, /平台推送费用后,可在这里核对并确认/)
assert.match(source, /@closed="emit\('closed'\)"/)
})
@@ -198,19 +198,19 @@ async function confirmFee() {
</section>
<el-dialog v-model="feeDialogOpen" class="billing-confirm-dialog" title="确认测量与计费数据" width="min(1180px, calc(100vw - 40px))" append-to-body @closed="emit('closed')">
<el-dialog v-model="feeDialogOpen" class="billing-confirm-dialog" title="确认费用" width="min(1180px, calc(100vw - 40px))" append-to-body @closed="emit('closed')">
<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>
<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>
<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><strong>{{ props.unavailableMessage }}</strong><span>平台推送费用后,可在这里核对并确认。</span></div>
</div>
<template v-else>
<dl class="fee-dialog-summary" aria-label="计费汇总">
@@ -623,7 +623,7 @@ test('loads merchant inbound billing as an isolated read-only detail section', (
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
assert.match(source, /error\.message \|\| unavailableMessage/)
assert.match(source, /order\.status === 'DRAFT'/)
assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送计费重'[\s\S]*: '订单提交后可查看入库结果'/)
assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送费用'[\s\S]*: '订单提交后可查看入库结果'/)
assert.match(source, /订单提交后可查看入库结果/)
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
})
@@ -636,10 +636,10 @@ test('keeps platform enterprise scope and tenant terminology out of the customer
test('opens billing-weight confirmation independently from order details', () => {
const handler = source.slice(source.indexOf('function openFeeConfirmation'), source.indexOf('async function retryDetail'))
assert.match(handler, /feeConfirmationOpen\.value = true/)
assert.match(handler, /loadInboundBilling\(order, '未推送计费重'\)/)
assert.match(handler, /loadInboundBilling\(order, '未推送费用'\)/)
assert.doesNotMatch(handler, /openDetail\(/)
assert.match(source, /v-if="feeConfirmationOpen"[\s\S]*dialog-only[\s\S]*auto-open/)
assert.match(source, /unavailable-message="未推送计费重"/)
assert.match(source, /unavailable-message="未推送费用"/)
})
test('shows billing-weight confirmation only when the backend marks it pending', () => {
@@ -9,6 +9,7 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
import type { TableAction } from '@/components/common/tableActions'
import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import ChargeableWeightDialog from '@/modules/first-leg/components/ChargeableWeightDialog.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
@@ -267,6 +268,7 @@ const inboundBillingUnavailableMessage = ref('还未测量推送')
const inboundBillingError = ref('')
const inboundBillingOrderId = ref('')
const feeConfirmationOpen = ref(false)
const weightConfirmationOrderId = ref('')
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
const customsEligibilityPending = new Set<string>()
const customsDialogOpen = ref(false)
@@ -1361,8 +1363,11 @@ function getOrderRowActions(row: object): TableAction[] {
if (canCreateOrder.value) {
actions.push({ key: 'copy', label: '复制下单', loading: copyingOrderId.value === order.id, run: () => copyOrder(order) })
}
if (canConfirmInboundFee.value && !['DRAFT', 'CANCELLED', 'REJECTED', 'SUBMITTED'].includes(order.status)) {
actions.push({ key: 'weight', label: '确认计费重', run: () => { weightConfirmationOrderId.value = order.id } })
}
if (canConfirmInboundFee.value && order.businessSummary.customerFeeConfirmationRequired) {
actions.push({ key: 'fee', label: '确认计费重', run: () => openFeeConfirmation(order) })
actions.push({ key: 'fee', label: '确认费用', run: () => openFeeConfirmation(order) })
}
if (canEditOrder.value && canClientEditFirstLegOrder(order.status)) {
actions.push({ key: 'edit', label: '编辑', loading: openingEditor.value, run: () => openEdit(order) })
@@ -1471,9 +1476,9 @@ async function loadDetailPage(orderId: string) {
function openFeeConfirmation(order: FirstLegOrder) {
resetInboundBillingState()
inboundBillingUnavailableMessage.value = '未推送计费重'
inboundBillingUnavailableMessage.value = '未推送费用'
feeConfirmationOpen.value = true
void loadInboundBilling(order, '未推送计费重')
void loadInboundBilling(order, '未推送费用')
}
async function retryDetail() {
@@ -1508,7 +1513,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
if (order.status === 'DRAFT') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
? '未推送费用'
: '订单提交后可查看入库结果'
return
}
@@ -1523,7 +1528,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
? '未推送费用'
: error.message || unavailableMessage
} else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
@@ -1535,7 +1540,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
async function retryInboundBilling() {
const order = inboundBillingOrderId.value ? store.getById(inboundBillingOrderId.value) : undefined
if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送计费重' : '还未测量推送')
if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送费用' : '还未测量推送')
}
function handleInboundFeeConfirmed() {
@@ -2548,13 +2553,14 @@ async function searchOrders(model: QueryModel) {
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"
unavailable-message="未推送计费重"
unavailable-message="未推送费用"
:error="inboundBillingError"
:can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed"
@closed="feeConfirmationOpen = false"
/>
<ChargeableWeightDialog v-if="weightConfirmationOrderId" :order-id="weightConfirmationOrderId" :can-confirm="canConfirmInboundFee" @close="weightConfirmationOrderId = ''" @confirmed="handleInboundFeeConfirmed" />
<CustomsDocumentsDialog
v-model="customsDialogOpen"
:order-id="customsDialogOrderId"
@@ -76,7 +76,7 @@ watch(originWarehouseId, (value, previous) => { if (!value || previous === '') r
<template>
<div class="pricing-page"><section class="pricing-inquiry"><div class="inquiry-copy"><span>SAILING PRICE INQUIRY</span><h2>船期价格查询</h2><p>重量或体积填写一项即可,系统会按计价规则自动换算</p></div><el-form class="pricing-form" label-position="top"><el-form-item label="起运仓"><el-select v-model="originWarehouseId" filterable :loading="loading" placeholder="选择起运仓"><el-option v-for="item in warehouses" :key="String(item.id)" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="item.id" /></el-select></el-form-item><el-form-item label="货物重量"><div class="unit-input"><el-input v-model.number="weight" type="number" min="0" placeholder="可选,输入重量" /><span>KG</span></div></el-form-item><el-form-item label="货物立方数"><div class="unit-input"><el-input v-model.number="volume" type="number" min="0" placeholder="可选,输入体积" /><span>CBM</span></div></el-form-item><el-form-item label="目的 FBA 仓"><el-select v-model="destinationWarehouseId" filterable :loading="loading" placeholder="输入或选择仓库代码"><el-option v-for="item in destinationWarehouses" :key="String(item.id)" :label="item.warehouseCode" :value="item.id" /></el-select></el-form-item><el-form-item label="报价方式"><el-radio-group v-model="taxMode"><el-radio-button value="TAX_INCLUDED" label="包税" /><el-radio-button value="TAX_EXCLUDED" label="不包税" /></el-radio-group></el-form-item><el-form-item class="query-item"><el-button type="primary" :loading="querying" @click="query"><Search :size="16" />立即查询</el-button></el-form-item></el-form>
<el-dialog v-model="quoteDialogOpen" class="quote-dialog" :show-close="false" :lock-scroll="false" width="min(1080px, calc(100vw - 48px))" top="6vh"><template #header><div class="quote-dialog-head"><div><h3>查询结果</h3><span>{{ resultSummary }}</span></div><button type="button" class="quote-close" aria-label="关闭" @click="quoteDialogOpen = false">×</button></div></template><div class="quote-grid"><article v-for="item in quoteCards" :key="item.quote.channelCode" class="quote-card" :class="recommendation(item)?.className"><div v-if="recommendation(item)" class="quote-ribbon">{{ recommendation(item)?.label }}</div><div class="quote-card-title"><strong>{{ item.channel.channelName }}</strong><span>{{ item.quote.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税' }} / {{ item.quote.chargeUnit || 'CBM' }}</span></div><div class="quote-price"><b>{{ formatMoney(item.quote) }}</b><small>预估运费</small></div><div class="quote-meta"><div><span>单价</span><strong>{{ formatUnitPrice(item.quote) }}</strong></div><div><span>参考时效</span><strong>{{ item.channel.referenceTransit || '待更新' }}</strong></div><div><span>最晚进仓</span><strong>{{ item.channel.latestWarehouseEntry || '—' }}</strong></div><div><span>船期</span><strong>{{ item.channel.estimatedDepartureText || item.channel.estimatedDepartureDate || '待更新' }}</strong></div></div><el-button type="primary" plain class="quote-order" @click="placeOrder(item.quote.channelCode, item.channel.channelName)">下单</el-button></article></div></el-dialog></section>
<section class="pricing-results"><div class="section-title"><div><h2>匹配渠道</h2><span>{{ channels.length }} 个渠道 · 参考价格以服务端估价为准</span></div><Calculator :size="20" /></div><el-skeleton v-if="loading || querying" :rows="4" animated /><el-alert v-else-if="errorMessage" :title="errorMessage" type="error" show-icon /><el-empty v-else-if="!channels.length" description="暂无可用船期价格" /><el-table v-else :data="channels" stripe><el-table-column label="渠道" min-width="160"><template #default="{ row }"><strong>{{ row.channelName }}</strong><small>{{ row.channelCode }}</small></template></el-table-column><el-table-column label="参考船期" min-width="150"><template #default="{ row }">{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}</template></el-table-column><el-table-column label="最晚进仓" min-width="130"><template #default="{ row }">{{ row.latestWarehouseEntry || '—' }}</template></el-table-column><el-table-column label="参考时效" width="120"><template #default="{ row }">{{ row.referenceTransit || '—' }}</template></el-table-column><el-table-column label="预估运费" width="150"><template #default="{ row }"><span v-if="quotes.find((quote) => quote.channelCode === row.channelCode)">{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}</span><span v-else class="muted">查询后显示</span></template></el-table-column><el-table-column label="附加费说明" min-width="180" prop="surchargeDescription" /><el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button link type="primary" :disabled="!quotes.some((quote) => quote.channelCode === row.channelCode)" @click="placeOrder(row.channelCode, row.channelName)">下单</el-button></template></el-table-column></el-table></section>
<section class="pricing-results"><div class="section-title"><div><h2>匹配渠道</h2><span>{{ channels.length }} 个渠道 · 参考价格以服务端估价为准</span></div><Calculator :size="20" /></div><el-skeleton v-if="loading || querying" :rows="4" animated /><el-alert v-else-if="errorMessage" :title="errorMessage" type="error" show-icon /><el-empty v-else-if="!channels.length" description="暂无可用船期价格" /><el-table v-else :data="channels" stripe><el-table-column label="渠道" min-width="160"><template #default="{ row }"><strong>{{ row.channelName }}</strong><small>{{ row.channelCode }}</small></template></el-table-column><el-table-column label="参考船期" min-width="150"><template #default="{ row }">{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}</template></el-table-column><el-table-column label="最晚进仓" min-width="130"><template #default="{ row }">{{ row.latestWarehouseEntry || '—' }}</template></el-table-column><el-table-column label="参考时效" width="120"><template #default="{ row }">{{ row.referenceTransit || '—' }}</template></el-table-column><el-table-column label="预估运费" width="150"><template #default="{ row }"><span v-if="quotes.find((quote) => quote.channelCode === row.channelCode)">{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}</span><span v-else class="muted">查询后显示</span></template></el-table-column><el-table-column label="附加费说明" min-width="180" prop="surchargeDescription" /></el-table></section>
</div>
</template>