From 2477667c27ea7892be423c78f2a2fa63b0bb54d5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 16:46:57 +0800 Subject: [PATCH 1/6] feat(fleet): add internal sales capacity pool workflow --- src/core/navigation/adminNavigation.test.ts | 4 +- src/core/navigation/adminNavigation.ts | 1 + .../overseas-business/PROTOTYPE_MATRIX.md | 13 ++ .../components/FleetArrangementSummary.vue | 31 +++++ .../overseas-business/fleetArrangement.ts | 29 +++++ .../pages/OverseasBusinessViews.test.ts | 23 +++- .../pages/TransportOrderFormView.vue | 12 +- .../pages/TransportOrdersView.vue | 13 +- src/modules/overseas-business/types.ts | 4 +- src/modules/overseas-master-data/AGENTS.md | 2 + .../overseas-master-data/PROTOTYPE_MATRIX.md | 15 +++ .../api/capacityOffers.test.ts | 29 +++++ .../api/capacityOffers.ts | 28 ++++ .../api/tripBookings.test.ts | 3 +- .../overseas-master-data/api/tripBookings.ts | 8 +- .../overseas-master-data/bookingTypes.ts | 4 +- .../capacityOfferTypes.ts | 41 ++++++ .../components/CapacityInquiryDialog.vue | 115 +++++++++++++++++ .../components/CapacityInquiryTable.vue | 37 ++++++ .../CapacityOfferSettingsDialog.vue | 62 +++++++++ .../TripBookingFulfillmentDialog.test.ts | 2 +- .../components/TripBookingPanel.test.ts | 12 +- .../components/TripBookingPanel.vue | 18 +-- .../pages/CapacityOfferDetailView.vue | 70 ++++++++++ .../pages/CapacityOffersBehavior.test.ts | 121 ++++++++++++++++++ .../pages/CapacityOffersView.vue | 103 +++++++++++++++ .../pages/TripDetailView.vue | 5 +- .../overseas-master-data/permissions.ts | 5 + .../overseas-master-data/routes.test.ts | 11 ++ src/modules/overseas-master-data/routes.ts | 12 ++ .../useCapacityInquiryCommands.ts | 29 +++++ .../useFleetDraftGuard.ts | 22 ++++ src/styles/adminDensity.test.ts | 6 + 33 files changed, 859 insertions(+), 31 deletions(-) create mode 100644 src/modules/overseas-business/components/FleetArrangementSummary.vue create mode 100644 src/modules/overseas-business/fleetArrangement.ts create mode 100644 src/modules/overseas-master-data/api/capacityOffers.test.ts create mode 100644 src/modules/overseas-master-data/api/capacityOffers.ts create mode 100644 src/modules/overseas-master-data/capacityOfferTypes.ts create mode 100644 src/modules/overseas-master-data/components/CapacityInquiryDialog.vue create mode 100644 src/modules/overseas-master-data/components/CapacityInquiryTable.vue create mode 100644 src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue create mode 100644 src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue create mode 100644 src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts create mode 100644 src/modules/overseas-master-data/pages/CapacityOffersView.vue create mode 100644 src/modules/overseas-master-data/useCapacityInquiryCommands.ts create mode 100644 src/modules/overseas-master-data/useFleetDraftGuard.ts diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 0b8a161b..edb2b452 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -18,7 +18,7 @@ describe('adminNavigation', () => { { title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] }, { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '预装清单'] }, { title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] }, - { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '出车记录'] }, + { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '客户服务', children: ['异常订单'] }, { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, @@ -26,7 +26,7 @@ describe('adminNavigation', () => { { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) expect(visible).toHaveLength(11) - expect(visible.flatMap(node => node.children ?? [])).toHaveLength(46) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(47) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 72cdbb8c..486394b5 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -125,6 +125,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, { key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' }, { key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' }, + { key: 'fleet-capacity-offers', title: '拼车需求池', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] }, { key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' }, ], }, diff --git a/src/modules/overseas-business/PROTOTYPE_MATRIX.md b/src/modules/overseas-business/PROTOTYPE_MATRIX.md index 70cef489..72d42afc 100644 --- a/src/modules/overseas-business/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-business/PROTOTYPE_MATRIX.md @@ -1,5 +1,18 @@ # 海外业务原型核对矩阵 +## 池来源本土订单承运安排 + +依据:[内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7节及本轮用户授权。 + +| 页面 | 操作 | 确认承载与实现 | +| --- | --- | --- | +| 本土卡车 / 本土散板列表 | 池来源承运安排 | 1080px居中只读摘要,使用列表已有fleetArrangement,打开不增加请求;不显示旧编辑动作 | +| 本土卡车 / 本土散板旧编辑深链 | 池来源只读 | 保持独立路由,读取原订单详情后仅展示真实安排与只读原因,不提交旧手工流程 | +| 承运安排 | 关联单据 | 有目标权限才开放车次/排车跳转,否则仅显示业务号;未知签收数量显示“—” | +| Client本土订单 | 列表摘要 / 原独立详情 | 同一客户订单响应展示承运安排,客户不访问运力池,不提供状态写入 | + +独立来源订单保持原承载。本轮视觉按1920×1080内置浏览器验收,自动检查与实际页面证据分别报告。 + 原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/07-overseas-business.html`;按钮函数来源:同原型目录 `prototype-src/app.js`。 | 页面 | 操作 | 原型证据 | 原型承载方式 | 冻结结论 | diff --git a/src/modules/overseas-business/components/FleetArrangementSummary.vue b/src/modules/overseas-business/components/FleetArrangementSummary.vue new file mode 100644 index 00000000..ff0d95ae --- /dev/null +++ b/src/modules/overseas-business/components/FleetArrangementSummary.vue @@ -0,0 +1,31 @@ + + diff --git a/src/modules/overseas-business/fleetArrangement.ts b/src/modules/overseas-business/fleetArrangement.ts new file mode 100644 index 00000000..9d93fe12 --- /dev/null +++ b/src/modules/overseas-business/fleetArrangement.ts @@ -0,0 +1,29 @@ +export interface FleetArrangement { + sourceType: 'FLEET_CAPACITY_OFFER' + businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK' + businessOrderNo: string + bookingNo: string + tripNo: string + plannedDepartureAt: string | null + originWarehouseName: string + destinationLabel: string + deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE' + deliveryInstructions: string | null + fulfillmentStage: string + stageOccurredAt: string | null + agreedFreightAmount: number | null + currency: string | null + incomingNo: string | null + dispatchNo: string | null + deliveryNo: string | null + signedPackages: number | null + unsignedPackages: number | null + returnedPackages: number | null + exceptionSummary: string | null + readOnlyReason: string +} + +export const poolOrderStatusLabels: Record = { + WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', + PENDING_DISPATCH: '待派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', +} diff --git a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts index c4a65bca..149e5772 100644 --- a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts +++ b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts @@ -9,6 +9,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import { listPlatformTenants } from '@/modules/iam' import AppDialog from '@/shared/components/AppDialog.vue' +import type { FleetArrangement } from '../fleetArrangement' import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types' import { createOverseasPurchaseFee, uploadOverseasPurchaseFile, createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder, @@ -44,10 +45,11 @@ const purchase: OverseasPurchaseDetail = { overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00', histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [], } -const truck: LocalTransportOrder = { id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' } +const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' } const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] } -const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' } +const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' } const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] } +const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' } function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia } function mountPage(component: Parameters[0], props?: Record) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) } @@ -65,6 +67,23 @@ describe('overseas business mounted pages', () => { }) + it('opens pool arrangements from the list without another request or exposing edit actions', async () => { + vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement, status: 'WAITING_INBOUND' }], page: 1, size: 20, total: 1, pages: 1 }) + const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises() + expect(wrapper.findAll('.el-table__body-wrapper button').some(b => b.text() === '编辑')).toBe(false) + await wrapper.findAll('.el-table__body-wrapper button').find(b => b.text() === '承运安排')!.trigger('click'); await flushPromises() + expect(document.body.textContent).toContain('池来源订单按实际运输履约推进') + expect(document.body.textContent).toContain('FB-TEST'); expect(document.body.textContent).toContain('— / — / —') + expect(listUsTruckOrders).toHaveBeenCalledOnce(); expect(getUsTruckOrder).not.toHaveBeenCalled() + }) + it('keeps a pool order read-only even when an old edit deep link is used', async () => { + routing.route.params = { orderId: 'TR-200' }; routing.route.query = { customerId: truck.customerId } + vi.mocked(getUsTruckOrder).mockResolvedValue({ ...truckDetail, fleetArrangement: arrangement, status: 'WAITING_INBOUND' }) + const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises() + expect(wrapper.text()).toContain('池来源订单按实际运输履约推进') + expect(wrapper.find('form').exists()).toBe(false); expect(wrapper.findAll('button').some(b => b.text() === '保存修改')).toBe(false) + expect(updateUsTruckOrder).not.toHaveBeenCalled(); expect(getUsTruckOrder).toHaveBeenCalledOnce() + }) it('keeps all procurement facts in dense groups and business references in the heading', async () => { routing.route.params = { purchaseId: 'internal-purchase-route' }; routing.route.query = { customerId: '10001' } const wrapper = mountPage(PurchaseOrderDetailView) diff --git a/src/modules/overseas-business/pages/TransportOrderFormView.vue b/src/modules/overseas-business/pages/TransportOrderFormView.vue index bfc13416..3e55f1c5 100644 --- a/src/modules/overseas-business/pages/TransportOrderFormView.vue +++ b/src/modules/overseas-business/pages/TransportOrderFormView.vue @@ -17,6 +17,8 @@ import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '@/shared/domai import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness' import { overseasBusinessPermissions } from '../permissions' +import FleetArrangementSummary from '../components/FleetArrangementSummary.vue' +import type { FleetArrangement } from '../fleetArrangement' import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types' const props = defineProps<{ domain: 'truck' | 'looseboard' }>() @@ -37,6 +39,7 @@ interface TransportFormModel { function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` } const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() }) const form = reactive(initial()); const loading = ref(false); const saving = ref(false) +const fleetArrangement = ref(null) const loadError = ref(null); const submitError = ref(null) const customers = ref([]); const customersLoading = ref(false); const customersError = ref('') const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' }) @@ -54,9 +57,9 @@ const rules: FormRules = { const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }] const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }] const truckStatusLabels: Record = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' } -const looseboardStatusLabels: Record = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } +const looseboardStatusLabels: Record = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus]) -const editableStatus = computed(() => !editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW')) +const editableStatus = computed(() => !fleetArrangement.value && (!editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW'))) const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission }) function optional(value: string | null) { return value?.trim() || null } function amount(value: string | null) { return value === null || value === '' ? null : Number(value) } @@ -68,16 +71,18 @@ async function loadCustomers() { finally { customersLoading.value = false } } async function load() { - Object.assign(form, initial()); loadError.value = null; submitError.value = null + Object.assign(form, initial()); loadError.value = null; submitError.value = null; fleetArrangement.value = null if (!editing.value) { captureBaseline(); return } if (!routeCustomerId.value) { loadError.value = { message: `缺少客户标识,请从${domainLabel.value}列表重新进入` }; captureBaseline(); return } loading.value = true try { if (isLooseboard.value) { const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value) + fleetArrangement.value = detail.fleetArrangement Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportMode: detail.transportMode, palletRequired: detail.palletRequired, transportType: detail.transportType, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version }) } else { const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value) + fleetArrangement.value = detail.fleetArrangement Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version }) } captureBaseline() @@ -110,6 +115,7 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu +
{{ customersError }} diff --git a/src/modules/overseas-business/pages/TransportOrdersView.vue b/src/modules/overseas-business/pages/TransportOrdersView.vue index 6f7c7faf..386c1e7a 100644 --- a/src/modules/overseas-business/pages/TransportOrdersView.vue +++ b/src/modules/overseas-business/pages/TransportOrdersView.vue @@ -9,6 +9,9 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' +import AppDialog from '@/shared/components/AppDialog.vue' +import FleetArrangementSummary from '../components/FleetArrangementSummary.vue' +import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness' @@ -23,9 +26,11 @@ const createPermission = computed(() => isLooseboard.value ? overseasBusinessPer const editPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.truckEdit) const rows = ref>([]); const loading = ref(false); const loadError = ref(null) const page = ref(1); const pageSize = ref(20); const total = ref(0) +const arrangementOpen = ref(false), arrangement = ref(null) +function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true } const filters = ref({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value }) const truckStatusLabels: Record = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' } -const looseboardStatusLabels: Record = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } +const looseboardStatusLabels: Record = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels } const transportTypeLabels: Record = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } const transportModeLabels: Record = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } const fields = computed(() => [ @@ -69,7 +74,7 @@ async function load() { } function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 } function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) } -function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW' } +function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') } function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) } watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() }) @@ -85,10 +90,10 @@ watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page - +
-
+ diff --git a/src/modules/overseas-business/types.ts b/src/modules/overseas-business/types.ts index b9fbeeda..27c2df5a 100644 --- a/src/modules/overseas-business/types.ts +++ b/src/modules/overseas-business/types.ts @@ -53,6 +53,7 @@ export interface LocalTransportQuery { transportType?: LocalTransportType | ''; customerId?: string; requestedFrom?: string; requestedTo?: string } export interface LocalTransportOrder { + fleetArrangement: import('./fleetArrangement').FleetArrangement | null id: string; customerId: string; orderNo: string; transportType: LocalTransportType | null origin: string; destination: string; containerType: string | null; palletCount: number; packageCount: number receivedPalletCount: number; allocatedPalletCount: number; shippedPalletCount: number @@ -83,12 +84,13 @@ export interface UpdateLocalTransportOrderRequest { export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION' export type PalletRequired = 'YES' | 'NO' -export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED' +export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED' export interface LocalLooseboardQuery { page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string status?: LocalLooseboardStatus | ''; transportMode?: LooseboardTransportMode | ''; requestedFrom?: string; requestedTo?: string } export interface LocalLooseboardOrder { + fleetArrangement: import('./fleetArrangement').FleetArrangement | null id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired transportType: LocalTransportType; origin: string; destination: string; containerType: string | null; packageCount: number totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md index bc08e05a..7ffd8cf7 100644 --- a/src/modules/overseas-master-data/AGENTS.md +++ b/src/modules/overseas-master-data/AGENTS.md @@ -1,5 +1,7 @@ # 海外通用资料与车辆管理 +- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。 + - 外接预订可选销售报价:`TripBookingPanel.vue`沿车次作用域`/bookings/quote-candidates`读取候选,不由浏览器推城市或有效日期;来源报价版本与实际成交价分别显示。引用要求quote:list,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。 - 车次“在途交接”页签按需挂载`TripHandoverPanel.vue`,沿`fleet-handovers-v1.yaml`读取剩余货物和交接记录;确认用trip:handover,旧资源结束用trip:release,saved刷新车次。原DEPART不重写,司机/车辆任务分别结束。 diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index 0ef4c2ff..303a7d2a 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -1,5 +1,20 @@ # 海外通用资料原型核对矩阵 +## 内部拼车需求池首版 + +依据:用户授权按现有车次样式整合;唯一范围为 [内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7、9节,没有另引入外部原型。 + +| 页面 | 操作 | 确认承载与实现 | +| --- | --- | --- | +| 拼车需求池 | 可接运力、我的意向、查询和分页 | 独立列表页,integrated DataTable,仅激活页签发请求;一行一发布,多站余位在行内分行 | +| 发布详情 | 运输摘要、分段余位、获准意向 | 独立路由;一次详情含首20条意向,后续按需分页;销售不读取车次敏感DTO | +| 意向 | 新增、编辑、只读详情 | 1080px居中三列表单;取消/X/Esc/路由离开保护;客户聚焦搜索和报价显式读取 | +| 意向 | 确认承运、取消 | 现有居中确认框;服务端允许动作与独立权限;未知结果原键原载荷重试 | +| 车次详情 | 发布设置 | 1080px居中三列表单,显式打开才读取;保存、发布、暂停、恢复、关闭各自命令 | +| 车次外接预订 | 池来源编辑/确认/取消 | 只读并提示在池中管理;实际来货/实收/关联排车仍走原履约弹框 | + +本轮用户授权内置浏览器1920×1080验收;自动测试与实际浏览器结果分别记录,未验收状态不推定通过。 + ## 2026-10-07 三个车辆列表样式统一 依据:本轮用户提供的司机管理、车次管理、出车记录三张截图;对齐已确认的 `VehiclesView.vue` 连续列表及 `docs/Admin高密度界面规范.md`。仅改列表容器与排版,不改变下列操作承载、权限或请求。 diff --git a/src/modules/overseas-master-data/api/capacityOffers.test.ts b/src/modules/overseas-master-data/api/capacityOffers.test.ts new file mode 100644 index 00000000..ce9d3d1a --- /dev/null +++ b/src/modules/overseas-master-data/api/capacityOffers.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listCapacityOffers, listCapacityInquiries, listOfferCustomers, normalizeInquiry, normalizeOffer, saveCapacityInquiry, commandCapacityInquiry } from './capacityOffers' +import type { CapacityOffer, CapacityInquiry, InquiryWrite } from '../capacityOfferTypes' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const offer = { offerNo: 'CO/1', tripNo: 'TR-1', recordVersion: '2', tripRecordVersion: '3', capacityKnown: false, destinations: [{ destinationKey: 'FBA:X', name: 'X', deliveryIndex: '1', remaining: null }], segments: [{ fromIndex: '0', toIndex: '1', fromName: '我仓', toName: 'X', planned: null, reserved: { palletSpaces: '0', weightKg: '0', volumeCbm: '0' }, remaining: null }] } as unknown as CapacityOffer +const inquiry = { bookingNo: 'FB/1', offerNo: 'CO/1', recordVersion: '1', tripRecordVersion: '3', offerRecordVersion: '2', expectedPalletCount: null, agreedAmount: null, quoteSource: null, cargo: { packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: '0' } } as unknown as CapacityInquiry +const envelope = (data: T) => ({ code: '200', data }) +beforeEach(() => vi.clearAllMocks()) +it('keeps unknown quantities distinct from a known zero price and normalizes segment facts', () => { + expect(normalizeInquiry(inquiry)).toMatchObject({ recordVersion: 1, expectedPalletCount: null, agreedAmount: null, cargo: { packageCount: null, unitPrice: 0 } }) + expect(normalizeOffer(offer)).toMatchObject({ capacityKnown: false, destinations: [{ remaining: null }], segments: [{ planned: null, reserved: { palletSpaces: 0 }, remaining: null }] }) +}) +it('uses only the dedicated pool endpoint for sales reads and forwards the requested page once', async () => { + vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [offer], total: '1', page: '2', size: '20', pages: '2' })).mockResolvedValueOnce(envelope({ records: [inquiry], total: '1', page: '1', size: '20', pages: '1' })).mockResolvedValueOnce(envelope([{ id: '9007199254740993', code: 'C-1', name: '测试客户' }])) + await listCapacityOffers({ page: 2, size: 20 }); await listCapacityInquiries({ scope: 'MINE' }); const customers = await listOfferCustomers('测试') + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/capacity-offers', { params: { page: 2, size: 20 } }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/capacity-offers/inquiries', { params: { scope: 'MINE' } }) + expect(customers[0]?.id).toBe('9007199254740993'); expect(http.get).toHaveBeenCalledTimes(3) +}) +it('submits one command with encoded business numbers and an unchanged retry key', async () => { + vi.mocked(http.post).mockResolvedValue(envelope({ offer, inquiry, replayed: false })) + const body = { customerId: '19', idempotencyKey: 'stable-test-key', cargo: { packageCount: null } } as InquiryWrite + await saveCapacityInquiry('CO/1', null, body) + expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/inquiries', body) + const command = { recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 3, idempotencyKey: 'confirm-stable-key' } + await commandCapacityInquiry('FB/1', 'confirm', command) + expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/capacity-offers/inquiries/FB%2F1/confirm', command) +}) diff --git a/src/modules/overseas-master-data/api/capacityOffers.ts b/src/modules/overseas-master-data/api/capacityOffers.ts new file mode 100644 index 00000000..a70a955c --- /dev/null +++ b/src/modules/overseas-master-data/api/capacityOffers.ts @@ -0,0 +1,28 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { CapacityOffer, CapacityInquiry, CapacityOfferDetail, CapacityLoad, OfferPage, OfferQuery, InquiryQuery, OfferWrite, OfferCommand, InquiryWrite, InquiryCommand, OfferResult, InquiryResult, OfferCustomerOption } from '../capacityOfferTypes' +import { normalizeFleetQuotePage, normalizeFleetQuoteSource, type WireQuotePage } from './fleetQuoteCandidates' +import type { TripBookingQuoteCriteria } from './tripBookings' +const base = '/platform/v1/last-leg/fleet/capacity-offers' +const path = (value: string) => `${base}/${encodeURIComponent(value)}` +const inquiryPath = (value: string) => `${base}/inquiries/${encodeURIComponent(value)}` +const number = (value: number) => { const n = Number(value); if (value == null || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n } +const nullable = (value: number | null) => value == null ? null : number(value) +const load = (value: CapacityLoad | null) => value == null ? null : { palletSpaces: number(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) } +export function normalizeOffer(value: CapacityOffer): CapacityOffer { return { ...value, recordVersion: number(value.recordVersion), tripRecordVersion: number(value.tripRecordVersion), destinations: value.destinations.map(d => ({ ...d, deliveryIndex: number(d.deliveryIndex), remaining: load(d.remaining) })), segments: value.segments.map(s => ({ ...s, fromIndex: number(s.fromIndex), toIndex: number(s.toIndex), planned: load(s.planned), reserved: load(s.reserved), remaining: load(s.remaining) })) } } +export function normalizeInquiry(value: CapacityInquiry): CapacityInquiry { return { ...value, recordVersion: number(value.recordVersion), offerRecordVersion: number(value.offerRecordVersion), tripRecordVersion: number(value.tripRecordVersion), expectedPalletCount: nullable(value.expectedPalletCount), agreedAmount: nullable(value.agreedAmount), quoteSource: value.quoteSource ? normalizeFleetQuoteSource(value.quoteSource) : null, cargo: { ...value.cargo, packageCount: nullable(value.cargo.packageCount), palletSpaces: nullable(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } } } +function page(value: OfferPage, normalize: (row: T) => T): OfferPage { return { records: value.records.map(normalize), page: number(value.page), size: number(value.size), total: number(value.total), pages: number(value.pages) } } +const offerResult = (value: OfferResult): OfferResult => ({ ...value, offer: normalizeOffer(value.offer) }) +const inquiryResult = (value: InquiryResult): InquiryResult => ({ ...offerResult(value), inquiry: normalizeInquiry(value.inquiry) }) +async function get(url: string, params?: object) { return unwrapApiResponse(await http.get, ApiEnvelope>(url, { params })) } +async function post(url: string, body: object) { return unwrapApiResponse(await http.post, ApiEnvelope>(url, body)) } +async function put(url: string, body: object) { return unwrapApiResponse(await http.put, ApiEnvelope>(url, body)) } +export async function listCapacityOffers(query: OfferQuery) { return page(await get>(base, query), normalizeOffer) } +export async function getCapacityOffer(offerNo: string): Promise { const value = await get(path(offerNo)); return { offer: normalizeOffer(value.offer), inquiries: page(value.inquiries, normalizeInquiry) } } +export async function listCapacityInquiries(query: InquiryQuery) { return page(await get>(`${base}/inquiries`, query), normalizeInquiry) } +export async function getCapacityInquiry(bookingNo: string) { return normalizeInquiry(await get(inquiryPath(bookingNo))) } +export async function saveCapacityOffer(offerNo: string | null, body: OfferWrite) { return offerResult(await (offerNo ? put(path(offerNo), body) : post(base, body))) } +export async function commandCapacityOffer(offerNo: string, action: 'publish' | 'pause' | 'resume' | 'close', body: OfferCommand) { return offerResult(await post(`${path(offerNo)}/${action}`, body)) } +export async function saveCapacityInquiry(offerNo: string, bookingNo: string | null, body: InquiryWrite) { return inquiryResult(await (bookingNo ? put(inquiryPath(bookingNo), body) : post(`${path(offerNo)}/inquiries`, body))) } +export async function commandCapacityInquiry(bookingNo: string, action: 'confirm' | 'cancel', body: InquiryCommand) { return inquiryResult(await post(`${inquiryPath(bookingNo)}/${action}`, body)) } +export const listOfferCustomers = (keyword?: string) => get(`${base}/customer-options`, { keyword }) +export async function listOfferQuotes(offerNo: string, query: TripBookingQuoteCriteria) { return normalizeFleetQuotePage(await get(`${path(offerNo)}/quote-candidates`, query)) } diff --git a/src/modules/overseas-master-data/api/tripBookings.test.ts b/src/modules/overseas-master-data/api/tripBookings.test.ts index fa6a4436..6dfd312a 100644 --- a/src/modules/overseas-master-data/api/tripBookings.test.ts +++ b/src/modules/overseas-master-data/api/tripBookings.test.ts @@ -12,10 +12,11 @@ it('uses trip-scoped candidate criteria without accepting browser-computed city expect(value.records[0]).toMatchObject({ recordVersion: source.recordVersion, quotedUnitPrice: 0 }) }) it('normalizes quote source without overwriting negotiated price and handles manual absence', () => { - const row: WireBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户', status: 'DRAFT', destinationLabel: 'ONT8', agreedAmount: '25.00', recordVersion: '1', updatedAt: '', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: source, + const row: WireBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户', status: 'DRAFT', destinationLabel: 'ONT8', agreedAmount: '25.00', recordVersion: '1', updatedAt: '', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: source, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: '1', palletSpaces: '2', weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: '12.50', currency: 'USD', contactName: null, contactPhone: null, remark: null } } const value = normalizeBooking(row) expect(value.quoteSource).toMatchObject({ quotedUnitPrice: 0, recordVersion: source.recordVersion }) expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25) expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull() + expect(normalizeBooking({ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } })).toMatchObject({ offerNo: 'CO-1', expectedPalletCount: null, cargo: { packageCount: null } }) }) diff --git a/src/modules/overseas-master-data/api/tripBookings.ts b/src/modules/overseas-master-data/api/tripBookings.ts index 3f3c242c..43384cf3 100644 --- a/src/modules/overseas-master-data/api/tripBookings.ts +++ b/src/modules/overseas-master-data/api/tripBookings.ts @@ -4,8 +4,8 @@ import type { FleetCustomerOption, TripBooking, TripBookingCargo, TripBookingCre const base = '/platform/v1/last-leg/fleet/trips' const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings` type N = number | string -type WireCargo = Omit & { packageCount: N; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null } -export type WireBooking = Omit & { recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null } +type WireCargo = Omit & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null } +export type WireBooking = Omit & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null } type WireIncoming = Omit & { receivedPackages: N; pallets: (Omit & { packages: N; weightKg: N | null; volumeCbm: N | null })[] } type WireReceipt = Omit & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] } type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean } @@ -13,8 +13,8 @@ const number = (value: N) => { const result = Number(value); if (value == null | const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result } const nullable = (value: N | null) => value == null ? null : number(value) const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) }) -export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo, - packageCount: integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } }) +export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo, + packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } }) const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true }) export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number } export async function listTripBookingQuoteCandidates(tripNo: string, criteria: TripBookingQuoteCriteria) { diff --git a/src/modules/overseas-master-data/bookingTypes.ts b/src/modules/overseas-master-data/bookingTypes.ts index d477877b..eda6cd6d 100644 --- a/src/modules/overseas-master-data/bookingTypes.ts +++ b/src/modules/overseas-master-data/bookingTypes.ts @@ -1,10 +1,10 @@ export interface FleetCustomerOption { id: string; code: string; name: string; contactName: string | null; contactPhone: string | null } export interface TripBookingCargo { - destinationKey: string; cargoName: string | null; packageCount: number; palletSpaces: number | null; + destinationKey: string; cargoName: string | null; packageCount: number | null; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; priceBasis: 'PALLET' | 'ORDER'; unitPrice: number | null; currency: string | null; contactName: string | null; contactPhone: string | null; remark: string | null; } -export interface TripBooking { bookingNo: string; tripNo: string; customerId: string; customerCode: string; customerName: string; +export interface TripBooking { offerNo: string | null; expectedPalletCount: number | null; bookingNo: string; tripNo: string; customerId: string; customerCode: string; customerName: string; status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'; cargo: TripBookingCargo; destinationLabel: string; agreedAmount: number | null; recordVersion: number; updatedAt: string; incomingNo: string | null; dispatchNo: string | null; receipt: TripAcceptedReceipt | null; quoteSource: import('./api/fleetQuoteCandidates').FleetQuoteSourceSnapshot | null } export interface TripBookingCreate { tripRecordVersion: number; customerId: string; cargo: TripBookingCargo; idempotencyKey: string; quoteRef?: import('./api/fleetQuoteCandidates').FleetQuoteReference | null } diff --git a/src/modules/overseas-master-data/capacityOfferTypes.ts b/src/modules/overseas-master-data/capacityOfferTypes.ts new file mode 100644 index 00000000..5ca8e1c2 --- /dev/null +++ b/src/modules/overseas-master-data/capacityOfferTypes.ts @@ -0,0 +1,41 @@ +import type { TripBookingCargo } from './bookingTypes' +import type { FleetQuoteReference, FleetQuoteSourceSnapshot } from './api/fleetQuoteCandidates' + +export interface OfferPage { records: T[]; page: number; size: number; total: number; pages: number } +export interface CapacityLoad { palletSpaces: number; weightKg: number; volumeCbm: number } +export interface OfferDestination { destinationKey: string; name: string; deliveryIndex: number; remaining: CapacityLoad | null } +export interface OfferSegment { fromIndex: number; toIndex: number; fromName: string; toName: string; planned: CapacityLoad | null; reserved: CapacityLoad | null; remaining: CapacityLoad | null } +export interface CapacityOffer { + offerNo: string; tripNo: string; status: 'DRAFT' | 'OPEN' | 'PAUSED' | 'CLOSED'; recordVersion: number; tripRecordVersion: number + warehouseId: string; warehouseName: string; plannedDepartureAt: string; cutoffAt: string + contactName: string | null; contactPhone: string | null; remark: string | null + destinations: OfferDestination[]; segments: OfferSegment[]; capacityKnown: boolean; canCreateInquiry: boolean; canConfirm: boolean; blockReason: string | null + actions: { canEdit: boolean; canPublish: boolean; canPause: boolean; canResume: boolean; canClose: boolean } +} +export interface CapacityInquiry { + bookingNo: string; offerNo: string; tripNo: string; status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED' + recordVersion: number; offerRecordVersion: number; tripRecordVersion: number; salesOwnerUserId: string + customerId: string; customerCode: string; customerName: string; businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK' + deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE'; estimatedArrivalAt: string | null; expectedPalletCount: number | null + cargo: TripBookingCargo; destinationLabel: string; agreedAmount: number | null; quoteSource: FleetQuoteSourceSnapshot | null + businessOrderNo: string | null; incomingNo: string | null; dispatchNo: string | null; updatedAt: string + actions: { canEdit: boolean; canConfirm: boolean; canCancel: boolean; blockReason: string | null } +} +export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage } +export interface OfferQuery { page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string } +export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' } +export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string } +export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string } +export interface InquiryWrite { + tripRecordVersion: number; offerRecordVersion: number; recordVersion: number | null; customerId: string + businessType: CapacityInquiry['businessType']; deliveryMode: CapacityInquiry['deliveryMode']; estimatedArrivalAt: string | null + expectedPalletCount: number | null; cargo: TripBookingCargo; quoteRef: FleetQuoteReference | null; idempotencyKey: string +} +export interface InquiryCommand extends OfferCommand { recordVersion: number } +export interface OfferResult { offer: CapacityOffer; replayed: boolean } +export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry } +export interface OfferCustomerOption { id: string; code: string; name: string } +export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' } +export const inquiryStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' } +export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' +export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对' diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue new file mode 100644 index 00000000..2bc4e7c9 --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue @@ -0,0 +1,115 @@ + + + diff --git a/src/modules/overseas-master-data/components/CapacityInquiryTable.vue b/src/modules/overseas-master-data/components/CapacityInquiryTable.vue new file mode 100644 index 00000000..3d230c7d --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityInquiryTable.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue b/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue new file mode 100644 index 00000000..dc9b0169 --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts index cf335961..a650a27e 100644 --- a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts +++ b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts @@ -7,7 +7,7 @@ import type { TripBooking, TripIncomingPreview } from '../bookingTypes' vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } })) vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() })) const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip -const booking: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null, +const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } } const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22, pallets: [{ warehousePalletId: '902', palletNo: 'PLT-1', packages: 22, weightKg: 150, volumeCbm: 1.5 }] } } diff --git a/src/modules/overseas-master-data/components/TripBookingPanel.test.ts b/src/modules/overseas-master-data/components/TripBookingPanel.test.ts index e96efd5f..14df5257 100644 --- a/src/modules/overseas-master-data/components/TripBookingPanel.test.ts +++ b/src/modules/overseas-master-data/components/TripBookingPanel.test.ts @@ -9,7 +9,7 @@ const access = vi.hoisted(() => ({ writable: true, quote: true })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission === 'platform:fleet:quote:list' ? access.quote : access.writable }) })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } })) vi.mock('../api/tripBookings', () => ({ listTripBookingQuoteCandidates: vi.fn(), listFleetCustomers: vi.fn(), listTripBookings: vi.fn(), createTripBooking: vi.fn(), updateTripBooking: vi.fn(), confirmTripBooking: vi.fn(), cancelTripBooking: vi.fn() })) -const row: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z', +const row: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 10, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } } const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } as FleetTrip const wrappers: VueWrapper[] = [] @@ -27,6 +27,16 @@ function mount(realNumber?: Component, realInput?: Component) { function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! } beforeEach(() => { vi.clearAllMocks(); access.writable = true; access.quote = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) }) afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount())) +it('shows pool inquiries read-only without legacy edit, confirm or cancel actions', async () => { + vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] }) + const wrapper = mount(); await flushPromises() + for (const label of ['编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(node => node.text() === label)).toBe(false) + await button(wrapper, '详情').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('请从拼车需求池管理询价与承运') + expect(wrapper.findComponent({ name: 'AppDialog' }).props('showConfirm')).toBe(false) + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.confirmTripBooking).not.toHaveBeenCalled() +}) it('confirms using both current versions and updates the parent version without extra list reads', async () => { vi.mocked(api.confirmTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, status: 'CONFIRMED', recordVersion: 1 }, replayed: false }) const wrapper = mount(); await flushPromises(); await button(wrapper, '确认承运').trigger('click'); await flushPromises() diff --git a/src/modules/overseas-master-data/components/TripBookingPanel.vue b/src/modules/overseas-master-data/components/TripBookingPanel.vue index bc17e4e5..075edad9 100644 --- a/src/modules/overseas-master-data/components/TripBookingPanel.vue +++ b/src/modules/overseas-master-data/components/TripBookingPanel.vue @@ -24,7 +24,7 @@ const readOnly = ref(false) const open = ref(false), editing = ref(null), customers = ref([]), customersLoading = ref(false) const pendingCreate = ref(null), pendingCommand = ref<{ bookingNo: string; action: 'confirm' | 'cancel'; body: TripBookingCommand } | null>(null) const version = ref(null), customerId = ref('') -const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: 1, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }) +const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }) const form = reactive(blank()) const quoteSource = ref(null), quoteRef = ref(null) const candidates = ref([]), quoteLoading = ref(false), quoteError = ref(null) @@ -83,7 +83,7 @@ function searchCustomers(keyword = '') { }, 250) } function edit(row?: TripBooking, viewOnly = false) { - readOnly.value = viewOnly + readOnly.value = viewOnly || !!row?.offerNo if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return } editing.value = row || null resetCandidates() @@ -102,9 +102,9 @@ function failure(cause: unknown, fallback: string) { if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pendingCreate.value = null; pendingCommand.value = null } } async function save() { - if (busy.value || readOnly.value || version.value == null) return + if (busy.value || readOnly.value || editing.value?.offerNo || version.value == null) return if (editing.value?.quoteSource && !canReadQuotes.value) { ElMessage.warning('编辑带报价来源的询价需要报价读取权限'); return } - if (!customerId.value || !form.destinationKey || !form.packageCount) { ElMessage.warning('请选择客户、卸货站并填写箱数'); return } + if (!customerId.value || !form.destinationKey) { ElMessage.warning('请选择客户和卸货站'); return } const current = generation; busy.value = true; error.value = null try { let value: TripBookingResult @@ -118,7 +118,7 @@ async function save() { finally { busy.value = false } } async function command(row: TripBooking, action: 'confirm' | 'cancel') { - if (busy.value || version.value == null) return + if (busy.value || row.offerNo || version.value == null) return if (pendingCommand.value && (pendingCommand.value.bookingNo !== row.bookingNo || pendingCommand.value.action !== action)) { ElMessage.warning('请先重试上一条操作或刷新核对'); return } const current = generation if (!pendingCommand.value) { @@ -145,18 +145,18 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi - + - + - +

池来源意向 {{ editing.offerNo }},请从拼车需求池管理询价与承运。

- + diff --git a/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue new file mode 100644 index 00000000..1d29c378 --- /dev/null +++ b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue @@ -0,0 +1,70 @@ + + + diff --git a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts new file mode 100644 index 00000000..ca9a04c2 --- /dev/null +++ b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { createPinia } from 'pinia' +import { ElMessageBox } from 'element-plus' +import CapacityOffersView from './CapacityOffersView.vue' +import CapacityOfferDetailView from './CapacityOfferDetailView.vue' +import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue' +import CapacityOfferSettingsDialog from '../components/CapacityOfferSettingsDialog.vue' +import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' +import * as api from '../api/capacityOffers' +import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes' +import type { FleetTrip } from '../tripTypes' +const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, route: { params: { offerNo: 'CO-1' } }, push: vi.fn() })) +vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write }) })) +vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } })) +vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() })) +export const offer: CapacityOffer = { offerNo: 'CO-1', tripNo: 'TR-1', status: 'OPEN', recordVersion: 2, tripRecordVersion: 5, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-10T12:00:00Z', cutoffAt: '2026-10-10T10:00:00Z', contactName: '调度', contactPhone: '测试联系信息', remark: null, capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待调度核对', destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } } +export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } } +const pageOf = (records: T[], total = records.length) => ({ records, total, page: 1, size: 20, pages: Math.ceil(total / 20) }) +const wrappers: VueWrapper[] = [] +const layout = { template: '
' } +const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '' } +function mount(component: object, props: Record = {}) { + const pinia = createPinia() + const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: { + AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, + DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page'], template: '
' }, + QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], template: '
' }, + CapacityInquiryTable: { name: 'CapacityInquiryTable', props: ['rows', 'total', 'failed'], emits: ['action', 'update:page'], template: '
' }, + CapacityInquiryDialog: component === CapacityInquiryDialog ? false : true, + AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmText'], emits: ['update:modelValue', 'confirm'], template: '
' }, + ElButton: { props: ['disabled', 'loading'], template: '' }, ElForm: layout, ElFormItem: layout, + ElInput: { ...field, name: 'ElInput' }, ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: { ...field, name: 'ElDatePicker' }, ElOption: true, ApiErrorMeta: true, LoadErrorState: true, + } } }); wrappers.push(wrapper); return { wrapper, pinia } +} +const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)! +beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) }) +afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() }) +it('loads only the active tab and sends exactly one query after resetting from a later page', async () => { + const { wrapper } = mount(CapacityOffersView); await flushPromises() + expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled() + wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises() + wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises() + expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 })) + await button(wrapper, '我的意向').trigger('click'); await flushPromises() + expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'MINE' })); expect(api.getCapacityOffer).not.toHaveBeenCalled() + const query = wrapper.findComponent({ name: 'QueryToolbar' }); query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises() + expect(api.listCapacityInquiries).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'MINE' })) +}) +it('ignores an older page response after changing tabs and retains an explicit error state', async () => { + let resolve!: (value: ReturnType>) => void + vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done })) + const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的意向').trigger('click'); await flushPromises() + resolve(pageOf([offer], 900)); await flushPromises() + expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('total')).toBe(1) + vi.mocked(api.listCapacityInquiries).mockRejectedValue(new Error('读取失败')); wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises() + expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true) + expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('failed')).toBe(true) +}) +it('reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes', async () => { + const { wrapper } = mount(CapacityOfferDetailView); await flushPromises() + expect(api.getCapacityOffer).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled() + await button(wrapper, '登记意向').trigger('click'); await flushPromises() + expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(true) + expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled() +}) +it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => { + vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true }) + const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises() + const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', inquiry.customerId); selects[2]!.vm.$emit('update:modelValue', inquiry.cargo.destinationKey); await flushPromises() + const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises() + const first = vi.mocked(api.saveCapacityInquiry).mock.calls[0]![2] + expect(first).toMatchObject({ expectedPalletCount: null, cargo: { packageCount: null, palletSpaces: null }, idempotencyKey: expect.any(String) }) + expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1) + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, null, first) + expect(wrapper.emitted('saved')).toHaveLength(1); expect(api.listOfferCustomers).not.toHaveBeenCalled() +}) +it('protects X/Esc/cancel model-close requests and does not emit close when discard is declined', async () => { + vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel') + const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry, readonly: false }); await flushPromises() + wrapper.findAllComponents({ name: 'ElInput' })[0]!.vm.$emit('update:modelValue', '改名'); await flushPromises() + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('update:modelValue', false); await flushPromises() + expect(ElMessageBox.confirm).toHaveBeenCalledOnce(); expect(wrapper.emitted('update:modelValue')).toBeUndefined() +}) +it('retains an uncertain request and route protection after closing and reopening its form', async () => { + vi.mocked(api.saveCapacityInquiry).mockRejectedValue(new Error('network')) + const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry, readonly: false }); await flushPromises() + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + const first = vi.mocked(api.saveCapacityInquiry).mock.calls[0]![2] + await (wrapper as VueWrapper>).setProps({ modelValue: false }); await flushPromises() + expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1) + await (wrapper as VueWrapper>).setProps({ modelValue: true }); await flushPromises() + expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1) + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(api.saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, inquiry.bookingNo, first) +}) +it('keeps sales from confirming even if a stale row advertises the action', async () => { + const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } } + const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises() + wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises() + expect(api.commandCapacityInquiry).not.toHaveBeenCalled(); expect(ElMessageBox.confirm).not.toHaveBeenCalled() +}) +it('loads publishing settings only when explicitly opened, not with the trip page', async () => { + state.manage = true + const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises() + expect(api.listCapacityOffers).not.toHaveBeenCalled(); await (wrapper as VueWrapper>).setProps({ modelValue: true }); await flushPromises() + expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ tripNo: 'TR-1', page: 1, size: 1 }) +}) +it('retries an uncertain dispatch confirmation with the same command and no second confirmation box', async () => { + state.manage = true + const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } } + vi.mocked(api.commandCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry: { ...row, status: 'CONFIRMED' }, replayed: true }) + const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises() + wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises() + const first = vi.mocked(api.commandCapacityInquiry).mock.calls[0]! + await button(wrapper, '重试原操作').trigger('click'); await flushPromises() + expect(api.commandCapacityInquiry).toHaveBeenNthCalledWith(2, ...first); expect(ElMessageBox.confirm).toHaveBeenCalledOnce() + expect(api.listCapacityInquiries).toHaveBeenCalledOnce() +}) diff --git a/src/modules/overseas-master-data/pages/CapacityOffersView.vue b/src/modules/overseas-master-data/pages/CapacityOffersView.vue new file mode 100644 index 00000000..e29834a6 --- /dev/null +++ b/src/modules/overseas-master-data/pages/CapacityOffersView.vue @@ -0,0 +1,103 @@ + + diff --git a/src/modules/overseas-master-data/pages/TripDetailView.vue b/src/modules/overseas-master-data/pages/TripDetailView.vue index 1bb8c1e9..b6768581 100644 --- a/src/modules/overseas-master-data/pages/TripDetailView.vue +++ b/src/modules/overseas-master-data/pages/TripDetailView.vue @@ -15,6 +15,7 @@ import TripPlanDialog from '../components/TripPlanDialog.vue' import TripAssignmentPanel from '../components/TripAssignmentPanel.vue' import TripLoadingPanel from '../components/TripLoadingPanel.vue' import TripBookingPanel from '../components/TripBookingPanel.vue' +import CapacityOfferSettingsDialog from '../components/CapacityOfferSettingsDialog.vue' import TripCostPanel from '../components/TripCostPanel.vue' import TripHandoverPanel from '../components/TripHandoverPanel.vue' import { getTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, getTripLoading, updateTripPlan, @@ -27,6 +28,7 @@ import { tripStatusLabels, tripReviewLabels, type TripDetail, type TripLoading, const route = useRoute(), router = useRouter(), access = useAccessPreviewStore() const detail = ref(null), loading = ref(false), error = ref(null), operationError = ref(null) const activeTab = ref('overview'), editorOpen = ref(false), busy = ref(''), dispatchNo = ref('') +const offerSettingsOpen = ref(false) const inspection = ref(null), inspectionLoading = ref(false) const history = ref([]), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false) const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new Date().toISOString()), endLocation = ref('') @@ -181,7 +183,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
diff --git a/src/modules/overseas-master-data/permissions.ts b/src/modules/overseas-master-data/permissions.ts index 7b3cad05..89f3ac3c 100644 --- a/src/modules/overseas-master-data/permissions.ts +++ b/src/modules/overseas-master-data/permissions.ts @@ -1,4 +1,9 @@ export const overseasMasterDataPermissions = { + capacityOfferList: 'platform:fleet:capacity-offer:list', + capacityOfferManage: 'platform:fleet:capacity-offer:manage', + capacityInquiryCreate: 'platform:fleet:capacity-offer:inquiry-create', + capacityInquiryEdit: 'platform:fleet:capacity-offer:inquiry-edit', + capacityInquiryCancel: 'platform:fleet:capacity-offer:inquiry-cancel', fleetQuoteList: 'platform:fleet:quote:list', fleetQuoteCreate: 'platform:fleet:quote:create', fleetQuoteEdit: 'platform:fleet:quote:edit', diff --git a/src/modules/overseas-master-data/routes.test.ts b/src/modules/overseas-master-data/routes.test.ts index 80f22766..82389457 100644 --- a/src/modules/overseas-master-data/routes.test.ts +++ b/src/modules/overseas-master-data/routes.test.ts @@ -7,6 +7,8 @@ import { overseasMasterDataRoutes } from './routes' describe('overseas master data routes', () => { it('uses the frozen stable paths and list permissions', () => { expect(overseasMasterDataRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([ + ['overseas-master-data/capacity-offers', overseasMasterDataPermissions.capacityOfferList], + ['overseas-master-data/capacity-offers/:offerNo', overseasMasterDataPermissions.capacityOfferList], ['overseas-master-data/fleet-quotes', overseasMasterDataPermissions.fleetQuoteList], ['overseas-master-data/trip-operations', overseasMasterDataPermissions.operationsList], ['overseas-master-data/trips', overseasMasterDataPermissions.tripList], @@ -20,6 +22,15 @@ describe('overseas master data routes', () => { ]) }) + it('allows dispatch management to read the pool without granting full trip access to sales', () => { + const pool = overseasMasterDataRoutes.filter(route => route.path.includes('capacity-offers')) + expect(pool).toHaveLength(2) + for (const route of pool) { + expect(route.meta?.permissionAnyOf).toEqual([overseasMasterDataPermissions.capacityOfferManage]) + expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.tripList) + } + }) + it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => { expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false) expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false) diff --git a/src/modules/overseas-master-data/routes.ts b/src/modules/overseas-master-data/routes.ts index 6a8dc871..f7d3fc47 100644 --- a/src/modules/overseas-master-data/routes.ts +++ b/src/modules/overseas-master-data/routes.ts @@ -2,6 +2,18 @@ import type { RouteRecordRaw } from 'vue-router' import { overseasMasterDataPermissions as permission } from './permissions' export const overseasMasterDataRoutes = [ + { + path: 'overseas-master-data/capacity-offers', + name: 'overseas-master-data-capacity-offers', + component: () => import('./pages/CapacityOffersView.vue'), + meta: { title: '拼车需求池', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false }, + }, + { + path: 'overseas-master-data/capacity-offers/:offerNo', + name: 'overseas-master-data-capacity-offer-detail', + component: () => import('./pages/CapacityOfferDetailView.vue'), + meta: { title: '拼车需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false }, + }, { path: 'overseas-master-data/fleet-quotes', name: 'overseas-master-data-fleet-quotes', diff --git a/src/modules/overseas-master-data/useCapacityInquiryCommands.ts b/src/modules/overseas-master-data/useCapacityInquiryCommands.ts new file mode 100644 index 00000000..cfcc980e --- /dev/null +++ b/src/modules/overseas-master-data/useCapacityInquiryCommands.ts @@ -0,0 +1,29 @@ +import { computed, reactive, ref } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' +import { commandCapacityInquiry } from './api/capacityOffers' +import type { CapacityInquiry, InquiryCommand, InquiryResult } from './capacityOfferTypes' +export function useCapacityInquiryCommands(key: string, accept: (value: InquiryResult) => void) { + const access = useAccessPreviewStore(), busy = ref(false), error = ref(null) + const state = reactive<{ pending: { bookingNo: string; action: 'confirm' | 'cancel'; body: InquiryCommand } | null }>({ pending: null }) + useUnsavedWorkRegistration({ key, label: computed(() => '待核对拼车命令'), isOpen: computed(() => !!state.pending), model: state, requiredPermission: computed(() => null) }) + async function retry() { + if (!state.pending || busy.value) return + busy.value = true; error.value = null + try { const value = await commandCapacityInquiry(state.pending.bookingNo, state.pending.action, state.pending.body); state.pending = null; accept(value); ElMessage.success('意向已更新') } + catch (cause) { error.value = getApiErrorDetails(cause, '意向操作未完成'); if (cause instanceof ApiResponseError) state.pending = null } + finally { busy.value = false } + } + async function run(row: CapacityInquiry, action: 'confirm' | 'cancel') { + if (busy.value) return + const manage = access.can('platform:fleet:capacity-offer:manage') + if (action === 'confirm' ? !manage || !row.actions.canConfirm : !row.actions.canCancel || !(manage || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-cancel'))) return + if (state.pending) { ElMessage.warning('请先重试上一条结果未知的操作'); return } + try { await ElMessageBox.confirm(action === 'confirm' ? `确认承运 ${row.bookingNo},按约定货量占位并创建本土订单?` : `取消 ${row.bookingNo}?未登记来货的已确认承运将一并取消商业订单并释放预留。`, action === 'confirm' ? '确认拼车承运' : '取消拼车意向', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '返回' }) } catch { return } + state.pending = { bookingNo: row.bookingNo, action, body: { tripRecordVersion: row.tripRecordVersion, offerRecordVersion: row.offerRecordVersion, recordVersion: row.recordVersion, idempotencyKey: crypto.randomUUID() } } + await retry() + } + return { busy, error, state, run, retry } +} diff --git a/src/modules/overseas-master-data/useFleetDraftGuard.ts b/src/modules/overseas-master-data/useFleetDraftGuard.ts new file mode 100644 index 00000000..90083e1b --- /dev/null +++ b/src/modules/overseas-master-data/useFleetDraftGuard.ts @@ -0,0 +1,22 @@ +import { computed, getCurrentInstance, reactive, ref, type Ref } from 'vue' +import { ElMessageBox } from 'element-plus' +import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' + +/** All dialog close paths use requestClose; the shared registry also protects route/tab exits. */ +export function useFleetDraftGuard(key: string, open: Ref, model: object, busy: Ref, uncertain: Ref, close: () => void) { + const baseline = ref(''), checking = ref(false) + const snapshot = computed(() => JSON.stringify(model)) + const registration = useUnsavedWorkRegistration({ key: `${key}:${getCurrentInstance()?.uid}`, label: computed(() => '拼车表单'), isOpen: computed(() => open.value || uncertain.value), model: reactive({ form: model, uncertain }), requiredPermission: computed(() => null) }) + function capture() { baseline.value = snapshot.value; if (!uncertain.value) registration.captureBaseline() } + async function requestClose() { + if (busy.value || checking.value) return + if (snapshot.value !== baseline.value || uncertain.value) { + checking.value = true + try { await ElMessageBox.confirm(uncertain.value ? '上次提交结果尚未确定,原请求将保留。关闭后可重新打开并核对或重试,是否关闭?' : '表单有未保存内容,确认放弃本次修改?', '关闭表单', { customClass: 'admin-density-confirm', confirmButtonText: '确认关闭', cancelButtonText: '继续填写' }) } + catch { return } + finally { checking.value = false } + } + close() + } + return { capture, requestClose, dirty: computed(() => snapshot.value !== baseline.value) } +} diff --git a/src/styles/adminDensity.test.ts b/src/styles/adminDensity.test.ts index 0f546ec1..784c0798 100644 --- a/src/styles/adminDensity.test.ts +++ b/src/styles/adminDensity.test.ts @@ -64,6 +64,11 @@ const generalCatalogConsumers = [ 'overseas-master-data/pages/VehicleDetailView.vue', 'overseas-master-data/pages/DriversView.vue', 'overseas-master-data/pages/TripsView.vue', + 'overseas-master-data/pages/CapacityOffersView.vue', + 'overseas-master-data/pages/CapacityOfferDetailView.vue', + 'overseas-master-data/components/CapacityInquiryDialog.vue', + 'overseas-master-data/components/CapacityInquiryTable.vue', + 'overseas-master-data/components/CapacityOfferSettingsDialog.vue', 'overseas-master-data/pages/TripOperationsView.vue', 'overseas-master-data/pages/TripDetailView.vue', 'overseas-master-data/components/TripPlanDialog.vue', @@ -85,6 +90,7 @@ const notificationConsumers = [ ] const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue'] const consumers = [ + 'overseas-business/components/FleetArrangementSummary.vue', ...notificationConsumers, ...systemConsumers, ...generalCatalogConsumers, From 2bc095ecacb89bdbe5fc2a64e73d0b0b116661cd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 18:21:03 +0800 Subject: [PATCH 2/6] fix(fleet): preserve drafts before dialog close and respect dashboard permissions --- .../dashboard/pages/DashboardView.test.ts | 28 ++++++++- src/modules/dashboard/pages/DashboardView.vue | 33 ++++++----- .../pages/OverseasBusinessViews.test.ts | 3 + .../components/CapacityDialogClosing.test.ts | 58 +++++++++++++++++++ .../components/CapacityInquiryDialog.vue | 4 +- .../CapacityOfferSettingsDialog.vue | 4 +- .../pages/CapacityOffersBehavior.test.ts | 10 ++-- .../pages/CapacityOffersView.vue | 2 +- .../useFleetDraftGuard.ts | 15 ++--- src/shared/components/AppDialog.test.ts | 17 +++++- src/shared/components/AppDialog.vue | 10 +++- 11 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts diff --git a/src/modules/dashboard/pages/DashboardView.test.ts b/src/modules/dashboard/pages/DashboardView.test.ts index ad3e702b..a1c14dbf 100644 --- a/src/modules/dashboard/pages/DashboardView.test.ts +++ b/src/modules/dashboard/pages/DashboardView.test.ts @@ -1,7 +1,10 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import DashboardView from './DashboardView.vue' -import { getTodayNewOrderCount } from '../api/dashboard' +import { getTodayNewOrderCount, getActiveCustomerCount, getDashboardOperationalMetrics } from '../api/dashboard' + +const access = vi.hoisted(() => ({ permissions: new Set() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) })) vi.mock('../api/dashboard', () => ({ getTodayNewOrderCount: vi.fn().mockResolvedValue(12), @@ -18,7 +21,28 @@ vi.mock('../api/dashboard', () => ({ enableAutoUnmount(afterEach) describe('DashboardView', () => { - beforeEach(() => vi.clearAllMocks()) + beforeEach(() => { + vi.clearAllMocks() + access.permissions = new Set(['platform:order:list', 'platform:customer:list', 'platform:firstleg:container:list', 'platform:firstleg:customs:list']) + }) + + it('makes no metric requests for a minimal pool sales account and gives an authorized-menu hint', async () => { + access.permissions = new Set(['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:capacity-offer:inquiry-edit', 'platform:fleet:capacity-offer:inquiry-cancel']) + const wrapper = mount(DashboardView); await flushPromises() + expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getActiveCustomerCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled() + expect(wrapper.findAll('.dashboard-metric')).toHaveLength(0) + expect(wrapper.text()).toContain('请从左侧菜单进入已授权业务') + expect(wrapper.text()).not.toContain('指标加载失败'); expect(wrapper.text()).not.toContain('刷新数据') + }) + + it('requests only granted metric sources and requires both container and customs permissions', async () => { + access.permissions = new Set(['platform:customer:list', 'platform:firstleg:container:list']) + const wrapper = mount(DashboardView); await flushPromises() + expect(getActiveCustomerCount).toHaveBeenCalledOnce(); expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled() + expect(wrapper.findAll('.dashboard-metric p').map(node => node.text())).toEqual(['活跃客户']) + await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click'); await flushPromises() + expect(getActiveCustomerCount).toHaveBeenCalledTimes(2); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled() + }) it('renders only the four live metrics', async () => { const wrapper = mount(DashboardView) diff --git a/src/modules/dashboard/pages/DashboardView.vue b/src/modules/dashboard/pages/DashboardView.vue index a78d58a6..874f8225 100644 --- a/src/modules/dashboard/pages/DashboardView.vue +++ b/src/modules/dashboard/pages/DashboardView.vue @@ -7,6 +7,7 @@ import { RefreshCw, } from '@lucide/vue' import AdminPage from '@/shared/components/AdminPage.vue' +import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getActiveCustomerCount, getDashboardOperationalMetrics, @@ -22,6 +23,11 @@ const todayNewOrderCount = ref(null) const activeCustomerCount = ref(null) const operationalMetrics = ref(null) const metricsUpdatedAt = ref(null) +const access = useAccessPreviewStore() +const canReadOrders = computed(() => access.can('platform:order:list')) +const canReadCustomers = computed(() => access.can('platform:customer:list')) +const canReadOperations = computed(() => access.can('platform:firstleg:container:list') && access.can('platform:firstleg:customs:list')) +const hasMetrics = computed(() => canReadOrders.value || canReadCustomers.value || canReadOperations.value) function displayNumber(value: number | null | undefined, maximumFractionDigits = 0) { if (metricsLoading.value) return '…' @@ -30,17 +36,18 @@ function displayNumber(value: number | null | undefined, maximumFractionDigits = } const metrics = computed(() => [ - { label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue' }, + { label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue', visible: canReadOrders.value }, { label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU', note: operationalMetrics.value ? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}` : '已开船且尚未到仓', tone: 'green', + visible: canReadOperations.value, }, - { label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple' }, - { label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose' }, -]) + { label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple', visible: canReadCustomers.value }, + { label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose', visible: canReadOperations.value }, +].filter(metric => metric.visible)) async function toggleFullscreen() { if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen() @@ -55,17 +62,17 @@ async function loadMetrics() { metricsLoading.value = true metricsError.value = false const [orders, customers, operations] = await Promise.allSettled([ - getTodayNewOrderCount(), - getActiveCustomerCount(), - getDashboardOperationalMetrics(), + canReadOrders.value ? getTodayNewOrderCount() : Promise.resolve(null), + canReadCustomers.value ? getActiveCustomerCount() : Promise.resolve(null), + canReadOperations.value ? getDashboardOperationalMetrics() : Promise.resolve(null), ]) todayNewOrderCount.value = orders.status === 'fulfilled' ? orders.value : null activeCustomerCount.value = customers.status === 'fulfilled' ? customers.value : null operationalMetrics.value = operations.status === 'fulfilled' ? operations.value : null metricsError.value = [orders, customers, operations].some(result => result.status === 'rejected') - metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value.asOf + metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value?.asOf ? operations.value.asOf - : dayjs().format('YYYY-MM-DDTHH:mm:ss') + : hasMetrics.value ? dayjs().format('YYYY-MM-DDTHH:mm:ss') : null metricsLoading.value = false } @@ -80,10 +87,10 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
-

{{ metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}

+

{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}

- 数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }} - @@ -95,7 +102,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
-
+