This commit is contained in:
2026-08-17 12:31:27 +08:00
parent c6fa22c1b5
commit 6a0c0b9229
22 changed files with 399 additions and 171 deletions
@@ -15,7 +15,7 @@ vi.mock('@/shared/api/http', () => ({
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: '2', capacityCbm: '68', maxWeightKg: '28000' }
const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'EDITING', items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1' }] }
describe('platform container loading API', () => {
@@ -33,14 +33,14 @@ describe('platform container loading API', () => {
it('normalizes paged container ids and numeric metadata', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } })
const result = await listContainers({ page: 1, size: 20 })
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: 2, capacityCbm: 68 })
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', version: 2, capacityCbm: 76 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
})
it('uses full-edit and transport whitelist request shapes', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
const data = { cabinetNo: 'CB-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
const data = { cabinetNo: 'CB-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
await updateContainer('7', 2, data)
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
@@ -50,10 +50,10 @@ describe('platform container loading API', () => {
it('uses the frozen simulation create and ocean booking link contracts', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: 101, oceanBookingRequirementId: 202 } })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: null, oceanBookingRequirementId: null } })
await createContainerSimulation({ channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 })
await createContainerSimulation({ channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', expectedVersion: 3 })
await unlinkContainerOceanBooking('7', 4, '空柜改配')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { oceanBookingId: '101', requirementId: '202', expectedVersion: 3 })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } })
})
@@ -38,8 +38,8 @@ describe('ContainersView', () => {
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
@@ -70,10 +70,10 @@ describe('ContainersView', () => {
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', planStatus: 'WAIT_LOADING' as const, version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
@@ -99,10 +99,10 @@ describe('ContainersView', () => {
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
const base = {
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', version: 0,
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
@@ -234,9 +234,14 @@ describe('ContainersView', () => {
expect(source).not.toContain('新增货柜')
expect(source).toContain('新增模拟柜')
expect(source).toContain('createContainerSimulation({ ...simulationForm })')
expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000")
expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000")
expect(source).toContain('CONTAINER_SPECIFICATIONS')
expect(source).toContain('selectSimulationContainerType')
expect(source).toContain('服务端会生成稳定的柜次号')
expect(source).not.toContain('openCreate')
expect(source).not.toContain("['20GP','40GP','40HQ','45HQ']")
expect(source).not.toContain('v-model="form.capacityCbm"')
expect(source).not.toContain('v-model="form.maxWeightKg"')
})
it('allows first linking after preloading but only offers unlinking for wait-loading containers', () => {
@@ -18,6 +18,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, unlinkContainerOceanBooking, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
@@ -82,7 +83,7 @@ const simulationError = ref<ApiErrorDetails | null>(null)
const simulationChannels = ref<OceanLogisticsChannel[]>([])
const simulationChannelsLoading = ref(false)
const simulationFormRef = ref<FormInstance>()
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 })
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
const linkOpen = ref(false)
const linkLoading = ref(false)
const linkSaving = ref(false)
@@ -99,10 +100,10 @@ let detailSequence = 0
function emptyForm(): ContainerForm {
return {
id: '', version: 0, containerNo: null, cabinetNo: '', warehouseId: '', warehouseName: null,
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ', shippingCompanyId: null,
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', shippingCompanyId: null,
shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null,
destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
capacityCbm: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null,
capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null,
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null,
estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
@@ -133,8 +134,6 @@ const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
capacityCbm: [{ required: true, message: '请输入容量', trigger: 'change' }],
maxWeightKg: [{ required: true, message: '请输入限重', trigger: 'change' }],
}
interface PortOption { value: string; code: string | null; name: string | null }
@@ -165,7 +164,7 @@ const destinationWarehouseValue = computed(() => form.destinationWarehouseCode |
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: ['20GP', '40GP', '40HQ', '45HQ'].map((value) => ({ label: value, value })), props: { 'aria-label': '柜型' } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } },
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
@@ -200,6 +199,8 @@ function containerViewLabel(row: ContainerBookingResource) {
}
function replaceForm(source: ContainerBookingResource | null) {
Object.assign(form, emptyForm(), source || {})
const specification = containerSpecification(form.containerType)
if (specification) applyContainerSpecification(form, specification.type)
selectedTruckingSupplierId.value = truckingCompanies.value.find((supplier) => supplier.name === form.terminalTruckingCompany)?.id || ''
}
function payload(): ContainerSaveRequest {
@@ -315,11 +316,14 @@ async function loadAllOceanBookings() {
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
}
function openSimulation() {
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000 })
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
simulationChannels.value = []
simulationError.value = null
simulationOpen.value = true
}
function selectSimulationContainerType(containerType: string) {
applyContainerSpecification(simulationForm, containerType)
}
async function selectSimulationWarehouse(value: unknown) {
simulationForm.warehouseId = String(value || '')
simulationForm.channelCode = ''
@@ -629,9 +633,8 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select v-model="simulationForm.channelCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="channel in simulationChannels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select></el-form-item>
<el-form-item label="柜型" prop="containerType"><el-select v-model="simulationForm.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="容量(m³)" prop="capacityCbm"><el-input-number v-model="simulationForm.capacityCbm" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="限重(kg)" prop="maxWeightKg"><el-input-number v-model="simulationForm.maxWeightKg" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="固定规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /></el-form-item>
</el-form>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
@@ -661,13 +664,13 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" :disabled="transportOnly" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select v-model="form.containerType" placeholder="请选择箱型" :disabled="transportOnly"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" :disabled="transportOnly" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" :disabled="transportOnly" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" :disabled="transportOnly" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="货柜容量(m³)"><el-input-number v-model="form.capacityCbm" :min="0.001" :precision="3" controls-position="right" placeholder="请输入货柜容量" :disabled="transportOnly" /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" placeholder="请输入重量上限" :disabled="transportOnly" /></el-form-item>
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" :disabled="transportOnly" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" :disabled="transportOnly" /></el-form-item>
@@ -41,10 +41,10 @@ vi.mock('../api/platformContainerLoading', () => ({
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
@@ -103,7 +103,7 @@ describe('GlobalSmartLoadingView', () => {
containerNo: null,
channelName: '美森正班海卡包税',
warehouseName: '义乌国内仓',
containerType: '40HQ',
containerType: '40HC',
}],
page: 1,
size: 100,
@@ -120,7 +120,7 @@ describe('GlobalSmartLoadingView', () => {
expect(option.text()).toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('仓库:义乌国内仓')
expect(option.text()).toContain('柜型:40HQ')
expect(option.text()).toContain('柜型:40HC')
expect(option.text()).not.toContain('2088475410134773762')
expect(wrapper.text()).not.toContain('真实柜号')
})
@@ -165,7 +165,6 @@ describe('GlobalSmartLoadingView', () => {
})
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{ ...container, capacityCbm: 100, maxWeightKg: 100000 }], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
items: [{ ...plan.items[0], volumeCbm: 0.187, grossWeightKg: 13.6 }],
@@ -173,8 +172,8 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('0.17%')
expect(wrapper.text()).toContain('0.01%')
expect(wrapper.text()).toContain('0.22%')
expect(wrapper.text()).toContain('0.04%')
expect(wrapper.text()).not.toContain('0.169999999')
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
})
@@ -18,10 +18,10 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
@@ -33,10 +33,28 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps the account detail as a full page with traceable ledger controls', () => {
expect(page).toContain('v-else-if="!detailOpen"')
expect(page).toContain('返回客户账户')
expect(page).toContain('<DetailPageLayout')
expect(page).toContain('class="account-metrics"')
expect(page).toContain('待收金额')
expect(page).toContain('待开账金额')
expect(page).toContain('class="ledger-query"')
expect(page).toContain('class="ledger-table"')
expect(page).toContain('<template #toolbar-actions>')
expect(page).toContain('<template #pagination-left>共 {{ ledgerTotal }} 条</template>')
expect(page).not.toContain('class="detail-toolbar"')
expect(page).not.toContain('<AppDrawer')
for (const marker of ['流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger']) expect(page).toContain(marker)
})
it('keeps term editing aligned with the account summary and nearby actions', () => {
for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
expect(page).toContain('grid-template-columns:repeat(4,minmax(0,1fr))')
expect(page).toContain('grid-template-columns:minmax(180px,1fr) auto')
expect(page).toContain('class="surface-panel finance-account-detail__surface"')
expect(page).toContain('min-height:88px')
expect(page).toContain('class="detail-table-scroll"')
})
it('shows only derived CNY summaries and no wallet actions', () => {
for (const marker of ['未收', '待出账', '本期已收', '应付', 'CNY']) expect(page).toContain(marker)
for (const forbidden of ['手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden)
@@ -9,6 +9,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -221,18 +222,25 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
</section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail" spacing="normal">
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><div><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }}</p></div></div>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<el-descriptions :column="2" class="account-detail"><el-descriptions-item label="客户">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
<FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><QueryToolbar v-model="ledgerFilters" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" /><div class="detail-toolbar"><span>共 {{ ledgerTotal }} 条</span><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></div><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></FormSection>
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
</template>
<div v-if="editing" class="detail-footer"><el-button @click="closeDetail">关闭</el-button><el-button v-if="canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div>
<section class="surface-panel finance-account-detail__surface">
<DetailPageLayout class="finance-account-detail__layout" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · 客户财务往来总览</p></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户汇总"><div class="account-metric is-receivable"><span>待收金额</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-pending"><span>待开账金额</span><strong>{{ money(selected.pendingBilling) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>本期已收</span><strong>{{ money(selected.receivedInPeriod) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付金额</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div></section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection v-if="!editing" class="ledger-section" title="账户流水" description="展示应收、应付及每次变动后的未收金额。" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section" title="账期设置" description="到期日按账单日期加客户账期计算;每个客户独立设置,默认 30 天。" :bordered="false"><div class="term-editor"><div><span class="term-label">账期天数</span><p>请输入 0 至 3650 天</p></div><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></div></div></FormSection>
</template>
<template v-if="editing && selected && !ledgerError" #footer><div class="detail-footer"><el-button @click="closeDetail">关闭</el-button><el-button v-if="canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
</DetailPageLayout>
</section>
</AdminPage>
</AdminPage>
</template>
<style scoped>
.finance-account-page,.finance-account-detail{min-width:0}.row-actions,.detail-footer,.detail-heading,.detail-toolbar{display:flex;align-items:center;gap:8px}.row-actions .el-button{margin-left:0}.detail-heading{align-items:flex-start;margin-bottom:16px}.detail-heading h2{margin:0;font-size:18px}.detail-heading p{margin:4px 0 0;color:var(--cc-muted)}.detail-toolbar,.detail-footer{justify-content:space-between;margin:12px 0}.detail-footer{justify-content:flex-end}.account-detail{margin-bottom:16px}.term-unit{margin-left:8px;color:var(--cc-muted)}@media(max-width:720px){.detail-heading{align-items:stretch;flex-direction:column}.detail-footer .el-button{min-height:44px}:deep(.account-detail .el-descriptions__body .el-descriptions__table){table-layout:fixed}}@media(max-width:480px){.detail-footer .el-button{flex:1 1 120px}:deep(.account-detail .el-descriptions__cell){display:block;width:100%}}
.finance-account-page{min-width:0}.finance-account-detail{width:100%;min-width:0}.finance-account-detail__surface{width:min(100%,1600px);margin:0 auto;overflow:hidden}.finance-account-detail__layout{width:100%;margin:0 auto}.finance-account-detail__layout :deep(.detail-page-layout__header){min-height:88px;align-items:center;padding:20px 24px;border-bottom:1px solid var(--cc-border,#e3e8ef)}.finance-account-detail__layout :deep(.detail-page-layout__summary){padding:20px 24px 0}.finance-account-detail__layout :deep(.detail-page-layout__content){padding:0 24px 20px}.finance-account-detail__layout :deep(.detail-page-layout__footer){padding:0 24px 24px}.row-actions,.detail-footer,.detail-heading,.term-control{display:flex;align-items:center;gap:8px}.row-actions .el-button{margin-left:0}.detail-heading{width:100%;align-items:center}.detail-title{min-width:0}.detail-heading h2{margin:0;color:var(--cc-text);font-size:20px;line-height:1.4}.detail-heading p{margin:4px 0 0;color:var(--cc-muted);font-size:13px}.detail-back{margin-left:auto}.account-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.account-metric{position:relative;min-width:0;padding:16px 18px;border:1px solid var(--cc-border,#e3e8ef);border-radius:6px;background:#fff;overflow:hidden}.account-metric::before{position:absolute;top:0;right:0;left:0;height:3px;background:#64748b;content:""}.account-metric.is-pending::before{background:#d97706}.account-metric.is-received::before{background:#16a34a}.account-metric.is-payable::before{background:#dc2626}.account-metric span{display:block;color:var(--cc-muted);font-size:12px}.account-metric strong{display:inline-block;margin-top:9px;color:var(--cc-text);font-size:25px;line-height:1;font-variant-numeric:tabular-nums}.account-metric small{margin-left:6px;color:var(--cc-muted);font-size:11px}.ledger-section{padding:18px;border-top:1px solid var(--cc-border,#e3e8ef);border-bottom:1px solid var(--cc-border,#e3e8ef);background:#fff}.ledger-query{margin-bottom:10px}.detail-table-scroll{overflow-x:auto}.ledger-table{min-width:0}:deep(.ledger-table .data-table__toolbar.is-compact){min-height:42px;padding:0 0 10px;border-bottom:0}:deep(.ledger-table .data-table__pagination){min-height:48px;padding:8px 12px}.term-section{width:100%;padding:20px;border:1px solid var(--cc-border,#e3e8ef);border-radius:6px;background:#fff}.term-editor{display:grid;grid-template-columns:minmax(180px,1fr) auto;align-items:center;gap:24px;padding:4px 0}.term-label{color:var(--cc-text);font-size:14px;font-weight:600}.term-editor p{margin:5px 0 0;color:var(--cc-muted);font-size:12px}.term-control :deep(.el-input-number){width:220px}.term-unit{color:var(--cc-muted)}.detail-footer{width:100%;justify-content:flex-end;margin-top:-4px;padding-top:14px;border-top:0}@media(max-width:1080px){.account-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.finance-account-detail__surface{width:100%}}@media(max-width:720px){.detail-heading{align-items:stretch;flex-direction:column}.detail-back{margin-left:0}.detail-footer .el-button{min-height:44px}.term-editor{grid-template-columns:1fr}.account-metrics{grid-template-columns:1fr}}@media(max-width:480px){.detail-footer .el-button{flex:1 1 120px}}
</style>
@@ -137,7 +137,8 @@ describe('FinanceCustomerArapView G2 operations', () => {
await vm.openDetail(row); await flushPromises()
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
expect(document.body.textContent).toContain('IB-91')
expect(document.body.textContent).toContain('100.00 CNY')
expect(document.body.textContent).toContain('100.00')
expect(document.body.textContent).toContain('CNY')
await vm.openReview(row); vm.reviewForm.action = 'APPROVE'; vm.reviewForm.reason = '核对无误'; await vm.submitReview()
expect(reviewFinanceFeeSource).toHaveBeenCalledWith('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 })
@@ -167,6 +168,21 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.router.currentRoute.value.name).toBe('finance-customer-arap-detail')
})
it('uses the desktop detail layout for summary, source information, and operation history', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
expect(wrapper.find('.finance-arap-detail__layout').exists()).toBe(true)
expect(wrapper.find('.finance-arap-detail__surface').exists()).toBe(true)
expect(wrapper.find('.detail-page-layout__header').exists()).toBe(true)
expect(wrapper.find('.detail-page-layout__summary').exists()).toBe(true)
expect(wrapper.find('.arap-metrics').exists()).toBe(true)
expect(wrapper.findAll('.arap-metric')).toHaveLength(4)
expect(wrapper.find('.arap-information').text()).toContain('单据信息')
expect(wrapper.find('.arap-settlement').text()).toContain('审核与结算')
expect(wrapper.find('.operation-log').text()).toContain('操作记录')
expect(wrapper.find('.arap-information').text()).toContain('业务日期')
expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00')
})
it('resolves blank detail customer names and keeps source labels business-facing', async () => {
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName: '', sourceType: 'NEW_SOURCE' })
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
@@ -10,6 +10,8 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -260,12 +262,14 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
</DataTable></section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-arap-detail" spacing="normal">
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联单据">{{ detail.inboundCode || detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
</template>
<section class="surface-panel finance-arap-detail__surface">
<DetailPageLayout class="finance-arap-detail__layout" aria-label="费用来源详情">
<template #header><div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div class="detail-title"><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div></template>
<template v-if="detail" #summary><section class="arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><span>费用金额</span><strong>{{ money(detail.amount) }}</strong><small>CNY</small></div><div class="arap-metric"><span>收付方向</span><strong>{{ financeDirectionLabel(detail.direction) }}</strong></div><div class="arap-metric"><span>参与结算</span><strong>{{ detail.settlementIncluded ? '是' : '否' }}</strong></div><div class="arap-metric"><span>审核状态</span><strong>{{ statusLabel(detail.reviewStatus) }}</strong></div></section></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" description="费用来源与关联业务摘要" :bordered="false"><el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联单据">{{ detail.inboundCode || detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="业务日期" :span="2">{{ formatDateTime(detail.businessDate) }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement" title="审核与结算" description="当前审核、参与结算和出账状态" :bordered="false"><el-descriptions :column="1" class="arap-detail"><el-descriptions-item label="费用状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log" title="操作记录" description="按时间查看审核与结算调整记录" :bordered="false"><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></FormSection></template>
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-alert v-if="reviewError" type="error" :closable="false"><ApiErrorMeta :details="reviewError" /></el-alert>
@@ -283,18 +287,38 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
</template>
<style scoped>
.finance-arap-page,.finance-arap-detail { min-width: 0; }
.finance-arap-page { min-width: 0; }
.finance-arap-detail { width: 100%; min-width: 0; }
.finance-arap-detail__surface { width: min(100%, 1600px); margin: 0 auto; overflow: hidden; }
.finance-arap-detail__layout { width: 100%; margin: 0 auto; }
.finance-arap-detail__layout :deep(.detail-page-layout__header) { min-height: 88px; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.finance-arap-detail__layout :deep(.detail-page-layout__summary) { padding: 20px 24px 0; }
.finance-arap-detail__layout :deep(.detail-page-layout__content) { padding: 0 24px 20px; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions .el-button { margin-left: 0; }
.generation-actions,.detail-heading,.detail-actions { display: flex; align-items: center; gap: 8px; }
.detail-heading { align-items: flex-start; margin-bottom: 16px; }
.detail-heading { min-width: 0; align-items: flex-start; }
.detail-title { min-width: 0; }
.detail-heading h2 { margin: 0; font-size: 18px; }
.detail-heading p { margin: 4px 0 0; color: var(--cc-muted); }
.detail-heading p { overflow: hidden; margin: 4px 0 0; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }
.detail-actions { margin-left: auto; }
.muted-action { color: var(--cc-muted); }
.arap-detail { margin-bottom: 16px; }
.operation-log h3 { margin: 0 0 14px; font-size: 15px; }
.arap-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.arap-metric { min-width: 0; padding: 16px 18px; border: 1px solid var(--cc-border, #e3e8ef); border-radius: 6px; background: #fff; }
.arap-metric span { display: block; color: var(--cc-muted); font-size: 12px; }
.arap-metric strong { display: inline-block; margin-top: 9px; color: var(--cc-text); font-size: 23px; line-height: 1.1; font-variant-numeric: tabular-nums; }
.arap-metric small { margin-left: 6px; color: var(--cc-muted); font-size: 11px; }
.arap-metric.is-amount { border-color: #c7d2fe; background: #f7f8ff; }
.arap-metric.is-amount strong { color: #4f46e5; }
.arap-detail-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, .65fr); gap: 14px; }
.arap-information,.arap-settlement,.operation-log { padding: 20px; border: 1px solid var(--cc-border, #e3e8ef); background: #fff; }
.operation-log { min-height: 150px; }
:deep(.arap-detail .el-descriptions__content) { min-width: 0; overflow-wrap: anywhere; }
:deep(.arap-detail .el-descriptions__label) { width: 112px; color: var(--cc-muted); }
.operation-log :deep(.el-empty) { padding: 12px 0 4px; }
.operation-log :deep(.el-empty__image) { display: none; }
.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); }
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-actions { margin-left: 0; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 1080px) { .finance-arap-detail__surface { width: 100%; } .arap-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .arap-detail-grid { grid-template-columns: 1fr; } }
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-actions { margin-left: 0; } .arap-metrics { grid-template-columns: 1fr; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
</style>
@@ -79,9 +79,26 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.body.textContent).toContain('优惠调整')
expect(document.body.textContent).toContain('创建人A')
expect(document.body.textContent).toContain('2026-08-20 11:00')
expect(document.querySelector('[aria-label="客户对账单详情"]')).toBeTruthy()
expect(document.querySelector('[aria-label="所选对账单摘要"]')?.textContent).toContain('200.00 CNY')
expect(document.querySelector('.statement-detail-head')?.textContent).toContain('REC-501')
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.el-drawer')).toBeNull()
})
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
const wrapper = await mountPage()
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' }
await vm.openDetail(statement)
vm.activeDetailTab = 'payments'
await flushPromises()
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
expect(document.querySelectorAll('.statement-detail-panel .detail-page-layout')).toHaveLength(1)
expect(document.querySelectorAll('.statement-detail-panel .surface-panel')).toHaveLength(0)
})
it('resolves customer names from the active customer list and never exposes internal customer or operator ids', async () => {
const internalCustomerId = '2088123456789'
const internalOperatorId = '1000001'
@@ -9,6 +9,8 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -114,6 +116,30 @@ const columns: DataTableColumn[] = [
{ key: 'updatedBy', label: '更新人', width: 130, slotName: 'updatedBy' },
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
]
const detailLineColumns: DataTableColumn[] = [
{ key: 'inboundCode', label: '入仓号', minWidth: 150, slotName: 'inboundCode' },
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
{ key: 'feeItemName', label: '费用项目', minWidth: 160 },
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' },
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' },
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 },
{ key: 'amount', label: '账单金额(CNY)', width: 150 },
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
{ key: 'periodEnd', label: '结算结束日', width: 130, slotName: 'periodEnd' },
]
const paymentColumns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
{ key: 'createdBy', label: '登记人', minWidth: 140, slotName: 'createdBy' },
{ key: 'occurredAt', label: '登记时间', width: 170, slotName: 'occurredAt' },
{ key: 'remark', label: '备注', minWidth: 200 },
]
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName.trim()])))
@@ -419,47 +445,67 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
</section>
<section class="surface-panel statement-detail-panel" aria-label="所选对账单联动明细">
<header class="statement-detail-header">
<div><strong>已选择 {{ selectedId ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ customerDisplayName(detail.customerId, detail.customerName) }}</span></div>
<el-button v-if="selectedId" link @click="clearSelection">清空</el-button>
</header>
<el-tabs v-model="activeDetailTab">
<el-tab-pane label="账单明细表" name="lines">
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
<template v-else-if="detail">
<el-table :data="detail.lines" size="small" border stripe empty-text="该对账单暂无账单明细">
<el-table-column label="入仓号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template></el-table-column>
<el-table-column label="柜次号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template></el-table-column>
<el-table-column label="业务发生日" width="130"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template></el-table-column>
<el-table-column prop="feeItemName" label="费用项目" min-width="150" />
<el-table-column label="计费量" width="110"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template></el-table-column>
<el-table-column label="单价(CNY)" width="120"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template></el-table-column>
<el-table-column prop="sourceAmount" label="来源金额(CNY)" width="150" />
<el-table-column prop="amount" label="账单金额(CNY)" width="150" />
<el-table-column prop="adjustmentReason" label="调整说明" min-width="180" />
<el-table-column label="对账单号" min-width="180"><template #default>{{ detail.billNo }}</template></el-table-column>
<el-table-column label="结算起始日" width="130"><template #default>{{ detail.periodStart || '—' }}</template></el-table-column>
<el-table-column label="结算结束日" width="130"><template #default>{{ detail.periodEnd || '—' }}</template></el-table-column>
</el-table>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
</template>
<el-empty v-else description="请在主表中选择一张对账单" />
</el-tab-pane>
<el-tab-pane label="账单支付历史表" name="payments">
<el-table v-if="detail" :data="detail.paymentHistory" size="small" border stripe empty-text="该对账单暂无有效收款记录">
<el-table-column label="客户" min-width="170"><template #default>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template></el-table-column>
<el-table-column label="支付日期" min-width="130"><template #default="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template></el-table-column>
<el-table-column prop="amount" label="支付金额(CNY)" min-width="150" />
<el-table-column label="支付方式" min-width="130"><template #default="{ row }">{{ paymentMethodLabel(row.method) }}</template></el-table-column>
<el-table-column prop="referenceNo" label="支付流水号" min-width="170" />
<el-table-column label="登记人" min-width="130"><template #default="{ row }">{{ operatorDisplayName(row.createdBy) }}</template></el-table-column>
<el-table-column label="登记时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" />
</el-table>
<el-empty v-else description="请在主表中选择一张对账单" />
</el-tab-pane>
</el-tabs>
<DetailPageLayout aria-label="客户对账单详情">
<template #header>
<header class="statement-detail-header">
<div class="statement-detail-context"><span>所选对账单</span><strong>{{ selectedId ? '已选择 1 项' : '尚未选择' }}</strong></div>
<el-button v-if="selectedId" link @click="clearSelection">清空</el-button>
</header>
</template>
<template v-if="detail" #summary>
<div class="statement-detail-head">
<div><span>客户对账单</span><h2>{{ detail.billNo }}</h2><p>{{ customerDisplayName(detail.customerId, detail.customerName) }} · {{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</p></div>
<el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
</div>
<FormSection class="statement-summary-section" title="对账单概要" description="核对账期、金额和结清状态后查看下方业务明细。" aria-label="所选对账单摘要">
<el-descriptions :column="3" border>
<el-descriptions-item label="总应收金额">{{ detail.totalAmount.toFixed(2) }} CNY</el-descriptions-item>
<el-descriptions-item label="已收金额">{{ detail.paidAmount.toFixed(2) }} CNY</el-descriptions-item>
<el-descriptions-item label="未收金额"><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></el-descriptions-item>
<el-descriptions-item label="账期">{{ detail.creditDays }} 天</el-descriptions-item>
<el-descriptions-item label="货币">{{ detail.currency }}</el-descriptions-item>
<el-descriptions-item label="是否结清">{{ detail.settled ? '是' : '否' }}</el-descriptions-item>
</el-descriptions>
</FormSection>
</template>
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs">
<el-tab-pane label="账单明细表" name="lines">
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
<FormSection v-else-if="detail" title="账单明细" description="展示当前对账单版本中的业务与计费明细。" :bordered="false">
<div class="detail-table-scroll">
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
<template #inboundCode="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template>
<template #containerNo="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template>
<template #businessDate="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template>
<template #quantity="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template>
<template #unitPrice="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template>
<template #billNo>{{ detail.billNo }}</template>
<template #periodStart>{{ detail.periodStart || '—' }}</template>
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
</DataTable>
</div>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
</FormSection>
<el-empty v-else description="请在主表中选择一张对账单" />
</el-tab-pane>
<el-tab-pane label="账单支付历史表" name="payments">
<FormSection v-if="detail" title="支付历史" description="只读展示当前对账单关联的有效收款记录。" :bordered="false">
<div class="detail-table-scroll">
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
<template #createdBy="{ row }">{{ operatorDisplayName(row.createdBy) }}</template>
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
</DataTable>
</div>
</FormSection>
<el-empty v-else description="请在主表中选择一张对账单" />
</el-tab-pane>
</el-tabs>
</DetailPageLayout>
</section>
</template>
</ListPageLayout>
@@ -476,10 +522,25 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<style scoped>
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.statement-detail-panel { min-width: 0; padding: 0 16px 16px; }
.statement-detail-header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-detail-header div { display: flex; align-items: center; gap: 12px; min-width: 0; }
.statement-detail-header span { color: var(--cc-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.statement-detail-panel { min-width: 0; overflow: hidden; }
.statement-detail-header { min-height: 56px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 24px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-detail-context { display: grid; min-width: 0; gap: 3px; }
.statement-detail-context span { color: var(--cc-text-secondary); font-size: 11px; }
.statement-detail-context strong { overflow: hidden; color: var(--cc-text-primary); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.statement-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-detail-head > div > span { color: var(--cc-primary); font-size: 11px; font-weight: 700; }
.statement-detail-head h2 { margin: 2px 0 0; font-size: 17px; line-height: 1.4; overflow-wrap: anywhere; }
.statement-detail-head p { margin: 5px 0 0; color: var(--cc-text-secondary); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.statement-summary-section { padding: 20px 24px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-outstanding { color: var(--el-color-warning-dark-2); }
.statement-detail-tabs { min-width: 0; }
.statement-detail-table { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; }
.statement-detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 24px; }
.statement-detail-tabs :deep(.el-tabs__content) { min-width: 0; }
.statement-detail-tabs :deep(.form-section) { padding: 20px 24px 24px; }
.statement-line-table { min-width: 1840px; }
.statement-payment-table { min-width: 1300px; }
.revision-collapse { margin-top: 12px; }
:deep(.is-selected-statement > td.el-table__cell) { background: var(--el-color-primary-light-9) !important; }
@media (max-width: 640px) {
@@ -10,6 +10,8 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -373,11 +375,11 @@ onMounted(() => {
<template>
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<section v-else-if="detailMode" class="settlement-detail-page" :aria-label="`${title}详情`">
<header class="settlement-detail-header">
<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel" :aria-label="`${title}详情`">
<template #header><header class="settlement-detail-header">
<div class="settlement-detail-heading">
<el-button aria-label="返回列表" title="返回列表" @click="backToList"><ArrowLeft :size="17" /></el-button>
<div><div class="settlement-detail-breadcrumb">财务管理 / {{ title }} / {{ detail?.settlementNo || settlementId }}</div><h2>{{ title }}详情</h2></div>
<div><div class="settlement-detail-breadcrumb">财务管理 / {{ title }} / {{ detail?.settlementNo || settlementId }}</div><h1>{{ title }}详情</h1></div>
<el-tag v-if="detail" :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
</div>
<div class="settlement-detail-actions">
@@ -387,19 +389,20 @@ onMounted(() => {
<el-button v-if="detail && canVoid && detail.status === 'ISSUED'" type="danger" :loading="actionBusy" @click="performAction(detailActionRow(), 'VOID')"><XCircle :size="15" />作废</el-button>
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
</div>
</header>
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="10" animated />
<template v-else-if="detail">
</header></template>
<template v-if="detail" #summary>
<section class="settlement-detail-summary" aria-label="结算金额汇总">
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</span><strong>{{ money(detail.amount) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div>
<div class="summary-item"><span>未结余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div>
<div class="summary-item"><span>到期日</span><strong class="is-date">{{ displayDate(detail.dueDate) }}</strong></div>
</section>
<section class="settlement-detail-info surface-panel">
<h3>基本信息</h3>
<el-descriptions :column="2">
</template>
<LoadErrorState v-if="detailError" class="settlement-detail-state" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated />
<section v-else-if="detail" class="settlement-detail-content">
<FormSection title="基本信息">
<el-descriptions :column="4" class="settlement-detail-info">
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
<el-descriptions-item label="币种">CNY</el-descriptions-item>
@@ -409,9 +412,9 @@ onMounted(() => {
<el-descriptions-item label="生成日期">{{ displayDate(detail.generatedDate) }}</el-descriptions-item>
<el-descriptions-item label="到期日">{{ displayDate(detail.dueDate) }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="detail-section surface-panel">
<h3>费用明细</h3>
</FormSection>
<FormSection title="费用明细">
<div class="detail-table-scroll">
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140" />
@@ -421,9 +424,10 @@ onMounted(() => {
<el-table-column prop="sourceVersion" label="来源版本" width="100" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section surface-panel">
<h3>{{ kind === 'receivable' ? '收款记录' : '付款记录' }}</h3>
</div>
</FormSection>
<FormSection :title="kind === 'receivable' ? '收款记录' : '付款记录'">
<div class="detail-table-scroll">
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
@@ -431,9 +435,10 @@ onMounted(() => {
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section surface-panel">
<h3>修订记录</h3>
</div>
</FormSection>
<FormSection title="修订记录" :bordered="false">
<div class="detail-table-scroll">
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录">
<el-table-column prop="action" label="动作" min-width="120" />
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
@@ -441,9 +446,10 @@ onMounted(() => {
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
<el-table-column prop="occurredAt" label="时间" min-width="160" />
</el-table>
</section>
</template>
</section>
</div>
</FormSection>
</section>
</DetailPageLayout>
<ListPageLayout v-else :aria-label="title">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" @reset="load" />
@@ -493,18 +499,18 @@ onMounted(() => {
</el-form>
</AppDialog>
<AppDialog v-model="paymentOpen" title="登记付款" width="min(560px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
<AppDialog v-model="paymentOpen" title="登记付款" width="min(760px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>供应商</span><strong>{{ detailPartyName() }}</strong></div><div><span>应付金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已付金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
<el-form label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><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="paymentForm.referenceNo" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" /></el-form-item></el-form>
<el-form class="settlement-operation-form" label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><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="paymentForm.referenceNo" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" /></el-form-item></el-form>
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(paymentForm.amount) }}</strong></div>
</AppDialog>
<AppDialog v-model="receiptOpen" title="登记收款" width="min(560px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
<AppDialog v-model="receiptOpen" title="登记收款" width="min(760px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>客户公司</span><strong>{{ detailPartyName() }}</strong></div><div><span>应收金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已收金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
<el-alert v-if="receiptValidationError" type="error" :title="receiptValidationError" :closable="false" show-icon class="operation-error" />
<el-alert v-if="receiptError" type="error" :title="receiptError.message" :closable="false" show-icon class="operation-error" />
<ApiErrorMeta v-if="receiptError" :details="receiptError" class="operation-error-meta" />
<el-form label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><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-upload :key="receiptUploadKey" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
<el-form class="settlement-operation-form" label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><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-upload :key="receiptUploadKey" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
</AppDialog>
</AdminPage>
@@ -518,33 +524,51 @@ onMounted(() => {
.summary-item strong { color: var(--cc-text-primary, #1f2937); font-size: 18px; line-height: 1.2; }
.summary-item strong.is-success { color: #16803c; }
.summary-item strong.is-danger { color: #c2413a; }
.settlement-detail-page { display: grid; min-width: 0; gap: 14px; }
.settlement-detail-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-width: 0; }
.settlement-detail-page { display: flex; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
.settlement-detail-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.settlement-detail-heading, .settlement-detail-actions { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
.settlement-detail-heading h2 { margin: 2px 0 0; font-size: 18px; letter-spacing: 0; }
.settlement-detail-heading h1 { margin: 0; font-size: 20px; line-height: 1.35; letter-spacing: 0; }
.settlement-detail-breadcrumb { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 14px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.settlement-detail-summary .summary-item { align-items: flex-start; flex-direction: column; gap: 5px; padding: 0 20px; border-left: 1px solid var(--cc-border, #e3e8ef); }
.settlement-detail-summary .summary-item:first-child { padding-left: 0; border-left: 0; }
.summary-item strong.is-date { font-size: 20px; }
.settlement-detail-info, .detail-section { min-width: 0; padding: 16px; }
.settlement-detail-info h3, .detail-section h3 { margin: 0 0 12px; color: var(--cc-text-primary, #1f2937); font-size: 15px; font-weight: 600; }
.partial-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; padding: 14px; border: 1px solid var(--el-color-primary-light-7); border-radius: 8px; background: var(--el-color-primary-light-9); }
.partial-summary div { min-width: 0; }
.settlement-detail-state { margin: 20px 24px; }
.settlement-detail-content { min-width: 0; padding: 0 24px 20px; overflow: hidden; }
.settlement-detail-info { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; }
:deep(.settlement-detail-info .el-descriptions__table) { table-layout: fixed; }
:deep(.settlement-detail-info .el-descriptions__content) { min-width: 0; overflow-wrap: anywhere; }
:deep(.detail-table-scroll .el-table) { min-width: 920px; }
.partial-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-bottom: 18px; padding: 14px 0; border: 1px solid var(--el-color-primary-light-7); border-radius: 6px; background: var(--el-color-primary-light-9); }
.partial-summary div { min-width: 0; padding: 0 12px; border-left: 1px solid var(--el-color-primary-light-7); }
.partial-summary div:first-child { border-left: 0; }
.partial-summary span, .partial-summary strong { display: block; overflow-wrap: anywhere; }
.partial-summary span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.partial-summary strong { margin-top: 4px; color: var(--cc-text-primary, #1f2937); font-size: 15px; }
.partial-summary strong.is-success { color: #16803c; }
.partial-summary strong.is-danger { color: #c2413a; }
.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px; }
.settlement-operation-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.settlement-operation-form :deep(.el-input-number) { width: 100%; }
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
.remaining-preview strong { color: #b45309; font-size: 16px; }
.operation-error { margin-bottom: 12px; }
.operation-error-meta { margin: -6px 0 12px; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
@media (max-width: 720px) {
.settlement-detail-summary, .partial-summary { grid-template-columns: 1fr; }
.settlement-detail-summary, .partial-summary, .settlement-operation-form { grid-template-columns: 1fr; }
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
.settlement-detail-content { padding: 0 16px 16px; }
.settlement-detail-state { margin: 16px; }
.partial-summary { gap: 10px; padding: 12px; }
.partial-summary div { padding: 0; border-left: 0; }
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
.summary-item strong { font-size: 21px; }
.settlement-detail-header { align-items: flex-start; flex-direction: column; }
.settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; }
.settlement-detail-actions { width: 100%; }
.settlement-detail-actions :deep(.el-button) { min-height: 40px; }
:deep(.settlement-detail-info .el-descriptions__cell) { display: block; width: 100%; }
}
</style>
@@ -21,7 +21,7 @@ describe('finance presentation governance', () => {
expect(financeRoutes).toContain("finance/settlement-receivables/:settlementId")
expect(financeRoutes).toContain("finance/settlement-payables/:settlementId")
expect(financeRoutes).toContain('showPageHeading: false')
expect(settlement).toContain('class="settlement-detail-page"')
expect(settlement).toContain('class="settlement-detail-page surface-panel"')
expect(settlement).toContain('返回列表')
expect(settlement).not.toContain('AppDrawer')
expect(settlement).toContain('登记后剩余余额')
@@ -29,6 +29,23 @@ describe('finance presentation governance', () => {
expect(settlement).toContain('remainingAfter(paymentForm.amount)')
})
it('uses one continuous desktop detail surface with balanced summary and scroll-safe tables', () => {
expect(settlement).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel"')
expect(settlement).toContain('class="settlement-detail-content"')
expect(settlement).toContain('min-height: 88px; align-items: center; justify-content: space-between')
expect(settlement).toContain('<el-descriptions :column="4" class="settlement-detail-info">')
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain(`title="${title}"`)
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
expect(settlement.match(/class="detail-table-scroll"/g)).toHaveLength(3)
expect(settlement).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(settlement).not.toContain('class="detail-section surface-panel"')
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
expect(settlement.match(/class="settlement-operation-form"/g)).toHaveLength(2)
expect(settlement).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))')
expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px')
})
it('uses optional all-customer filters without a customer-selection blocker', () => {
for (const source of [settlement, statements, arap]) {
expect(source).toContain("placeholder: '全部客户'")
@@ -62,9 +62,12 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).toContain('柏悦客户 · 海运')
expect(wrapper.text()).toContain('合计货物重量')
expect(wrapper.text()).toContain('全部称重图片')
expect(wrapper.text()).toContain('托盘数量')
expect(wrapper.text()).toContain('1 托')
expect(wrapper.text()).toContain('称重图片')
expect(wrapper.text()).toContain('货物重量')
expect(wrapper.text()).toContain('110 kg')
expect(wrapper.text()).toContain('容量 1.2 m³')
expect(wrapper.text()).not.toContain('申报毛重')
expect(wrapper.text()).not.toContain('托盘皮重')
expect(wrapper.text()).not.toContain('OCR 原文')
@@ -110,6 +113,7 @@ describe('MeasurementReviewPanel', () => {
'货物重量120 kg',
])
expect(wrapper.find('.weight-overview').text()).toContain('330 kg')
expect(wrapper.find('.weight-overview').text()).toContain('3 托')
expect(downloadWeightThumbnail).toHaveBeenCalledTimes(3)
})
@@ -57,7 +57,6 @@ const canRelease = computed(() => access.can('firstleg:inbound:exception-release
const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.netWeightKg !== null).length ?? 0)
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
function formatNumber(value: number | null | undefined, unit = '') {
@@ -262,7 +261,7 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
<template v-else-if="detail">
<dl class="weight-overview" aria-label="重量审核摘要">
<div class="weight-overview__primary"><dt>合计货物重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
<div><dt>称重图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }} 张已确认</dd></div>
<div><dt>托盘数量</dt><dd>{{ detail.weightResults.length }} 托</dd></div>
</dl>
<div v-if="detail.reviewReason" class="review-reason">
@@ -288,6 +287,10 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
<span>货物重量</span>
<strong>{{ formatNumber(row.netWeightKg, ' kg') }}</strong>
</div>
<div class="weight-evidence-card__record">
<span>{{ row.palletCode }} · {{ row.palletType }}</span>
<span>容量 {{ formatNumber(row.palletCapacityCbm, ' m³') }}</span>
</div>
</article>
</div>
<el-empty v-else description="暂无称重图片" :image-size="56" />
@@ -365,6 +368,7 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
.weight-evidence-card__weight { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; }
.weight-evidence-card__weight span { color: var(--cc-muted); font-size: 12px; }
.weight-evidence-card__weight strong { color: #067647; font-size: 20px; overflow-wrap: anywhere; text-align: right; }
.weight-evidence-card__record { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 12px; padding: 0 14px 12px; color: var(--cc-muted); font-size: 12px; }
.missing-evidence { color: var(--cc-muted); font-size: 12px; }
.weight-preview { display: grid; gap: 14px; }.weight-preview__canvas { display: grid; min-height: 320px; max-height: 62vh; overflow: hidden; place-items: center; border: 1px solid var(--cc-border); border-radius: 6px; background: #f2f4f7; }.weight-preview__canvas img { display: block; max-width: 100%; max-height: 62vh; object-fit: contain; }.weight-preview__canvas .el-alert { width: min(520px, calc(100% - 24px)); }.weight-preview__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; }.weight-preview__facts > div { display: grid; min-width: 0; gap: 5px; padding: 12px 14px; border-right: 1px solid var(--cc-border); }.weight-preview__facts > div:last-child { border-right: 0; }.weight-preview__facts dt { color: var(--cc-muted); font-size: 12px; }.weight-preview__facts dd { min-width: 0; margin: 0; font-weight: 700; overflow-wrap: anywhere; }
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
@@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest'
import { containerRequirementError, createBlankContainerRequirement } from './containerRequirements'
describe('ocean booking container requirements', () => {
it('does not guess capacity or weight for a new container type', () => {
it('uses the fixed platform specification for a new container type', () => {
expect(createBlankContainerRequirement()).toEqual({
containerType: '40HC', containerKind: 'FCL', capacityCbm: null, maxWeightKg: null, quantity: 1,
containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, quantity: 1,
})
})
it('rejects missing or non-positive capacity, weight and quantity', () => {
expect(containerRequirementError([createBlankContainerRequirement()])).toContain('容量、限重和数量必须大于 0')
expect(containerRequirementError([{ containerType: 'UNKNOWN', containerKind: 'FCL', capacityCbm: 1, maxWeightKg: 1, quantity: 0 }])).toContain('UNKNOWN')
expect(containerRequirementError([{ containerType: 'UNKNOWN', containerKind: 'FCL', capacityCbm: 1, maxWeightKg: 1, quantity: 1 }])).toBeNull()
expect(containerRequirementError([{ ...createBlankContainerRequirement(), quantity: 0 }])).toContain('固定规格')
expect(containerRequirementError([{ containerType: 'UNKNOWN', containerKind: 'FCL', capacityCbm: 1, maxWeightKg: 1, quantity: 1 }])).toContain('UNKNOWN')
expect(containerRequirementError([createBlankContainerRequirement()])).toBeNull()
})
})
@@ -1,10 +1,19 @@
import type { OceanBookingContainer } from './types'
import { applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
export function createBlankContainerRequirement(): OceanBookingContainer {
return { containerType: '40HC', containerKind: 'FCL', capacityCbm: null, maxWeightKg: null, quantity: 1 }
return { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, quantity: 1 }
}
export function selectContainerRequirementType(requirement: OceanBookingContainer, containerType: string) {
applyContainerSpecification(requirement, containerType)
}
export function containerRequirementError(requirements: OceanBookingContainer[]) {
const invalid = requirements.find((item) => !(Number(item.capacityCbm) > 0) || !(Number(item.maxWeightKg) > 0) || !(Number(item.quantity) > 0))
return invalid ? `箱型 ${invalid.containerType || '未命名'} 的容量、限重和数量必须大于 0,请补充后再提交` : null
const invalid = requirements.find((item) => {
const specification = containerSpecification(item.containerType)
return !specification || Number(item.capacityCbm) !== specification.capacityCbm
|| Number(item.maxWeightKg) !== specification.maxWeightKg || !(Number(item.quantity) > 0)
})
return invalid ? `箱型 ${invalid.containerType || '未命名'} 必须使用平台固定规格,请重新选择柜型` : null
}
@@ -89,14 +89,14 @@ describe('OceanBookingDetailView interaction contract', () => {
it('uses source-backed free text terms and keeps capacity snapshots behind one specification control', () => {
expect(source).not.toContain('serviceTermOptions')
expect(source).not.toContain('freightTermOptions')
expect(source).toContain("const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']")
expect(source).toContain('const containerTypeOptions = CONTAINER_SPECIFICATIONS')
expect(source).toContain('label="主要运输条款 Service Term" prop="serviceTerm"')
expect(source).toContain('<el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term"')
expect(source).toContain('label="运费条款 Freight Term" prop="freightTerm"')
expect(source).toContain('<el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term"')
expect(source).toContain('<el-select v-model="container.containerType"')
expect(source).toContain('<el-select :model-value="container.containerType"')
expect(source).toContain('<el-select v-model="container.containerKind"')
expect(source).toContain(':aria-label="`设置箱型 ${index + 1} 的容量与限重`"')
expect(source).toContain('规格(必填)')
expect(source).toContain('function containerSpecificationLabel(')
expect(source).not.toContain("['PREPAID', 'COLLECT']")
expect(source).not.toContain('<el-input v-model="container.containerType"')
@@ -117,15 +117,15 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('form.data.warehouseName = warehouses.value.find')
expect(source).toContain('@update:model-value="selectWarehouse"')
expect(source).not.toContain('@update:model-value="(value) =>')
expect(source).toContain('v-model="container.capacityCbm"')
expect(source).toContain('v-model="container.maxWeightKg"')
expect(source).toContain('selectContainerRequirementType(container')
expect(source).not.toContain('v-model="container.capacityCbm"')
expect(source).not.toContain('v-model="container.maxWeightKg"')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version')
expect(source).toContain('title="确认放舱"')
expect(source).toContain('createBlankContainerRequirement()')
expect(source).toContain('containerRequirementError(form.data.containerRequirements)')
expect(source).toContain('function validateContainerRequirements()')
expect(source).not.toContain('capacityCbm: 68')
expect(source).not.toContain('maxWeightKg: 28000')
expect(source).toContain('containerSpecificationLabel(container)')
})
it('keeps every container requirement removable without collapsing its fields', () => {
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, Save, SlidersHorizontal, Trash2 } from '@lucide/vue'
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -12,6 +12,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
@@ -34,14 +35,14 @@ import type {
OceanBookingStatus,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { containerRequirementError, createBlankContainerRequirement } from '../containerRequirements'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
{ value: 'CONSIGNEE', label: '收货人' },
{ value: 'NOTIFY_PARTY', label: '通知人' },
] as const
const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']
const containerTypeOptions = CONTAINER_SPECIFICATIONS
const containerKindOptions = [
{ value: 'FCL', label: '整箱 FCL' },
{ value: 'LCL', label: '拼箱 LCL' },
@@ -338,7 +339,6 @@ function validateContainerRequirements() {
return true
}
function containerSpecificationLabel(container: OceanBookingDraftData['containerRequirements'][number]) {
if (!(Number(container.capacityCbm) > 0) || !(Number(container.maxWeightKg) > 0)) return '设置规格'
return `${container.capacityCbm} m³ / ${container.maxWeightKg} kg`
}
function addContainer() { form.data.containerRequirements.push(createBlankContainerRequirement()) }
@@ -414,18 +414,9 @@ onMounted(() => { void load(); void loadOptions() })
</el-tooltip>
<span v-else aria-hidden="true">—</span>
</td>
<td><el-select v-model="container.containerType" :disabled="!editable" placeholder="请选择箱型"><el-option v-for="value in containerTypeOptions" :key="value" :label="value" :value="value" /></el-select></td>
<td><el-select :model-value="container.containerType" :disabled="!editable" placeholder="请选择箱型" @update:model-value="selectContainerRequirementType(container, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-select v-model="container.containerKind" :disabled="!editable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td class="container-table__specification">
<el-popover v-if="editable" placement="bottom" :width="300" trigger="click">
<template #reference><el-button size="small" plain :type="Number(container.capacityCbm) > 0 && Number(container.maxWeightKg) > 0 ? 'default' : 'warning'" :aria-label="`设置箱型 ${index + 1} 的容量与限重`"><SlidersHorizontal :size="14" />{{ containerSpecificationLabel(container) }}</el-button></template>
<div class="container-specification-fields">
<label><span>容量(m³)</span><el-input-number v-model="container.capacityCbm" :min="0.01" :precision="2" :controls="false" placeholder="请输入容量" /></label>
<label><span>限重(kg)</span><el-input-number v-model="container.maxWeightKg" :min="0.01" :precision="2" :controls="false" placeholder="请输入限重" /></label>
</div>
</el-popover>
<span v-else>{{ containerSpecificationLabel(container) }}</span>
</td>
<td class="container-table__specification"><span>{{ containerSpecificationLabel(container) }}</span></td>
<td><el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" :controls="false" placeholder="请输入数量" /></td>
</tr>
</tbody>
@@ -215,6 +215,11 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain("value.replaceAll('_', ' ')")
})
it('opens the measurement image preview at the clicked image index', () => {
expect(source).toContain('v-for="(url, index) in measurementImageUrls"')
expect(source).toContain(':initial-index="index"')
})
it('shows parent-child markers only for related rows and keeps the aggregate in detail', () => {
expect(source).not.toContain("type: 'expand'")
expect(source).toContain('v-if="(row as UnifiedOrderResource).relation"')
@@ -927,7 +927,7 @@ onMounted(() => {
<div v-if="!measurementCollapsed.actual">
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="measurementDetail?.items || []" border stripe empty-text="暂无实际测量数据"><el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span></div>
<div class="measurement-media"><h4>测量图片</h4><div v-if="measurementImageUrls.length" class="measurement-media-grid"><el-image v-for="url in measurementImageUrls" :key="url" :src="url" fit="contain" :preview-src-list="measurementImageUrls" class="measurement-media-image" /></div><el-empty v-else description="暂无测量图片" :image-size="48" /><h4>测量视频</h4><el-empty description="暂无测量视频" :image-size="48" /></div>
<div class="measurement-media"><h4>测量图片</h4><div v-if="measurementImageUrls.length" class="measurement-media-grid"><el-image v-for="(url, index) in measurementImageUrls" :key="url" :src="url" fit="contain" :preview-src-list="measurementImageUrls" :initial-index="index" class="measurement-media-image" /></div><el-empty v-else description="暂无测量图片" :image-size="48" /><h4>测量视频</h4><el-empty description="暂无测量视频" :image-size="48" /></div>
</div>
</section>
</template>
@@ -0,0 +1,23 @@
export const CONTAINER_SPECIFICATIONS = [
{ type: '20GP', capacityCbm: 33, maxWeightKg: 28000 },
{ type: '40GP', capacityCbm: 67, maxWeightKg: 28000 },
{ type: '40HC', capacityCbm: 76, maxWeightKg: 28000 },
{ type: '40NR', capacityCbm: 67, maxWeightKg: 28000 },
{ type: '45HC', capacityCbm: 86, maxWeightKg: 28000 },
] as const
export type ContainerSpecification = (typeof CONTAINER_SPECIFICATIONS)[number]
export function containerSpecification(containerType: string | null | undefined): ContainerSpecification | null {
const normalized = String(containerType || '').trim().toUpperCase()
return CONTAINER_SPECIFICATIONS.find((item) => item.type === normalized) || null
}
export function applyContainerSpecification<T extends { containerType: string; capacityCbm: number | null; maxWeightKg: number | null }>(target: T, containerType: string) {
const specification = containerSpecification(containerType)
if (!specification) return false
target.containerType = specification.type
target.capacityCbm = specification.capacityCbm
target.maxWeightKg = specification.maxWeightKg
return true
}