From b7b220f6e035ca314239c678f40b1f103fe76ebb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 10:45:59 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/navigation/adminNavigation.test.ts | 2 +- src/core/navigation/adminNavigation.ts | 1 - src/modules/finance/api/finance.test.ts | 45 +++++ src/modules/finance/api/finance.ts | 92 +++++++---- .../finance/pages/FinanceManualPage.vue | 155 +++++++++++++----- .../finance/pages/FinanceReportsView.vue | 2 - src/modules/finance/routes.ts | 1 - src/modules/finance/types.ts | 21 ++- 8 files changed, 233 insertions(+), 86 deletions(-) create mode 100644 src/modules/finance/api/finance.test.ts delete mode 100644 src/modules/finance/pages/FinanceReportsView.vue diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 8fa1a96e..b91e8660 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -93,7 +93,7 @@ describe('adminNavigation', () => { 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) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 9d9568d9..1892c6fb 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -137,7 +137,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/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts new file mode 100644 index 00000000..e22856db --- /dev/null +++ b/src/modules/finance/api/finance.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { createFinanceCost, createFinanceReceipt, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts } 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('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..24f3008b 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -1,46 +1,80 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { FinanceBillResource, FinanceCostResource, FinanceReceiptResource, FinanceReportResource } from '../types' +import type { 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) } +} + +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) => ({ + id: stringValue(item.id), billNo: stringValue(item.bill_no ?? item.billNo), customerId: params.customerId, + customerName: stringValue(item.customer_name_snapshot ?? item.customerName) || `客户 ${params.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), + })) } 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 }, { headers: { 'Idempotency-Key': idempotencyKey('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 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.expense_name), amount: numberValue(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 }, { headers: { 'Idempotency-Key': idempotencyKey('bill-issue') } }) return unwrapApiResponse(response) } diff --git a/src/modules/finance/pages/FinanceManualPage.vue b/src/modules/finance/pages/FinanceManualPage.vue index fba70d2c..9badd54f 100644 --- a/src/modules/finance/pages/FinanceManualPage.vue +++ b/src/modules/finance/pages/FinanceManualPage.vue @@ -1,11 +1,11 @@ @@ -91,11 +145,14 @@ onMounted(() => void load())
- + - - - + + + @@ -105,32 +162,44 @@ onMounted(() => void load())
- - + +
- +
- - - - + + +
- + +
- + 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..69bf494c 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,29 +14,31 @@ export interface FinanceBillResource { paidAmount: number outstandingAmount: number status: FinanceBillStatus + version: number } 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 } From 9b994040b8b9fbba6a94a976d2d65d0de3ee3066 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 11:15:44 +0800 Subject: [PATCH 2/6] fix(admin): complete container loading workflows --- .../pages/ContainersView.test.ts | 61 +++++++- .../pages/ContainersView.vue | 58 ++++++-- .../pages/GlobalSmartLoadingView.test.ts | 48 ++++-- .../pages/GlobalSmartLoadingView.vue | 61 +++++--- .../pages/SmartLoadingWorkbenchView.test.ts | 40 ++++- .../pages/SmartLoadingWorkbenchView.vue | 50 +++++-- .../components/LoadingListDetailDrawer.vue | 8 +- .../components/LoadingListsView.test.ts | 21 +++ .../components/LoadingListsView.vue | 62 +++++++- .../unified-orders/workflows/api.test.ts | 8 + src/modules/unified-orders/workflows/api.ts | 11 ++ .../UnifiedOrderWorkflowPanel.test.ts | 26 +++- .../components/UnifiedOrderWorkflowPanel.vue | 137 ++++++++++-------- 13 files changed, 456 insertions(+), 135 deletions(-) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index a5a37e5a..b8734de7 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() @@ -29,6 +39,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: 'EDITING' as const, version: 0, @@ -46,7 +68,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() @@ -57,4 +79,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 71bcf161..28a25478 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -1,7 +1,7 @@ @@ -404,7 +404,7 @@ onMounted(() => { void refresh() }) {{ notice }} {{ idempotentMessage(busy) }} - 当前仅开放进度查询;五图取证和装柜完成门禁接通后再开放操作。 + 当前为只读查看,不能修改作业数据。 @@ -472,29 +472,37 @@ onMounted(() => { void refresh() }) - - + + + + + 当前账号无装柜证据查询权限。
- {{ entry.label }} + {{ entry.label }}{{ activeEvidences(entry.type).length }} 张
- - {{ evidence(entry.type)?.fileName || '已上传文件' }} - + +
-
- 保存锁码 - 作废 +
+
+ {{ item.fileName || `证据 #${item.id}` }} +
+ 查看 + 作废 +
+
+ 尚未上传有效照片

五类证据各至少一张有效照片;锁码文本与带锁码照同时必填。错误照片只能作废并保留审计记录。

-
- 完成装柜 +
+ 完成装柜 + 五类有效照片及锁码齐全后可提交;其他门禁仍由服务端校验 缺少入库版本,刷新订单详情后可提交
- - + 已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。 @@ -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; } From c8eb2d75cdcadde15a1d3d58270c3c192d6c86d0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 11:26:31 +0800 Subject: [PATCH 3/6] feat(admin): add phase3 service center pages --- .../customs/api/platformCustoms.test.ts | 5 ++ src/modules/customs/api/platformCustoms.ts | 11 ++++ src/modules/customs/index.ts | 2 + .../customs/pages/CustomsCasesView.test.ts | 5 ++ .../customs/pages/CustomsCasesView.vue | 34 +++++++++++ src/modules/customs/routes.ts | 2 + src/modules/customs/types.ts | 7 +++ .../pages/OceanBookingDetailView.vue | 5 +- .../pages/OceanBookingsView.vue | 3 +- .../api/platformOutboundDispatch.test.ts | 5 ++ .../api/platformOutboundDispatch.ts | 9 +++ src/modules/outbound-dispatch/index.ts | 2 + .../pages/OutboundDispatchesView.test.ts | 5 ++ .../pages/OutboundDispatchesView.vue | 28 +++++++++ src/modules/outbound-dispatch/routes.ts | 2 + src/modules/outbound-dispatch/types.ts | 5 ++ .../api/platformPortSchedules.test.ts | 6 ++ .../api/platformPortSchedules.ts | 24 ++++++++ src/modules/port-schedules/index.ts | 2 + .../pages/PortSchedulesView.test.ts | 5 ++ .../pages/PortSchedulesView.vue | 60 +++++++++++++++++++ src/modules/port-schedules/routes.ts | 2 + src/modules/port-schedules/types.ts | 34 +++++++++++ 23 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 src/modules/customs/api/platformCustoms.test.ts create mode 100644 src/modules/customs/api/platformCustoms.ts create mode 100644 src/modules/customs/index.ts create mode 100644 src/modules/customs/pages/CustomsCasesView.test.ts create mode 100644 src/modules/customs/pages/CustomsCasesView.vue create mode 100644 src/modules/customs/routes.ts create mode 100644 src/modules/customs/types.ts create mode 100644 src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts create mode 100644 src/modules/outbound-dispatch/api/platformOutboundDispatch.ts create mode 100644 src/modules/outbound-dispatch/index.ts create mode 100644 src/modules/outbound-dispatch/pages/OutboundDispatchesView.test.ts create mode 100644 src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue create mode 100644 src/modules/outbound-dispatch/routes.ts create mode 100644 src/modules/outbound-dispatch/types.ts create mode 100644 src/modules/port-schedules/api/platformPortSchedules.test.ts create mode 100644 src/modules/port-schedules/api/platformPortSchedules.ts create mode 100644 src/modules/port-schedules/index.ts create mode 100644 src/modules/port-schedules/pages/PortSchedulesView.test.ts create mode 100644 src/modules/port-schedules/pages/PortSchedulesView.vue create mode 100644 src/modules/port-schedules/routes.ts create mode 100644 src/modules/port-schedules/types.ts diff --git a/src/modules/customs/api/platformCustoms.test.ts b/src/modules/customs/api/platformCustoms.test.ts new file mode 100644 index 00000000..8ac3c5eb --- /dev/null +++ b/src/modules/customs/api/platformCustoms.test.ts @@ -0,0 +1,5 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { createCustomsCase, listCustomsCases, submitCustomsCase } from './platformCustoms' +vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } }) +describe('platformCustoms', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes case ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: '1' }], page: 1, size: 20, total: 1, pages: 1 } }); const page = await listCustomsCases({ page: 1, size: 20 }); expect(page.records[0]).toMatchObject({ id: '1', orderId: '2', inboundId: '3', checklistVersionId: '4', version: 1 }) }); it('writes with idempotency and version', async () => { const detail = { customsCase: { id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: 0 }, documents: [], supplements: [], submissionBatches: [], receipts: [], history: [] }; vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: detail }).mockResolvedValueOnce({ code: '200', data: {} }); await createCustomsCase({ customerId: '8', orderId: '2', inboundId: '3', checklistVersionId: '4', expectedVersion: 0 }); await submitCustomsCase('1', { customsAgentName: '代理', expectedVersion: 2 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 2 }) }) }) diff --git a/src/modules/customs/api/platformCustoms.ts b/src/modules/customs/api/platformCustoms.ts new file mode 100644 index 00000000..9061af92 --- /dev/null +++ b/src/modules/customs/api/platformCustoms.ts @@ -0,0 +1,11 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { CustomsCaseCreateRequest, CustomsCaseDetail, CustomsCasePage, CustomsCaseQuery, CustomsCaseResource } from '../types' +const basePath = '/platform/v1/customs' +const keys = ['id', 'tenantId', 'orderId', 'inboundId', 'containerBookingId', 'checklistVersionId'] as const +function item(raw: CustomsCaseResource) { const value = { ...raw } as CustomsCaseResource & Record; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value } +function detail(raw: CustomsCaseDetail): CustomsCaseDetail { return { ...raw, customsCase: item(raw.customsCase), documents: (raw.documents || []).map((x) => ({ ...x, id: String(x.id) })), submissionBatches: (raw.submissionBatches || []).map((x) => ({ ...x, id: String(x.id) })), receipts: (raw.receipts || []).map((x) => ({ ...x, id: String(x.id) })), history: (raw.history || []).map((x) => ({ ...x, id: String(x.id) })) } } +function idem() { return `customs-${Date.now()}-${Math.random().toString(36).slice(2)}` } +export async function listCustomsCases(query: CustomsCaseQuery) { const response = await http.get, ApiEnvelope>(`${basePath}/cases`, { params: query }); const data = unwrapApiResponse(response); return { ...data, records: (data.records || []).map(item), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) } } +export async function getCustomsCase(id: string) { const response = await http.get, ApiEnvelope>(`${basePath}/cases/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) } +export async function createCustomsCase(request: CustomsCaseCreateRequest) { const response = await http.post, ApiEnvelope>(`${basePath}/cases`, request, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) } +export async function submitCustomsCase(id: string, request: { customsAgentName: string; externalReference?: string; note?: string; expectedVersion: number }) { const response = await http.post, ApiEnvelope>(`${basePath}/cases/${encodeURIComponent(id)}/submission-batches`, request, { headers: { 'Idempotency-Key': idem() } }); return unwrapApiResponse(response) } diff --git a/src/modules/customs/index.ts b/src/modules/customs/index.ts new file mode 100644 index 00000000..e5f14b6e --- /dev/null +++ b/src/modules/customs/index.ts @@ -0,0 +1,2 @@ +export { customsRoutes } from './routes' +export * from './types' diff --git a/src/modules/customs/pages/CustomsCasesView.test.ts b/src/modules/customs/pages/CustomsCasesView.test.ts new file mode 100644 index 00000000..025cad96 --- /dev/null +++ b/src/modules/customs/pages/CustomsCasesView.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/customs/pages/CustomsCasesView.vue'), 'utf8') +describe('CustomsCasesView', () => { it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") }); it('supports create, detail, history and agent submission', () => { expect(source).toContain('createCustomsCase'); expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') }) }) diff --git a/src/modules/customs/pages/CustomsCasesView.vue b/src/modules/customs/pages/CustomsCasesView.vue new file mode 100644 index 00000000..fa8ddccf --- /dev/null +++ b/src/modules/customs/pages/CustomsCasesView.vue @@ -0,0 +1,34 @@ + + + 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/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index c20d5fde..2d4fb616 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -6,6 +6,7 @@ import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorDetails } from '@/shared/api/http' import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import AdminPage from '@/shared/components/AdminPage.vue' import AppDialog from '@/shared/components/AppDialog.vue' @@ -199,9 +200,9 @@ onMounted(load)
- +