Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
11 changed files with 325 additions and 48 deletions
Showing only changes of commit 653ce1c511 - Show all commits
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerFeeSaveRequest } from './overseasContainerFees'
const raw = { id: 91, sourceFeeId: 81, sourceVersion: 2, customerId: 11, customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: 99, businessNo: 'CONT-99', containerBookingId: 99, containerNo: 'CONT-99', feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', amount: 300, currency: 'CNY', reviewStatus: 'DRAFT', sourceStatus: 'ACTIVE', businessDate: '2026-09-08' }
const fee: OverseasContainerFeeSaveRequest = { customerId: '11', customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: '99', businessNo: 'CONT-99', orderId: null, inboundId: null, inboundCode: null, containerId: null, containerBookingId: '99', containerNo: 'CONT-99', warehouseId: null, feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', supplierId: '71', supplierName: '港口服务商', supplierCategory: 'TRUCKING', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: '2026-09-08', currency: 'CNY', amount: 300 }
describe('overseas container fees API', () => {
beforeEach(() => vi.restoreAllMocks())
it('loads all fees and customers with two container aggregate requests', async () => {
const get = vi.spyOn(overseasHttp, 'get').mockResolvedValueOnce({ code: '200', data: [raw] }).mockResolvedValueOnce({ code: '200', data: [{ customerId: 11, customerName: '客户A' }] })
await expect(listOverseasContainerFees('99')).resolves.toMatchObject([{ sourceFeeId: '81', containerBookingId: '99', feeItemCode: 'PORT_FEE' }])
await expect(listOverseasContainerCustomers('99')).resolves.toEqual([{ customerId: '11', customerName: '客户A' }])
expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/finance-fee-sources', { params: { containerBookingId: '99' } })
expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/finance-fee-sources/container-customers', { params: { containerBookingId: '99' } })
})
it('uses the exact create and lifecycle action contracts', async () => {
const post = vi.spyOn(overseasHttp, 'post').mockResolvedValue({ code: '200', data: raw })
await createOverseasContainerFee(fee, 'create-1'); await submitOverseasContainerFee('81', 'submit-1'); await reviewOverseasContainerFee('81', 'APPROVE', null, 'review-1'); await adjustOverseasContainerFee('81', 2, fee, 'adjust-1'); await voidOverseasContainerFee('81', 2, '重复费用', 'void-1')
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources', fee, { headers: { 'Idempotency-Key': 'create-1' } })
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/submit', { idempotencyKey: 'submit-1' })
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/review', { decision: 'APPROVE', reason: null, idempotencyKey: 'review-1' })
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/adjust', { expectedVersion: 2, fee, idempotencyKey: 'adjust-1' })
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/void', { expectedVersion: 2, reason: '重复费用', idempotencyKey: 'void-1' })
})
})
@@ -0,0 +1,66 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
const basePath = '/platform/v1/last-leg/finance-fee-sources'
type RawResource = Record<string, unknown>
export type OverseasContainerFeeItem = 'PORT_FEE' | 'STORAGE_FEE' | 'CONTAINER_REPAIR_FEE' | 'CRANE_FEE' | 'TERMINAL_DRAYAGE_FEE' | 'OTHER'
export type OverseasContainerFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export interface OverseasContainerCustomer { customerId: string; customerName: string | null }
export interface OverseasContainerFeeSource {
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string | null
sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER'; businessId: string; businessNo: string
containerBookingId: string; containerNo: string; feeItemCode: OverseasContainerFeeItem; feeItemName: string
direction: OverseasContainerFeeDirection; supplierId: string | null; supplierName: string | null; supplierCategory: string | null
settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null
paymentVoucherFileIds: string[]; businessDate: string; currency: 'CNY'; amount: number
reviewStatus: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'; sourceStatus: 'ACTIVE' | 'SUPERSEDED' | 'VOIDED'
reviewReason: string | null; reviewedAt: string | null
}
export interface OverseasContainerFeeSaveRequest {
customerId: string; customerName: string | null; sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER'
businessId: string; businessNo: string; orderId: null; inboundId: null; inboundCode: null; containerId: null
containerBookingId: string; containerNo: string; warehouseId: null; feeItemCode: OverseasContainerFeeItem; feeItemName: string
direction: OverseasContainerFeeDirection; supplierId: string | null; supplierName: string | null; supplierCategory: string | null
settlementMode: 'ON_ACCOUNT' | 'DIRECT_PAID' | null; paidAt: string | null
paymentMethod: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER' | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null
businessDate: string; currency: 'CNY'; amount: number
}
const text = (value: unknown) => value == null || value === '' ? null : String(value)
const requiredText = (value: unknown) => String(value ?? '')
const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0
function normalizeFee(raw: RawResource): OverseasContainerFeeSource {
return {
id: requiredText(raw.id), sourceFeeId: requiredText(raw.sourceFeeId), sourceVersion: numberValue(raw.sourceVersion), customerId: requiredText(raw.customerId), customerName: text(raw.customerName),
sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: requiredText(raw.businessId), businessNo: requiredText(raw.businessNo), containerBookingId: requiredText(raw.containerBookingId), containerNo: requiredText(raw.containerNo),
feeItemCode: raw.feeItemCode as OverseasContainerFeeItem, feeItemName: requiredText(raw.feeItemName), direction: raw.direction as OverseasContainerFeeDirection,
supplierId: text(raw.supplierId), supplierName: text(raw.supplierName), supplierCategory: text(raw.supplierCategory), settlementMode: text(raw.settlementMode), paidAt: text(raw.paidAt), paymentMethod: text(raw.paymentMethod), paymentReferenceNo: text(raw.paymentReferenceNo),
paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(String) : [], businessDate: requiredText(raw.businessDate), currency: 'CNY', amount: numberValue(raw.amount),
reviewStatus: raw.reviewStatus as OverseasContainerFeeSource['reviewStatus'], sourceStatus: raw.sourceStatus as OverseasContainerFeeSource['sourceStatus'], reviewReason: text(raw.reviewReason), reviewedAt: text(raw.reviewedAt),
}
}
export async function listOverseasContainerFees(containerBookingId: string) {
const response = await overseasHttp.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(basePath, { params: { containerBookingId } })
return unwrapApiResponse(response).map(normalizeFee)
}
export async function listOverseasContainerCustomers(containerBookingId: string) {
const response = await overseasHttp.get<ApiEnvelope<Array<{ customerId: unknown; customerName?: unknown }>>, ApiEnvelope<Array<{ customerId: unknown; customerName?: unknown }>>>(`${basePath}/container-customers`, { params: { containerBookingId } })
return unwrapApiResponse(response).map((item) => ({ customerId: requiredText(item.customerId), customerName: text(item.customerName) }))
}
export async function createOverseasContainerFee(payload: OverseasContainerFeeSaveRequest, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey } })))
}
export async function submitOverseasContainerFee(sourceFeeId: string, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey })))
}
export async function reviewOverseasContainerFee(sourceFeeId: string, decision: 'APPROVE' | 'REJECT', reason: string | null, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/${encodeURIComponent(sourceFeeId)}/review`, { decision, reason, idempotencyKey })))
}
export async function adjustOverseasContainerFee(sourceFeeId: string, expectedVersion: number, fee: OverseasContainerFeeSaveRequest, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/${encodeURIComponent(sourceFeeId)}/adjust`, { expectedVersion, fee, idempotencyKey })))
}
export async function voidOverseasContainerFee(sourceFeeId: string, expectedVersion: number, reason: string, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/${encodeURIComponent(sourceFeeId)}/void`, { expectedVersion, reason, idempotencyKey })))
}
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { listContainerFeeSupplierOptions, uploadContainerFeePaymentVoucher, type ContainerFeePaymentMethod, type ContainerFeeSettlementMode, type ContainerFeeSupplierOption } from '@/modules/loading-lists/api/containerFees'
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerCustomer, type OverseasContainerFeeDirection, type OverseasContainerFeeItem, type OverseasContainerFeeSaveRequest, type OverseasContainerFeeSource } from '../api/overseasContainerFees'
const props = defineProps<{ containerBookingId: string; containerNo: string | null }>()
const access = useAccessPreviewStore()
const rows = ref<OverseasContainerFeeSource[]>([])
const customers = ref<OverseasContainerCustomer[]>([])
const suppliers = ref<ContainerFeeSupplierOption[]>([])
const loading = ref(false)
const actionLoading = ref(false)
const voucherUploading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const dialogOpen = ref(false)
const editing = ref<OverseasContainerFeeSource | null>(null)
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
const canEdit = computed(() => access.can('platform:finance:fee-source:edit'))
const canReview = computed(() => access.can('platform:finance:fee-source:review'))
const feeItems: Array<{ value: OverseasContainerFeeItem; label: string }> = [
{ value: 'PORT_FEE', label: '港口费' }, { value: 'STORAGE_FEE', label: '堆存费' },
{ value: 'CONTAINER_REPAIR_FEE', label: '修箱费' }, { value: 'CRANE_FEE', label: '吊机费' },
{ value: 'TERMINAL_DRAYAGE_FEE', label: '码头拖车费' }, { value: 'OTHER', label: '其他' },
]
const feeItemLabel = (value: string) => feeItems.find((item) => item.value === value)?.label || value
const reviewLabels: Record<string, string> = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' }
const form = reactive<{ customerId: string; feeItemCode: OverseasContainerFeeItem; direction: OverseasContainerFeeDirection; businessDate: string; amount: number | null; supplierId: string; settlementMode: ContainerFeeSettlementMode; paidAt: string; paymentMethod: ContainerFeePaymentMethod; paymentReferenceNo: string; paymentVoucherFileIds: string[] }>({ customerId: '', feeItemCode: 'PORT_FEE', direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), amount: null, supplierId: '', settlementMode: 'ON_ACCOUNT', paidAt: '', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: '', paymentVoucherFileIds: [] })
function key(scope: string) { return `${scope}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` }
function customerName(customerId: string) { return customers.value.find((item) => item.customerId === customerId)?.customerName || null }
function selectedSupplier() { return suppliers.value.find((item) => item.id === form.supplierId) || null }
function saveRequest(row?: OverseasContainerFeeSource): OverseasContainerFeeSaveRequest {
const item = feeItems.find((option) => option.value === form.feeItemCode)
return {
customerId: form.customerId, customerName: customerName(form.customerId), sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER',
businessId: props.containerBookingId, businessNo: props.containerNo || '', orderId: null, inboundId: null, inboundCode: null, containerId: null,
containerBookingId: props.containerBookingId, containerNo: props.containerNo || '', warehouseId: null,
feeItemCode: form.feeItemCode, feeItemName: item?.label || row?.feeItemName || form.feeItemCode, direction: form.direction,
supplierId: form.direction === 'PAYABLE' ? selectedSupplier()?.id || null : null,
supplierName: form.direction === 'PAYABLE' ? selectedSupplier()?.supplierName || null : null,
supplierCategory: form.direction === 'PAYABLE' ? selectedSupplier()?.category || null : null,
settlementMode: form.direction === 'PAYABLE' ? form.settlementMode : null,
paidAt: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' ? form.paidAt || null : null,
paymentMethod: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' ? form.paymentMethod : null,
paymentReferenceNo: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' ? form.paymentReferenceNo.trim() || null : null,
paymentVoucherFileIds: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' && form.paymentVoucherFileIds.length ? form.paymentVoucherFileIds : null,
businessDate: form.businessDate, currency: 'CNY', amount: Number(form.amount),
}
}
async function load() {
if (!canView.value) return
loading.value = true; error.value = null
try { [rows.value, customers.value, suppliers.value] = await Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), listContainerFeeSupplierOptions(null)]) }
catch (cause) { error.value = getApiErrorDetails(cause, '尾程计费加载失败') }
finally { loading.value = false }
}
function openCreate() {
editing.value = null; form.customerId = customers.value[0]?.customerId || ''; form.feeItemCode = 'PORT_FEE'; form.direction = 'PAYABLE'; form.businessDate = new Date().toISOString().slice(0, 10); form.amount = null; form.supplierId = ''; form.settlementMode = 'ON_ACCOUNT'; form.paidAt = ''; form.paymentMethod = 'BANK_TRANSFER'; form.paymentReferenceNo = ''; form.paymentVoucherFileIds = []; dialogOpen.value = true
}
function openAdjust(row: any) {
editing.value = row; form.customerId = row.customerId; form.feeItemCode = row.feeItemCode; form.direction = row.direction; form.businessDate = row.businessDate; form.amount = row.amount; form.supplierId = row.supplierId || ''; form.settlementMode = row.settlementMode === 'DIRECT_PAID' ? 'DIRECT_PAID' : 'ON_ACCOUNT'; form.paidAt = row.paidAt || ''; form.paymentMethod = (row.paymentMethod as ContainerFeePaymentMethod) || 'BANK_TRANSFER'; form.paymentReferenceNo = row.paymentReferenceNo || ''; form.paymentVoucherFileIds = [...row.paymentVoucherFileIds]; dialogOpen.value = true
}
async function mutate(task: () => Promise<unknown>, success: string) {
actionLoading.value = true
try { await task(); ElMessage.success(success); dialogOpen.value = false; await load() }
catch (cause) { ElMessage.error(getApiErrorMessage(cause, '尾程计费操作失败')) }
finally { actionLoading.value = false }
}
async function save() {
if (!form.customerId || !form.businessDate || !form.amount || form.amount <= 0) return ElMessage.warning('请完整填写客户、业务日期和大于 0 的金额')
if (!props.containerNo) return ElMessage.warning('请先补录真实集装箱号后再维护尾程费用')
if (form.direction === 'PAYABLE' && !selectedSupplier()) return ElMessage.warning('应付费用必须选择供应商')
if (form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' && (!form.paidAt || !form.paymentMethod || (!form.paymentReferenceNo.trim() && form.paymentVoucherFileIds.length === 0))) return ElMessage.warning('直接已付必须填写付款时间、方式,并提供流水号或付款凭证')
const payload = saveRequest(editing.value || undefined)
if (editing.value) await mutate(() => adjustOverseasContainerFee(editing.value!.sourceFeeId, editing.value!.sourceVersion, payload, key('container-fee-adjust')), '费用调整已提交审核')
else await mutate(() => createOverseasContainerFee(payload, key('container-fee-create')), '费用草稿已创建')
}
async function uploadVoucher(file: UploadFile) {
if (!form.customerId || !file.raw) { ElMessage.warning('请先选择费用所属客户'); return }
voucherUploading.value = true
try { const uploaded = await uploadContainerFeePaymentVoucher(form.customerId, file.raw); form.paymentVoucherFileIds = [uploaded.fileId]; ElMessage.success('付款凭证已上传') }
catch (cause) { ElMessage.error(getApiErrorMessage(cause, '付款凭证上传失败')) }
finally { voucherUploading.value = false }
}
async function submit(row: any) {
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { type: 'warning' })
await mutate(() => submitOverseasContainerFee(row.sourceFeeId, key('container-fee-submit')), '费用已提交审核')
}
async function review(row: any, decision: 'APPROVE' | 'REJECT') {
let reason: string | null = null
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', '驳回费用', { inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, '审核费用', { type: 'warning' })
await mutate(() => reviewOverseasContainerFee(row.sourceFeeId, decision, reason, key('container-fee-review')), decision === 'APPROVE' ? '费用已审核通过' : '费用已驳回')
}
async function voidFee(row: any) {
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '费用已作废')
}
onMounted(load)
</script>
<template>
<div class="last-leg-fees">
<header class="last-leg-fees__header"><div><h2>尾程计费</h2><p>按真实货柜统一维护港口、堆存、修箱、吊机和码头拖车费用。</p></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
<el-alert v-if="!containerNo" type="warning" :closable="false" title="当前货柜尚未补录真实集装箱号,暂不能新增尾程费用。" />
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
<LoadErrorState v-else-if="error" title="尾程计费加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-table v-else v-loading="loading" :data="rows" empty-text="当前货柜暂无尾程费用">
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions"><el-button v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" link type="primary" @click="submit(row)">提交</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" link type="success" @click="review(row, 'APPROVE')">通过</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" link type="danger" @click="review(row, 'REJECT')">驳回</el-button><el-button v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" link type="primary" @click="openAdjust(row)">调整</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" link type="danger" @click="voidFee(row)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="feeItemCode" label="费用项目" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ feeItemLabel(row.feeItemCode) }}</template></el-table-column>
<el-table-column prop="direction" label="方向" width="100"><template #default="{ row }">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</template></el-table-column>
<el-table-column prop="supplierName" label="供应商" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.supplierName || '—' }}</template></el-table-column>
<el-table-column prop="amount" label="金额" min-width="130" align="right"><template #default="{ row }">¥{{ row.amount.toFixed(2) }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务日期" width="120" />
<el-table-column prop="reviewStatus" label="审核状态" width="110"><template #default="{ row }"><el-tag>{{ reviewLabels[row.reviewStatus] || row.reviewStatus }}</el-tag></template></el-table-column>
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
</el-table>
<AppDialog v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="save">
<el-form label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing)" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="uploadVoucher"><el-button :loading="voucherUploading">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button></el-upload></el-form-item></template></template></div></el-form>
</AppDialog>
</div>
</template>
<style scoped>
.last-leg-fees { display: grid; gap: 16px; min-width: 0; }
.last-leg-fees__header { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.last-leg-fees__header h2 { margin: 0; color: #253553; font-size: 20px; }
.last-leg-fees__header p { margin: 6px 0 0; color: #77839b; }
.row-actions { display: flex; align-items: center; white-space: nowrap; }
.fee-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.fee-form-grid :deep(.el-select), .fee-form-grid :deep(.el-date-editor), .fee-form-grid :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-form-grid { grid-template-columns: 1fr; } }
</style>
@@ -7,6 +7,7 @@ const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/containe
const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
const loadingRouteSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/routes.ts'), 'utf8')
const navigationSource = readFileSync(resolve(process.cwd(), 'src/core/navigation/adminNavigation.ts'), 'utf8')
const lastLegFeesSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerLastLegFeesPanel.vue'), 'utf8')
describe('ContainerDetailView customs integration', () => {
it('renders the requested detail sections in the confirmed business order', () => {
@@ -41,13 +42,35 @@ describe('ContainerDetailView customs integration', () => {
expect(routeSource).toContain("name: 'overseas-container-detail'")
expect(loadingRouteSource).not.toContain("name: 'loading-lists'")
expect(navigationSource).not.toContain("title: '装柜清单'")
expect(source).toContain('<el-tabs v-if="isDomestic"')
expect(source).toContain('<el-tabs v-model="activeTab"')
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
expect(source).toContain("getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)")
expect(source).toContain(':can-edit="false"')
})
it('separates domestic first-leg fees and connects the overseas fee lifecycle lazily', () => {
expect(source).toContain('label="头程计费" name="first-leg-fees"')
expect(source).toContain('label="尾程计费" name="last-leg-fees"')
expect(source).toContain('v-if="isDomestic && firstLegFeesOpened"')
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
expect(source).toContain('title="头程计费" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
expect(source).toContain('v-if="isOverseas && lastLegFeesOpened"')
expect(source).toContain('<ContainerLastLegFeesPanel :container-booking-id="detail.id" :container-no="detail.containerNo" />')
expect(source).toContain("if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)")
expect(source).toContain("tabName === 'loading-list' || tabName === 'first-leg-fees'")
expect(lastLegFeesSource.indexOf('label="操作"')).toBeLessThan(lastLegFeesSource.indexOf('label="客户"'))
expect(lastLegFeesSource).toContain('show-overflow-tooltip')
expect(lastLegFeesSource).toContain('尾程计费加载失败')
expect(lastLegFeesSource).toContain('empty-text="当前货柜暂无尾程费用"')
expect(lastLegFeesSource).toContain('v-loading="loading"')
expect(lastLegFeesSource).toContain('Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), listContainerFeeSupplierOptions(null)])')
expect(lastLegFeesSource).toContain("form.settlementMode === 'DIRECT_PAID'")
expect(lastLegFeesSource).toContain('uploadContainerFeePaymentVoucher(form.customerId, file.raw)')
expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:edit')")
expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:review')")
})
it('mounts the new container customs panel only after loading completes', () => {
expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms")
expect(source).toContain('<ContainerCustomsPanel')
@@ -11,7 +11,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, type LoadingDetail } from '@/modules/loading-lists'
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, type LoadingDetail } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
@@ -19,6 +19,7 @@ import {
type ContainerOutboundGateItem,
} from '@/modules/outbound-dispatch'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
@@ -44,6 +45,24 @@ const containerId = computed(() => String(route.params.containerId || ''))
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
const activeTab = ref(route.query.tab === 'loading-list' ? 'loading-list' : 'overview')
const firstLegFeesOpened = ref(false)
const lastLegFeesOpened = ref(false)
const feeCustomers = computed(() => {
const customers = new Map<string, string | null>()
for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
})
const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({
accountId: item.accountId, customerName: item.customerName, orderId: item.orderId, orderNo: item.orderNo,
inboundId: item.inboundId, inboundCode: item.inboundCode,
})))
async function rememberWorkbenchTab(tabName: string | number) {
if (tabName === 'first-leg-fees') firstLegFeesOpened.value = true
if (tabName === 'last-leg-fees') lastLegFeesOpened.value = true
if (isDomestic.value && (tabName === 'loading-list' || tabName === 'first-leg-fees') && detail.value && overview.value) {
await loadLoadingDetail(detail.value, overview.value)
}
}
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
@@ -90,6 +109,7 @@ const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const loadingDetail = ref<LoadingDetail | null>(null)
const loadingDetailLoading = ref(false)
const loadingDetailError = ref<ApiErrorDetails | null>(null)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
@@ -173,7 +193,7 @@ async function load() {
const [resource, aggregate] = await Promise.all([getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)])
workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate
if (activeTab.value === 'loading-list' && resource.planStatus !== 'COMPLETED') activeTab.value = 'overview'
if (isDomestic.value) await loadLoadingDetail(resource, aggregate)
if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)
if (isDomestic.value && resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
@@ -188,8 +208,10 @@ async function loadOutboundDispatch() {
}
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
if (loadingDetail.value || loadingDetailLoading.value) return
loadingDetail.value = null; loadingDetailError.value = null
if (!isDomestic.value || !aggregate.loadingPlanId || !resource.warehouseId) return
loadingDetailLoading.value = true
try {
if (resource.planStatus === 'COMPLETED') {
if (!canViewLoadingList.value) return
@@ -200,6 +222,8 @@ async function loadLoadingDetail(resource: ContainerBookingResource, aggregate:
loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId)
} catch (cause) {
loadingDetailError.value = getApiErrorDetails(cause, '装柜清单加载失败')
} finally {
loadingDetailLoading.value = false
}
}
async function refreshAfterCustoms() {
@@ -383,21 +407,21 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
</section>
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<el-tabs v-if="isDomestic" v-model="activeTab" class="workbench-tabs"><el-tab-pane label="送柜报关" name="overview" /><el-tab-pane v-if="detail.planStatus === 'COMPLETED' && canViewLoadingList" label="装柜清单" name="loading-list" /></el-tabs>
<el-tabs v-model="activeTab" class="workbench-tabs" @tab-change="rememberWorkbenchTab"><el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview" /><el-tab-pane v-if="isDomestic && detail.planStatus === 'COMPLETED' && canViewLoadingList" label="装柜清单" name="loading-list" /><el-tab-pane v-if="isDomestic && overview?.loadingPlanId" label="头程计费" name="first-leg-fees" /><el-tab-pane v-if="isOverseas" label="尾程计费" name="last-leg-fees" /></el-tabs>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<section v-if="isOverseas || activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
<section v-if="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
<h2 id="container-information-heading">货柜信息</h2>
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div></dl></div>
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
</section>
<section v-if="isOverseas || activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<section v-if="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<h2 id="container-voyage-heading">船名航次信息</h2>
<div class="detail-fields"><dl><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-if="isOverseas || activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<section v-if="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
@@ -425,16 +449,26 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
</div>
</section>
<section v-if="isOverseas" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
<section v-if="isOverseas && activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
<h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2>
<div class="detail-fields"><dl><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预约提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}</dd></div><div><dt>实际提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(workbenchDetail?.actualFacts?.unloadingCompletedAt as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">装柜清单</h2><p>清单明细、PDA 证据、费用与报关关联均以当前柜次为边界。</p></div><el-button v-if="loadingDetail" @click="printLoadingList">打印装柜清单</el-button></header>
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">装柜清单</h2><p>清单明细、PDA 证据与报关关联均以当前柜次为边界。</p></div><el-button v-if="loadingDetail" @click="printLoadingList">打印装柜清单</el-button></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer mode="confirmed" :loading="loading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
<LoadingListDetailDrawer mode="confirmed" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
</section>
<section v-if="isDomestic && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="头程计费" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<el-empty v-else-if="!loadingDetailLoading" description="当前柜次暂无可用装柜计划或费用归属明细" :image-size="72" />
</section>
<section v-if="isOverseas && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
<ContainerLastLegFeesPanel :container-booking-id="detail.id" :container-no="detail.containerNo" />
</section>
</template>
@@ -72,17 +72,17 @@ describe('container fee API', () => {
it('maps settled fees and allocation rows from one container aggregate request', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: {
customerId: 11, containerId: 99, settledSourceFeeIds: [7],
costs: [{ costId: 81, costNo: 'COST-81', costType: 'TRUCKING', costOrigin: 'CONTAINER_FEE', sourceFeeId: 7, sourceFeeVersion: 2, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', amount: 300, currency: 'CNY', status: 'CONFIRMED', allocations: [{ allocationId: 1, orderId: 8, inboundId: 9, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120, roundingAdjustment: false }] }],
customerId: 11, containerBookingId: 99, settledSourceFeeIds: [7],
costs: [{ costId: 81, costNo: 'COST-81', costType: 'TRUCKING', costOrigin: 'CONTAINER_FEE', sourceFeeId: 7, sourceFeeVersion: 2, containerBookingId: 99, supplierName: '承运商A', feeItemName: '拖车费', amount: 300, currency: 'CNY', status: 'CONFIRMED', allocations: [{ allocationId: 1, orderId: 8, inboundId: 9, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120, roundingAdjustment: false }] }],
} })
const result = await getContainerSettlementDetails('11', '99')
expect(result).toMatchObject({ customerId: '11', containerId: '99', settledSourceFeeIds: ['7'], costs: [{ id: '81', sourceFeeId: '7', amount: 300, allocations: [{ id: '1', orderId: '8', inboundId: '9', allocatedAmount: 120 }] }] })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/container-settlement-details', { params: { customerId: '11', containerId: '99' } })
expect(result).toMatchObject({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: ['7'], costs: [{ id: '81', containerBookingId: '99', sourceFeeId: '7', amount: 300, allocations: [{ id: '1', orderId: '8', inboundId: '9', allocatedAmount: 120 }] }] })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/container-settlement-details', { params: { customerId: '11', containerBookingId: '99' } })
expect(get).toHaveBeenCalledTimes(1)
})
it('fails closed when the Finance aggregate does not echo the selected customer and container', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: 12, containerId: 99, settledSourceFeeIds: [], costs: [] } })
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: 12, containerBookingId: 99, settledSourceFeeIds: [], costs: [] } })
await expect(getContainerSettlementDetails('11', '99')).rejects.toThrow('Finance 聚合结果与柜次或客户上下文不一致')
})
})
@@ -118,7 +118,7 @@ export interface ContainerCostDetail {
costOrigin: string
sourceFeeId: string | null
sourceFeeVersion: number
containerId: string | null
containerBookingId: string | null
supplierName: string | null
feeItemName: string
amount: number
@@ -129,7 +129,7 @@ export interface ContainerCostDetail {
export interface ContainerSettlementDetails {
customerId: string
containerId: string
containerBookingId: string
settledSourceFeeIds: string[]
costs: ContainerCostDetail[]
}
@@ -243,17 +243,17 @@ export async function uploadContainerFeePaymentVoucher(customerId: string, file:
return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) }
}
export async function getContainerSettlementDetails(customerId: string, containerId: string): Promise<ContainerSettlementDetails> {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>('/platform/finance/v1/container-settlement-details', { params: { customerId, containerId } })
export async function getContainerSettlementDetails(customerId: string, containerBookingId: string): Promise<ContainerSettlementDetails> {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>('/platform/finance/v1/container-settlement-details', { params: { customerId, containerBookingId } })
const raw = unwrapApiResponse(response)
if (id(raw.customerId) !== customerId || id(raw.containerId) !== containerId) throw new Error('Finance 聚合结果与柜次或客户上下文不一致')
if (id(raw.customerId) !== customerId || id(raw.containerBookingId) !== containerBookingId) throw new Error('Finance 聚合结果与柜次或客户上下文不一致')
const costs = (Array.isArray(raw.costs) ? raw.costs : []).map((value) => {
const cost = value as Record<string, unknown>
if (id(cost.containerId) !== containerId) throw new Error('Finance 聚合成本与柜次上下文不一致')
if (id(cost.containerBookingId) !== containerBookingId) throw new Error('Finance 聚合成本与柜次上下文不一致')
return {
id: id(cost.costId), costNo: String(cost.costNo || ''), costType: String(cost.costType || ''), costOrigin: String(cost.costOrigin || ''),
sourceFeeId: cost.sourceFeeId == null ? null : id(cost.sourceFeeId), sourceFeeVersion: numberValue(cost.sourceFeeVersion),
containerId: cost.containerId == null ? null : id(cost.containerId), supplierName: cost.supplierName == null ? null : String(cost.supplierName),
containerBookingId: cost.containerBookingId == null ? null : id(cost.containerBookingId), supplierName: cost.supplierName == null ? null : String(cost.supplierName),
feeItemName: String(cost.feeItemName || ''), amount: numberValue(cost.amount), currency: String(cost.currency || ''), status: String(cost.status || ''),
allocations: (Array.isArray(cost.allocations) ? cost.allocations : []).map((value) => {
const allocation = value as Record<string, unknown>
@@ -261,5 +261,5 @@ export async function getContainerSettlementDetails(customerId: string, containe
}),
}
})
return { customerId, containerId, settledSourceFeeIds: (Array.isArray(raw.settledSourceFeeIds) ? raw.settledSourceFeeIds : []).map(id), costs }
return { customerId, containerBookingId, settledSourceFeeIds: (Array.isArray(raw.settledSourceFeeIds) ? raw.settledSourceFeeIds : []).map(id), costs }
}
@@ -16,6 +16,7 @@ vi.mock('../api/containerFees', async (importOriginal) => {
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
const containerDetail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
class ResizeObserverStub { observe() {}; unobserve() {}; disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
@@ -31,7 +32,7 @@ beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage())
vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([])
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset()
vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset()
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
document.body.innerHTML = ''
@@ -45,9 +46,12 @@ describe('ContainerFeesPanel contract', () => {
expect(panel).toContain('.fees-panel :deep(.data-table__toolbar) { min-height: 48px;')
})
it('is exposed from loading-list detail and derives customer context from real items', () => {
expect(detail).toContain(':container-booking-id="summary.containerBookingId"')
expect(detail).toContain('item.accountId')
it('is exposed from the domestic container fee tab and derives customer context from real items', () => {
expect(detail).not.toContain('ContainerFeesPanel')
expect(containerDetail).toContain('title="头程计费"')
expect(containerDetail).toContain(':container-booking-id="detail.id"')
expect(containerDetail).toContain(':plan-id="overview.loadingPlanId"')
expect(containerDetail).toContain('item.accountId')
expect(panel).toContain('该柜次包含多个客户,请先选择费用归属客户')
expect(panel).toContain('装柜明细缺少客户归属,无法安全维护费用')
})
@@ -154,7 +158,7 @@ describe('ContainerFeesPanel contract', () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: ['1'], costs: [] })
vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: ['1'], costs: [] })
const wrapper = await mountPanel([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review',
])
@@ -11,12 +11,13 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
const props = defineProps<{
const props = withDefaults(defineProps<{
title?: string
planId: string
containerBookingId: string
customers: Array<{ accountId: string; customerName: string | null }>
businessReferences: Array<{ accountId: string; customerName: string | null; orderId: string; orderNo: string; inboundId: string | null; inboundCode: string | null }>
}>()
}>(), { title: '柜次费用' })
const access = useAccessPreviewStore()
const accountId = ref('')
const fees = ref<ContainerFeeResource[]>([])
@@ -350,7 +351,7 @@ onMounted(() => void load())
<template>
<section class="fees-panel" aria-label="柜次费用维护">
<header class="fees-header">
<div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
<div><h2>{{ title }}</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
<div class="fees-header__actions">
<label v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label>
<el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button>
@@ -6,7 +6,6 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import ContainerFeesPanel from './ContainerFeesPanel.vue'
import { loadingListStatusLabel } from '../status'
import type { ApiErrorDetails } from '@/shared/api/http'
import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
@@ -115,19 +114,6 @@ const pdaTaskStatusLabels: Record<string, string> = {
const blockingReasonText = computed(() => (preloadSummary.value?.readiness.blockingReasons || [])
.map((reason) => blockingReasonLabels[reason] || '存在未满足的确认条件')
.join(';'))
const feeCustomers = computed(() => {
const customers = new Map<string, string | null>()
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
})
const feeBusinessReferences = computed(() => (props.detail?.items || []).map((item) => ({
accountId: item.accountId,
customerName: item.customerName,
orderId: item.orderId,
orderNo: item.orderNo,
inboundId: item.inboundId,
inboundCode: item.inboundCode,
})))
const title = computed(() => {
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
const number = props.mode === 'preload'
@@ -225,8 +211,6 @@ function formatNumber(value?: number | null, digits = 3) {
</DataTable>
</section>
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<section class="history-section" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
<el-empty v-if="visibleHistories.length === 0" description="暂无操作历史" :image-size="72" />
@@ -42,7 +42,7 @@ describe('LoadingListsView', () => {
expect(preloadDetailSource).toContain('AppDialog')
expect(confirmedDetailSource).not.toContain('AppDialog')
expect(detailSource).not.toContain('AppDrawer')
expect(detailSource).toContain('ContainerFeesPanel')
expect(detailSource).not.toContain('ContainerFeesPanel')
})
it('distinguishes the generated cabinet reference from the physical container number', () => {