Release/test 合并上生产 #2
@@ -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: '76', maxWeightKg: '28000' }
|
||||
const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, 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', () => {
|
||||
@@ -51,10 +51,10 @@ describe('platform container loading API', () => {
|
||||
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: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
||||
await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', expectedVersion: 3 })
|
||||
await linkContainerOceanBooking('7', { realContainerBookingId: '101', expectedVersion: 3 })
|
||||
await unlinkContainerOceanBooking('7', 4, '空柜改配')
|
||||
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.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { realContainerBookingId: '101', expectedVersion: 3 })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } })
|
||||
})
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
...raw,
|
||||
id: id(raw.id),
|
||||
planId: optionalId(raw.planId),
|
||||
mergedIntoContainerBookingId: optionalId(raw.mergedIntoContainerBookingId),
|
||||
warehouseId: id(raw.warehouseId),
|
||||
oceanBookingId: optionalId(raw.oceanBookingId),
|
||||
oceanBookingRequirementId: optionalId(raw.oceanBookingRequirementId),
|
||||
|
||||
@@ -9,17 +9,17 @@ import ContainersView from './ContainersView.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { listPlatformPartners } from '@/modules/master-data'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
import { listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading'
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
|
||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookings: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
|
||||
const accessCan = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({ history: createMemoryHistory(), routes: [
|
||||
@@ -38,6 +38,7 @@ 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(getOceanBooking).mockResolvedValue({} as never)
|
||||
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: [] })
|
||||
@@ -246,12 +247,14 @@ describe('ContainersView', () => {
|
||||
|
||||
it('allows first linking after preloading but only offers unlinking for wait-loading containers', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain("!row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)")
|
||||
expect(source).toContain("row.oceanBookingId && row.planStatus === 'WAIT_LOADING'")
|
||||
expect(source).toContain("row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && !row.oceanBookingId")
|
||||
expect(source).toContain("row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && row.oceanBookingId")
|
||||
expect(source).toContain('已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联')
|
||||
expect(source).toContain('柜次号与装柜明细保持不变')
|
||||
expect(source).toContain('只有尚未排货的空柜可以解除')
|
||||
expect(source).toContain('linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })')
|
||||
expect(source).toContain('booking.containers')
|
||||
expect(source).toContain('realContainerBookingId: container.containerBookingId')
|
||||
expect(source).toContain('linkContainerOceanBooking(target.id, { realContainerBookingId: candidate.realContainerBookingId, expectedVersion: target.version })')
|
||||
expect(source).toContain('unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingDetail, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||
import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -90,7 +90,7 @@ const linkSaving = ref(false)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkTarget = ref<ContainerBookingResource | null>(null)
|
||||
const linkCandidateKey = ref('')
|
||||
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; detail: OceanBookingDetail }>>([])
|
||||
const linkCandidates = ref<Array<{ key: string; realContainerBookingId: string; label: string }>>([])
|
||||
const selectedTruckingSupplierId = ref('')
|
||||
const unlinkOpen = ref(false)
|
||||
const unlinkReason = ref('')
|
||||
@@ -365,20 +365,17 @@ async function openLink(source: object) {
|
||||
const bookings = (await Promise.all([loadBookingStatus('CONFIRMED'), loadBookingStatus('RELEASED')])).flat()
|
||||
.filter((booking) => booking.warehouseId === row.warehouseId && booking.channelCode === row.channelCode)
|
||||
const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
|
||||
linkCandidates.value = details.flatMap((booking) => {
|
||||
const allocated = new Set(booking.containerAllocations.filter((item) => item.containerBookingId).map((item) => item.requirementId))
|
||||
return booking.containerRequirements.filter((requirement) => requirement.id
|
||||
&& !allocated.has(requirement.id)
|
||||
&& requirement.containerType === row.containerType
|
||||
&& Number(requirement.capacityCbm) === Number(row.capacityCbm)
|
||||
&& Number(requirement.maxWeightKg) === Number(row.maxWeightKg)).map((requirement) => ({
|
||||
key: `${booking.id}:${requirement.id}`,
|
||||
oceanBookingId: booking.id,
|
||||
requirementId: requirement.id!,
|
||||
detail: booking,
|
||||
label: `${booking.bookingNo || '订舱号待补'} · ${requirement.containerType} · ${booking.customerName || '未关联客户'}`,
|
||||
}))
|
||||
})
|
||||
linkCandidates.value = details.flatMap((booking) => booking.containers
|
||||
.filter((container) => container.warehouseId === row.warehouseId
|
||||
&& container.channelCode === row.channelCode
|
||||
&& container.containerType === row.containerType
|
||||
&& Number(container.capacityCbm) === Number(row.capacityCbm)
|
||||
&& Number(container.maxWeightKg) === Number(row.maxWeightKg))
|
||||
.map((container) => ({
|
||||
key: container.containerBookingId,
|
||||
realContainerBookingId: container.containerBookingId,
|
||||
label: `${booking.bookingNo || '订舱号待补'} · 集装箱号 ${container.containerNo} · 柜次号 ${container.cabinetNo} · ${container.containerType}`,
|
||||
})))
|
||||
} catch (error) { linkError.value = getApiErrorDetails(error, '真实订舱柜候选加载失败') }
|
||||
finally { linkLoading.value = false }
|
||||
}
|
||||
@@ -389,7 +386,7 @@ async function saveLink() {
|
||||
linkSaving.value = true
|
||||
linkError.value = null
|
||||
try {
|
||||
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })
|
||||
await linkContainerOceanBooking(target.id, { realContainerBookingId: candidate.realContainerBookingId, expectedVersion: target.version })
|
||||
linkOpen.value = false
|
||||
ElMessage.success('已关联真实订舱柜,柜次号与装柜明细保持不变')
|
||||
await loadRows()
|
||||
@@ -600,7 +597,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联真实订舱柜</el-dropdown-item><el-dropdown-item v-if="canEdit && row.oceanBookingId && row.planStatus === 'WAIT_LOADING'" command="unlink"><Unlink :size="15" />解除订舱关联</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联真实订舱柜</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && row.oceanBookingId && row.planStatus === 'WAIT_LOADING'" command="unlink"><Unlink :size="15" />解除订舱关联</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号待录入' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface ContainerBookingResource {
|
||||
bookingNo: string
|
||||
containerNo: string | null
|
||||
cabinetNo: string
|
||||
sourceType?: 'SIMULATION' | 'OCEAN_BOOKING'
|
||||
recordStatus?: 'ACTIVE' | 'MERGED'
|
||||
mergedIntoContainerBookingId?: string | null
|
||||
warehouseId: string
|
||||
warehouseName: string | null
|
||||
channelCode: string | null
|
||||
@@ -52,8 +55,7 @@ export interface ContainerSimulationCreateRequest {
|
||||
}
|
||||
|
||||
export interface ContainerOceanBookingLinkRequest {
|
||||
oceanBookingId: string
|
||||
requirementId: string
|
||||
realContainerBookingId: string
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,11 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
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('grid-template-columns:120px minmax(0,220px)')
|
||||
expect(page).toContain('justify-content:start')
|
||||
expect(page).toContain('class="surface-panel finance-account-detail__surface"')
|
||||
expect(page).toContain('min-height:88px')
|
||||
expect(page).toContain('min-height:72px')
|
||||
expect(page).toContain('gap:0;padding:0 0 16px')
|
||||
expect(page).toContain('class="detail-table-scroll"')
|
||||
})
|
||||
|
||||
|
||||
@@ -228,11 +228,11 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<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">
|
||||
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :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>
|
||||
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><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>
|
||||
@@ -242,5 +242,5 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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}}
|
||||
.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){gap:0}.finance-account-detail__layout :deep(.detail-page-layout__header){min-height:72px;align-items:center;padding:16px 24px;border-bottom:1px solid var(--cc-border,#e3e8ef)}.finance-account-detail__layout :deep(.detail-page-layout__summary){padding:16px 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:0;padding:0 0 16px;border-bottom:1px solid var(--cc-border,#e3e8ef)}.account-metric{min-width:0;padding:0 20px}.account-metric:first-child{padding-left:0}.account-metric+.account-metric{border-left:1px solid var(--cc-border,#e3e8ef)}.account-metric::before{display:none}.account-metric span{display:block;color:var(--cc-muted);font-size:12px}.account-metric strong{display:inline-block;margin-top:7px;color:var(--cc-text);font-size:22px;line-height:1;font-variant-numeric:tabular-nums}.account-metric small{margin-left:6px;color:var(--cc-muted);font-size:11px}.ledger-section{padding:16px 0 0}.ledger-query{margin-bottom:12px}:deep(.ledger-query.query-toolbar){padding:0;border:0;background:transparent}.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:16px 0}.term-editor{display:grid;grid-template-columns:120px minmax(0,220px);justify-content:start;align-items:center;gap:24px;padding:4px 0}.term-label{color:var(--cc-text);font-size:14px;font-weight:600}.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))}.account-metric:nth-child(3){padding-left:0;border-left:0}.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;gap:12px}.account-metric,.account-metric:first-child,.account-metric:nth-child(3){padding-left:0}.account-metric+.account-metric{border-left:0}}@media(max-width:480px){.detail-footer .el-button{flex:1 1 120px}}
|
||||
</style>
|
||||
|
||||
@@ -267,7 +267,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<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>
|
||||
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" :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="审核与结算" :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="操作记录" :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>
|
||||
@@ -291,8 +291,9 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
.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) { gap: 0; }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__header) { min-height: 72px; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__summary) { padding: 16px 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; }
|
||||
@@ -303,22 +304,24 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
.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-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-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; padding: 0 0 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.arap-metric { min-width: 0; padding: 0 20px; }
|
||||
.arap-metric:first-child { padding-left: 0; }
|
||||
.arap-metric + .arap-metric { border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.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 strong { display: inline-block; margin-top: 7px; color: var(--cc-text); font-size: 20px; 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; }
|
||||
.arap-detail-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, .65fr); gap: 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.arap-information,.arap-settlement,.operation-log { padding: 16px 0; }
|
||||
.arap-settlement { padding-left: 24px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.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: 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: 1080px) { .finance-arap-detail__surface { width: 100%; } .arap-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .arap-metric:nth-child(3) { padding-left: 0; border-left: 0; } .arap-detail-grid { grid-template-columns: 1fr; gap: 0; } .arap-settlement { padding-left: 0; border-left: 0; border-top: 1px solid var(--cc-border, #e3e8ef); } }
|
||||
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-actions { margin-left: 0; } .arap-metrics { grid-template-columns: 1fr; gap: 12px; } .arap-metric,.arap-metric:first-child,.arap-metric:nth-child(3) { padding-left: 0; } .arap-metric + .arap-metric { border-left: 0; } :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>
|
||||
|
||||
@@ -457,7 +457,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<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="所选对账单摘要">
|
||||
<FormSection class="statement-summary-section" title="对账单概要" 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>
|
||||
@@ -473,7 +473,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<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">
|
||||
<FormSection v-else-if="detail" title="账单明细" :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>
|
||||
@@ -491,7 +491,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<el-empty v-else description="请在主表中选择一张对账单" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="账单支付历史表" name="payments">
|
||||
<FormSection v-if="detail" title="支付历史" description="只读展示当前对账单关联的有效收款记录。" :bordered="false">
|
||||
<FormSection v-if="detail" title="支付历史" :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>
|
||||
@@ -523,15 +523,16 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
.finance-statements-page { min-width: 0; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
.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-panel :deep(.detail-page-layout) { gap: 0; }
|
||||
.statement-detail-header { min-height: 48px; 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 { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; padding: 16px 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 h2 { margin: 2px 0 0; font-size: 16px; 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-summary-section { padding: 16px 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; }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
|
||||
|
||||
describe('Finance formal settlement desktop presentation', () => {
|
||||
it('keeps settlement totals and list actions in one compact toolbar', () => {
|
||||
expect(source).toContain('<section class="settlement-list-toolbar" aria-label="结算汇总与列表操作">')
|
||||
expect(source).toContain('<div class="settlement-summary" aria-label="结算汇总">')
|
||||
expect(source).toContain('<div class="settlement-list-actions">')
|
||||
expect(source).toContain('.settlement-list-toolbar { display: flex; min-height: 54px;')
|
||||
expect(source).not.toContain('<template #toolbar-actions>')
|
||||
})
|
||||
|
||||
it('uses a continuous detail layout with locally scrollable wide tables', () => {
|
||||
expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel"')
|
||||
expect(source).toContain('class="settlement-detail-content"')
|
||||
expect(source).toContain('class="detail-table-scroll"')
|
||||
expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
|
||||
expect(source).not.toContain('class="settlement-detail-card"')
|
||||
})
|
||||
|
||||
it('presents fee sources and revisions as finance business labels', () => {
|
||||
expect(source).toContain('financeSourceTypeLabel(scope.row.sourceType)')
|
||||
expect(source).not.toContain('prop="sourceVersion" label="来源版本"')
|
||||
expect(source).toContain("VOID: '作废'")
|
||||
expect(source).toContain("REOPEN: '重开'")
|
||||
expect(source).toContain("RECEIPT: '收款登记'")
|
||||
expect(source).toContain("PAYMENT: '付款登记'")
|
||||
expect(source).toContain('settlementRevisionActionLabel(scope.row.action)')
|
||||
})
|
||||
|
||||
it('keeps receipt and payment dialogs readable at desktop width', () => {
|
||||
expect(source.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(source).toContain('.partial-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
|
||||
expect(source).toContain('.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(source).toContain('<el-alert v-if="receiptValidationError"')
|
||||
expect(source).toContain('<el-alert v-if="receiptError"')
|
||||
})
|
||||
})
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
listFinanceStatements,
|
||||
uploadFinanceVoucher,
|
||||
} from '../api/finance'
|
||||
import { financeStatusLabel, financeStatusTone } from '../status'
|
||||
import { financeOperationActionLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type {
|
||||
FinanceFeePoolItem,
|
||||
FinancePayableSettlement,
|
||||
@@ -136,6 +136,20 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
|
||||
function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
|
||||
function displayDate(value?: string) { return value || '—' }
|
||||
function displayDateTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function settlementRevisionActionLabel(action: string) {
|
||||
return ({
|
||||
SUBMIT: '提交审核',
|
||||
APPROVE: '审核通过',
|
||||
REJECT: '审核驳回',
|
||||
ISSUE: '出账',
|
||||
ADJUST: '调整',
|
||||
VOID: '作废',
|
||||
REOPEN: '重开',
|
||||
RECEIPT: '收款登记',
|
||||
PAYMENT: '付款登记',
|
||||
} as Record<string, string>)[action] || financeOperationActionLabel(action)
|
||||
}
|
||||
function rowTotal(value: unknown) { return totalAmount(value as SettlementRow) }
|
||||
function rowPaid(value: unknown) { return paidAmount(value as SettlementRow) }
|
||||
function rowOutstanding(value: unknown) { return Number((value as SettlementRow).outstandingAmount || 0) }
|
||||
@@ -417,11 +431,10 @@ onMounted(() => {
|
||||
<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" />
|
||||
<el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column>
|
||||
<el-table-column prop="inboundCode" label="入仓码" min-width="130"><template #default="scope">{{ scope.row.inboundCode || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="containerNo" label="柜次号" min-width="130"><template #default="scope">{{ scope.row.containerNo || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="businessDate" label="业务发生日" width="120"><template #default="scope">{{ displayDate(scope.row.businessDate) }}</template></el-table-column>
|
||||
<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>
|
||||
</div>
|
||||
@@ -440,11 +453,11 @@ onMounted(() => {
|
||||
<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="action" label="动作" min-width="120"><template #default="scope">{{ settlementRevisionActionLabel(scope.row.action) || financeOperationActionLabel(scope.row.action) }}</template></el-table-column>
|
||||
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
|
||||
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
|
||||
<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-column prop="occurredAt" label="时间" min-width="160"><template #default="scope">{{ displayDateTime(scope.row.occurredAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</FormSection>
|
||||
@@ -458,17 +471,19 @@ onMounted(() => {
|
||||
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
|
||||
</template>
|
||||
<template v-if="!customerError && !error">
|
||||
<section class="settlement-summary" aria-label="结算汇总">
|
||||
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</span><strong>{{ money(totals.amount) }}</strong></div>
|
||||
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收' : '已付' }}</span><strong class="is-success">{{ money(totals.paid) }}</strong></div>
|
||||
<div class="summary-item"><span>{{ kind === 'receivable' ? '待收' : '待付' }}</span><strong class="is-danger">{{ money(totals.outstanding) }}</strong></div>
|
||||
<section class="settlement-list-toolbar" aria-label="结算汇总与列表操作">
|
||||
<div class="settlement-summary" aria-label="结算汇总">
|
||||
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</span><strong>{{ money(totals.amount) }}</strong></div>
|
||||
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收' : '已付' }}</span><strong class="is-success">{{ money(totals.paid) }}</strong></div>
|
||||
<div class="summary-item"><span>{{ kind === 'receivable' ? '待收' : '待付' }}</span><strong class="is-danger">{{ money(totals.outstanding) }}</strong></div>
|
||||
</div>
|
||||
<div class="settlement-list-actions">
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button>
|
||||
<el-button @click="exportRows"><Download :size="15" />导出</el-button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable :data="filteredRows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button>
|
||||
<el-button @click="exportRows"><Download :size="15" />导出</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button>
|
||||
@@ -518,8 +533,13 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.finance-settlement-page { min-width: 0; }
|
||||
.settlement-summary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 28px; padding: 0 2px; }
|
||||
.settlement-list-toolbar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 20px; padding: 0 2px 10px; }
|
||||
.settlement-summary, .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
|
||||
.settlement-summary { flex: 1; gap: 8px 0; }
|
||||
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: 8px; }
|
||||
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.summary-item { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 24px; padding-left: 24px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.summary-item span { color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
||||
.summary-item strong { color: var(--cc-text-primary, #1f2937); font-size: 18px; line-height: 1.2; }
|
||||
.summary-item strong.is-success { color: #16803c; }
|
||||
@@ -564,7 +584,10 @@ onMounted(() => {
|
||||
.settlement-detail-state { margin: 16px; }
|
||||
.partial-summary { gap: 10px; padding: 12px; }
|
||||
.partial-summary div { padding: 0; border-left: 0; }
|
||||
.settlement-list-toolbar { align-items: stretch; flex-direction: column; padding: 0 0 10px; }
|
||||
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||
.settlement-list-actions { width: 100%; justify-content: flex-start; }
|
||||
.summary-item strong { font-size: 21px; }
|
||||
.settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; }
|
||||
.settlement-detail-actions { width: 100%; }
|
||||
|
||||
@@ -46,6 +46,22 @@ describe('finance presentation governance', () => {
|
||||
expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px')
|
||||
})
|
||||
|
||||
it('keeps settlement totals and list actions in one compact toolbar', () => {
|
||||
expect(settlement).toContain('class="settlement-list-toolbar"')
|
||||
expect(settlement).toContain('class="settlement-summary" aria-label="结算汇总"')
|
||||
expect(settlement).toContain('class="settlement-list-actions"')
|
||||
expect(settlement).toContain('min-height: 54px; align-items: center; justify-content: space-between')
|
||||
expect(settlement).toContain('.settlement-summary .summary-item + .summary-item')
|
||||
expect(settlement).not.toContain('<template #toolbar-actions>')
|
||||
})
|
||||
|
||||
it('keeps settlement details focused on finance-facing labels and fields', () => {
|
||||
expect(settlement).toContain('financeSourceTypeLabel(scope.row.sourceType)')
|
||||
expect(settlement).toContain('financeOperationActionLabel(scope.row.action)')
|
||||
expect(settlement).toContain('displayDateTime(scope.row.occurredAt)')
|
||||
expect(settlement).not.toContain('label="来源版本"')
|
||||
})
|
||||
|
||||
it('uses optional all-customer filters without a customer-selection blocker', () => {
|
||||
for (const source of [settlement, statements, arap]) {
|
||||
expect(source).toContain("placeholder: '全部客户'")
|
||||
@@ -97,4 +113,33 @@ describe('finance presentation governance', () => {
|
||||
expect(statements).toContain(':data="detail.revisions" size="small" border stripe')
|
||||
expect(statements).toContain(':data="adjustLines" size="small" max-height="420" border stripe')
|
||||
})
|
||||
|
||||
it('keeps the statement detail as a compact, continuous list-linked surface', () => {
|
||||
expect(statements).toContain('class="surface-panel statement-detail-panel"')
|
||||
expect(statements).toContain('@row-click="openDetail"')
|
||||
expect(statements).toContain('.statement-detail-panel :deep(.detail-page-layout) { gap: 0; }')
|
||||
expect(statements).toContain('min-height: 48px')
|
||||
expect(statements).not.toContain('核对账期、金额和结清状态后查看下方业务明细。')
|
||||
expect(statements).not.toContain('展示当前对账单版本中的业务与计费明细。')
|
||||
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
|
||||
})
|
||||
|
||||
it('uses a compact financial summary row for customer accounts', () => {
|
||||
expect(accounts).toContain('.finance-account-detail__layout :deep(.detail-page-layout){gap:0}')
|
||||
expect(accounts).toContain('.account-metric+.account-metric{border-left:1px solid')
|
||||
expect(accounts).toContain('.account-metric::before{display:none}')
|
||||
expect(accounts).toContain(':deep(.ledger-query.query-toolbar){padding:0;border:0;background:transparent}')
|
||||
expect(accounts).not.toContain('展示应收、应付及每次变动后的未收金额。')
|
||||
expect(accounts).not.toContain('到期日按账单日期加客户账期计算;每个客户独立设置,默认 30 天。')
|
||||
})
|
||||
|
||||
it('keeps fee-source details continuous without nested metric and section cards', () => {
|
||||
expect(arap).toContain('.finance-arap-detail__layout :deep(.detail-page-layout) { gap: 0; }')
|
||||
expect(arap).toContain('.arap-metric + .arap-metric { border-left: 1px solid')
|
||||
expect(arap).toContain('.arap-settlement { padding-left: 24px; border-left: 1px solid')
|
||||
expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
|
||||
expect(arap).not.toContain('description="费用来源与关联业务摘要"')
|
||||
expect(arap).not.toContain('description="当前审核、参与结算和出账状态"')
|
||||
expect(arap).not.toContain('description="按时间查看审核与结算调整记录"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ export function financeStatusTone(status: unknown): FinanceStatusTone {
|
||||
}
|
||||
|
||||
const financeSourceTypeLabels: Record<string, string> = {
|
||||
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
|
||||
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
|
||||
}
|
||||
const financeBusinessTypeLabels: Record<string, string> = {
|
||||
BILL: '对账单', RECEIPT: '收款', COST: '成本', ADJUSTMENT: '账务调整', SETTLEMENT: '结算',
|
||||
|
||||
@@ -5,9 +5,10 @@ import { http } from '@/shared/api/http'
|
||||
const detailResponse = (version = 0, status = 'CONFIRMED') => ({
|
||||
oceanBooking: {
|
||||
id: 123, bookingNo: 'OB123', status, version, customerId: 456, customerName: '客户',
|
||||
containers: [],
|
||||
},
|
||||
parties: [], containerRequirements: [], containerAllocations: [], histories: [],
|
||||
parties: [],
|
||||
containers: [{ containerBookingId: 9007199254740993, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', requirementId: 9007199254740994, allocationId: 9007199254740995, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, warehouseId: 9007199254740996, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', customerId: 456, customerName: '客户' }],
|
||||
histories: [],
|
||||
})
|
||||
|
||||
describe('ocean bookings api', () => {
|
||||
@@ -22,15 +23,19 @@ describe('ocean bookings api', () => {
|
||||
|
||||
it('uses version zero for create and extracts the id from the returned Detail', async () => {
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(0, 'CONFIRMED') })
|
||||
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: 'SEA', warehouseId: '8', warehouseName: '义乌仓', billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00', serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [] } })
|
||||
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: 'SEA', warehouseId: '8', billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00', serviceTerm: 'CY-CY', freightTerm: 'PREPAID', parties: [], containers: [{ containerNo: 'MSCU1234567', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }] } })
|
||||
const sentPayload = post.mock.calls[0]?.[1] as { expectedVersion: number; data: Record<string, unknown> }
|
||||
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, serviceTerm: null, freightTerm: null } })
|
||||
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, serviceTerm: 'CY-CY', freightTerm: 'PREPAID' } })
|
||||
expect(sentPayload).not.toMatchObject({ data: { customerId: '', customerName: '' } })
|
||||
expect(sentPayload.data).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
|
||||
expect(sentPayload.data).toMatchObject({ containers: [{ containerNo: 'MSCU1234567', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }] })
|
||||
expect(sentPayload.data).not.toHaveProperty('containerRequirements')
|
||||
expect(sentPayload.data).not.toHaveProperty('warehouseName')
|
||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(created.id).toBe('123')
|
||||
expect(created.version).toBe(0)
|
||||
expect(created.status).toBe('CONFIRMED')
|
||||
expect(created.containers[0]).toMatchObject({ containerBookingId: '9007199254740992', requirementId: '9007199254740994', allocationId: '9007199254740996', warehouseId: '9007199254740996', customerId: '456' })
|
||||
})
|
||||
|
||||
it('uses action and change-specific request contracts', async () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ function booking(raw: OceanBookingResource): OceanBookingResource {
|
||||
bookingAgent: raw.bookingAgent ?? raw.bookingAgentName ?? null,
|
||||
etd: raw.etd ?? raw.estimatedSailingAt ?? null,
|
||||
eta: raw.eta ?? raw.estimatedArrivalAt ?? null,
|
||||
containers: (raw.containers || []).map((container) => ({ ...container, id: String(container.id ?? '') })),
|
||||
}
|
||||
}
|
||||
function detail(raw: OceanBookingDetail): OceanBookingDetail {
|
||||
@@ -34,8 +33,14 @@ function detail(raw: OceanBookingDetail): OceanBookingDetail {
|
||||
...source,
|
||||
oceanBooking: source,
|
||||
parties: (raw.parties || []).map((party) => ({ ...party, id: party.id == null ? undefined : String(party.id) })),
|
||||
containerRequirements: (raw.containerRequirements || []).map((container) => ({ ...container, id: container.id == null ? undefined : String(container.id) })),
|
||||
containerAllocations: raw.containerAllocations || [],
|
||||
containers: (raw.containers || []).map((container) => ({
|
||||
...container,
|
||||
containerBookingId: String(container.containerBookingId),
|
||||
requirementId: String(container.requirementId),
|
||||
allocationId: String(container.allocationId),
|
||||
warehouseId: String(container.warehouseId),
|
||||
customerId: container.customerId == null ? null : String(container.customerId),
|
||||
})),
|
||||
histories,
|
||||
statusHistory: histories,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { containerRequirementError, createBlankContainerRequirement } from './containerRequirements'
|
||||
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from './containerRequirements'
|
||||
|
||||
describe('ocean booking container requirements', () => {
|
||||
it('uses the fixed platform specification for a new container type', () => {
|
||||
expect(createBlankContainerRequirement()).toEqual({
|
||||
containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, quantity: 1,
|
||||
describe('ocean booking real containers', () => {
|
||||
it('starts with one real container and applies the fixed platform specification', () => {
|
||||
const container = createBlankBookingContainer()
|
||||
expect(container).toEqual({
|
||||
containerNo: '', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
selectBookingContainerType(container, '20GP')
|
||||
expect(container).toMatchObject({ containerType: '20GP', capacityCbm: 33, maxWeightKg: 28000 })
|
||||
})
|
||||
|
||||
it('rejects missing or non-positive capacity, weight and quantity', () => {
|
||||
expect(containerRequirementError([{ ...createBlankContainerRequirement(), quantity: 0 }])).toContain('固定规格')
|
||||
expect(containerRequirementError([{ containerType: 'UNKNOWN', containerKind: 'FCL', capacityCbm: 1, maxWeightKg: 1, quantity: 1 }])).toContain('UNKNOWN')
|
||||
expect(containerRequirementError([createBlankContainerRequirement()])).toBeNull()
|
||||
it('requires unique real container numbers and fixed specifications', () => {
|
||||
const first = { ...createBlankBookingContainer(), containerNo: 'MSCU1234567' }
|
||||
expect(bookingContainerError([createBlankBookingContainer()])).toContain('尚未填写集装箱号')
|
||||
expect(bookingContainerError([first, { ...first, containerNo: 'mscu1234567' }])).toContain('重复')
|
||||
expect(bookingContainerError([{ ...first, capacityCbm: 75 }])).toContain('固定规格')
|
||||
expect(bookingContainerError([first])).toBeNull()
|
||||
})
|
||||
|
||||
it('limits one booking to twenty real containers', () => {
|
||||
const containers = Array.from({ length: 21 }, (_, index) => ({ ...createBlankBookingContainer(), containerNo: `MSCU${index}` }))
|
||||
expect(bookingContainerError(containers)).toContain('最多登记 20 个')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { OceanBookingContainer } from './types'
|
||||
import type { OceanBookingContainerInput } from './types'
|
||||
import { applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
|
||||
export function createBlankContainerRequirement(): OceanBookingContainer {
|
||||
return { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, quantity: 1 }
|
||||
export function createBlankBookingContainer(): OceanBookingContainerInput {
|
||||
return { containerNo: '', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
|
||||
}
|
||||
|
||||
export function selectContainerRequirementType(requirement: OceanBookingContainer, containerType: string) {
|
||||
applyContainerSpecification(requirement, containerType)
|
||||
export function selectBookingContainerType(container: OceanBookingContainerInput, containerType: string) {
|
||||
applyContainerSpecification(container, containerType)
|
||||
}
|
||||
|
||||
export function containerRequirementError(requirements: OceanBookingContainer[]) {
|
||||
const invalid = requirements.find((item) => {
|
||||
export function bookingContainerError(containers: OceanBookingContainerInput[]) {
|
||||
if (!containers.length) return '请至少登记一个真实柜'
|
||||
if (containers.length > 20) return '单次订舱最多登记 20 个真实柜'
|
||||
const normalizedNumbers = containers.map((item) => item.containerNo.trim().toUpperCase())
|
||||
const missingNumberIndex = normalizedNumbers.findIndex((value) => !value)
|
||||
if (missingNumberIndex >= 0) return `第 ${missingNumberIndex + 1} 个真实柜尚未填写集装箱号`
|
||||
const duplicateNumber = normalizedNumbers.find((value, index) => normalizedNumbers.indexOf(value) !== index)
|
||||
if (duplicateNumber) return `集装箱号 ${duplicateNumber} 在本次订舱中重复`
|
||||
const invalid = containers.find((item) => {
|
||||
const specification = containerSpecification(item.containerType)
|
||||
return !specification || Number(item.capacityCbm) !== specification.capacityCbm
|
||||
|| Number(item.maxWeightKg) !== specification.maxWeightKg || !(Number(item.quantity) > 0)
|
||||
|| Number(item.maxWeightKg) !== specification.maxWeightKg
|
||||
})
|
||||
return invalid ? `箱型 ${invalid.containerType || '未命名'} 必须使用平台固定规格,请重新选择柜型` : null
|
||||
return invalid ? `柜型 ${invalid.containerType || '未命名'} 必须使用平台固定规格,请重新选择柜型` : null
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
expect(source).toContain('登记后直接确认')
|
||||
})
|
||||
|
||||
it('uses source-backed free text terms and keeps capacity snapshots behind one specification control', () => {
|
||||
it('uses source-backed free text terms and fixed container specifications', () => {
|
||||
expect(source).not.toContain('serviceTermOptions')
|
||||
expect(source).not.toContain('freightTermOptions')
|
||||
expect(source).toContain('const containerTypeOptions = CONTAINER_SPECIFICATIONS')
|
||||
@@ -96,8 +96,9 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
expect(source).toContain('<el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term"')
|
||||
expect(source).toContain('<el-select :model-value="container.containerType"')
|
||||
expect(source).toContain('<el-select v-model="container.containerKind"')
|
||||
expect(source).toContain('规格(必填)')
|
||||
expect(source).toContain('function containerSpecificationLabel(')
|
||||
expect(source).toContain('<th scope="col">容量</th><th scope="col">限重</th>')
|
||||
expect(source).toContain('{{ container.capacityCbm }} m³')
|
||||
expect(source).toContain('{{ container.maxWeightKg }} kg')
|
||||
expect(source).not.toContain("['PREPAID', 'COLLECT']")
|
||||
expect(source).not.toContain('<el-input v-model="container.containerType"')
|
||||
})
|
||||
@@ -114,30 +115,35 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
expect(source).not.toContain('v-model="form.data.channelName"')
|
||||
expect(source).toContain('form.data.channelCode = \'\'')
|
||||
expect(source).toContain('function selectWarehouse(value: unknown)')
|
||||
expect(source).toContain('form.data.warehouseName = warehouses.value.find')
|
||||
expect(source).not.toContain('form.data.warehouseName')
|
||||
expect(source).toContain('@update:model-value="selectWarehouse"')
|
||||
expect(source).not.toContain('@update:model-value="(value) =>')
|
||||
expect(source).toContain('selectContainerRequirementType(container')
|
||||
expect(source).toContain('selectBookingContainerType(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).toContain('containerSpecificationLabel(container)')
|
||||
expect(source).toContain('createBlankBookingContainer()')
|
||||
expect(source).toContain('bookingContainerError(form.data.containers)')
|
||||
expect(source).toContain('function validateBookingContainers()')
|
||||
expect(source).toContain('form.data.containers.forEach')
|
||||
})
|
||||
|
||||
it('keeps every container requirement removable without collapsing its fields', () => {
|
||||
it('registers one real container per row and keeps each row removable', () => {
|
||||
expect(source).toContain('@click="removeContainer(index)"')
|
||||
expect(source).toContain(':aria-label="`删除箱型 ${index + 1}`"')
|
||||
expect(source).toContain("form.data.containerRequirements.length === 1 ? '至少保留一条箱型' : '删除该箱型'")
|
||||
expect(source).toContain(':disabled="form.data.containerRequirements.length === 1"')
|
||||
expect(source).toContain('function removeContainer(index: number) { if (form.data.containerRequirements.length > 1)')
|
||||
expect(source).toContain(':aria-label="`删除真实柜 ${index + 1}`"')
|
||||
expect(source).toContain("form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'")
|
||||
expect(source).toContain(':disabled="form.data.containers.length === 1"')
|
||||
expect(source).toContain('function removeContainer(index: number) { if (form.data.containers.length > 1)')
|
||||
expect(source).toContain('<table class="container-table">')
|
||||
expect(source).toContain('.container-table-wrap{overflow-x:auto')
|
||||
expect(source).toContain('.container-table td :deep(.el-select),.container-table td :deep(.el-input-number){width:100%;min-width:0}')
|
||||
expect(source).toContain(':controls="false" placeholder="请输入数量"')
|
||||
expect(source).toContain('v-model="container.containerNo"')
|
||||
expect(source).toContain('maxlength="64" placeholder="请输入真实集装箱号"')
|
||||
expect(source).toContain(':disabled="form.data.containers.length >= 20"')
|
||||
expect(source).toContain('{{ form.data.containers.length }} / 20 个')
|
||||
expect(source).toContain("detail.containers[index]?.cabinetNo || '—'")
|
||||
expect(source).not.toContain('container.quantity')
|
||||
expect(source).not.toContain('form.data.containerRequirements')
|
||||
expect(source).not.toContain('class="container-row"')
|
||||
})
|
||||
|
||||
@@ -151,13 +157,13 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
it('groups real detail fields into the frozen three-column prototype structure', () => {
|
||||
expect(source).toContain('aria-label="基本信息"')
|
||||
expect(source).toContain('aria-label="航线船期"')
|
||||
expect(source).toContain('aria-label="条款与柜量"')
|
||||
expect(source).toContain('aria-label="条款与真实柜"')
|
||||
expect(source).toContain('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))')
|
||||
expect(source).toContain('.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}')
|
||||
expect(source).toContain('class="booking-field--wide" label="交货地点"')
|
||||
expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}')
|
||||
expect(source).toContain('@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}')
|
||||
expect(source).toContain('min-width:520px')
|
||||
expect(source).toContain('min-width:780px')
|
||||
})
|
||||
|
||||
it('keeps the three real booking parties in their own three-column section', () => {
|
||||
@@ -171,7 +177,7 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
expect(source).toContain(':title="actionError.message"')
|
||||
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }")
|
||||
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
|
||||
expect(source).toContain('规格(必填)')
|
||||
expect(source).toContain('aria-label="真实柜登记"')
|
||||
expect(source).toContain('placeholder="单位名称(必填)"')
|
||||
})
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import type {
|
||||
OceanBookingStatus,
|
||||
} from '../types'
|
||||
import { oceanBookingStatusLabel } from '../status'
|
||||
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
|
||||
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from '../containerRequirements'
|
||||
|
||||
const partyTypes = [
|
||||
{ value: 'SHIPPER', label: '发货人' },
|
||||
@@ -98,9 +98,9 @@ function blankParties(): OceanBookingParty[] {
|
||||
}
|
||||
function blankData(): OceanBookingDraftData {
|
||||
return {
|
||||
customerId: null, customerName: null, channelCode: '', warehouseId: '', warehouseName: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
|
||||
customerId: null, customerName: null, channelCode: '', warehouseId: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
|
||||
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
|
||||
containerRequirements: [createBlankContainerRequirement()],
|
||||
containers: [createBlankBookingContainer()],
|
||||
}
|
||||
}
|
||||
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
|
||||
@@ -162,11 +162,11 @@ function normalizeDraftData() {
|
||||
form.data.billOfLadingNo = form.data.billOfLadingNo.trim()
|
||||
form.data.vesselName = form.data.vesselName.trim()
|
||||
form.data.voyageNo = form.data.voyageNo.trim()
|
||||
form.data.containers.forEach((container) => { container.containerNo = container.containerNo.trim() })
|
||||
}
|
||||
function selectWarehouse(value: unknown) {
|
||||
const warehouseId = String(value || '')
|
||||
form.data.warehouseId = warehouseId
|
||||
form.data.warehouseName = warehouses.value.find((warehouse) => String(warehouse.id) === warehouseId)?.warehouseName || ''
|
||||
form.data.channelCode = ''
|
||||
selectedChannelName.value = ''
|
||||
channels.value = []
|
||||
@@ -208,7 +208,7 @@ function applyDetail(value: OceanBookingDetail) {
|
||||
detail.value = value
|
||||
form.expectedVersion = value.version
|
||||
form.data = {
|
||||
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '',
|
||||
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '',
|
||||
billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '',
|
||||
estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null,
|
||||
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
|
||||
@@ -218,7 +218,7 @@ function applyDetail(value: OceanBookingDetail) {
|
||||
placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm,
|
||||
remark: value.remark,
|
||||
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
|
||||
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm: capacityCbm ?? null, maxWeightKg: maxWeightKg ?? null, quantity: quantity ?? null })) : [createBlankContainerRequirement()],
|
||||
containers: value.containers.length ? value.containers.map(({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg }) => ({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg })) : [createBlankBookingContainer()],
|
||||
}
|
||||
selectedChannelName.value = value.channelName || ''
|
||||
channels.value = []
|
||||
@@ -248,7 +248,7 @@ async function save() {
|
||||
actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }
|
||||
return
|
||||
}
|
||||
if (!validateContainerRequirements()) return
|
||||
if (!validateBookingContainers()) return
|
||||
saving.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
@@ -319,7 +319,6 @@ async function runAction(action: OceanBookingAction) {
|
||||
if (!detail.value || action === 'change-request') { if (action === 'change-request') openChangeDialog(); return }
|
||||
if (action === 'release') { openReleaseDialog(); return }
|
||||
actionError.value = null
|
||||
if (!validateContainerRequirements()) return
|
||||
try {
|
||||
const reason = await requiredReason(action)
|
||||
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
||||
@@ -330,19 +329,18 @@ async function runAction(action: OceanBookingAction) {
|
||||
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
|
||||
} finally { actionSaving.value = false }
|
||||
}
|
||||
function validateContainerRequirements() {
|
||||
const message = containerRequirementError(form.data.containerRequirements)
|
||||
function validateBookingContainers() {
|
||||
const message = bookingContainerError(form.data.containers)
|
||||
if (message) {
|
||||
actionError.value = { message }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
function containerSpecificationLabel(container: OceanBookingDraftData['containerRequirements'][number]) {
|
||||
return `${container.capacityCbm} m³ / ${container.maxWeightKg} kg`
|
||||
function addContainer() {
|
||||
if (form.data.containers.length < 20) form.data.containers.push(createBlankBookingContainer())
|
||||
}
|
||||
function addContainer() { form.data.containerRequirements.push(createBlankContainerRequirement()) }
|
||||
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
|
||||
function removeContainer(index: number) { if (form.data.containers.length > 1) form.data.containers.splice(index, 1) }
|
||||
|
||||
onMounted(() => { void load(); void loadOptions() })
|
||||
</script>
|
||||
@@ -397,32 +395,34 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="booking-group" aria-label="条款与柜量">
|
||||
<h3>条款与柜量</h3><div class="booking-group__body">
|
||||
<section class="booking-group" aria-label="条款与真实柜">
|
||||
<h3>条款与真实柜</h3><div class="booking-group__body">
|
||||
<div class="booking-terms">
|
||||
<el-form-item label="主要运输条款 Service Term" prop="serviceTerm"><el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term" /></el-form-item>
|
||||
<el-form-item label="运费条款 Freight Term" prop="freightTerm"><el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term" /></el-form-item>
|
||||
</div>
|
||||
<div class="container-table-wrap" aria-label="箱型箱量">
|
||||
<div class="container-table-wrap" aria-label="真实柜登记">
|
||||
<table class="container-table">
|
||||
<thead><tr><th scope="col">操作</th><th scope="col">箱型</th><th scope="col">箱种</th><th scope="col">规格(必填)</th><th scope="col">数量</th></tr></thead>
|
||||
<thead><tr><th scope="col">操作</th><th scope="col">集装箱号</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail" scope="col">柜次号</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(container, index) in form.data.containerRequirements" :key="index">
|
||||
<tr v-for="(container, index) in form.data.containers" :key="detail?.containers[index]?.containerBookingId || index">
|
||||
<td class="container-table__action">
|
||||
<el-tooltip v-if="editable" :content="form.data.containerRequirements.length === 1 ? '至少保留一条箱型' : '删除该箱型'" placement="top">
|
||||
<span><el-button circle plain size="small" type="danger" :aria-label="`删除箱型 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="14" /></el-button></span>
|
||||
<el-tooltip v-if="editable" :content="form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'" placement="top">
|
||||
<span><el-button circle plain size="small" type="danger" :aria-label="`删除真实柜 ${index + 1}`" :disabled="form.data.containers.length === 1" @click="removeContainer(index)"><Trash2 :size="14" /></el-button></span>
|
||||
</el-tooltip>
|
||||
<span v-else aria-hidden="true">—</span>
|
||||
</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-input v-model="container.containerNo" :disabled="!editable" maxlength="64" placeholder="请输入真实集装箱号" /></td>
|
||||
<td><el-select :model-value="container.containerType" :disabled="!editable" placeholder="请选择柜型" @update:model-value="selectBookingContainerType(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"><span>{{ containerSpecificationLabel(container) }}</span></td>
|
||||
<td><el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" :controls="false" placeholder="请输入数量" /></td>
|
||||
<td class="container-table__specification"><span>{{ container.capacityCbm }} m³</span></td>
|
||||
<td class="container-table__specification"><span>{{ container.maxWeightKg }} kg</span></td>
|
||||
<td v-if="detail"><span>{{ detail.containers[index]?.cabinetNo || '—' }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<el-button v-if="editable" class="add-container" size="small" @click="addContainer"><Plus :size="15" />新增箱型</el-button>
|
||||
<div v-if="editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containers.length >= 20" @click="addContainer"><Plus :size="15" />新增真实柜</el-button><span>{{ form.data.containers.length }} / 20 个</span></div>
|
||||
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
@@ -478,5 +478,5 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<style scoped>
|
||||
.channel-code{display:block;margin-left:8px;color:var(--cc-muted);font-size:11px}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:5px;color:var(--cc-muted);font-size:12px;line-height:20px}.field-error{color:var(--el-color-danger)}
|
||||
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr));gap:16px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:8px;background:#fff}.booking-group>h3{margin:0;padding:14px 16px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:16px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:520px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:110px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:110px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:180px}.container-table th:nth-child(5),.container-table td:nth-child(5){width:78px}.container-table td :deep(.el-select),.container-table td :deep(.el-input-number){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification :deep(.el-button){max-width:100%;overflow:hidden}.container-table__specification :deep(.el-button span){overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.container-specification-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.container-specification-fields label{display:grid;gap:6px;color:var(--cc-muted);font-size:12px}.container-specification-fields :deep(.el-input-number){width:100%}.add-container{margin-bottom:18px}.history-panel strong{font-size:14px}@media(max-width:1599px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.actions :deep(.el-button){min-height:44px}}
|
||||
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr));gap:16px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:8px;background:#fff}.booking-group>h3{margin:0;padding:14px 16px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:16px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:780px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification{color:var(--cc-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:10px;margin-bottom:18px;color:var(--cc-muted);font-size:12px}.add-container{margin:0}.history-panel strong{font-size:14px}@media(max-width:1599px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.actions :deep(.el-button){min-height:44px}}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
export type OceanBookingStatus = 'CONFIRMED' | 'RELEASED' | 'CHANGE_PENDING' | 'CANCELLED'
|
||||
export type OceanBookingContainerType = '20GP' | '40GP' | '40HC' | '40NR' | '45HC'
|
||||
|
||||
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
|
||||
export interface OceanBookingContainer { id?: string; containerType: string; quantity: number | null; containerKind?: string | null; capacityCbm: number | null; maxWeightKg: number | null }
|
||||
export interface OceanBookingContainerAllocation { id?: string; oceanBookingId?: string; requirementId?: string; containerBookingId?: string | null; allocatedQuantity?: number | null }
|
||||
export interface OceanBookingContainerInput { containerNo: string; containerType: OceanBookingContainerType; containerKind: string; capacityCbm: number; maxWeightKg: number }
|
||||
export interface OceanBookingContainerSnapshot extends OceanBookingContainerInput {
|
||||
containerBookingId: string; cabinetNo: string; requirementId: string; allocationId: string
|
||||
warehouseId: string; warehouseName: string; channelCode: string; channelName: string
|
||||
customerId: string | null; customerName: string | null
|
||||
}
|
||||
export interface OceanBookingDraftData {
|
||||
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
|
||||
channelCode: string; warehouseId: string; warehouseName: string
|
||||
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containers: OceanBookingContainerInput[]
|
||||
channelCode: string; warehouseId: string
|
||||
billOfLadingNo: string; vesselName: string; voyageNo: string
|
||||
estimatedSailingAt: string | null; estimatedArrivalAt: string | null
|
||||
shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null
|
||||
@@ -27,9 +32,9 @@ export interface OceanBookingResource {
|
||||
orderNo: string | null; carrierName: string | null; bookingAgent: string | null; pol: string | null; pod: string | null
|
||||
mblShipper: string | null; mblConsignee: string | null; soNo: string | null; cutoffAt: string | null
|
||||
etd: string | null; atd: string | null; eta: string | null; ata: string | null; containerSummary: string | null
|
||||
containers: OceanBookingContainer[]; amount: string | null; currency: string | null
|
||||
amount: string | null; currency: string | null
|
||||
}
|
||||
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]; containerAllocations: OceanBookingContainerAllocation[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
|
||||
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containers: OceanBookingContainerSnapshot[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
|
||||
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
|
||||
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
|
||||
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
|
||||
|
||||
Reference in New Issue
Block a user