From f6556c57b5b0dd2a339b0012bc3b5daffb8526ad Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 15:36:53 +0800 Subject: [PATCH] =?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/app/router/index.ts | 6 + src/core/navigation/adminNavigation.test.ts | 34 ++ src/core/navigation/adminNavigation.ts | 9 +- .../api/platformContainerLoading.test.ts | 60 +++ .../api/platformContainerLoading.ts | 128 +++++ src/modules/container-loading/index.ts | 2 + .../pages/ContainersView.test.ts | 31 ++ .../pages/ContainersView.vue | 231 +++++++++ .../pages/SmartLoadingWorkbenchView.test.ts | 45 ++ .../pages/SmartLoadingWorkbenchView.vue | 236 +++++++++ src/modules/container-loading/routes.ts | 16 + src/modules/container-loading/types.ts | 139 ++++++ .../fee-items/api/platformFeeItems.test.ts | 68 +++ src/modules/fee-items/api/platformFeeItems.ts | 119 +++++ .../fee-items/pages/FeeItemsView.test.ts | 82 +++ src/modules/fee-items/pages/FeeItemsView.vue | 472 ++++++++++++++++++ src/modules/fee-items/routes.test.ts | 13 + src/modules/fee-items/routes.ts | 14 + .../PlatformFirstLegOrdersPanel.vue | 4 +- src/modules/iam/pages/InternalUsersView.vue | 64 +-- src/modules/iam/pages/PermissionsView.test.ts | 6 + src/modules/iam/pages/PermissionsView.vue | 35 +- src/modules/iam/pages/RolesView.test.ts | 6 + src/modules/iam/pages/RolesView.vue | 34 +- .../inbounds/pages/InboundOrdersView.vue | 10 +- .../review/MeasurementReviewPanel.vue | 2 +- .../api/platformLoadingLists.test.ts | 87 ++++ .../loading-lists/api/platformLoadingLists.ts | 257 ++++++++++ .../components/LoadingListDetailDrawer.vue | 163 ++++++ .../components/LoadingListsView.test.ts | 36 ++ .../components/LoadingListsView.vue | 333 ++++++++++++ src/modules/loading-lists/index.ts | 2 + .../pages/ConfirmedLoadingListsView.vue | 5 + .../loading-lists/pages/PreloadPlansView.vue | 5 + src/modules/loading-lists/routes.test.ts | 11 + src/modules/loading-lists/routes.ts | 24 + .../pages/ExpressCompaniesView.vue | 41 +- .../pages/UnifiedOrdersView.vue | 20 +- .../unified-orders/workflows/api.test.ts | 77 ++- src/modules/unified-orders/workflows/api.ts | 312 +++++++++++- .../components/SmartLoadingWorkbench.vue | 377 ++++++++++++++ .../UnifiedOrderWorkflowPanel.test.ts | 97 ++++ .../components/UnifiedOrderWorkflowPanel.vue | 183 +++++-- .../workflows/preloadForm.test.ts | 22 + .../unified-orders/workflows/preloadForm.ts | 38 ++ .../pallets/pages/PalletsView.vue | 6 - src/shared/components/AdminPage.vue | 25 + src/shared/components/DetailPageLayout.vue | 21 + src/shared/components/FormSection.vue | 30 ++ src/shared/components/ListPageLayout.vue | 21 + src/shared/components/PageLayouts.test.ts | 55 ++ src/shared/components/PageToolbar.vue | 41 ++ src/styles/foundation.css | 78 ++- src/styles/shell.css | 44 +- 54 files changed, 4107 insertions(+), 170 deletions(-) create mode 100644 src/modules/container-loading/api/platformContainerLoading.test.ts create mode 100644 src/modules/container-loading/api/platformContainerLoading.ts create mode 100644 src/modules/container-loading/index.ts create mode 100644 src/modules/container-loading/pages/ContainersView.test.ts create mode 100644 src/modules/container-loading/pages/ContainersView.vue create mode 100644 src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts create mode 100644 src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue create mode 100644 src/modules/container-loading/routes.ts create mode 100644 src/modules/container-loading/types.ts create mode 100644 src/modules/fee-items/api/platformFeeItems.test.ts create mode 100644 src/modules/fee-items/api/platformFeeItems.ts create mode 100644 src/modules/fee-items/pages/FeeItemsView.test.ts create mode 100644 src/modules/fee-items/pages/FeeItemsView.vue create mode 100644 src/modules/fee-items/routes.test.ts create mode 100644 src/modules/fee-items/routes.ts create mode 100644 src/modules/loading-lists/api/platformLoadingLists.test.ts create mode 100644 src/modules/loading-lists/api/platformLoadingLists.ts create mode 100644 src/modules/loading-lists/components/LoadingListDetailDrawer.vue create mode 100644 src/modules/loading-lists/components/LoadingListsView.test.ts create mode 100644 src/modules/loading-lists/components/LoadingListsView.vue create mode 100644 src/modules/loading-lists/index.ts create mode 100644 src/modules/loading-lists/pages/ConfirmedLoadingListsView.vue create mode 100644 src/modules/loading-lists/pages/PreloadPlansView.vue create mode 100644 src/modules/loading-lists/routes.test.ts create mode 100644 src/modules/loading-lists/routes.ts create mode 100644 src/modules/unified-orders/workflows/components/SmartLoadingWorkbench.vue create mode 100644 src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.test.ts create mode 100644 src/modules/unified-orders/workflows/preloadForm.test.ts create mode 100644 src/modules/unified-orders/workflows/preloadForm.ts create mode 100644 src/shared/components/AdminPage.vue create mode 100644 src/shared/components/DetailPageLayout.vue create mode 100644 src/shared/components/FormSection.vue create mode 100644 src/shared/components/ListPageLayout.vue create mode 100644 src/shared/components/PageLayouts.test.ts create mode 100644 src/shared/components/PageToolbar.vue diff --git a/src/app/router/index.ts b/src/app/router/index.ts index 6fbb20f9..c27a1d5c 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 { feeItemRoutes } from '@/modules/fee-items/routes' +import { containerLoadingRoutes } from '@/modules/container-loading/routes' +import { loadingListRoutes } from '@/modules/loading-lists/routes' const router = createRouter({ history: createWebHistory(), @@ -46,6 +49,8 @@ const router = createRouter({ ...prototypeModuleRoutes, ...oceanBookingRoutes, ...truckingRoutes, + ...containerLoadingRoutes, + ...loadingListRoutes, ...customerRoutes, ...warehouseRoutes, ...unifiedOrderRoutes, @@ -53,6 +58,7 @@ const router = createRouter({ ...inboundRoutes, ...fbaPricingRoutes, ...warehouseResourceRoutes, + ...feeItemRoutes, ...masterDataRoutes, ...downloadRoutes, ...iamRoutes, diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 593b8b6e..8abe0d7c 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -40,6 +40,11 @@ describe('adminNavigation', () => { '快递公司', '往来单位', ]) + expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([ + '货柜列表', + '预装清单', + '装柜清单', + ]) expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([ '入库单', '换标单', @@ -78,6 +83,14 @@ describe('adminNavigation', () => { expect(paths).not.toContain('/inbounds') expect(paths).not.toContain('/first-leg-pricing/customer-rules') expect(paths).toContain('/downloads') + expect(paths).toContain('/master-data/fee-items') + expect(paths).toContain('/container-loading') + expect(paths).toContain('/loading-plans') + expect(paths).toContain('/loading-lists') + 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('/prototype/system-management/brand-settings') expect(paths).toContain('/system/permissions') @@ -128,6 +141,27 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toContain('/customers') }) + it('shows the formal fee item page only with its view permission', () => { + const filtered = filterAdminNavigation(adminNavigation, new Set(['firstleg:fee-item:view'])) + + expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items']) + }) + + it('shows each formal container workflow entry only with its matching permission', () => { + const filtered = filterAdminNavigation(adminNavigation, new Set([ + 'platform:firstleg:container:list', + 'platform:firstleg:preload:list', + 'platform:firstleg:loading-list:list', + ])) + + expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([ + '/dashboard', + '/container-loading', + '/loading-plans', + '/loading-lists', + ]) + }) + 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 6fc2f348..fbe81abf 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -95,11 +95,10 @@ export const adminNavigation: AdminNavigationNode[] = [ key: 'container-management', title: '货柜管理', icon: WarehouseIcon, - hidden: true, children: [ - { key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/prototype/container-management/container-list' }, - { key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/prototype/container-management/manifest' }, - { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/prototype/container-management/preload-list' }, + { key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' }, + { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, + { key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' }, ], }, { @@ -161,7 +160,7 @@ export const adminNavigation: AdminNavigationNode[] = [ children: [ // { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list', hidden: true }, { key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true }, - { key: 'general-fee', title: '费用项目', icon: Database, path: '/prototype/general/fee-items', hidden: true }, + { key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' }, { key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' }, { key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' }, { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list', hidden: true }, diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts new file mode 100644 index 00000000..057a9583 --- /dev/null +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { + adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, + listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem, + updateContainer, updateContainerTransport, +} from './platformContainerLoading' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, version: '2', capacityCbm: '68', maxWeightKg: '28000' } +const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'EDITING', items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1' }] } + +describe('platform container loading API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('normalizes paged container ids and numeric metadata', async () => { + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } }) + const result = await listContainers({ page: 1, size: 20 }) + expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), warehouseId: String(container.warehouseId), version: 2, capacityCbm: 68 }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } }) + }) + + it('uses full-edit and transport whitelist request shapes', async () => { + vi.mocked(http.put).mockResolvedValue({ code: '200', data: container }) + vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container }) + const data = { cabinetNo: 'CB-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null } + await updateContainer('7', 2, data) + await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data }) + expect(http.patch).toHaveBeenCalledWith('/platform/v1/containers/7/transport', { version: 3, containerNo: 'MSCU1', voyageNo: 'V8' }) + }) + + it('normalizes every long id in candidates and plans', async () => { + vi.mocked(http.get) + .mockResolvedValueOnce({ code: '200', data: container }) + .mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, warehouseId: 5, destinationWarehouseId: 6, availablePackageCount: '7' }] }) + expect((await getContainer('9')).id).toBe(String(container.id)) + expect(await listSmartLoadingCandidates('5')).toMatchObject([{ accountId: '1', inboundDetailId: '3', destinationWarehouseId: '6', availablePackageCount: 7 }]) + }) + + it('sends plan and item versions plus operation and idempotency keys', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) + vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan }) + vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan }) + await createSmartLoadingPlan('7', null) + await assignSmartLoadingItem('10', { inboundId: '2', inboundDetailId: '3', packageCount: 4, expectedVersion: 5 }) + await adjustSmartLoadingItem('10', '11', 6, 7, 8) + await removeSmartLoadingItem('10', '11', 9, 10) + await pushSmartLoadingPreload('10', 11) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans', { bookingId: '7', remark: null }) + expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-') }) + expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') }) + expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } }) + expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } }) + }) +}) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts new file mode 100644 index 00000000..1c70206d --- /dev/null +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -0,0 +1,128 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { + ContainerBookingResource, + ContainerPage, + ContainerQuery, + ContainerSaveRequest, + SmartLoadingCandidate, + SmartLoadingItem, + SmartLoadingPlan, +} from '../types' + +const containerPath = '/platform/v1/containers' +const smartLoadingPath = '/platform/v1/first-leg/smart-loading' + +function id(value: unknown): string { return value == null ? '' : String(value) } +function optionalId(value: unknown): string | null { return value == null ? null : String(value) } +function numberValue(value: unknown, fallback = 0): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} +function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` } + +function container(raw: ContainerBookingResource): ContainerBookingResource { + return { + ...raw, + id: id(raw.id), + planId: optionalId(raw.planId), + warehouseId: id(raw.warehouseId), + shippingCompanyId: optionalId(raw.shippingCompanyId), + version: numberValue(raw.version), + capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm), + maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg), + } +} + +function item(raw: SmartLoadingItem): SmartLoadingItem { + return { + ...raw, + id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), accountId: id(raw.accountId), + inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId), + destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version), + packageCount: numberValue(raw.packageCount), + productQuantity: raw.productQuantity == null ? null : numberValue(raw.productQuantity), + grossWeightKg: raw.grossWeightKg == null ? null : numberValue(raw.grossWeightKg), + volumeCbm: raw.volumeCbm == null ? null : numberValue(raw.volumeCbm), + } +} + +function plan(raw: SmartLoadingPlan): SmartLoadingPlan { + return { ...raw, id: id(raw.id), bookingId: id(raw.bookingId), version: numberValue(raw.version), items: (raw.items || []).map(item) } +} + +export async function listContainers(query: ContainerQuery): Promise { + const response = await http.get, ApiEnvelope>(containerPath, { params: query }) + const result = unwrapApiResponse(response) + return { ...result, records: (result.records || []).map(container), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) } +} + +export async function getContainer(containerId: string) { + const response = await http.get, ApiEnvelope>(`${containerPath}/${encodeURIComponent(containerId)}`) + return container(unwrapApiResponse(response)) +} + +export async function createContainer(payload: ContainerSaveRequest) { + const response = await http.post, ApiEnvelope>(containerPath, payload) + return container(unwrapApiResponse(response)) +} + +export async function updateContainer(containerId: string, version: number, data: ContainerSaveRequest) { + const response = await http.put, ApiEnvelope>(`${containerPath}/${encodeURIComponent(containerId)}`, { version, data }) + return container(unwrapApiResponse(response)) +} + +export async function updateContainerTransport(containerId: string, version: number, payload: { containerNo: string | null; voyageNo: string | null }) { + const response = await http.patch, ApiEnvelope>(`${containerPath}/${encodeURIComponent(containerId)}/transport`, { ...payload, version }) + return container(unwrapApiResponse(response)) +} + +export async function listSmartLoadingCandidates(warehouseId: string) { + const response = await http.get, ApiEnvelope>(`${smartLoadingPath}/candidates`, { params: { warehouseId } }) + return unwrapApiResponse(response).map((raw) => ({ + ...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), + orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), + availablePackageCount: numberValue(raw.availablePackageCount), + unitWeightKg: raw.unitWeightKg == null ? null : numberValue(raw.unitWeightKg), + unitVolumeCbm: raw.unitVolumeCbm == null ? null : numberValue(raw.unitVolumeCbm), + })) +} + +export async function getSmartLoadingPlanByBooking(bookingId: string) { + const response = await http.get, ApiEnvelope>(`${smartLoadingPath}/plans/by-booking/${encodeURIComponent(bookingId)}`) + return plan(unwrapApiResponse(response)) +} + +export async function getSmartLoadingPlan(planId: string) { + const response = await http.get, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}`) + return plan(unwrapApiResponse(response)) +} + +export async function createSmartLoadingPlan(bookingId: string, remark: string | null) { + const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans`, { bookingId, remark }) + return plan(unwrapApiResponse(response)) +} + +export async function assignSmartLoadingItem(planId: string, payload: { inboundId: string; inboundDetailId: string; packageCount: number; expectedVersion: number }) { + const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/assignments`, { ...payload, operationId: operationId('assign') }) + return plan(unwrapApiResponse(response)) +} + +export async function recommendSmartLoading(planId: string, expectedVersion: number) { + const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') }) + return plan(unwrapApiResponse(response)) +} + +export async function adjustSmartLoadingItem(planId: string, itemId: string, packageCount: number, expectedPlanVersion: number, expectedItemVersion: number) { + const response = await http.patch, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}`, { packageCount, expectedPlanVersion, expectedItemVersion, operationId: operationId('adjust') }) + return plan(unwrapApiResponse(response)) +} + +export async function removeSmartLoadingItem(planId: string, itemId: string, expectedPlanVersion: number, expectedItemVersion: number) { + const response = await http.delete, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}`, { data: { expectedPlanVersion, expectedItemVersion, operationId: operationId('remove') } }) + return plan(unwrapApiResponse(response)) +} + +export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) { + const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } }) + return plan(unwrapApiResponse(response)) +} diff --git a/src/modules/container-loading/index.ts b/src/modules/container-loading/index.ts new file mode 100644 index 00000000..f03c0327 --- /dev/null +++ b/src/modules/container-loading/index.ts @@ -0,0 +1,2 @@ +export * from './api/platformContainerLoading' +export * from './types' diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts new file mode 100644 index 00000000..c7762c51 --- /dev/null +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -0,0 +1,31 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { createMemoryHistory, createRouter } from 'vue-router' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ContainersView from './ContainersView.vue' +import { listAllPlatformWarehouses } from '@/modules/warehouses' +import { listContainers } from '../api/platformContainerLoading' + +vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) +vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() })) + +describe('ContainersView', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(listAllPlatformWarehouses).mockResolvedValue([]) + vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING', 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 }], page: 1, size: 20, total: 1, pages: 1 }) + }) + + it('loads the real paged contract and renders core container fields', async () => { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] }) + await router.push('/'); await router.isReady() + const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 })) + expect(wrapper.text()).toContain('CB-1') + expect(wrapper.text()).toContain('MSCU-1') + expect(wrapper.text()).toContain('宁波 → 长滩') + expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true) + }) +}) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue new file mode 100644 index 00000000..d04bbc58 --- /dev/null +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts new file mode 100644 index 00000000..39a9adf8 --- /dev/null +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -0,0 +1,45 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { createMemoryHistory, createRouter } from 'vue-router' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue' +import { getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates } from '../api/platformContainerLoading' + +vi.mock('../api/platformContainerLoading', () => ({ + getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(), + createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), recommendSmartLoading: vi.fn(), + adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), pushSmartLoadingPreload: vi.fn(), +})) + +const container = { + id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', + warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, + billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, + departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, + destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000, + terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, + estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, +} + +describe('SmartLoadingWorkbenchView', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(getContainer).mockResolvedValue(container) + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] }) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }]) + }) + + it('restores the real draft and explains candidate capacity', async () => { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] }) + await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady() + const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + expect(getContainer).toHaveBeenCalledWith(container.id) + expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) + expect(listSmartLoadingCandidates).toHaveBeenCalledWith('8') + expect(wrapper.text()).toContain('LP-99') + expect(wrapper.text()).toContain('ORD-1') + expect(wrapper.text()).toContain('按 110% 上限') + }) +}) diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue new file mode 100644 index 00000000..ac27b608 --- /dev/null +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/src/modules/container-loading/routes.ts b/src/modules/container-loading/routes.ts new file mode 100644 index 00000000..5c06b759 --- /dev/null +++ b/src/modules/container-loading/routes.ts @@ -0,0 +1,16 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const containerLoadingRoutes = [ + { + path: 'container-loading', + name: 'container-loading', + component: () => import('./pages/ContainersView.vue'), + meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' }, + }, + { + path: 'container-loading/:containerId/smart-loading', + name: 'smart-loading-workbench', + component: () => import('./pages/SmartLoadingWorkbenchView.vue'), + meta: { title: '智能排柜', description: '生成、调整并确认可解释的装柜计划', permission: 'platform:firstleg:smart-loading:list' }, + }, +] satisfies RouteRecordRaw[] diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts new file mode 100644 index 00000000..08ab06fb --- /dev/null +++ b/src/modules/container-loading/types.ts @@ -0,0 +1,139 @@ +export type ContainerPlanStatus = 'EDITING' | 'LOADING' | 'COMPLETED' + +export interface ContainerBookingResource { + id: string + planId: string | null + bookingNo: string + containerNo: string | null + cabinetNo: string + warehouseId: string + warehouseName: string | null + billOfLadingNo: string | null + vesselName: string | null + voyageNo: string | null + containerType: string + shippingCompanyId: string | null + shippingCompanyName: string | null + departurePortCode: string | null + departurePortName: string | null + destinationPortCode: string | null + destinationPortName: string | null + destinationWarehouseCode: string | null + destinationWarehouseName: string | null + capacityCbm: number | null + maxWeightKg: number | null + terminalTruckingCompany: string | null + loadingAt: string | null + estimatedSailingAt: string | null + actualSailingAt: string | null + estimatedPortArrivalAt: string | null + actualPortArrivalAt: string | null + estimatedWarehouseArrivalAt: string | null + actualWarehouseArrivalAt: string | null + planStatus: ContainerPlanStatus + version: number + createdAt?: string | null + updatedAt?: string | null +} + +export interface ContainerPage { + records: ContainerBookingResource[] + page: number + size: number + total: number + pages: number +} + +export interface ContainerQuery { + page: number + size: number + keyword?: string + containerNo?: string + cabinetNo?: string + warehouseId?: string + containerType?: string + status?: ContainerPlanStatus | '' + estimatedSailingFrom?: string + estimatedSailingTo?: string +} + +export interface ContainerSaveRequest { + containerNo: string | null + cabinetNo: string + warehouseId: string + warehouseName: string | null + billOfLadingNo: string | null + vesselName: string | null + voyageNo: string | null + containerType: string + shippingCompanyId: string | null + shippingCompanyName: string | null + departurePortCode: string | null + departurePortName: string | null + destinationPortCode: string | null + destinationPortName: string | null + destinationWarehouseCode: string | null + destinationWarehouseName: string | null + capacityCbm: number | null + maxWeightKg: number | null + terminalTruckingCompany: string | null + loadingAt: string | null + estimatedSailingAt: string | null + actualSailingAt: string | null + estimatedPortArrivalAt: string | null + actualPortArrivalAt: string | null + estimatedWarehouseArrivalAt: string | null + actualWarehouseArrivalAt: string | null +} + +export interface SmartLoadingCandidate { + accountId: string + inboundId: string + inboundDetailId: string + orderId: string + orderNo: string | null + inboundCode: string | null + warehouseId: string + destinationWarehouseId: string | null + destinationWarehouseCode: string | null + destinationWarehouseName: string | null + customerName: string | null + goodsName: string | null + availablePackageCount: number + unitWeightKg: number | null + unitVolumeCbm: number | null +} + +export interface SmartLoadingItem { + id: string + loadingPlanId: string + accountId: string + inboundId: string + inboundDetailId: string + orderId: string + orderNo: string | null + inboundCode: string | null + customerName: string | null + destinationWarehouseId: string | null + destinationWarehouseCode: string | null + destinationWarehouseName: string | null + skuCode: string | null + productNameZh: string | null + packageCount: number + productQuantity: number | null + grossWeightKg: number | null + volumeCbm: number | null + loadSequence: number | null + version: number +} + +export interface SmartLoadingPlan { + id: string + planNo: string + status: ContainerPlanStatus + bookingId: string + containerNo: string | null + voyageNo: string | null + version: number + items: SmartLoadingItem[] +} diff --git a/src/modules/fee-items/api/platformFeeItems.test.ts b/src/modules/fee-items/api/platformFeeItems.test.ts new file mode 100644 index 00000000..2f37c59f --- /dev/null +++ b/src/modules/fee-items/api/platformFeeItems.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { + changePlatformFeeItemStatus, + createPlatformFeeItem, + deletePlatformFeeItem, + getPlatformFeeItem, + listPlatformFeeItems, + updatePlatformFeeItem, + type SavePlatformFeeItemRequest, +} from './platformFeeItems' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const payload: SavePlatformFeeItemRequest = { + category: '仓储成本', + tradeScope: 'INTERNATIONAL', + feeTypeName: '仓库租赁费', + pickupPayable: false, + pickupReceivable: false, + warehousePayable: true, + warehouseReceivable: true, + allocationFee: true, + description: null, +} + +describe('platform fee item API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('sends normalized public list filters without customer context', async () => { + vi.mocked(http.get).mockResolvedValue({ + code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '21', pages: '2' }, + }) + + await expect(listPlatformFeeItems({ + category: ' 仓储成本 ', tradeScope: 'INTERNATIONAL', + keyword: ' 租赁 ', status: 'ACTIVE', page: 2, size: 20, + })).resolves.toMatchObject({ page: 2, size: 20, total: 21, pages: 2 }) + + expect(http.get).toHaveBeenCalledWith('/platform/v1/fee-items', { params: { + category: '仓储成本', tradeScope: 'INTERNATIONAL', + keyword: '租赁', status: 'ACTIVE', page: 2, size: 20, + } }) + }) + + it('uses public detail and mutation contracts', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '11' } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '11' } }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '11' } }) + vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null }) + vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null }) + + await getPlatformFeeItem('11') + await createPlatformFeeItem(payload) + await updatePlatformFeeItem('11', payload) + await changePlatformFeeItemStatus('11', 'DISABLED') + await deletePlatformFeeItem('11') + + expect(http.get).toHaveBeenCalledWith('/platform/v1/fee-items/11') + expect(http.post).toHaveBeenCalledWith('/platform/v1/fee-items', payload) + expect(http.put).toHaveBeenCalledWith('/platform/v1/fee-items/11', payload) + expect(http.patch).toHaveBeenCalledWith('/platform/v1/fee-items/11/status', { status: 'DISABLED' }) + expect(http.delete).toHaveBeenCalledWith('/platform/v1/fee-items/11') + }) +}) diff --git a/src/modules/fee-items/api/platformFeeItems.ts b/src/modules/fee-items/api/platformFeeItems.ts new file mode 100644 index 00000000..fb417782 --- /dev/null +++ b/src/modules/fee-items/api/platformFeeItems.ts @@ -0,0 +1,119 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type FeeItemTradeScope = 'DOMESTIC' | 'INTERNATIONAL' +export type FeeItemStatus = 'ACTIVE' | 'DISABLED' + +export interface PlatformFeeItemResource { + id: number | string + category: string + tradeScope: FeeItemTradeScope + feeCode: string | null + feeTypeName: string + pickupPayable: boolean + pickupReceivable: boolean + warehousePayable: boolean + warehouseReceivable: boolean + allocationFee: boolean + description: string | null + status: FeeItemStatus + createdByName?: string | null + updatedByName?: string | null + createdAt?: string | null + updatedAt?: string | null +} + +export interface SavePlatformFeeItemRequest { + category: string + tradeScope: FeeItemTradeScope + feeTypeName: string + pickupPayable: boolean + pickupReceivable: boolean + warehousePayable: boolean + warehouseReceivable: boolean + allocationFee: boolean + description: string | null +} + +export interface PlatformFeeItemQuery { + category?: string + tradeScope?: FeeItemTradeScope | '' + keyword?: string + status?: FeeItemStatus | '' + page: number + size: number +} + +export interface PlatformFeeItemPage { + records: PlatformFeeItemResource[] + page: number + size: number + total: number + pages: number +} + +const basePath = '/platform/v1/fee-items' + +export async function listPlatformFeeItems(query: PlatformFeeItemQuery) { + const response = await http.get, ApiEnvelope>(basePath, { + params: { + category: query.category?.trim() || undefined, + tradeScope: query.tradeScope || undefined, + keyword: query.keyword?.trim() || undefined, + status: query.status || undefined, + page: query.page, + size: query.size, + }, + }) + const result = unwrapApiResponse(response) + return { + ...result, + records: result.records || [], + page: Number(result.page), + size: Number(result.size), + total: Number(result.total), + pages: Number(result.pages), + } +} + +export async function getPlatformFeeItem(id: number | string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${id}`, + + ) + return unwrapApiResponse(response) +} + +export async function createPlatformFeeItem(payload: SavePlatformFeeItemRequest) { + const response = await http.post, ApiEnvelope>( + basePath, + payload, + + ) + return unwrapApiResponse(response) +} + +export async function updatePlatformFeeItem(id: number | string, payload: SavePlatformFeeItemRequest) { + const response = await http.put, ApiEnvelope>( + `${basePath}/${id}`, + payload, + + ) + return unwrapApiResponse(response) +} + +export async function changePlatformFeeItemStatus(id: number | string, status: FeeItemStatus) { + const response = await http.patch, ApiEnvelope>( + `${basePath}/${id}/status`, + { status }, + + ) + return unwrapApiResponse(response) +} + +export async function deletePlatformFeeItem(id: number | string) { + const response = await http.delete, ApiEnvelope>( + `${basePath}/${id}`, + + ) + return unwrapApiResponse(response) +} diff --git a/src/modules/fee-items/pages/FeeItemsView.test.ts b/src/modules/fee-items/pages/FeeItemsView.test.ts new file mode 100644 index 00000000..37ab818f --- /dev/null +++ b/src/modules/fee-items/pages/FeeItemsView.test.ts @@ -0,0 +1,82 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useAuthStore } from '@/core/auth/auth' +import { listPlatformFeeItems } from '@/modules/fee-items/api/platformFeeItems' +import FeeItemsView from './FeeItemsView.vue' + +vi.mock('@/modules/fee-items/api/platformFeeItems', () => ({ + listPlatformFeeItems: vi.fn(), + getPlatformFeeItem: vi.fn(), + createPlatformFeeItem: vi.fn(), + updatePlatformFeeItem: vi.fn(), + changePlatformFeeItemStatus: vi.fn(), + deletePlatformFeeItem: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +function createMemoryStorage(): Storage { + const values = new Map() + return { + get length() { return values.size }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } +} + +async function mountView(permissions: string[]) { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = permissions + useAccessPreviewStore().initializeAfterLogin() + const wrapper = mount(FeeItemsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + return wrapper +} + +enableAutoUnmount(afterEach) +afterAll(() => vi.unstubAllGlobals()) + +describe('FeeItemsView public master data and access', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('localStorage', createMemoryStorage()) + vi.stubGlobal('sessionStorage', createMemoryStorage()) + document.body.innerHTML = '' + vi.mocked(listPlatformFeeItems).mockReset().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + }) + + it('loads public fee items immediately without customer selection', async () => { + const wrapper = await mountView(['firstleg:fee-item:view', 'firstleg:fee-item:create']) + + expect(wrapper.find('.query-toolbar').exists()).toBe(true) + expect(wrapper.find('.data-table').exists()).toBe(true) + expect(wrapper.text()).toContain('平台公共主数据') + expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 })) + + const vm = wrapper.vm as unknown as { applyFilters(model: Record): void } + vm.applyFilters({ keyword: '', category: '', tradeScope: '', status: '' }) + await flushPromises() + + expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 })) + }) + + it('renders an explicit no-access state without loading customers or fee items', async () => { + const wrapper = await mountView([]) + + expect(wrapper.text()).toContain('无权查看费用项目') + expect(listPlatformFeeItems).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/fee-items/pages/FeeItemsView.vue b/src/modules/fee-items/pages/FeeItemsView.vue new file mode 100644 index 00000000..77be6583 --- /dev/null +++ b/src/modules/fee-items/pages/FeeItemsView.vue @@ -0,0 +1,472 @@ + + + + + diff --git a/src/modules/fee-items/routes.test.ts b/src/modules/fee-items/routes.test.ts new file mode 100644 index 00000000..ac520b8a --- /dev/null +++ b/src/modules/fee-items/routes.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { feeItemRoutes } from './routes' + +describe('fee item routes', () => { + it('registers the formal platform master-data management page', () => { + expect(feeItemRoutes).toHaveLength(1) + expect(feeItemRoutes[0]).toMatchObject({ + path: 'master-data/fee-items', + name: 'fee-items', + meta: { permission: 'firstleg:fee-item:view' }, + }) + }) +}) diff --git a/src/modules/fee-items/routes.ts b/src/modules/fee-items/routes.ts new file mode 100644 index 00000000..372cda41 --- /dev/null +++ b/src/modules/fee-items/routes.ts @@ -0,0 +1,14 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const feeItemRoutes = [ + { + path: 'master-data/fee-items', + name: 'fee-items', + component: () => import('@/modules/fee-items/pages/FeeItemsView.vue'), + meta: { + title: '费用项目', + description: '按客户维护头程费用项目及其适用范围', + permission: 'firstleg:fee-item:view', + }, + }, +] satisfies RouteRecordRaw[] diff --git a/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue b/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue index ad1cc257..eaf00780 100644 --- a/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue +++ b/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue @@ -566,13 +566,13 @@ onMounted(() => void loadTenants()) - + - + diff --git a/src/modules/iam/pages/InternalUsersView.vue b/src/modules/iam/pages/InternalUsersView.vue index ffe40b17..0024da80 100644 --- a/src/modules/iam/pages/InternalUsersView.vue +++ b/src/modules/iam/pages/InternalUsersView.vue @@ -17,6 +17,12 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' +import AdminPage from '@/shared/components/AdminPage.vue' +import AppDialog from '@/shared/components/AppDialog.vue' +import FormSection from '@/shared/components/FormSection.vue' +import ListPageLayout from '@/shared/components/ListPageLayout.vue' +import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import PageToolbar from '@/shared/components/PageToolbar.vue' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' @@ -295,24 +301,27 @@ onMounted(loadInitialData) - + diff --git a/src/modules/loading-lists/api/platformLoadingLists.test.ts b/src/modules/loading-lists/api/platformLoadingLists.test.ts new file mode 100644 index 00000000..fe911807 --- /dev/null +++ b/src/modules/loading-lists/api/platformLoadingLists.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { + getConfirmedLoadingList, + getPreloadPlan, + listConfirmedLoadingLists, + listPreloadPlans, + updatePreloadTransport, +} from './platformLoadingLists' + +const preloadSummary = { + planId: 1900000000000000001n as unknown as string, + preloadNo: 'PL-001', + containerNo: 'MSCU1234567', + internalContainerNo: 'YG-001', + customers: [{ accountId: 1900000000000000002n as unknown as string, customerName: '测试客户' }], + warehouseId: 1900000000000000003n as unknown as string, + warehouseName: '义乌仓', + vesselName: 'MATSON', + voyageNo: '001E', + containerType: '40HC', + status: 'LOADING', + skuCount: '2', + totalPackageCount: '10', + totalVolumeCbm: 12.3, + totalGrossWeightKg: 1200, + createdAt: '2026-08-10T10:00:00', + version: 3, +} + +const detail = { + summary: preloadSummary, + items: [{ + itemId: 11, accountId: 12, inboundId: 13, inboundDetailId: 14, productQuantity: 20, + customerName: '测试客户', inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null, + goodsImageUrl: null, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1, + dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null, + internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null, + }], + histories: [{ historyId: 21, itemId: 11, actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }], +} + +describe('platform loading lists API', () => { + beforeEach(() => vi.restoreAllMocks()) + + it('queries preload plans with required warehouse and fixed LOADING status', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: { records: [preloadSummary], page: '1', size: '20', total: '1', pages: '1' } }) + const result = await listPreloadPlans({ page: 1, size: 20, warehouseId: '300', keyword: 'PL' }) + + expect(get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-plans', { params: { page: 1, size: 20, warehouseId: '300', keyword: 'PL', status: 'LOADING' } }) + expect(result.total).toBe(1) + expect(result.records[0]?.planId).toBe('1900000000000000001') + expect(result.records[0]?.customers[0]?.accountId).toBe('1900000000000000002') + }) + + it('normalizes long ids in preload detail items and history', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: detail }) + const result = await getPreloadPlan('100', '300') + + expect(result.items[0]).toMatchObject({ itemId: '11', accountId: '12', inboundId: '13', inboundDetailId: '14', productQuantity: '20' }) + expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operatorId: '22' }) + }) + + it('updates transport with expectedVersion, warehouse and idempotency key', async () => { + const patch = vi.spyOn(http, 'patch').mockResolvedValue({ code: '200', message: 'ok', data: detail }) + await updatePreloadTransport('100', '300', { containerNo: 'MSCU1234567', voyageNo: '001E', expectedVersion: 3, operationReason: '调整' }) + + expect(patch.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-plans/100/transport') + expect(patch.mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 3, voyageNo: '001E' }) + expect(patch.mock.calls[0]?.[2]).toMatchObject({ params: { warehouseId: '300' }, headers: { 'Idempotency-Key': expect.any(String) } }) + }) + + it('uses distinct completed list and detail endpoints', async () => { + const confirmed = { ...preloadSummary, loadingListNo: 'LL-001', totalNetWeightKg: 1100, totalQuantity: '20', remark: null, createdBy: 9, createdByName: '操作员', status: 'COMPLETED' } + const get = vi.spyOn(http, 'get') + .mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [confirmed], page: '1', size: '20', total: '1', pages: '1' } }) + .mockResolvedValueOnce({ code: '200', message: 'ok', data: { ...detail, summary: confirmed } }) + + const page = await listConfirmedLoadingLists({ page: 1, size: 20, warehouseId: '300' }) + const loaded = await getConfirmedLoadingList('100', '300') + + expect(get.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-lists') + expect(get.mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100') + expect(page.records[0]?.createdBy).toBe('9') + expect(loaded.summary.status).toBe('COMPLETED') + }) +}) diff --git a/src/modules/loading-lists/api/platformLoadingLists.ts b/src/modules/loading-lists/api/platformLoadingLists.ts new file mode 100644 index 00000000..3ea73469 --- /dev/null +++ b/src/modules/loading-lists/api/platformLoadingLists.ts @@ -0,0 +1,257 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type LoadingListMode = 'preload' | 'confirmed' + +export interface LoadingCustomer { + accountId: string + customerName: string | null +} + +export interface PreloadSummary { + planId: string + preloadNo: string + containerNo: string | null + internalContainerNo: string | null + customers: LoadingCustomer[] + warehouseId: string | null + warehouseName: string | null + vesselName: string | null + voyageNo: string | null + containerType: string | null + status: string + skuCount: string + totalPackageCount: string + totalVolumeCbm: number + totalGrossWeightKg: number + createdAt: string + version: number +} + +export interface ConfirmedSummary { + planId: string + loadingListNo: string + warehouseId: string | null + warehouseName: string | null + containerNo: string | null + internalContainerNo: string | null + status: string + totalPackageCount: string + totalGrossWeightKg: number + totalNetWeightKg: number + totalVolumeCbm: number + totalQuantity: string + remark: string | null + createdBy: string | null + createdByName: string | null + createdAt: string + version: number +} + +export type LoadingListSummary = PreloadSummary | ConfirmedSummary + +export interface LoadingItem { + itemId: string + accountId: string + customerName: string | null + inboundId: string + inboundDetailId: string + inboundCode: string | null + skuCode: string | null + destinationWarehouse: string | null + chineseName: string | null + englishName: string | null + goodsImageUrl: string | null + packageCount: number + palletCount: number + grossWeightKg: number + netWeightKg: number + volumeCbm: number + dimensionalWeightKg: number + productQuantity: string + unitsPerPackage: number + fbaTrackingNo: string | null + shipmentNo: string | null + shippingMark: string | null + internalRemark: string | null + customerRemark: string | null + warehouseRemark: string | null + loadSequence: number + frozenAt: string | null +} + +export interface LoadingHistory { + historyId: string + itemId: string | null + actionType: string + fromStatus: string | null + toStatus: string | null + beforeSnapshot: string | null + afterSnapshot: string | null + operatorId: string + operatorName: string | null + operationReason: string | null + occurredAt: string +} + +export interface LoadingDetail { + summary: T + items: LoadingItem[] + histories: LoadingHistory[] +} + +export interface LoadingListPage { + records: T[] + page: number + size: number + total: number + pages: number +} + +export interface LoadingListQuery { + page: number + size: number + warehouseId: string + keyword?: string + createdFrom?: string + createdTo?: string +} + +export interface TransportUpdateRequest { + containerNo: string | null + voyageNo: string | null + expectedVersion: number + operationReason: string | null +} + +type RawId = string | number | null | undefined +type RawPage = Omit, 'page' | 'size' | 'total' | 'pages'> & { + page: string | number + size: string | number + total: string | number + pages: string | number +} + +const basePath = '/platform/v1/first-leg' + +function requiredId(value: RawId) { + return String(value ?? '') +} + +function optionalId(value: RawId) { + return value === null || value === undefined ? null : String(value) +} + +function normalizePreloadSummary(raw: PreloadSummary): PreloadSummary { + return { + ...raw, + planId: requiredId(raw.planId), + warehouseId: optionalId(raw.warehouseId), + skuCount: String(raw.skuCount ?? '0'), + totalPackageCount: String(raw.totalPackageCount ?? '0'), + customers: (raw.customers || []).map((customer) => ({ + ...customer, + accountId: requiredId(customer.accountId), + })), + } +} + +function normalizeConfirmedSummary(raw: ConfirmedSummary): ConfirmedSummary { + return { + ...raw, + planId: requiredId(raw.planId), + warehouseId: optionalId(raw.warehouseId), + createdBy: optionalId(raw.createdBy), + totalPackageCount: String(raw.totalPackageCount ?? '0'), + totalQuantity: String(raw.totalQuantity ?? '0'), + } +} + +function normalizeItem(raw: LoadingItem): LoadingItem { + return { + ...raw, + itemId: requiredId(raw.itemId), + accountId: requiredId(raw.accountId), + inboundId: requiredId(raw.inboundId), + inboundDetailId: requiredId(raw.inboundDetailId), + productQuantity: String(raw.productQuantity ?? '0'), + } +} + +function normalizeHistory(raw: LoadingHistory): LoadingHistory { + return { + ...raw, + historyId: requiredId(raw.historyId), + itemId: optionalId(raw.itemId), + operatorId: requiredId(raw.operatorId), + } +} + +function normalizePage(raw: RawPage, normalize: (row: T) => T): LoadingListPage { + return { + records: (raw.records || []).map(normalize), + page: Number(raw.page), + size: Number(raw.size), + total: Number(raw.total), + pages: Number(raw.pages), + } +} + +function idempotencyKey(planId: string) { + return `admin-preload-transport-${planId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +export async function listPreloadPlans(query: LoadingListQuery) { + const response = await http.get>, ApiEnvelope>>( + `${basePath}/loading-plans`, + { params: { ...query, status: 'LOADING' } }, + ) + return normalizePage(unwrapApiResponse(response), normalizePreloadSummary) +} + +export async function getPreloadPlan(planId: string, warehouseId: string) { + const response = await http.get>, ApiEnvelope>>( + `${basePath}/loading-plans/${planId}`, + { params: { warehouseId } }, + ) + const result = unwrapApiResponse(response) + return { + summary: normalizePreloadSummary(result.summary), + items: (result.items || []).map(normalizeItem), + histories: (result.histories || []).map(normalizeHistory), + } +} + +export async function updatePreloadTransport(planId: string, warehouseId: string, request: TransportUpdateRequest) { + const response = await http.patch>, ApiEnvelope>>( + `${basePath}/loading-plans/${planId}/transport`, + request, + { params: { warehouseId }, headers: { 'Idempotency-Key': idempotencyKey(planId) } }, + ) + const result = unwrapApiResponse(response) + return { + summary: normalizePreloadSummary(result.summary), + items: (result.items || []).map(normalizeItem), + histories: (result.histories || []).map(normalizeHistory), + } +} + +export async function listConfirmedLoadingLists(query: LoadingListQuery) { + const response = await http.get>, ApiEnvelope>>( + `${basePath}/loading-lists`, + { params: query }, + ) + return normalizePage(unwrapApiResponse(response), normalizeConfirmedSummary) +} + +export async function getConfirmedLoadingList(planId: string, warehouseId: string) { + const response = await http.get>, ApiEnvelope>>( + `${basePath}/loading-lists/${planId}`, + { params: { warehouseId } }, + ) + const result = unwrapApiResponse(response) + return { + summary: normalizeConfirmedSummary(result.summary), + items: (result.items || []).map(normalizeItem), + histories: (result.histories || []).map(normalizeHistory), + } +} diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue new file mode 100644 index 00000000..d1d733b5 --- /dev/null +++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/src/modules/loading-lists/components/LoadingListsView.test.ts b/src/modules/loading-lists/components/LoadingListsView.test.ts new file mode 100644 index 00000000..f772ad57 --- /dev/null +++ b/src/modules/loading-lists/components/LoadingListsView.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListsView.vue'), 'utf8') +const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8') + +describe('LoadingListsView', () => { + it('keeps the operation column first and fixed left in both lists', () => { + const actionColumns = [...source.matchAll(/key: 'actions', label: '操作', fixed: 'left'/g)] + expect(actionColumns).toHaveLength(2) + expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("key: 'preloadNo'")) + expect(source.indexOf("key: 'actions'", source.indexOf("key: 'actions'") + 1)).toBeLessThan(source.indexOf("key: 'loadingListNo'")) + }) + + it('requires a warehouse before sending a list request', () => { + expect(source).toContain("if (!warehouseId) return null") + expect(source).toContain("ElMessage.warning('请先选择仓库')") + expect(source).toContain("'请先选择仓库后查询'") + }) + + it('uses the shared query, table, drawer, dialog and error components', () => { + expect(source).toContain('QueryToolbar') + expect(source).toContain('DataTable') + expect(source).toContain('AppDialog') + expect(source).toContain('LoadErrorState') + expect(source).toContain('ApiErrorMeta') + expect(detailSource).toContain('AppDrawer') + }) + + it('only exposes transport editing for LOADING preload details', () => { + expect(detailSource).toContain("mode === 'preload' && canEdit && summary.status === 'LOADING'") + expect(detailSource).toContain('装柜完成后清单、明细和运输信息永久只读') + expect(source).toContain('expectedVersion: detail.value.summary.version') + }) +}) diff --git a/src/modules/loading-lists/components/LoadingListsView.vue b/src/modules/loading-lists/components/LoadingListsView.vue new file mode 100644 index 00000000..f50a7668 --- /dev/null +++ b/src/modules/loading-lists/components/LoadingListsView.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/src/modules/loading-lists/index.ts b/src/modules/loading-lists/index.ts new file mode 100644 index 00000000..e75cb834 --- /dev/null +++ b/src/modules/loading-lists/index.ts @@ -0,0 +1,2 @@ +export * from './api/platformLoadingLists' +export { loadingListRoutes } from './routes' diff --git a/src/modules/loading-lists/pages/ConfirmedLoadingListsView.vue b/src/modules/loading-lists/pages/ConfirmedLoadingListsView.vue new file mode 100644 index 00000000..fc79c1f4 --- /dev/null +++ b/src/modules/loading-lists/pages/ConfirmedLoadingListsView.vue @@ -0,0 +1,5 @@ + + + diff --git a/src/modules/loading-lists/pages/PreloadPlansView.vue b/src/modules/loading-lists/pages/PreloadPlansView.vue new file mode 100644 index 00000000..805d6112 --- /dev/null +++ b/src/modules/loading-lists/pages/PreloadPlansView.vue @@ -0,0 +1,5 @@ + + + diff --git a/src/modules/loading-lists/routes.test.ts b/src/modules/loading-lists/routes.test.ts new file mode 100644 index 00000000..fa7e5d70 --- /dev/null +++ b/src/modules/loading-lists/routes.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { loadingListRoutes } from './routes' + +describe('loading list routes', () => { + it('declares separate preload and completed list entries with backend permissions', () => { + expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([ + ['loading-plans', 'platform:firstleg:preload:list'], + ['loading-lists', 'platform:firstleg:loading-list:list'], + ]) + }) +}) diff --git a/src/modules/loading-lists/routes.ts b/src/modules/loading-lists/routes.ts new file mode 100644 index 00000000..c63c224f --- /dev/null +++ b/src/modules/loading-lists/routes.ts @@ -0,0 +1,24 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const loadingListRoutes = [ + { + path: 'loading-plans', + name: 'loading-plans', + component: () => import('./pages/PreloadPlansView.vue'), + meta: { + title: '预装清单', + description: '按仓库查看装柜中的预装计划、货物明细和操作历史', + permission: 'platform:firstleg:preload:list', + }, + }, + { + path: 'loading-lists', + name: 'loading-lists', + component: () => import('./pages/ConfirmedLoadingListsView.vue'), + meta: { + title: '装柜清单', + description: '按仓库查看已完成且永久只读的装柜清单', + permission: 'platform:firstleg:loading-list:list', + }, + }, +] satisfies RouteRecordRaw[] diff --git a/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue b/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue index c8f20497..86d7664b 100644 --- a/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue +++ b/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue @@ -3,8 +3,12 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { ExternalLink, Pencil, Plus, Power, Truck, Upload } from '@lucide/vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' +import AdminPage from '@/shared/components/AdminPage.vue' import BatchImportDialog from '@/shared/components/BatchImportDialog.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' +import FormSection from '@/shared/components/FormSection.vue' +import ListPageLayout from '@/shared/components/ListPageLayout.vue' +import PageToolbar from '@/shared/components/PageToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' @@ -278,15 +282,26 @@ onMounted(() => {