This commit is contained in:
2026-08-17 19:13:25 +08:00
parent 28f2cd4cc5
commit d618683467
22 changed files with 263 additions and 50 deletions
@@ -70,11 +70,11 @@ describe('unified orders API contract', () => {
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: 90001, parentOrderId: null, splitSequence: null, childCount: '2' }, measurement: null, preload: null, loading: null, delivery: null, transport: null }],
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', actualGrossWeightKg: '12.5', actualVolumeCbm: '0.45', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: 90001, parentOrderId: null, splitSequence: null, childCount: '2' }, measurement: null, preload: null, loading: null, delivery: null, transport: null }],
page: 1, size: 20, total: 1, pages: 1,
} })
await expect(listPlatformUnifiedOrders({ page: 1, size: 20 })).resolves.toMatchObject({
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: '90001', childCount: 2 } }],
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', actualGrossWeightKg: 12.5, actualVolumeCbm: 0.45, customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: '90001', childCount: 2 } }],
})
})
@@ -89,6 +89,10 @@ export interface UnifiedOrderResource {
inboundCode: string | null
expectedShipDate: string | null
totalPackageCount: number | null
recommendedPalletType: string | null
locationCodes: string | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
measurement: UnifiedOrderMeasurementSummary | null
preload: UnifiedOrderPreloadSummary | null
loading: UnifiedOrderLoadingSummary | null
@@ -112,8 +116,6 @@ export interface UnifiedOrderResource {
taxMode?: string | null
fullContainer?: boolean | null
customsInspection?: boolean | null
recommendedPalletType?: string | null
locationCodes?: string | null
/** Legacy aliases retained while older order responses are still deployed. */
totalPalletCount?: number | null
chargeableVolumeCbm?: number | null
@@ -334,6 +336,8 @@ function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
childCount: integer(raw.relation.childCount, 0),
} : null,
totalPackageCount: nullableInteger(raw.totalPackageCount),
actualGrossWeightKg: numberValue(raw.actualGrossWeightKg),
actualVolumeCbm: numberValue(raw.actualVolumeCbm),
totalPalletCount: nullableInteger(raw.totalPalletCount),
recommendedPalletType: raw.recommendedPalletType ?? null,
locationCodes: raw.locationCodes ?? raw.location ?? null,
@@ -71,6 +71,16 @@ describe('InboundFeeDetailManager', () => {
expect(source).toMatch(/size="small" plain type="danger"[^>]+[^>]*>\s*<X/)
})
it('keeps FBA_PRICING rows derived and read-only regardless of write permissions', async () => {
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...approved, sourceType: 'FBA_PRICING' }])
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review'])
await flushPromises()
const row = wrapper.find('.el-table__row')
expect(row.text()).toContain('头程派生')
const buttons = row.findAll('button').map((button) => button.text())
for (const action of ['编辑', '审核', '驳回', '调整', '作废', '删除']) expect(buttons).not.toContain(action)
})
it('does not request or expose fee rows without view permission', async () => {
mountManager([]); await flushPromises()
expect(listInboundFeeDetails).not.toHaveBeenCalled()
@@ -19,7 +19,8 @@ import type {
} from '@/modules/unified-orders/api/inboundFeeDetails'
const props = defineProps<{ accountId: string; inboundId: string }>()
const emit = defineEmits<{ changed: [] }>()
interface OtherFeeSummary { currency: string; approvedReceivable: number; approvedPayable: number }
const emit = defineEmits<{ changed: []; summary: [value: OtherFeeSummary] }>()
const access = useAccessPreviewStore()
const rows = ref<InboundFeeDetail[]>([])
const feeItems = ref<InboundFeeItemOption[]>([])
@@ -62,18 +63,27 @@ const directionOptions = computed(() => [
])
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
function canEdit(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus !== 'APPROVED' }
function canDelete(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:delete') && row.reviewStatus !== 'APPROVED' }
function canReview(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'PENDING' }
function canAdjust(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function canVoid(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function isManual(row: InboundFeeDetail) { return row.sourceType === 'MANUAL' }
function canEdit(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'PENDING' }
function canDelete(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:delete') && row.reviewStatus === 'PENDING' }
function canReview(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'PENDING' }
function canAdjust(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function canVoid(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
function directionLabel(value: string) { return value === 'RECEIVABLE' ? '应收' : '应付' }
async function load() {
if (!canView.value) return
loading.value = true; loadError.value = null
try { rows.value = await listInboundFeeDetails(props.accountId, props.inboundId) }
try {
rows.value = await listInboundFeeDetails(props.accountId, props.inboundId)
const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' && row.settlementIncluded)
emit('summary', {
currency: approved.find((row) => row.currency)?.currency || 'CNY',
approvedReceivable: approved.filter((row) => row.direction === 'RECEIVABLE').reduce((sum, row) => sum + Number(row.amount || 0), 0),
approvedPayable: approved.filter((row) => row.direction === 'PAYABLE').reduce((sum, row) => sum + Number(row.amount || 0), 0),
})
}
catch (error) { loadError.value = getApiErrorDetails(error, '费用明细加载失败') }
finally { loading.value = false }
}
@@ -208,6 +218,7 @@ onMounted(() => void load())
</template>
</el-table-column>
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></template></el-table-column>
<el-table-column label="来源" width="120"><template #default="{ row }">{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</template></el-table-column>
<el-table-column label="应收/应付" width="110"><template #default="{ row }">{{ directionLabel(row.direction) }}</template></el-table-column>
<el-table-column prop="feeTypeNameSnapshot" label="费用类型" min-width="150" />
<el-table-column label="往来单位/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.partnerName : row.customerName || '—' }}</template></el-table-column>
@@ -95,21 +95,21 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('v-model:page-size="pageSize"')
const columnSource = source.slice(source.indexOf('const columns ='), source.indexOf('function emptyFilters'))
const expectedColumnKeys = [
'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
'destinationWarehouseName', 'totalPackageCount', 'measurementReviewStatus', 'actualGrossWeightKg',
'actions', 'orderNo', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
'destinationWarehouseName', 'channelName', 'totalPackageCount', 'recommendedPalletType', 'locationCodes', 'measurementReviewStatus', 'actualGrossWeightKg',
'actualVolumeCbm', 'chargeableWeightKg', 'expectedShipDate', 'loadingStatus', 'containerNo', 'cabinetNo',
'sailingAt', 'portArrivalAt', 'customerRemark', 'latestInternalNote', 'createdAt', 'updatedAt',
]
for (const key of expectedColumnKeys) expect(columnSource).toContain(`key: '${key}'`)
for (const uncontractedKey of [
'internalOrderStatus', 'expectedArrivalAt', 'feeReviewStatus', 'holdCargo', 'companyName',
'pickupMethod', 'chargeableVolumeCbm', 'recommendedPalletType', 'locationCodes', 'associatedFiles', 'createdBy',
'pickupMethod', 'chargeableVolumeCbm', 'associatedFiles', 'createdBy',
]) expect(columnSource).not.toContain(`key: '${uncontractedKey}'`)
expect(columnSource).not.toContain("key: 'orderNo'")
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'"))
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'orderNo'"))
expect(columnSource.indexOf("key: 'orderNo'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'"))
expect(columnSource).toContain("{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态'")
expect(columnSource).toContain("{ key: 'loadingStatus', prop: 'loadingStatus', label: '配载状态', minWidth: 130, slotName: 'field', defaultVisible: false }")
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(8)
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(10)
expect(source).toContain('.table-card { overflow: hidden; }')
expect(source).toContain('slotName: \'measurementReviewStatus\'')
expect(source).toContain('unifiedOrderReviewStatusLabel(value, \'未录入\')')
@@ -288,6 +288,7 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('InboundFeePanel')
expect(source).toContain('InboundFeeDetailManager')
expect(source).toContain(':customer-id="String(detail.summary.tenantId)"')
expect(source).toContain('compact')
expect(source).toContain('头程自动初算')
expect(source).toContain('其他费用明细')
expect(source).toContain('@changed="loadDetail(true)"')
@@ -103,6 +103,18 @@ const detail = ref<UnifiedOrderDetailResource | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const activeDetailTab = ref('basic')
const automaticFee = ref<AutomaticFeeSummary | null>(null)
const otherFeeSummary = ref<OtherFeeSummary>({ currency: 'CNY', approvedReceivable: 0, approvedPayable: 0 })
const customerFeeTotal = computed(() => (
(automaticFee.value?.feeStatus === 'SENT' ? Number(automaticFee.value.totalAmount || 0) : 0)
+ otherFeeSummary.value.approvedReceivable
))
function feeMoney(value: number) {
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: otherFeeSummary.value.currency || 'CNY' }).format(value)
}
type OtherFeeSummary = { currency: string; approvedReceivable: number; approvedPayable: number }
type AutomaticFeeSummary = { feeStatus: string; totalAmount: number }
const approvalOpen = ref(false)
const approvalLoadingId = ref('')
const approvalSaving = ref(false)
@@ -226,12 +238,16 @@ const columns = computed<DataTableColumn[]>(() => [
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: (row: UnifiedOrderResource) => canApprove(row) && !batchApprovalSaving.value,
} satisfies DataTableColumn] : []),
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 180, slotName: 'field' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, slotName: 'inboundCode' },
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
{ key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 150, slotName: 'field' },
{ key: 'destinationWarehouseName', prop: 'destinationWarehouseName', label: '目的仓', minWidth: 150, slotName: 'field' },
{ key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 140, slotName: 'field' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 100, slotName: 'field' },
{ key: 'recommendedPalletType', prop: 'recommendedPalletType', label: '推荐托盘类型', minWidth: 140, slotName: 'field', defaultVisible: false },
{ key: 'locationCodes', prop: 'locationCodes', label: '库位位置', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'measurementReviewStatus' },
{ key: 'actualGrossWeightKg', prop: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 130, slotName: 'field' },
{ key: 'actualVolumeCbm', prop: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 130, slotName: 'field' },
@@ -351,7 +367,6 @@ function displayField(row: UnifiedOrderResource, property: string | undefined) {
measurementReviewStatus: 'measurement.reviewStatus', expectedArrivalAt: 'expectedArrivalAt',
loadingStatus: 'transport.loadingStatus', containerNo: 'transport.containerNo', cabinetNo: 'transport.cabinetNo',
sailingAt: 'transport.sailingAt', portArrivalAt: 'transport.portArrivalAt',
actualGrossWeightKg: 'measurement.actualGrossWeightKg', actualVolumeCbm: 'measurement.actualVolumeCbm',
chargeableWeightKg: 'measurement.chargeableWeightKg', measurementCompletedAt: 'measurement.completedAt',
expressTrackingNo: 'delivery.trackingNo',
}
@@ -978,16 +993,23 @@ onBeforeUnmount(() => {
<template v-else-if="activeDetailTab === 'fees'">
<div class="panel-title"><div><h2>费用信息</h2><p>头程自动初算与其他人工费用在同一页维护</p></div><WalletCards :size="19" /></div>
<dl class="fee-overview" aria-label="费用汇总">
<div><dt>对客费用合计</dt><dd>{{ feeMoney(customerFeeTotal) }}</dd><small>已发布头程费用 + 已审核其他应收</small></div>
<div><dt>已审核其他应付</dt><dd>{{ feeMoney(otherFeeSummary.approvedPayable) }}</dd><small>单列展示,不计入对客费用</small></div>
</dl>
<h3 class="fee-section-title">头程自动初算</h3>
<InboundFeePanel
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
compact
@loaded="automaticFee = $event"
@changed="loadDetail(true)"
/>
<h3 class="fee-section-title fee-section-title--secondary">其他费用明细</h3>
<InboundFeeDetailManager
:account-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
@summary="otherFeeSummary = $event"
@changed="loadDetail(true)"
/>
</template>
@@ -1295,6 +1317,12 @@ onBeforeUnmount(() => {
.panel-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; }
.panel-title > svg { color: var(--cc-primary); }
.fee-section-title { margin: 14px 0 8px; color: #344054; font-size: 13px; line-height: 1.4; }
.fee-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 8px 0 0; border: 1px solid var(--cc-border); }
.fee-overview > div { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 4px 12px; padding: 10px 12px; }
.fee-overview > div + div { border-left: 1px solid var(--cc-border); }
.fee-overview dt, .fee-overview small { color: var(--cc-muted); font-size: 11px; }
.fee-overview dd { margin: 0; font-size: 18px; font-weight: 700; text-align: right; }
.fee-overview small { grid-column: 1 / -1; }
.fee-section-title--secondary { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
.order-info-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; margin: 0; overflow: hidden; border: 1px solid #edf0f4; background: #fff; }
.order-info-grid > div, .measurement-grid > div { display: flex; min-width: 0; min-height: 38px; align-items: baseline; gap: 8px; padding: 8px 12px; border-right: 1px solid #edf0f4; border-bottom: 1px solid #edf0f4; background: #fff; }
@@ -1346,5 +1374,6 @@ onBeforeUnmount(() => {
@media (max-width: 960px) { .detail-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .detail-overview article:nth-child(2) { border-right: 0; } .detail-overview article:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
@media (max-width: 720px) { .fee-overview { grid-template-columns: 1fr; } .fee-overview > div + div { border-top: 1px solid var(--cc-border); border-left: 0; } }
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } .weight-evidence { padding: 10px; } .weight-evidence-grid { grid-template-columns: 1fr; } }
</style>