优化
This commit is contained in:
@@ -80,7 +80,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('客户A · C-001')
|
||||
expect(wrapper.text()).toContain('客户A')
|
||||
expect(wrapper.text()).not.toContain('C-001')
|
||||
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
|
||||
wrapper.unmount()
|
||||
|
||||
@@ -56,9 +56,6 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
||||
{ key: 'customerCode', label: '客户编号', width: 150 },
|
||||
{ key: 'contactName', label: '联系人', width: 120 },
|
||||
{ key: 'contactPhone', label: '联系电话', width: 140 },
|
||||
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
|
||||
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
|
||||
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
|
||||
@@ -175,7 +172,7 @@ function downloadCsv(fileName: string, headers: string[], data: unknown[][]) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' }))
|
||||
const link = document.createElement('a'); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url)
|
||||
}
|
||||
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [customerName(row.customerId, row.customerName), row.customerCode, row.contactName, row.contactPhone, row.outstandingReceivable, row.pendingBilling, row.receivedInPeriod, row.outstandingPayable, row.status, row.lastChangedAt])) }
|
||||
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [customerName(row.customerId, row.customerName), row.outstandingReceivable, row.pendingBilling, row.receivedInPeriod, row.outstandingPayable, row.status, row.lastChangedAt])) }
|
||||
function exportLedger() { downloadCsv(`${selected.value ? customerName(selected.value.customerId, selected.value.customerName) : '客户'}-账户流水.csv`, ledgerColumns.map((item) => String(item.label)), ledger.value.map((row) => [row.ledgerId, formatDateTime(row.occurredAt), ledgerBusinessType(row.businessType), row.documentNo, row.receivableChange, row.payableChange, row.outstandingReceivableAfter, displayOperator(row.operatorName), row.remark])) }
|
||||
async function closeDetail() {
|
||||
detailOpen.value = false; selected.value = null; editing.value = false; ledger.value = []
|
||||
@@ -223,10 +220,10 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AdminPage v-else as="div" class="finance-account-detail" spacing="normal">
|
||||
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><div><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · {{ selected.customerCode }}</p></div></div>
|
||||
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><div><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }}</p></div></div>
|
||||
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
|
||||
<template v-else-if="selected">
|
||||
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
|
||||
<el-descriptions :column="2" class="account-detail"><el-descriptions-item label="客户">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
|
||||
<FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><QueryToolbar v-model="ledgerFilters" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" /><div class="detail-toolbar"><span>共 {{ ledgerTotal }} 条</span><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></div><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></FormSection>
|
||||
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
|
||||
</template>
|
||||
|
||||
@@ -243,7 +243,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div>
|
||||
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
|
||||
<el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联单据">{{ detail.inboundCode || detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
|
||||
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
|
||||
</template>
|
||||
</AdminPage>
|
||||
|
||||
@@ -74,8 +74,8 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(document.body.textContent).toContain('REC-501')
|
||||
expect(document.body.textContent).toContain('CONT-31')
|
||||
expect(document.body.textContent).toContain('IN-21')
|
||||
expect(document.body.textContent).toContain('来源版本')
|
||||
expect(document.body.textContent).toContain('hash-81')
|
||||
expect(document.body.textContent).not.toContain('来源版本')
|
||||
expect(document.body.textContent).not.toContain('hash-81')
|
||||
expect(document.body.textContent).toContain('优惠调整')
|
||||
expect(document.body.textContent).toContain('创建人A')
|
||||
expect(document.body.textContent).toContain('2026-08-20 11:00')
|
||||
@@ -150,13 +150,13 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(listPlatformTenants).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses statement list permission for fee-source details and keeps frozen trace fields visible', async () => {
|
||||
it('uses statement list permission for fee-source details without exposing technical trace fields', async () => {
|
||||
const wrapper = await mountPage(['platform:finance:customer-statement:list'])
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
await flushPromises()
|
||||
expect(getFinanceFeeSource).toHaveBeenCalledWith('81')
|
||||
expect(document.body.textContent).toContain('CONT-31')
|
||||
expect(document.body.textContent).toContain('hash-81')
|
||||
expect(document.body.textContent).not.toContain('hash-81')
|
||||
})
|
||||
|
||||
it('surfaces detail hydration failures instead of silently returning an empty filtered list', async () => {
|
||||
|
||||
@@ -429,20 +429,14 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
|
||||
<template v-else-if="detail">
|
||||
<el-table :data="detail.lines" size="small" border stripe empty-text="该对账单暂无账单明细">
|
||||
<el-table-column label="应收/应付" width="110"><template #default>应收</template></el-table-column>
|
||||
<el-table-column label="客户" min-width="170"><template #default>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template></el-table-column>
|
||||
<el-table-column label="入仓号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template></el-table-column>
|
||||
<el-table-column label="柜次号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template></el-table-column>
|
||||
<el-table-column label="业务发生日" width="130"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template></el-table-column>
|
||||
<el-table-column label="来源类型" min-width="140"><template #default="{ row }">{{ financeSourceTypeLabel(lineSourceCache.get(row.sourceFeeId)?.sourceType) }}</template></el-table-column>
|
||||
<el-table-column prop="feeItemName" label="费用项目" min-width="150" />
|
||||
<el-table-column label="计费量" width="110"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="单价(CNY)" width="120"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template></el-table-column>
|
||||
<el-table-column prop="sourceAmount" label="来源金额(CNY)" width="150" />
|
||||
<el-table-column prop="amount" label="账单金额(CNY)" width="150" />
|
||||
<el-table-column prop="sourceFeeId" label="来源费用ID" min-width="140" />
|
||||
<el-table-column prop="sourceFeeVersion" label="来源版本" width="110" />
|
||||
<el-table-column prop="sourceHash" label="来源快照Hash" min-width="190" />
|
||||
<el-table-column prop="adjustmentReason" label="调整说明" min-width="180" />
|
||||
<el-table-column label="对账单号" min-width="180"><template #default>{{ detail.billNo }}</template></el-table-column>
|
||||
<el-table-column label="结算起始日" width="130"><template #default>{{ detail.periodStart || '—' }}</template></el-table-column>
|
||||
|
||||
@@ -394,7 +394,7 @@ onMounted(() => {
|
||||
</section>
|
||||
<section class="settlement-detail-info surface-panel">
|
||||
<h3>基本信息</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions :column="2">
|
||||
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">CNY</el-descriptions-item>
|
||||
@@ -429,8 +429,7 @@ onMounted(() => {
|
||||
</section>
|
||||
<section class="detail-section surface-panel">
|
||||
<h3>修订记录</h3>
|
||||
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无修订记录">
|
||||
<el-table-column prop="revisionNo" label="版本" width="90" />
|
||||
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录">
|
||||
<el-table-column prop="action" label="动作" min-width="120" />
|
||||
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
|
||||
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
|
||||
@@ -508,10 +507,10 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.finance-settlement-page { min-width: 0; }
|
||||
.settlement-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.summary-item { min-width: 0; padding: 16px 18px; border: 1px solid var(--cc-border, #e5e7eb); border-radius: 8px; background: var(--cc-surface, #fff); }
|
||||
.summary-item span { display: block; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
||||
.summary-item strong { display: block; margin-top: 8px; color: var(--cc-text-primary, #1f2937); font-size: 24px; line-height: 1.2; }
|
||||
.settlement-summary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 28px; padding: 0 2px; }
|
||||
.summary-item { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.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; }
|
||||
.summary-item strong.is-danger { color: #c2413a; }
|
||||
.settlement-detail-page { display: grid; min-width: 0; gap: 14px; }
|
||||
@@ -536,7 +535,8 @@ onMounted(() => {
|
||||
.operation-error-meta { margin: -6px 0 12px; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
@media (max-width: 720px) {
|
||||
.settlement-summary, .settlement-detail-summary, .partial-summary { grid-template-columns: 1fr; }
|
||||
.settlement-detail-summary, .partial-summary { grid-template-columns: 1fr; }
|
||||
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.summary-item strong { font-size: 21px; }
|
||||
.settlement-detail-header { align-items: flex-start; flex-direction: column; }
|
||||
.settlement-detail-actions { width: 100%; }
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('finance presentation governance', () => {
|
||||
})
|
||||
|
||||
it('translates source codes and provides reason placeholders', () => {
|
||||
expect(arap).toContain('financeSourceTypeLabel(detail.sourceType)')
|
||||
expect(arap).toContain('sourceTypeDisplay(detail.sourceType)')
|
||||
expect(arap).toContain('financeOperationActionLabel(log.action)')
|
||||
expect(arap).toContain('financeOperationResultLabel(log.result)')
|
||||
expect(arap).toContain('financeDirectionLabel(row.direction)')
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { createOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, performOceanBookingAction, releaseOceanBooking, requestOceanBookingChange } from './platformOceanBookings'
|
||||
import { http } from '@/shared/api/http'
|
||||
|
||||
const detailResponse = (version = 0) => ({
|
||||
const detailResponse = (version = 0, status = 'CONFIRMED') => ({
|
||||
oceanBooking: {
|
||||
id: 123, bookingNo: 'OB123', status: 'DRAFT', version, customerId: 456, customerName: '客户',
|
||||
id: 123, bookingNo: 'OB123', status, version, customerId: 456, customerName: '客户',
|
||||
containers: [],
|
||||
},
|
||||
parties: [], containerRequirements: [], containerAllocations: [], histories: [],
|
||||
@@ -21,23 +21,22 @@ 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() })
|
||||
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: 'SEA', warehouseId: '8', warehouseName: '义乌仓', serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [] } })
|
||||
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 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).not.toMatchObject({ data: { customerId: '', customerName: '' } })
|
||||
for (const releaseOnlyField of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
|
||||
expect(sentPayload.data).not.toHaveProperty(releaseOnlyField)
|
||||
}
|
||||
expect(sentPayload.data).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
|
||||
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')
|
||||
})
|
||||
|
||||
it('uses action and change-specific request contracts', async () => {
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(4) })
|
||||
await performOceanBookingAction('123', 'confirm', { expectedVersion: 3, reason: 'ok' })
|
||||
expect(post.mock.calls[0]?.[0]).toContain('/123/confirm')
|
||||
await performOceanBookingAction('123', 'cancel', { expectedVersion: 3, reason: 'ok' })
|
||||
expect(post.mock.calls[0]?.[0]).toContain('/123/cancel')
|
||||
expect(post.mock.calls[0]?.[1]).toEqual({ expectedVersion: 3, reason: 'ok' })
|
||||
|
||||
await requestOceanBookingChange('123', { expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
|
||||
|
||||
@@ -48,8 +48,7 @@ export async function listOceanBookings(query: OceanBookingQuery) {
|
||||
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
|
||||
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function updateOceanBooking(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export type OceanBookingAction = 'submit' | 'confirm' | 'release' | 'fail' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail'
|
||||
export type OceanBookingAction = 'release' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail'
|
||||
export async function performOceanBookingAction(id: string, action: Exclude<OceanBookingAction, 'change-request' | 'release'>, request: OceanBookingActionRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function requestOceanBookingChange(id: string, request: OceanBookingChangeRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/change-request`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
|
||||
@@ -5,6 +5,13 @@ import { describe, expect, it } from 'vitest'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
|
||||
|
||||
describe('OceanBookingDetailView interaction contract', () => {
|
||||
it('recognizes the dedicated create route as an editable new booking', () => {
|
||||
expect(source).toContain("route.name === 'ocean-booking-create' || id.value === 'new'")
|
||||
expect(source).toContain('const editable = computed(() => isNew.value)')
|
||||
expect(source).not.toContain("status === 'DRAFT'")
|
||||
expect(source).toContain("? access.can('platform:firstleg:ocean-booking:create')")
|
||||
})
|
||||
|
||||
it('selects business objects and automatically keeps their ids and names together', () => {
|
||||
expect(source).toContain('placeholder="请选择客户(可选)"')
|
||||
expect(source).toContain('filterable clearable')
|
||||
@@ -51,17 +58,23 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
]) expect(source).toContain(`placeholder="${placeholder}"`)
|
||||
})
|
||||
|
||||
it('keeps release-only transport facts out of draft editing and saving', () => {
|
||||
it('captures known offline booking facts during creation and only reviews them during release', () => {
|
||||
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
|
||||
expect(source).not.toContain(`v-model="form.data.${field}"`)
|
||||
expect(source).toContain(`v-model="form.data.${field}"`)
|
||||
expect(source).toContain(`prop="${field}"`)
|
||||
}
|
||||
expect(source).toContain(':model-value="detail?.billOfLadingNo || \'\'" disabled placeholder="确认放舱时录入"')
|
||||
expect(source).toContain(':model-value="detail?.vesselName || \'\'" disabled placeholder="确认放舱时录入"')
|
||||
expect(source).toContain('v-model="releaseForm.billOfLadingNo"')
|
||||
expect(source).toContain('v-model="releaseForm.vesselName"')
|
||||
expect(source).toContain('v-model="releaseForm.voyageNo"')
|
||||
expect(source).toContain('v-model="releaseForm.estimatedSailingAt"')
|
||||
expect(source).toContain('v-model="releaseForm.estimatedArrivalAt"')
|
||||
expect(source).not.toContain('确认放舱时录入')
|
||||
expect(source).toContain('billOfLadingNo: value.billOfLadingNo || \'\'')
|
||||
expect(source).toContain('estimatedSailingAt: value.estimatedSailingAt || null')
|
||||
expect(source).toContain('if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!)')
|
||||
expect(source).toContain('请复核已登记的真实订舱资料。放舱不会重新采集或修改这些信息。')
|
||||
expect(source).toContain('const billOfLadingNo = detail.value.billOfLadingNo?.trim()')
|
||||
expect(source).toContain('const estimatedSailingAt = detail.value.estimatedSailingAt')
|
||||
expect(source).toContain('v-model="releaseReason"')
|
||||
expect(source).not.toContain('v-model="releaseForm.')
|
||||
expect(source).toContain('线下订舱资料已登记并确认')
|
||||
expect(source).toContain("{{ isNew ? '登记订舱' : '保存' }}")
|
||||
expect(source).toContain('登记后直接确认')
|
||||
})
|
||||
|
||||
it('uses source-backed free text terms and keeps capacity snapshots behind one specification control', () => {
|
||||
@@ -147,9 +160,17 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
|
||||
it('shows client-side validation failures instead of rendering an empty error alert', () => {
|
||||
expect(source).toContain(':title="actionError.message"')
|
||||
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道和运输条款等必填信息' }")
|
||||
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }")
|
||||
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
|
||||
expect(source).toContain('规格(必填)')
|
||||
expect(source).toContain('placeholder="单位名称(必填)"')
|
||||
})
|
||||
|
||||
it('does not expose the removed draft submission lifecycle', () => {
|
||||
expect(source).not.toContain("submit: '提交'")
|
||||
expect(source).not.toContain("confirm: '确认订舱'")
|
||||
expect(source).not.toContain("fail: '标记失败'")
|
||||
expect(source).not.toContain("status === 'DRAFT'")
|
||||
expect(source).not.toContain('updateOceanBooking')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
performOceanBookingAction,
|
||||
releaseOceanBooking,
|
||||
requestOceanBookingChange,
|
||||
updateOceanBooking,
|
||||
listWarehouseLogisticsChannels,
|
||||
type OceanLogisticsChannel,
|
||||
type OceanBookingAction,
|
||||
@@ -47,28 +46,24 @@ const containerKindOptions = [
|
||||
{ value: 'LCL', label: '拼箱 LCL' },
|
||||
]
|
||||
const actionLabels: Record<OceanBookingAction, string> = {
|
||||
submit: '提交', confirm: '确认订舱', release: '确认放舱', fail: '标记失败', cancel: '取消订舱',
|
||||
release: '确认放舱', cancel: '取消订舱',
|
||||
'change-request': '申请改船', 'change-confirm': '确认改船', 'change-fail': '改船失败',
|
||||
}
|
||||
const actionPermissions: Record<OceanBookingAction, string> = {
|
||||
submit: 'platform:firstleg:ocean-booking:submit', confirm: 'platform:firstleg:ocean-booking:confirm',
|
||||
release: 'platform:firstleg:ocean-booking:release', fail: 'platform:firstleg:ocean-booking:confirm',
|
||||
release: 'platform:firstleg:ocean-booking:release',
|
||||
cancel: 'platform:firstleg:ocean-booking:cancel', 'change-request': 'platform:firstleg:ocean-booking:change',
|
||||
'change-confirm': 'platform:firstleg:ocean-booking:change', 'change-fail': 'platform:firstleg:ocean-booking:change',
|
||||
}
|
||||
const actionsByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction[]>> = {
|
||||
DRAFT: ['submit', 'cancel'],
|
||||
SUBMITTED: ['confirm', 'fail', 'cancel'],
|
||||
CONFIRMED: ['release', 'change-request', 'cancel'],
|
||||
CHANGE_PENDING: ['change-confirm', 'change-fail', 'cancel'],
|
||||
FAILED: ['cancel'],
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const id = computed(() => String(route.params.bookingId || ''))
|
||||
const isNew = computed(() => id.value === 'new')
|
||||
const isNew = computed(() => route.name === 'ocean-booking-create' || id.value === 'new')
|
||||
const detail = ref<OceanBookingDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -99,6 +94,7 @@ function blankParties(): OceanBookingParty[] {
|
||||
function blankData(): OceanBookingDraftData {
|
||||
return {
|
||||
customerId: null, customerName: null, channelCode: '', warehouseId: '', warehouseName: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
|
||||
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
|
||||
containerRequirements: [createBlankContainerRequirement()],
|
||||
}
|
||||
}
|
||||
@@ -106,20 +102,25 @@ const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blank
|
||||
const rules: FormRules = {
|
||||
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
||||
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
|
||||
billOfLadingNo: [{ required: true, message: '请输入提单号', trigger: 'blur' }],
|
||||
vesselName: [{ required: true, message: '请输入船名', trigger: 'blur' }],
|
||||
voyageNo: [{ required: true, message: '请输入航次', trigger: 'blur' }],
|
||||
estimatedSailingAt: [{ required: true, message: '请选择预计开船时间', trigger: 'change' }],
|
||||
estimatedArrivalAt: [{ required: true, message: '请选择预计到港时间', trigger: 'change' }],
|
||||
serviceTerm: [{ required: true, message: '请输入主要运输条款 Service Term', trigger: 'blur' }],
|
||||
freightTerm: [{ required: true, message: '请输入运费条款 Freight Term', trigger: 'blur' }],
|
||||
}
|
||||
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
|
||||
const editable = computed(() => isNew.value)
|
||||
const canSave = computed(() => isNew.value
|
||||
? access.can('platform:firstleg:ocean-booking:create')
|
||||
: editable.value && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const availableActions = computed(() => (actionsByStatus[detail.value?.status || 'DRAFT'] || [])
|
||||
const availableActions = computed(() => (actionsByStatus[detail.value?.status || 'CONFIRMED'] || [])
|
||||
.filter((action) => access.can(actionPermissions[action])))
|
||||
|
||||
const changeDialogOpen = ref(false)
|
||||
const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
|
||||
const releaseDialogOpen = ref(false)
|
||||
const releaseForm = reactive({ billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
|
||||
const releaseReason = ref('')
|
||||
|
||||
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
|
||||
return oceanBookingStatusLabel(status)
|
||||
@@ -153,6 +154,9 @@ function normalizeDraftData() {
|
||||
form.data.customerName = form.data.customerId ? form.data.customerName : null
|
||||
form.data.serviceTerm = form.data.serviceTerm?.trim() || null
|
||||
form.data.freightTerm = form.data.freightTerm?.trim() || null
|
||||
form.data.billOfLadingNo = form.data.billOfLadingNo.trim()
|
||||
form.data.vesselName = form.data.vesselName.trim()
|
||||
form.data.voyageNo = form.data.voyageNo.trim()
|
||||
}
|
||||
function selectWarehouse(value: unknown) {
|
||||
const warehouseId = String(value || '')
|
||||
@@ -197,6 +201,8 @@ function applyDetail(value: OceanBookingDetail) {
|
||||
form.expectedVersion = value.version
|
||||
form.data = {
|
||||
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '',
|
||||
billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '',
|
||||
estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null,
|
||||
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
|
||||
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
|
||||
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
|
||||
@@ -223,7 +229,11 @@ async function save() {
|
||||
actionError.value = null
|
||||
normalizeDraftData()
|
||||
if (!await formRef.value?.validate().catch(() => false)) {
|
||||
actionError.value = { message: '请完善装柜仓库、物流渠道和运输条款等必填信息' }
|
||||
actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }
|
||||
return
|
||||
}
|
||||
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) {
|
||||
actionError.value = { message: '预计到港时间不能早于预计开船时间' }
|
||||
return
|
||||
}
|
||||
if (form.data.parties.some((party) => !party.partyName.trim())) {
|
||||
@@ -237,18 +247,15 @@ async function save() {
|
||||
const payload = { ...form.data }
|
||||
if (isNew.value) {
|
||||
const created = await createOceanBooking({ expectedVersion: 0, data: payload })
|
||||
ElMessage.success('订舱已创建')
|
||||
ElMessage.success('线下订舱资料已登记并确认')
|
||||
await router.replace(`/ocean-bookings/${created.id}`)
|
||||
applyDetail(created)
|
||||
} else {
|
||||
applyDetail(await updateOceanBooking(id.value, { expectedVersion: form.expectedVersion, data: payload }))
|
||||
ElMessage.success('订舱草稿已保存')
|
||||
}
|
||||
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function requiredReason(action: OceanBookingAction) {
|
||||
if (!['fail', 'cancel', 'change-fail'].includes(action)) return null
|
||||
if (!['cancel', 'change-fail'].includes(action)) return null
|
||||
const result = await ElMessageBox.prompt(`请填写“${actionLabels[action]}”原因,服务端将写入状态历史。`, actionLabels[action], {
|
||||
inputValidator: (value) => Boolean(value?.trim()) || '原因不能为空',
|
||||
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning',
|
||||
@@ -264,31 +271,28 @@ function openChangeDialog() {
|
||||
changeDialogOpen.value = true
|
||||
}
|
||||
function openReleaseDialog() {
|
||||
releaseForm.billOfLadingNo = detail.value?.billOfLadingNo || ''
|
||||
releaseForm.vesselName = detail.value?.vesselName || ''
|
||||
releaseForm.voyageNo = detail.value?.voyageNo || ''
|
||||
releaseForm.estimatedSailingAt = detail.value?.estimatedSailingAt || null
|
||||
releaseForm.estimatedArrivalAt = detail.value?.estimatedArrivalAt || null
|
||||
releaseForm.reason = ''
|
||||
releaseReason.value = ''
|
||||
releaseDialogOpen.value = true
|
||||
}
|
||||
async function submitRelease() {
|
||||
if (!detail.value) return
|
||||
const billOfLadingNo = releaseForm.billOfLadingNo.trim()
|
||||
const vesselName = releaseForm.vesselName.trim()
|
||||
const voyageNo = releaseForm.voyageNo.trim()
|
||||
if (!billOfLadingNo || !vesselName || !voyageNo || !releaseForm.estimatedSailingAt || !releaseForm.estimatedArrivalAt) {
|
||||
actionError.value = getApiErrorDetails(new Error('请完整填写提单号、船名、航次、预计开船和预计到港时间'))
|
||||
const billOfLadingNo = detail.value.billOfLadingNo?.trim()
|
||||
const vesselName = detail.value.vesselName?.trim()
|
||||
const voyageNo = detail.value.voyageNo?.trim()
|
||||
const estimatedSailingAt = detail.value.estimatedSailingAt
|
||||
const estimatedArrivalAt = detail.value.estimatedArrivalAt
|
||||
if (!billOfLadingNo || !vesselName || !voyageNo || !estimatedSailingAt || !estimatedArrivalAt) {
|
||||
actionError.value = getApiErrorDetails(new Error('当前订舱缺少已确认运输资料,无法放舱'))
|
||||
return
|
||||
}
|
||||
if (releaseForm.estimatedArrivalAt < releaseForm.estimatedSailingAt) {
|
||||
if (estimatedArrivalAt < estimatedSailingAt) {
|
||||
actionError.value = getApiErrorDetails(new Error('预计到港时间不能早于预计开船时间'))
|
||||
return
|
||||
}
|
||||
actionSaving.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt: releaseForm.estimatedSailingAt, estimatedArrivalAt: releaseForm.estimatedArrivalAt, reason: releaseForm.reason.trim() || null }))
|
||||
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt, reason: releaseReason.value.trim() || null }))
|
||||
releaseDialogOpen.value = false
|
||||
ElMessage.success('确认放舱成功')
|
||||
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
||||
@@ -344,7 +348,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '填写并保存订舱资料'">
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '登记线下已确认的真实订舱资料'">
|
||||
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
|
||||
</PageToolbar>
|
||||
</div>
|
||||
@@ -355,10 +359,10 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<el-alert v-if="actionError" type="error" :closable="false" show-icon :title="actionError.message"><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<section class="surface-panel">
|
||||
<div class="section-title">
|
||||
<div><h2>订舱信息</h2><p v-if="detail && !editable">当前状态仅可查看,草稿状态才允许编辑。</p></div>
|
||||
<div><h2>订舱信息</h2><p v-if="detail && !editable">已确认订舱资料仅可查看,船期变更请使用“申请改船”。</p></div>
|
||||
<div class="actions">
|
||||
<el-button v-for="item in availableActions" :key="item" :loading="actionSaving" @click="runAction(item)">{{ actionLabels[item] }}</el-button>
|
||||
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button>
|
||||
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />{{ isNew ? '登记订舱' : '保存' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
@@ -367,25 +371,25 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
|
||||
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
|
||||
<el-form-item label="客户(可选)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="请选择客户(可选)" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '待创建'" disabled /></el-form-item>
|
||||
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '登记后直接确认'" disabled /></el-form-item>
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
|
||||
<el-form-item label="提单号"><el-input :model-value="detail?.billOfLadingNo || ''" disabled placeholder="确认放舱时录入" /></el-form-item>
|
||||
<el-form-item label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="booking-group" aria-label="航线船期">
|
||||
<h3>航线船期</h3><div class="booking-group__body booking-group__body--fields">
|
||||
<el-form-item label="船名"><el-input :model-value="detail?.vesselName || ''" disabled placeholder="确认放舱时录入" /></el-form-item>
|
||||
<el-form-item label="航次"><el-input :model-value="detail?.voyageNo || ''" disabled placeholder="确认放舱时录入" /></el-form-item>
|
||||
<el-form-item label="船名" prop="vesselName"><el-input v-model="form.data.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
|
||||
<el-form-item label="航次" prop="voyageNo"><el-input v-model="form.data.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
|
||||
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
|
||||
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
|
||||
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
|
||||
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
|
||||
<el-form-item label="预计开船"><el-date-picker :model-value="detail?.estimatedSailingAt || null" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" disabled placeholder="确认放舱时录入" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="预计到港"><el-date-picker :model-value="detail?.estimatedArrivalAt || null" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" disabled placeholder="确认放舱时录入" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="预计开船" prop="estimatedSailingAt"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
|
||||
<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="条款与柜量">
|
||||
@@ -459,14 +463,17 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</el-form>
|
||||
<LoadErrorState v-if="actionError" compact title="提交失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!releaseForm.billOfLadingNo.trim() || !releaseForm.vesselName.trim() || !releaseForm.voyageNo.trim() || !releaseForm.estimatedSailingAt || !releaseForm.estimatedArrivalAt" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="提单号" required><el-input v-model="releaseForm.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
|
||||
<el-form-item label="船名" required><el-input v-model="releaseForm.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
|
||||
<el-form-item label="航次" required><el-input v-model="releaseForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item label="预计开船" required><el-date-picker v-model="releaseForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="预计到港" required><el-date-picker v-model="releaseForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="releaseForm.reason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></el-form-item>
|
||||
<el-alert title="请复核已登记的真实订舱资料。放舱不会重新采集或修改这些信息。" type="info" :closable="false" show-icon />
|
||||
<el-descriptions class="release-summary" :column="2" border>
|
||||
<el-descriptions-item label="提单号">{{ detail?.billOfLadingNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="船名">{{ detail?.vesselName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="航次">{{ detail?.voyageNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预计开船">{{ detail?.estimatedSailingAt || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预计到港">{{ detail?.estimatedArrivalAt || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-form-item label="备注"><el-input v-model="releaseReason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="actionError" compact title="放舱失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('does not expose internal draft creation from the formal booking list', () => { expect(source).not.toContain('<template #toolbar-actions>'); expect(source).not.toContain('新增订舱'); expect(source).not.toContain("'/ocean-bookings/new'"); expect(source).not.toContain('PageToolbar') }); it('shows only the formal booking detail action', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).not.toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('limits status filters to the frozen formal booking states', () => { expect(source).toContain("['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']"); expect(source).toContain('options: formalStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
|
||||
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps real booking creation available without mixing draft rows into the formal list', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).toContain('新增订舱'); expect(source).toContain("router.push('/ocean-bookings/new')"); expect(source).toContain("access.can('platform:firstleg:ocean-booking:create')"); expect(source).not.toContain('PageToolbar') }); it('shows only the formal booking detail action', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).not.toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('limits status filters to the frozen formal booking states', () => { expect(source).toContain("['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']"); expect(source).toContain('options: formalStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
|
||||
|
||||
describe('OceanBookingsView prototype field matrix', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import { Eye, Plus } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
@@ -16,6 +17,7 @@ import type { OceanBookingQuery, OceanBookingStatus } from '../types'
|
||||
import { oceanBookingStatusLabel, oceanBookingStatusOptions } from '../status'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<OceanBookingResource[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||||
@@ -26,6 +28,7 @@ const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const query = reactive<QueryModel>({ customerId: '', keyword: '', status: '' })
|
||||
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
|
||||
const canCreate = computed(() => access.can('platform:firstleg:ocean-booking:create'))
|
||||
const formalStatusOptions = oceanBookingStatusOptions.filter((item) => ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'].includes(item.value))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||
@@ -50,6 +53,7 @@ function formatDateTime(value?: string | null) { return value ? value.replace('T
|
||||
async function loadCustomers() { customersLoading.value = true; try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records.filter((customer) => customer.status === 'ACTIVE') } catch { customers.value = [] } finally { customersLoading.value = false } }
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, customerId: String(query.customerId || '') || undefined, keyword: String(query.keyword || '') || undefined, status: (query.status || undefined) as OceanBookingStatus | undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } }
|
||||
function open(id: string) { void router.push(`/ocean-bookings/${id}`) }
|
||||
function createBooking() { void router.push('/ocean-bookings/new') }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
watch([page, pageSize], () => void load()); onMounted(() => { void load(); void loadCustomers() })
|
||||
</script>
|
||||
@@ -60,6 +64,7 @@ watch([page, pageSize], () => void load()); onMounted(() => { void load(); void
|
||||
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createBooking"><Plus :size="16" />新增订舱</el-button></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Eye :size="15" />查看</el-button></div></template>
|
||||
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
||||
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:list' } }] satisfies RouteRecordRaw[]
|
||||
export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/new', name: 'ocean-booking-create', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '新增订舱', description: '登记线下已确认的真实海运订舱', permission: 'platform:firstleg:ocean-booking:create' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:list' } }] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -4,11 +4,8 @@ import { oceanBookingStatusLabel, oceanBookingStatusOptions } from './status'
|
||||
describe('ocean booking status labels', () => {
|
||||
it('translates every backend status for selectors and badges', () => {
|
||||
expect(oceanBookingStatusOptions).toEqual([
|
||||
{ value: 'DRAFT', label: '草稿' },
|
||||
{ value: 'SUBMITTED', label: '已提交' },
|
||||
{ value: 'CONFIRMED', label: '已确认' },
|
||||
{ value: 'RELEASED', label: '已放舱' },
|
||||
{ value: 'FAILED', label: '订舱失败' },
|
||||
{ value: 'CHANGE_PENDING', label: '变更中' },
|
||||
{ value: 'CANCELLED', label: '已取消' },
|
||||
])
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { OceanBookingStatus } from './types'
|
||||
|
||||
export const oceanBookingStatusLabels: Record<OceanBookingStatus, string> = {
|
||||
DRAFT: '草稿',
|
||||
SUBMITTED: '已提交',
|
||||
CONFIRMED: '已确认',
|
||||
RELEASED: '已放舱',
|
||||
FAILED: '订舱失败',
|
||||
CHANGE_PENDING: '变更中',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED' | 'FAILED' | 'CHANGE_PENDING' | 'CANCELLED'
|
||||
export type OceanBookingStatus = 'CONFIRMED' | 'RELEASED' | 'CHANGE_PENDING' | 'CANCELLED'
|
||||
|
||||
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 }
|
||||
@@ -6,6 +6,8 @@ export interface OceanBookingContainerAllocation { id?: string; oceanBookingId?:
|
||||
export interface OceanBookingDraftData {
|
||||
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
|
||||
channelCode: string; warehouseId: string; warehouseName: 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
|
||||
departurePortCode?: string | null; departurePortName?: string | null; destinationPortCode?: string | null; destinationPortName?: string | null
|
||||
placeOfDelivery?: string | null; serviceTerm: string | null; freightTerm: string | null
|
||||
|
||||
Reference in New Issue
Block a user