diff --git a/src/app/router/index.test.ts b/src/app/router/index.test.ts
index 41862693..d9012210 100644
--- a/src/app/router/index.test.ts
+++ b/src/app/router/index.test.ts
@@ -63,6 +63,20 @@ describe('admin router access metadata', () => {
expect(detailIndex).toBeGreaterThan(globalIndex)
})
+ it('registers phase-three service center routes with their stable permissions', () => {
+ const expected = [
+ ['port-schedules', '/port-schedules', 'platform:firstleg:port-schedule:list'],
+ ['customs-cases', '/customs', 'platform:firstleg:customs:list'],
+ ['outbound-dispatches', '/outbound-dispatches', 'platform:firstleg:outbound-dispatch:list'],
+ ]
+
+ for (const [name, path, permission] of expected) {
+ const route = router.getRoutes().find((item) => item.name === name)
+ expect(route?.path).toBe(path)
+ expect(route?.meta.permission).toBe(permission)
+ }
+ })
+
it('protects counterparty records with a stable master-data permission', () => {
const route = router.getRoutes().find((item) => item.name === 'counterparties')
expect(route?.path).toBe('/master-data/counterparties')
diff --git a/src/app/router/index.ts b/src/app/router/index.ts
index da61bb89..8bfb0e66 100644
--- a/src/app/router/index.ts
+++ b/src/app/router/index.ts
@@ -21,6 +21,9 @@ import { warehouseRoutes } from '@/modules/warehouses/routes'
import { prototypeModuleRoutes } from '@/prototype/routes'
import { oceanBookingRoutes } from '@/modules/ocean-bookings/routes'
import { truckingRoutes } from '@/modules/trucking/routes'
+import { portScheduleRoutes } from '@/modules/port-schedules/routes'
+import { customsRoutes } from '@/modules/customs/routes'
+import { outboundDispatchRoutes } from '@/modules/outbound-dispatch/routes'
import { feeItemRoutes } from '@/modules/fee-items/routes'
import { containerLoadingRoutes } from '@/modules/container-loading/routes'
import { loadingListRoutes } from '@/modules/loading-lists/routes'
@@ -49,7 +52,10 @@ const router = createRouter({
...dashboardRoutes,
...prototypeModuleRoutes,
...oceanBookingRoutes,
+ ...portScheduleRoutes,
...truckingRoutes,
+ ...customsRoutes,
+ ...outboundDispatchRoutes,
...containerLoadingRoutes,
...loadingListRoutes,
...financeRoutes,
diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts
index 8fa1a96e..a6e7cb0f 100644
--- a/src/core/navigation/adminNavigation.test.ts
+++ b/src/core/navigation/adminNavigation.test.ts
@@ -35,6 +35,13 @@ describe('adminNavigation', () => {
'库位管理',
'托盘管理',
])
+ expect(adminNavigation.find((item) => item.key === 'service-center')?.children?.map((item) => item.title)).toEqual([
+ '订舱列表',
+ '船期信息',
+ '拖车列表',
+ '报关协同',
+ '国内出仓',
+ ])
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
'供应商列表',
'快递公司',
@@ -89,11 +96,14 @@ describe('adminNavigation', () => {
expect(paths).toContain('/container-loading/smart-loading')
expect(paths).toContain('/loading-plans')
expect(paths).toContain('/loading-lists')
+ expect(paths).toContain('/port-schedules')
+ expect(paths).toContain('/customs')
+ expect(paths).toContain('/outbound-dispatches')
expect(paths).not.toContain('/prototype/container-management/container-list')
expect(paths).not.toContain('/prototype/container-management/preload-list')
expect(paths).not.toContain('/prototype/container-management/manifest')
expect(paths).not.toContain('/prototype/general/fee-items')
- expect(paths).toContain('/prototype/finance/profit-report')
+ expect(paths).toContain('/finance/bills')
expect(paths).toContain('/prototype/system-management/brand-settings')
expect(paths).toContain('/system/permissions')
expect(new Set(paths).size).toBe(paths.length)
@@ -166,6 +176,25 @@ describe('adminNavigation', () => {
])
})
+ it('shows each phase-three service center entry only with its matching permission', () => {
+ const filtered = filterAdminNavigation(adminNavigation, new Set([
+ 'platform:firstleg:ocean-booking:list',
+ 'platform:firstleg:port-schedule:list',
+ 'platform:firstleg:truck-appointment:list',
+ 'platform:firstleg:customs:list',
+ 'platform:firstleg:outbound-dispatch:list',
+ ]))
+
+ expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
+ '/dashboard',
+ '/ocean-bookings',
+ '/port-schedules',
+ '/trucking',
+ '/customs',
+ '/outbound-dispatches',
+ ])
+ })
+
it('supports manually hiding a module without removing its route definition', () => {
const filtered = filterAdminNavigation([{ key: 'hidden', title: '隐藏模块', icon: adminNavigation[0].icon, path: '/hidden', hidden: true }], new Set())
diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts
index 9d9568d9..80d05523 100644
--- a/src/core/navigation/adminNavigation.ts
+++ b/src/core/navigation/adminNavigation.ts
@@ -78,7 +78,10 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: ClipboardList,
children: [
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
+ { key: 'port-schedules', title: '船期信息', icon: ClipboardList, path: '/port-schedules', permission: 'platform:firstleg:port-schedule:list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
+ { key: 'customs-cases', title: '报关协同', icon: FileCheck2, path: '/customs', permission: 'platform:firstleg:customs:list' },
+ { key: 'outbound-dispatches', title: '国内出仓', icon: Truck, path: '/outbound-dispatches', permission: 'platform:firstleg:outbound-dispatch:list' },
],
},
{
@@ -137,7 +140,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'finance-bills', title: '客户账单', icon: FileCheck2, path: '/finance/bills', permission: 'platform:finance:bill:view' },
{ key: 'finance-receipts', title: '收款登记', icon: CircleDollarSign, path: '/finance/receipts', permission: 'platform:finance:receipt:view' },
{ key: 'finance-costs', title: '成本录入', icon: Database, path: '/finance/costs', permission: 'platform:finance:cost:view' },
- { key: 'finance-reports', title: '财务统计', icon: BarChart3, path: '/finance/reports', permission: 'platform:finance:report:view' },
],
},
{
diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts
index 818c4fc4..40fade8c 100644
--- a/src/modules/container-loading/pages/ContainersView.test.ts
+++ b/src/modules/container-loading/pages/ContainersView.test.ts
@@ -8,8 +8,18 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listContainers } from '../api/platformContainerLoading'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
+function createTestRouter() {
+ return createRouter({ history: createMemoryHistory(), routes: [
+ { path: '/', component: ContainersView },
+ { path: '/container-loading/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '
' } },
+ { path: '/loading-plans', name: 'loading-plans', component: { template: '' } },
+ { path: '/loading-lists', name: 'loading-lists', component: { template: '' } },
+ ] })
+}
+
describe('ContainersView', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -18,7 +28,7 @@ describe('ContainersView', () => {
})
it('loads the real paged contract and renders core container fields', async () => {
- const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
+ const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
@@ -30,6 +40,18 @@ describe('ContainersView', () => {
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
})
+ it('keeps the cross-warehouse list usable when warehouse form options fail', async () => {
+ vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new Error('warehouse unavailable'))
+ const router = createTestRouter()
+ await router.push('/'); await router.isReady()
+ const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('CB-1')
+ expect(wrapper.text()).not.toContain('装柜仓库加载失败')
+ expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
+ })
+
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
@@ -47,7 +69,7 @@ describe('ContainersView', () => {
],
page: 1, size: 20, total: 3, pages: 1,
} as never)
- const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
+ const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
@@ -58,4 +80,39 @@ describe('ContainersView', () => {
expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情']))
expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
})
+
+ it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
+ const base = {
+ id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', version: 0,
+ billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
+ departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
+ destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
+ terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
+ estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
+ }
+ vi.mocked(listContainers).mockResolvedValueOnce({
+ records: [
+ { ...base, planStatus: 'EDITING' as const },
+ { ...base, id: '9002', planId: '9102', cabinetNo: 'CB-2', planStatus: 'LOADING' as const },
+ { ...base, id: '9003', planId: '9103', cabinetNo: 'CB-3', planStatus: 'COMPLETED' as const },
+ ],
+ page: 1, size: 20, total: 3, pages: 1,
+ })
+ const router = createTestRouter()
+ await router.push('/'); await router.isReady()
+ const push = vi.spyOn(router, 'push').mockResolvedValue(undefined as never)
+ const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('排柜')
+ expect(wrapper.text()).toContain('查看预装清单')
+ expect(wrapper.text()).toContain('查看装柜清单')
+
+ await wrapper.findAll('button').find((button) => button.text().trim() === '排柜')!.trigger('click')
+ await wrapper.findAll('button').find((button) => button.text().includes('查看预装清单'))!.trigger('click')
+ await wrapper.findAll('button').find((button) => button.text().includes('查看装柜清单'))!.trigger('click')
+ expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
+ expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
+ expect(push).toHaveBeenNthCalledWith(3, { name: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
+ })
})
diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue
index 40121610..64c1af48 100644
--- a/src/modules/container-loading/pages/ContainersView.vue
+++ b/src/modules/container-loading/pages/ContainersView.vue
@@ -1,7 +1,7 @@
+
+
+{{ detail.customsCase.customsNo }}{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}{{ detail.documents.length }} / {{ detail.receipts.length }}登记线下提交状态历史
{{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}{{ entry.note || '' }}
+
diff --git a/src/modules/customs/routes.ts b/src/modules/customs/routes.ts
new file mode 100644
index 00000000..f2eb6622
--- /dev/null
+++ b/src/modules/customs/routes.ts
@@ -0,0 +1,2 @@
+import type { RouteRecordRaw } from 'vue-router'
+export const customsRoutes = [{ path: 'customs', name: 'customs-cases', component: () => import('./pages/CustomsCasesView.vue'), meta: { title: '报关协同', description: '维护报关资料、代理提交与放行回执', permission: 'platform:firstleg:customs:list' } }] satisfies RouteRecordRaw[]
diff --git a/src/modules/customs/types.ts b/src/modules/customs/types.ts
new file mode 100644
index 00000000..3692c9c4
--- /dev/null
+++ b/src/modules/customs/types.ts
@@ -0,0 +1,7 @@
+export interface CustomsCaseResource { id: string; customsNo: string; tenantId?: string | null; orderId: string; inboundId: string; containerBookingId?: string | null; checklistVersionId: string; checklistCode: string; checklistVersionNo: string; status: string; remark?: string | null; version: number; updatedAt?: string | null }
+export interface CustomsDocumentResource { id: string; itemCode: string; fileName?: string | null; status?: string | null; version?: number }
+export interface CustomsHistoryResource { id: string; action: string; fromStatus?: string | null; toStatus?: string | null; note?: string | null; operatorName?: string | null; createdAt: string }
+export interface CustomsCaseDetail { customsCase: CustomsCaseResource; documents: CustomsDocumentResource[]; supplements: unknown[]; submissionBatches: Array<{ id: string; customsAgentName?: string; status?: string; createdAt?: string }>; receipts: Array<{ id: string; receiptType?: string; status?: string; createdAt?: string }>; history: CustomsHistoryResource[] }
+export interface CustomsCasePage { records: CustomsCaseResource[]; page: number; size: number; total: number; pages: number }
+export interface CustomsCaseQuery { page: number; size: number; customerId?: string; orderId?: string; inboundId?: string; containerBookingId?: string; status?: string }
+export interface CustomsCaseCreateRequest { customerId: string; orderId: string; inboundId: string; containerBookingId?: string | null; checklistVersionId: string; remark?: string | null; expectedVersion: number }
diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts
new file mode 100644
index 00000000..2d1b69d0
--- /dev/null
+++ b/src/modules/finance/api/finance.test.ts
@@ -0,0 +1,70 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { http } from '@/shared/api/http'
+import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, voidFinanceBill } from './finance'
+
+describe('finance api', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('uses the OpenAPI bill path and pageSize parameter', async () => {
+ const get = vi.spyOn(http, 'get').mockResolvedValue({
+ code: '200', message: 'ok', data: [{ id: 1, bill_no: 'B-1', customer_name_snapshot: '客户A', bill_date: '2026-08-11', due_date: '2026-09-10', total_amount: 100, received_amount: 0, outstanding_amount: 100, status: 'DRAFT', version: 2 }],
+ })
+
+ const result = await listFinanceBills({ customerId: '10001', status: 'DRAFT', page: 1, pageSize: 20 })
+
+ expect(get).toHaveBeenCalledWith('/platform/finance/v1/bills', { params: { customerId: '10001', status: 'DRAFT', page: 1, pageSize: 20 } })
+ expect(result.records[0]).toMatchObject({ id: '1', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2 })
+ })
+
+ it('uses the receipt and cost entry paths and fixes currency to CNY', async () => {
+ const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] })
+ const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} })
+
+ await listFinanceReceipts('10001')
+ await listFinanceCosts('10001')
+ await createFinanceReceipt({ customerId: '10001', amount: 100 }, ['90001'])
+ await createFinanceCost({ customerId: '10001', amount: 100 }, ['90001'])
+
+ expect(get).toHaveBeenNthCalledWith(1, '/platform/finance/v1/receipts', { params: { customerId: '10001' } })
+ expect(get).toHaveBeenNthCalledWith(2, '/platform/finance/v1/cost-entries', { params: { customerId: '10001' } })
+ expect(post.mock.calls[0]?.[1]).toMatchObject({ currency: 'CNY', voucherFileIds: ['90001'] })
+ expect(post.mock.calls[1]?.[1]).toMatchObject({ currency: 'CNY', voucherFileIds: ['90001'] })
+ expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
+ expect(post.mock.calls[1]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
+ })
+
+ it('maps the real cost fee_item_name and cost_amount fields', async () => {
+ vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ id: 71, tenant_id: 10001, cost_type: 'CONTAINER_SHARED', container_id: 81, fee_item_name: '拖车费', cost_amount: '680.500000', voucher_file_ids: '["90001"]', created_at: '2026-08-11T10:00:00' }] })
+
+ const result = await listFinanceCosts('10001')
+
+ expect(result.records[0]).toMatchObject({ id: '71', customerId: '10001', containerId: '81', expenseName: '拖车费', amount: 680.5, voucherFileIds: ['90001'] })
+ })
+
+ it('normalizes bill details and uses update, adjust and void contracts', async () => {
+ const raw = { id: 81, tenant_id: 10001, bill_no: 'B-81', customer_name_snapshot: '客户A', bill_date: '2026-08-11', due_date: '2026-09-10', total_amount: 100, received_amount: 0, outstanding_amount: 100, status: 'DRAFT', version: 3, lines: [{ source_id: 91, source_version: 2, fee_item_name: '运费', amount: 100 }] }
+ vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: raw })
+ const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'ok', data: raw })
+ const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: raw })
+
+ const detail = await getFinanceBill('81')
+ await updateFinanceBill('81', { customerId: '10001', expectedVersion: 3, lines: [] })
+ await adjustFinanceBill('81', { expectedVersion: 3, reason: '金额修正', lines: [] })
+ await voidFinanceBill('81', 3, '重复账单')
+
+ expect(detail).toMatchObject({ id: '81', customerId: '10001', version: 3, lines: [{ sourceFeeId: '91', sourceFeeVersion: 2, feeItemName: '运费', amount: 100 }] })
+ expect(put).toHaveBeenCalledWith('/platform/finance/v1/bills/81', expect.objectContaining({ expectedVersion: 3 }), { headers: { 'Idempotency-Key': expect.any(String) } })
+ expect(post).toHaveBeenNthCalledWith(1, '/platform/finance/v1/bills/81/adjust', expect.objectContaining({ expectedVersion: 3, reason: '金额修正' }), { headers: { 'Idempotency-Key': expect.any(String) } })
+ expect(post).toHaveBeenNthCalledWith(2, '/platform/finance/v1/bills/81/void', { expectedVersion: 3, reason: '重复账单' }, { headers: { 'Idempotency-Key': expect.any(String) } })
+ })
+
+ it('issues a bill with optimistic version and an idempotency key', async () => {
+ const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} })
+
+ await issueFinanceBill('81001', 3)
+
+ expect(post).toHaveBeenCalledWith('/platform/finance/v1/bills/81001/issue', { expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } })
+ })
+})
diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts
index 2e81e5f6..b98188ad 100644
--- a/src/modules/finance/api/finance.ts
+++ b/src/modules/finance/api/finance.ts
@@ -1,46 +1,115 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
-import type { FinanceBillResource, FinanceCostResource, FinanceReceiptResource, FinanceReportResource } from '../types'
+import type { FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceCostResource, FinancePaymentMethod, FinanceReceiptResource } from '../types'
-const basePath = '/platform/v1/finance'
-interface PageResult { records: T[]; total: number; page: number; size: number }
+const basePath = '/platform/finance/v1'
+function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
+type RawResource = Record
+interface PageResult { records: T[]; total: number; serverPaged: boolean }
+type RawPage = RawResource[] | { records?: RawResource[]; total?: number }
-export async function listFinanceBills(params: Record) {
- const response = await http.get>, ApiEnvelope>>(`${basePath}/bills`, { params: { ...params, currency: 'CNY' } })
- return unwrapApiResponse(response)
+function stringValue(value: unknown) { return value == null ? '' : String(value) }
+function numberValue(value: unknown) { return Number(value ?? 0) }
+function fileIds(value: unknown): string[] {
+ if (Array.isArray(value)) return value.map(String)
+ if (typeof value !== 'string' || !value.trim()) return []
+ try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed.map(String) : [] } catch { return [] }
+}
+function normalizePage(payload: RawPage, map: (item: RawResource) => T): PageResult {
+ const records = Array.isArray(payload) ? payload : payload.records || []
+ return { records: records.map(map), total: Array.isArray(payload) ? records.length : Number(payload.total ?? records.length), serverPaged: !Array.isArray(payload) }
+}
+function normalizeBill(item: RawResource, customerId = stringValue(item.tenant_id ?? item.customerId)): FinanceBillResource {
+ return {
+ id: stringValue(item.id), billNo: stringValue(item.bill_no ?? item.billNo), customerId,
+ customerName: stringValue(item.customer_name_snapshot ?? item.customerName) || `客户 ${customerId}`,
+ billDate: stringValue(item.bill_date ?? item.billDate), dueDate: stringValue(item.due_date ?? item.dueDate), currency: 'CNY',
+ totalAmount: numberValue(item.total_amount ?? item.totalAmount), paidAmount: numberValue(item.received_amount ?? item.paidAmount),
+ outstandingAmount: numberValue(item.outstanding_amount ?? item.outstandingAmount), status: stringValue(item.status) as FinanceBillResource['status'],
+ version: numberValue(item.version),
+ }
+}
+function normalizeBillLine(item: RawResource): FinanceBillLine {
+ return {
+ sourceFeeId: stringValue(item.source_id ?? item.sourceFeeId), sourceFeeVersion: numberValue(item.source_version ?? item.sourceFeeVersion) || 1,
+ orderId: item.order_id == null && item.orderId == null ? null : stringValue(item.order_id ?? item.orderId),
+ inboundId: item.inbound_id == null && item.inboundId == null ? null : stringValue(item.inbound_id ?? item.inboundId),
+ feeItemName: stringValue(item.fee_item_name ?? item.feeItemName), amount: numberValue(item.amount),
+ }
+}
+function normalizeBillDetail(item: RawResource): FinanceBillDetail {
+ return { ...normalizeBill(item), remark: stringValue(item.remark), lines: Array.isArray(item.lines) ? item.lines.map((line) => normalizeBillLine(line as RawResource)) : [] }
+}
+function mutationHeaders(operation: string) { return { headers: { 'Idempotency-Key': idempotencyKey(operation) } } }
+
+export async function listFinanceBills(params: { customerId: string; status?: string; page: number; pageSize: number }) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/bills`, { params })
+ return normalizePage(unwrapApiResponse(response), (item) => normalizeBill(item, params.customerId))
}
export async function createFinanceBill(payload: Record) {
- const response = await http.post, ApiEnvelope>(`${basePath}/bills`, { ...payload, currency: 'CNY' })
+ const response = await http.post, ApiEnvelope>(`${basePath}/bills`, { ...payload }, mutationHeaders('bill'))
return unwrapApiResponse(response)
}
-export async function listFinanceReceipts(params: Record) {
- const response = await http.get>, ApiEnvelope>>(`${basePath}/receipts`, { params: { ...params, currency: 'CNY' } })
+export async function getFinanceBill(billId: string) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/bills/${billId}`)
+ return normalizeBillDetail(unwrapApiResponse(response))
+}
+
+export async function updateFinanceBill(billId: string, payload: Record) {
+ const response = await http.put, ApiEnvelope>(`${basePath}/bills/${billId}`, payload, mutationHeaders('bill-update'))
+ return normalizeBillDetail(unwrapApiResponse(response))
+}
+
+export async function adjustFinanceBill(billId: string, payload: Record) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/bills/${billId}/adjust`, payload, mutationHeaders('bill-adjust'))
+ return normalizeBillDetail(unwrapApiResponse(response))
+}
+
+export async function voidFinanceBill(billId: string, expectedVersion: number, reason: string) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/bills/${billId}/void`, { expectedVersion, reason }, mutationHeaders('bill-void'))
+ return normalizeBillDetail(unwrapApiResponse(response))
+}
+
+export async function listFinanceReceipts(customerId?: string) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/receipts`, { params: { customerId: customerId || undefined } })
+ const payload = unwrapApiResponse(response)
+ const normalized = normalizePage(payload, (item) => ({
+ id: stringValue(item.id), customerId: stringValue(item.tenant_id), customerName: `客户 ${stringValue(item.tenant_id)}`,
+ receivedDate: stringValue(item.received_at).slice(0, 10), amount: numberValue(item.received_amount), currency: 'CNY',
+ paymentMethod: stringValue(item.payment_method) as FinancePaymentMethod, voucherFileIds: fileIds(item.voucher_file_ids),
+ allocationCount: item.bill_id == null ? 0 : 1,
+ }))
+ const grouped = new Map()
+ for (const item of normalized.records) {
+ const existing = grouped.get(item.id)
+ if (existing) existing.allocationCount += item.allocationCount
+ else grouped.set(item.id, { ...item })
+ }
+ return { ...normalized, records: [...grouped.values()], total: normalized.serverPaged ? normalized.total : grouped.size }
+}
+
+export async function createFinanceReceipt(payload: Record, voucherFileIds: string[]) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/receipts`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('receipt') } })
return unwrapApiResponse(response)
}
-export async function createFinanceReceipt(payload: Record, voucher?: File) {
- const body = new FormData()
- body.append('payload', JSON.stringify({ ...payload, currency: 'CNY' }))
- if (voucher) body.append('file', voucher)
- const response = await http.post, ApiEnvelope>(`${basePath}/receipts`, body)
+export async function listFinanceCosts(customerId?: string) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/cost-entries`, { params: { customerId: customerId || undefined } })
+ return normalizePage(unwrapApiResponse(response), (item) => ({
+ id: stringValue(item.id), customerId: stringValue(item.tenant_id), costType: stringValue(item.cost_type) as FinanceCostResource['costType'],
+ orderId: item.order_id == null ? null : stringValue(item.order_id), containerId: item.container_id == null ? null : stringValue(item.container_id),
+ expenseName: stringValue(item.fee_item_name ?? item.expense_name), amount: numberValue(item.cost_amount ?? item.amount), currency: 'CNY',
+ voucherFileIds: fileIds(item.voucher_file_ids), createdAt: stringValue(item.created_at),
+ }))
+}
+
+export async function createFinanceCost(payload: Record, voucherFileIds: string[]) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/cost-entries`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('cost') } })
return unwrapApiResponse(response)
}
-export async function listFinanceCosts(params: Record) {
- const response = await http.get>, ApiEnvelope>>(`${basePath}/costs`, { params: { ...params, currency: 'CNY' } })
- return unwrapApiResponse(response)
-}
-
-export async function createFinanceCost(payload: Record, voucher?: File) {
- const body = new FormData()
- body.append('payload', JSON.stringify({ ...payload, currency: 'CNY' }))
- if (voucher) body.append('file', voucher)
- const response = await http.post, ApiEnvelope>(`${basePath}/costs`, body)
- return unwrapApiResponse(response)
-}
-
-export async function listFinanceReports(params: Record) {
- const response = await http.get, ApiEnvelope>(`${basePath}/reports`, { params: { ...params, currency: 'CNY' } })
+export async function issueFinanceBill(billId: string, expectedVersion: number) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/bills/${billId}/issue`, { expectedVersion }, mutationHeaders('bill-issue'))
return unwrapApiResponse(response)
}
diff --git a/src/modules/finance/pages/FinanceManualPage.test.ts b/src/modules/finance/pages/FinanceManualPage.test.ts
new file mode 100644
index 00000000..5e2836d2
--- /dev/null
+++ b/src/modules/finance/pages/FinanceManualPage.test.ts
@@ -0,0 +1,35 @@
+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/FinanceManualPage.vue'), 'utf8')
+
+describe('FinanceManualPage delivery contract', () => {
+ it('keeps every business table operation column first and fixed left', () => {
+ const declarations = [...source.matchAll(/return \[([^\n]+)\]/g)].map((match) => match[1] || '').filter((value) => value.includes("key: 'actions'"))
+ expect(declarations).toHaveLength(3)
+ for (const declaration of declarations) {
+ expect(declaration.trim().startsWith("{ key: 'actions'")).toBe(true)
+ expect(declaration).toContain("fixed: 'left'")
+ }
+ })
+
+ it('exposes supported bill operations with permission checks and traceable errors', () => {
+ expect(source).toContain("platform:finance:bill:update")
+ expect(source).toContain("platform:finance:bill:adjust")
+ expect(source).toContain("platform:finance:bill:void")
+ expect(source).toContain("openBill(row, 'detail')")
+ expect(source).toContain("openBill(row, 'edit')")
+ expect(source).toContain("openBill(row, 'adjust')")
+ expect(source).toContain(' {
+ expect(source).toContain('凭证上传接口尚未接通')
+ expect(source).toContain('type="file" accept="image/*" disabled')
+ expect(source).toContain('@media (max-width: 720px)')
+ expect(source).toContain('@media (max-width: 480px)')
+ expect(source).toContain('grid-template-columns: 1fr')
+ })
+})
diff --git a/src/modules/finance/pages/FinanceManualPage.vue b/src/modules/finance/pages/FinanceManualPage.vue
index fba70d2c..2f6de2cf 100644
--- a/src/modules/finance/pages/FinanceManualPage.vue
+++ b/src/modules/finance/pages/FinanceManualPage.vue
@@ -1,11 +1,11 @@
@@ -90,47 +193,101 @@ onMounted(() => void load())
+
-
+
{{ props.mode === 'bills' ? '新建账单' : props.mode === 'receipts' ? '登记收款' : '录入成本' }}
- 人工台账
- {{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : '作废' }}
- {{ row.costType === 'CONTAINER' ? '整柜公共成本' : '单票成本' }}
+
+
+
查看
+
编辑
+
调整
+
作废
+
+
+ 出账作废
+
+
+ 人工台账
+
+ {{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : '作废' }}
+ {{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}
币种:CNY · 数据由平台财务人工维护
-
-
+
+
+
+
+ {{ selectedBill.billNo }}v{{ selectedBill.version }}
+ {{ selectedBill.customerName }}{{ selectedBill.status }}
+ {{ selectedBill.billDate }}{{ selectedBill.dueDate }}
+ {{ selectedBill.totalAmount }} CNY{{ selectedBill.outstandingAmount }} CNY
+
+
+ {{ line.feeItemName }}来源ID:{{ line.sourceFeeId }}{{ line.amount }} CNY
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+ 费用明细
+
+
+
+
+
+
+ 添加费用明细
+
+
+
+
+
+
+
+
+
+
+ 账单分配
+
+ 添加账单分配
+
+
+
+
+
-
+
diff --git a/src/modules/finance/pages/FinanceReportsView.vue b/src/modules/finance/pages/FinanceReportsView.vue
deleted file mode 100644
index af61cb23..00000000
--- a/src/modules/finance/pages/FinanceReportsView.vue
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
diff --git a/src/modules/finance/routes.ts b/src/modules/finance/routes.ts
index 89427e2a..0dbb8ef2 100644
--- a/src/modules/finance/routes.ts
+++ b/src/modules/finance/routes.ts
@@ -4,5 +4,4 @@ export const financeRoutes = [
{ path: 'finance/bills', name: 'finance-bills', component: () => import('./pages/FinanceBillsView.vue'), meta: { title: '客户账单', description: '人工维护 CNY 客户账单并在出账后冻结', permission: 'platform:finance:bill:view' } },
{ path: 'finance/receipts', name: 'finance-receipts', component: () => import('./pages/FinanceReceiptsView.vue'), meta: { title: '收款登记', description: '人工登记收款并上传凭证图片', permission: 'platform:finance:receipt:view' } },
{ path: 'finance/costs', name: 'finance-costs', component: () => import('./pages/FinanceCostsView.vue'), meta: { title: '成本录入', description: '人工登记单票成本和整柜公共成本', permission: 'platform:finance:cost:view' } },
- { path: 'finance/reports', name: 'finance-reports', component: () => import('./pages/FinanceReportsView.vue'), meta: { title: '财务统计', description: '按账单统计 CNY 收入、成本和利润', permission: 'platform:finance:report:view' } },
] satisfies RouteRecordRaw[]
diff --git a/src/modules/finance/types.ts b/src/modules/finance/types.ts
index f71c0fee..98929bc5 100644
--- a/src/modules/finance/types.ts
+++ b/src/modules/finance/types.ts
@@ -1,5 +1,6 @@
-export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOID'
-export type FinanceCostType = 'WAYBILL' | 'CONTAINER'
+export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
+export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED'
+export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
export interface FinanceBillResource {
id: string
@@ -13,36 +14,44 @@ export interface FinanceBillResource {
paidAmount: number
outstandingAmount: number
status: FinanceBillStatus
+ version: number
+}
+
+export interface FinanceBillLine {
+ sourceFeeId: string
+ sourceFeeVersion: number
+ orderId: string | null
+ inboundId: string | null
+ feeItemName: string
+ amount: number
+}
+
+export interface FinanceBillDetail extends FinanceBillResource {
+ remark: string
+ lines: FinanceBillLine[]
}
export interface FinanceReceiptResource {
id: string
customerId: string
customerName: string
- receivedAt: string
+ receivedDate: string
amount: number
currency: 'CNY'
- paymentMethod: string
- voucherFileName: string | null
+ paymentMethod: FinancePaymentMethod
+ voucherFileIds: string[]
allocationCount: number
}
export interface FinanceCostResource {
id: string
costType: FinanceCostType
- referenceNo: string
- feeName: string
- supplierName: string | null
+ customerId: string
+ orderId: string | null
+ containerId: string | null
+ expenseName: string
amount: number
currency: 'CNY'
- voucherFileName: string | null
+ voucherFileIds: string[]
createdAt: string
}
-
-export interface FinanceReportResource {
- customerName: string
- billedAmount: number
- receivedAmount: number
- costAmount: number
- profitAmount: number
-}
diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue
index b335b1b0..79bd5839 100644
--- a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue
+++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue
@@ -108,6 +108,7 @@ function formatNumber(value?: number | null, digits = 3) {
+
- 装柜货物明细
{{ row.chineseName || row.englishName || '—' }}{{ row.englishName }}
{{ formatNumber(row.grossWeightKg) }}
{{ formatNumber(row.netWeightKg) }}
@@ -153,7 +153,9 @@ function formatNumber(value?: number | null, digits = 3) {
.detail-summary__wide { grid-column: span 2; }
.detail-actions { display: flex; justify-content: flex-end; }
.detail-section, .history-section { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
-.detail-section :deep(.data-table__toolbar) { min-height: 56px; }
+.detail-section__header { display: flex; align-items: baseline; gap: 10px; padding: 18px 18px 0; }
+.detail-section__header h2 { margin: 0; font-size: 16px; }
+.detail-section__header span { color: var(--cc-muted); font-size: 12px; }
.detail-section :deep(.el-table) { width: 100%; }
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
.history-section { padding: 18px; }
diff --git a/src/modules/loading-lists/components/LoadingListsView.test.ts b/src/modules/loading-lists/components/LoadingListsView.test.ts
index 7508b8c0..54b7417f 100644
--- a/src/modules/loading-lists/components/LoadingListsView.test.ts
+++ b/src/modules/loading-lists/components/LoadingListsView.test.ts
@@ -21,6 +21,7 @@ describe('LoadingListsView', () => {
expect(source).toContain("selectedWarehouseId.value = String(row.warehouseId || '')")
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
expect(source).not.toContain("'请先选择仓库后查询'")
+ expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
})
it('uses the shared query, table, drawer, dialog and error components', () => {
@@ -53,4 +54,24 @@ describe('LoadingListsView', () => {
expect(source).toContain('openEditFromRow(row)')
expect(source).toContain('await loadDetail()')
})
+
+ it('refreshes the detail version on A0409 while preserving transport input', () => {
+ expect(source).toContain("details.code === 'A0409'")
+ expect(source).toContain('await loadDetail()')
+ expect(source).toContain('当前输入已保留,请核对后重新保存')
+ })
+
+ it('opens a validated plan and warehouse deep link without mixing list modes', () => {
+ expect(source).toContain('useRoute()')
+ expect(source).toContain('route.query.planId')
+ expect(source).toContain('route.query.warehouseId')
+ expect(source).toContain("const expectedStatus = isPreload.value ? 'LOADING' : 'COMPLETED'")
+ expect(source).toContain('MODE_STATUS_MISMATCH')
+ })
+
+ it('passes translated status text directly to the badge', () => {
+ expect(source).toContain('')
+ expect(source).not.toContain('')
+ expect(detailSource).toContain('')
+ })
})
diff --git a/src/modules/loading-lists/components/LoadingListsView.vue b/src/modules/loading-lists/components/LoadingListsView.vue
index 9f6267f6..e858e927 100644
--- a/src/modules/loading-lists/components/LoadingListsView.vue
+++ b/src/modules/loading-lists/components/LoadingListsView.vue
@@ -1,5 +1,6 @@
+查看确认撤销{{ row.gate?.passed ? '通过' : '未通过' }}{{ detail.aggregate.orderNo }}{{ detail.aggregate.inboundCode }}{{ detail.aggregate.containerNo || '—' }}统一门禁
{{ scope.row.passed ? '通过' : '阻断' }}整体时间线
{{ entry.module }} · {{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}
+
diff --git a/src/modules/outbound-dispatch/routes.ts b/src/modules/outbound-dispatch/routes.ts
new file mode 100644
index 00000000..1fe6a3b7
--- /dev/null
+++ b/src/modules/outbound-dispatch/routes.ts
@@ -0,0 +1,2 @@
+import type { RouteRecordRaw } from 'vue-router'
+export const outboundDispatchRoutes = [{ path: 'outbound-dispatches', name: 'outbound-dispatches', component: () => import('./pages/OutboundDispatchesView.vue'), meta: { title: '国内出仓', description: '查看装柜门禁、整体时间线并确认国内出仓', permission: 'platform:firstleg:outbound-dispatch:list' } }] satisfies RouteRecordRaw[]
diff --git a/src/modules/outbound-dispatch/types.ts b/src/modules/outbound-dispatch/types.ts
new file mode 100644
index 00000000..4a9d5b18
--- /dev/null
+++ b/src/modules/outbound-dispatch/types.ts
@@ -0,0 +1,5 @@
+export interface OutboundGateItem { code: string; name: string; passed: boolean; actual?: string | null; requirement?: string | null }
+export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[]; checkedAt: string }
+export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null }
+export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> }
+export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string }
diff --git a/src/modules/port-schedules/api/platformPortSchedules.test.ts b/src/modules/port-schedules/api/platformPortSchedules.test.ts
new file mode 100644
index 00000000..20fc0643
--- /dev/null
+++ b/src/modules/port-schedules/api/platformPortSchedules.test.ts
@@ -0,0 +1,6 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { http } from '@/shared/api/http'
+import { createPortSchedule, listPortSchedules, updatePortSchedule } from './platformPortSchedules'
+vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } } })
+const data = { customerId: '1', oceanBookingId: '2', sourceType: 'MANUAL', sourceName: '美森', sourceRef: 'web', vesselName: 'MATSON', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', observedAt: '2026-08-11T10:00:00', expiresAt: '2026-08-14T10:00:00' }
+describe('platformPortSchedules', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes long ids in pages', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ schedule: { ...data, id: 9, scheduleNo: 'PS1', customerId: 1, oceanBookingId: 2, version: '3' }, expired: false }], page: 1, size: 20, total: 1, pages: 1 } }); const result = await listPortSchedules({ page: 1, size: 20 }); expect(result.records[0]?.schedule).toMatchObject({ id: '9', customerId: '1', oceanBookingId: '2', version: 3 }) }); it('uses idempotency and expectedVersion for writes', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 0 }, expired: false }, histories: [] } }); vi.mocked(http.put).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 2 }, expired: false }, histories: [] } }); await createPortSchedule(data); await updatePortSchedule('9', { expectedVersion: 1, data }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.put).mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 1 }) }) })
diff --git a/src/modules/port-schedules/api/platformPortSchedules.ts b/src/modules/port-schedules/api/platformPortSchedules.ts
new file mode 100644
index 00000000..16d95d8c
--- /dev/null
+++ b/src/modules/port-schedules/api/platformPortSchedules.ts
@@ -0,0 +1,24 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import type { PortScheduleData, PortScheduleDetail, PortSchedulePage, PortScheduleQuery, PortScheduleResource, PortScheduleSaveRequest, PortScheduleView } from '../types'
+
+const basePath = '/platform/v1/port-schedules'
+const ids = ['id', 'customerId', 'oceanBookingId', 'containerBookingId'] as const
+function resource(raw: PortScheduleResource): PortScheduleResource {
+ const value = { ...raw } as PortScheduleResource & Record
+ for (const key of ids) if (value[key] != null) value[key] = String(value[key])
+ value.version = Number(value.version ?? 0)
+ return value
+}
+function view(raw: PortScheduleView): PortScheduleView { return { ...raw, schedule: resource(raw.schedule) } }
+function detail(raw: PortScheduleDetail): PortScheduleDetail {
+ return { ...raw, portSchedule: view(raw.portSchedule), histories: (raw.histories || []).map((item) => ({ ...item, id: String(item.id) })) }
+}
+function idem() { return `port-schedule-${Date.now()}-${Math.random().toString(36).slice(2)}` }
+export async function listPortSchedules(query: PortScheduleQuery) {
+ const response = await http.get, ApiEnvelope>(basePath, { params: query })
+ const data = unwrapApiResponse(response)
+ return { ...data, records: (data.records || []).map(view), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) }
+}
+export async function getPortSchedule(id: string) { const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
+export async function createPortSchedule(data: PortScheduleData) { const response = await http.post, ApiEnvelope>(basePath, { expectedVersion: 0, data }, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
+export async function updatePortSchedule(id: string, request: PortScheduleSaveRequest) { const response = await http.put, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`, request, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
diff --git a/src/modules/port-schedules/index.ts b/src/modules/port-schedules/index.ts
new file mode 100644
index 00000000..ab38aabf
--- /dev/null
+++ b/src/modules/port-schedules/index.ts
@@ -0,0 +1,2 @@
+export { portScheduleRoutes } from './routes'
+export * from './types'
diff --git a/src/modules/port-schedules/pages/PortSchedulesView.test.ts b/src/modules/port-schedules/pages/PortSchedulesView.test.ts
new file mode 100644
index 00000000..e955d594
--- /dev/null
+++ b/src/modules/port-schedules/pages/PortSchedulesView.test.ts
@@ -0,0 +1,5 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/pages/PortSchedulesView.vue'), 'utf8')
+describe('PortSchedulesView', () => { it('uses standard list components and a left action column', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain("fixed: 'left'"); expect(source).toContain('AppDialog') }); it('exposes expiry and conflict handling', () => { expect(source).toContain("query.expired === 'true'"); expect(source).toContain("actionError.value.code === 'A0409'"); expect(source).toContain('ApiErrorMeta') }) })
diff --git a/src/modules/port-schedules/pages/PortSchedulesView.vue b/src/modules/port-schedules/pages/PortSchedulesView.vue
new file mode 100644
index 00000000..f3d0bf9b
--- /dev/null
+++ b/src/modules/port-schedules/pages/PortSchedulesView.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+ 新增船期
+ 查看编辑
+ {{ row.schedule.vesselName }} / {{ row.schedule.voyageNo }}{{ row.expired ? '已过期' : '有效' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/port-schedules/routes.ts b/src/modules/port-schedules/routes.ts
new file mode 100644
index 00000000..9faf8a2f
--- /dev/null
+++ b/src/modules/port-schedules/routes.ts
@@ -0,0 +1,2 @@
+import type { RouteRecordRaw } from 'vue-router'
+export const portScheduleRoutes = [{ path: 'port-schedules', name: 'port-schedules', component: () => import('./pages/PortSchedulesView.vue'), meta: { title: '船期信息', description: '维护船公司船期、查询时间与过期标识', permission: 'platform:firstleg:port-schedule:list' } }] satisfies RouteRecordRaw[]
diff --git a/src/modules/port-schedules/types.ts b/src/modules/port-schedules/types.ts
new file mode 100644
index 00000000..10c29ea3
--- /dev/null
+++ b/src/modules/port-schedules/types.ts
@@ -0,0 +1,34 @@
+export interface PortScheduleResource {
+ id: string
+ scheduleNo: string
+ customerId: string
+ oceanBookingId: string
+ containerBookingId?: string | null
+ sourceType: string
+ sourceName: string
+ sourceRef: string
+ vesselName: string
+ voyageNo: string
+ departurePortCode: string
+ departurePortName?: string | null
+ destinationPortCode: string
+ destinationPortName?: string | null
+ etdAt?: string | null
+ etaAt?: string | null
+ bookingCutoffAt?: string | null
+ customsCutoffAt?: string | null
+ portCutoffAt?: string | null
+ observedAt: string
+ expiresAt: string
+ remark?: string | null
+ version: number
+ updatedAt?: string | null
+}
+
+export interface PortScheduleView { schedule: PortScheduleResource; expired: boolean }
+export interface PortScheduleHistory { id: string; action: string; fromVersion?: number | null; toVersion?: number | null; reason?: string | null; operatorName?: string | null; createdAt: string }
+export interface PortScheduleDetail { portSchedule: PortScheduleView; histories: PortScheduleHistory[] }
+export interface PortSchedulePage { records: PortScheduleView[]; page: number; size: number; total: number; pages: number }
+export interface PortScheduleQuery { page: number; size: number; keyword?: string; expired?: boolean }
+export type PortScheduleData = Omit
+export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }
diff --git a/src/modules/unified-orders/workflows/api.test.ts b/src/modules/unified-orders/workflows/api.test.ts
index 1b9ebc22..a859ea45 100644
--- a/src/modules/unified-orders/workflows/api.test.ts
+++ b/src/modules/unified-orders/workflows/api.test.ts
@@ -6,6 +6,7 @@ import {
completeLoading,
confirmingPreload,
deleteSmartLoadingItem,
+ downloadEvidenceFile,
getContainerBooking,
getOutboundDispatch,
getPicking,
@@ -86,6 +87,13 @@ describe('unified order workflow API contract', () => {
)
})
+ it('downloads loading evidence through the tenant and inbound scoped file endpoint', async () => {
+ const blob = new Blob(['photo'], { type: 'image/jpeg' })
+ vi.mocked(http.get).mockResolvedValue(blob)
+ await expect(downloadEvidenceFile('2', '8', '88')).resolves.toBe(blob)
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/files/loading-evidence/2/8/88', { responseType: 'blob' })
+ })
+
it('normalizes smart-loading long identifiers as strings', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ data: { id: '2086085512165634050', warehouseId: '2086085512165634051', capacityCbm: '68', maxWeightKg: '25000', version: 2 } })
diff --git a/src/modules/unified-orders/workflows/api.ts b/src/modules/unified-orders/workflows/api.ts
index 10dba606..1074cab7 100644
--- a/src/modules/unified-orders/workflows/api.ts
+++ b/src/modules/unified-orders/workflows/api.ts
@@ -538,6 +538,17 @@ export async function uploadEvidenceFile(
return unwrapApiResponse(response)
}
+export function downloadEvidenceFile(
+ tenantId: string | number,
+ inboundId: string | number,
+ fileId: string | number,
+) {
+ return http.get(
+ `/platform/v1/files/loading-evidence/${encodeURIComponent(String(tenantId))}/${encodeURIComponent(String(inboundId))}/${encodeURIComponent(String(fileId))}`,
+ { responseType: 'blob' },
+ )
+}
+
export async function voidEvidence(
tenantId: string | number,
inboundId: string | number,
diff --git a/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.test.ts b/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.test.ts
index aa8d52ac..db41d9a7 100644
--- a/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.test.ts
+++ b/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.test.ts
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
import {
+ getEvidence,
getOutboundDispatch,
getPreload,
smartSplit,
@@ -14,6 +15,7 @@ vi.mock('../api', async (importOriginal) => {
const original = await importOriginal()
return {
...original,
+ getEvidence: vi.fn(),
getOutboundDispatch: vi.fn(),
getPreload: vi.fn(),
smartSplit: vi.fn(),
@@ -29,14 +31,14 @@ function preload(cargoReference: string, version = 3) {
}
}
-function mountPanel(permissions: string[]) {
+function mountPanel(permissions: string[], mainStatus: 'WAIT_SPLIT' | 'LOADING' | 'LOADED' = 'WAIT_SPLIT') {
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().permissions = permissions
return mount(UnifiedOrderWorkflowPanel, {
props: {
tenantId: '2086085512165634052', inboundId: '2086085512165634051', orderId: '2086085512165634053',
- mainStatus: 'WAIT_SPLIT', inboundVersion: 4,
+ mainStatus, inboundVersion: 4,
},
global: { plugins: [pinia, ElementPlus] },
})
@@ -45,6 +47,7 @@ function mountPanel(permissions: string[]) {
describe('UnifiedOrderWorkflowPanel preload editor', () => {
beforeEach(() => {
vi.mocked(getOutboundDispatch).mockReset().mockResolvedValue(null as never)
+ vi.mocked(getEvidence).mockReset().mockResolvedValue({ inboundId: '2086085512165634051', evidences: [] })
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
vi.mocked(smartSplit).mockReset()
})
@@ -94,4 +97,23 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
expect(wrapper.text()).toContain('REF-OLD')
expect(wrapper.text()).not.toContain('REF-SERVER')
})
+
+ it('keeps completed loading evidence visible and renders every active photo', async () => {
+ vi.mocked(getEvidence).mockResolvedValue({
+ inboundId: '2086085512165634051',
+ evidences: [
+ { id: '1', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '11', fileName: 'empty-1.jpg', sealNo: null, remark: null, version: 0, status: 'ACTIVE', createdAt: null, updatedAt: null },
+ { id: '2', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '12', fileName: 'empty-2.jpg', sealNo: null, remark: null, version: 0, status: 'ACTIVE', createdAt: null, updatedAt: null },
+ ],
+ })
+ const wrapper = mountPanel(['platform:firstleg:inbound:loading:view'], 'LOADED')
+ await flushPromises()
+
+ expect(getEvidence).toHaveBeenCalledWith('2086085512165634052', '2086085512165634051')
+ expect(wrapper.text()).toContain('empty-1.jpg')
+ expect(wrapper.text()).toContain('empty-2.jpg')
+ expect(wrapper.text()).toContain('2 张')
+ expect(wrapper.text()).not.toContain('完成装柜')
+ expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
+ })
})
diff --git a/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue b/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue
index c091aaae..d7d6d4af 100644
--- a/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue
+++ b/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue
@@ -1,6 +1,6 @@
@@ -404,7 +404,7 @@ onMounted(() => { void refresh() })
{{ notice }}
{{ idempotentMessage(busy) }}
- 当前仅开放进度查询;五图取证和装柜完成门禁接通后再开放操作。
+ 当前为只读查看,不能修改作业数据。
子流程与门禁摘要{{ dispatch.gate?.passed ? '已通过' : '待补齐' }}
@@ -472,29 +472,37 @@ onMounted(() => { void refresh() })
-
- 装柜五类证据与锁码五类必传
+
+
+
+ 装柜五类证据与锁码{{ evidenceReady ? '证据齐全' : '五类必传' }}
+ 当前账号无装柜证据查询权限。
-
{{ entry.label }}
+
{{ entry.label }}{{ activeEvidences(entry.type).length }} 张
-
- {{ evidence(entry.type)?.fileName || '已上传文件' }}
-
+
+
-
-
保存锁码
-
作废
+
+
+
{{ item.fileName || `证据 #${item.id}` }} · 锁码 {{ item.sealNo }}
+
+ 查看
+ 作废
+
+
+
尚未上传有效照片
五类证据各至少一张有效照片;锁码文本与带锁码照同时必填。错误照片只能作废并保留审计记录。
-
-
完成装柜
+
+ 完成装柜
+ 五类有效照片及锁码齐全后可提交;其他门禁仍由服务端校验
缺少入库版本,刷新订单详情后可提交
-
-
+
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
@@ -538,10 +546,13 @@ onMounted(() => { void refresh() })
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
-.evidence-item { display: grid; grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
+.evidence-item { display: grid; min-width: 0; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--cc-border); border-radius: 6px; }
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.evidence-control { display: flex; min-width: 0; align-items: center; gap: 8px; }
-.evidence-actions { display: flex; align-items: center; gap: 4px; }
+.evidence-files { display: grid; gap: 4px; }
+.evidence-file { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
+.evidence-file span { min-width: 0; overflow-wrap: anywhere; }
+.evidence-file__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 4px; }
.gate-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.gate-grid > div { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 9px; border: 1px solid var(--cc-border); border-radius: 5px; }
.gate-grid span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }