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 @@ + + 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()) +
- + - - - + + +
- - + + + +
- - + +
-
- - -
- - - - - -
- - + + +
- +
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) {
+

装柜货物明细

{{ detail.items.length }} 条
- @@ -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 @@ + + 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 @@ + + + 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) }} - 当前仅开放进度查询;五图取证和装柜完成门禁接通后再开放操作。 + 当前为只读查看,不能修改作业数据。 @@ -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; }