From b47323edf352e2d1d15dca97e022fd962dbb88de Mon Sep 17 00:00:00 2001 From: wujinliang <2318309077@qq.com> Date: Thu, 27 Aug 2026 21:48:15 +0800 Subject: [PATCH] 1 --- .idea/.gitignore | 8 ++ .idea/admin-web.iml | 9 ++ .idea/misc.xml | 6 + .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 + docker-compose.test.yml | 4 +- src/app/DomesticOutboundListStates.test.ts | 2 +- src/app/layouts/AppLayout.test.ts | 2 +- src/app/layouts/AppLayout.vue | 6 +- src/app/router/index.test.ts | 1 + src/deployment/nginxConfig.test.ts | 2 + .../pages/GlobalSmartLoadingView.test.ts | 10 ++ .../pages/GlobalSmartLoadingView.vue | 9 +- .../pages/SmartLoadingWorkbenchView.test.ts | 10 ++ .../pages/SmartLoadingWorkbenchView.vue | 9 +- .../customers/overseasStorageFee.test.ts | 22 +++- src/modules/customers/overseasStorageFee.ts | 3 +- src/modules/finance/api/finance.test.ts | 22 ++++ src/modules/finance/api/finance.ts | 8 +- .../pages/FinanceCustomerArapView.test.ts | 7 ++ .../finance/pages/FinanceCustomerArapView.vue | 17 +-- .../FinanceCustomerStatementsView.test.ts | 29 +++++ .../pages/FinanceCustomerStatementsView.vue | 23 ++-- .../FinanceFormalSettlementFields.test.ts | 4 + .../pages/FinanceFormalSettlementPage.vue | 27 +++-- .../finance/pages/FinanceG3G4Views.test.ts | 34 ++++++ .../finance/pages/FinancePresentation.test.ts | 4 +- src/modules/finance/status.test.ts | 24 +++- src/modules/finance/status.ts | 13 +-- src/modules/finance/types.ts | 3 +- .../components/InboundTaskList.test.ts | 2 + .../inbounds/components/InboundTaskList.vue | 2 +- src/modules/inbounds/review/types.ts | 2 + .../components/ContainerFeesPanel.test.ts | 31 +++++ .../components/ContainerFeesPanel.vue | 33 +++++- .../api/notificationCenter.test.ts | 25 ++++ .../notifications/api/notificationCenter.ts | 29 ++++- .../components/MessageComposerDialog.vue | 19 ++- .../notifications/notifications.test.ts | 7 ++ .../pages/PlatformNotificationsView.vue | 110 ++++++++++++++++++ src/modules/notifications/routes.ts | 6 + src/modules/notifications/types.ts | 4 + .../pages/OceanBookingDetailView.test.ts | 5 + .../pages/OceanBookingDetailView.vue | 1 + .../components/TruckAppointmentForm.test.ts | 10 ++ .../components/TruckAppointmentForm.vue | 29 ++++- .../pages/TruckAppointmentDetailView.test.ts | 6 + .../pages/TruckAppointmentDetailView.vue | 12 +- .../pages/UnifiedOrdersView.test.ts | 6 + .../pages/UnifiedOrdersView.vue | 7 +- src/shared/api/http.test.ts | 7 ++ src/shared/api/http.ts | 13 ++- src/shared/components/QueryToolbar.vue | 8 +- 53 files changed, 624 insertions(+), 82 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/admin-web.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 src/modules/notifications/pages/PlatformNotificationsView.vue diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/admin-web.iml b/.idea/admin-web.iml new file mode 100644 index 00000000..d6ebd480 --- /dev/null +++ b/.idea/admin-web.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..07115cdf --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..e8ace242 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 6a5933bb..c8c35400 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -12,6 +12,8 @@ services: ports: - "${HOST_PORT:-9001}:80" environment: - CLIENT_WEB_URL: "${CLIENT_WEB_URL:-}" + # The test client is exposed on the same host through port 9002. Allow + # deployments to override this, but never fall back to the server IP. + CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://test-admin.buggoglobal.com:9002}" API_UPSTREAM: "http://test-api-cn.buggoglobal.com" OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com" diff --git a/src/app/DomesticOutboundListStates.test.ts b/src/app/DomesticOutboundListStates.test.ts index 470fa9e5..738e665a 100644 --- a/src/app/DomesticOutboundListStates.test.ts +++ b/src/app/DomesticOutboundListStates.test.ts @@ -30,7 +30,7 @@ describe('domestic outbound list state contracts', () => { const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue') expect(layout).toContain('') - expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute))"') + expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + \':\' + JSON.stringify(renderedRoute.params)"') expect(oceanDetail).toContain("router.push('/ocean-bookings')") expect(truckingDetail).toContain("router.push({ name: 'trucking' })") }) diff --git a/src/app/layouts/AppLayout.test.ts b/src/app/layouts/AppLayout.test.ts index 1eed235a..8a3f58f6 100644 --- a/src/app/layouts/AppLayout.test.ts +++ b/src/app/layouts/AppLayout.test.ts @@ -125,7 +125,7 @@ describe('AppLayout', () => { expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined() expect(wrapper.text()).toContain('消息管理') expect(wrapper.text()).toContain('模板管理') - expect(wrapper.text()).not.toContain('我的消息') + expect(wrapper.text()).toContain('我的消息') const notificationTrigger = wrapper.get('.notification-trigger') expect(notificationTrigger.find('.notification-count').exists()).toBe(false) }) diff --git a/src/app/layouts/AppLayout.vue b/src/app/layouts/AppLayout.vue index ff3b690a..39bfcbff 100644 --- a/src/app/layouts/AppLayout.vue +++ b/src/app/layouts/AppLayout.vue @@ -37,7 +37,8 @@ const menuExpanded = computed(() => ( isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value )) const canOpenNotificationManagement = computed(() => ( - access.can('platform:notification:task:list') + access.can('platform:notification:view') + || access.can('platform:notification:task:list') || access.can('platform:notification:template:list') )) @@ -177,6 +178,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId) 消息中心 @@ -193,7 +195,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId) diff --git a/src/app/router/index.test.ts b/src/app/router/index.test.ts index b4a68aeb..b316b27c 100644 --- a/src/app/router/index.test.ts +++ b/src/app/router/index.test.ts @@ -110,6 +110,7 @@ describe('admin router access metadata', () => { it('registers message-center routes with the frozen IAM permissions', () => { const expected = [ + ['notification-inbox', '/notifications', 'platform:notification:view'], ['notification-messages', '/notifications/messages', 'platform:notification:task:list'], ['notification-templates', '/notifications/templates', 'platform:notification:template:list'], ] diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index ce5339d3..a687f321 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -38,6 +38,8 @@ describe('admin nginx deployment config', () => { it('declares an explicit overseas gateway upstream in container deployments', () => { expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"') expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"') + expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://test-admin.buggoglobal.com:9002}"') + expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.') expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"') expect(config).toContain('proxy_set_header Host $proxy_host;') }) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index d31afa41..8f94a453 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -193,6 +193,12 @@ describe('GlobalSmartLoadingView', () => { expect(source).toContain('assignForm.packageCount = candidate.availablePackageCount') }) + it('keeps the package count editor inside its table cell without overflow ellipsis', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8') + expect(source).toContain('
{ vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, @@ -496,6 +502,10 @@ describe('GlobalSmartLoadingView', () => { expect(source).toContain('智能推荐') expect(source).toContain('推送预装') expect(source).toContain('推送预装的流程说明已由操作确认框承载') + expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?') + expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认') + expect(source).not.toContain('关联并放舱真实订舱') + expect(source).not.toContain('订舱放舱') expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo") }) }) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index a0381e8d..fa3828db 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -370,13 +370,13 @@ async function removeAll() { } async function pushPreload() { if (!plan.value) return - const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', { + const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消', }).catch(() => false) if (!confirmed || !plan.value) return await mutate(async () => { acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version)) - ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认') + ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') }, '推送预装清单失败') } @@ -540,7 +540,7 @@ onMounted(() => { void load(); void loadCustomers() })
- + @@ -603,7 +603,8 @@ onMounted(() => { void load(); void loadCustomers() }) .readonly-status-card strong { font-size: 16px; } .readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; } .meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; } -.plan-pane :deep(.el-input-number) { width: 120px; } +.package-count-editor { display: flex; min-width: 0; } +.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; } .assign-dialog p { margin: 0 0 10px; } .assign-dialog :deep(.el-input-number) { width: 100%; } @media (max-width: 1180px) { diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index 2621cc3c..bff9d0eb 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -99,6 +99,10 @@ describe('SmartLoadingWorkbenchView', () => { expect(source).toContain("canPush && plan?.status === 'EDITING'") expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo") expect(source).toContain('推送后生成预装清单,不会直接开放 PDA') + expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?') + expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认') + expect(source).not.toContain('关联并放舱真实订舱') + expect(source).not.toContain('订舱放舱') expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)') expect(source).not.toContain("label: '目的仓'") expect(source).not.toContain('plan.planNo') @@ -107,4 +111,10 @@ describe('SmartLoadingWorkbenchView', () => { expect(source).toContain('集装箱号') expect(source).not.toContain('当前柜号') }) + + it('keeps the package count editor inside its table cell without overflow ellipsis', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8') + expect(source).toContain('
false) + const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false) if (!confirmed || !plan.value) return mutating.value = true mutationError.value = null - try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认') } + try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') } catch (error) { await handleMutationError(error, '推送失败') } finally { mutating.value = false } } @@ -268,7 +268,7 @@ onMounted(load) - + @@ -306,7 +306,8 @@ onMounted(load) .readonly-status-card p { margin: 0; color: var(--cc-muted); } .meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; } .row-actions { min-width: 150px; } -.workbench :deep(.el-input-number) { width: 130px; } +.package-count-editor { display: flex; min-width: 0; } +.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; } .assign-dialog p { margin: 0 0 10px; } .assign-dialog :deep(.el-input-number) { width: 100%; } @media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } } diff --git a/src/modules/customers/overseasStorageFee.test.ts b/src/modules/customers/overseasStorageFee.test.ts index 5bc622d1..0cae8f3d 100644 --- a/src/modules/customers/overseasStorageFee.test.ts +++ b/src/modules/customers/overseasStorageFee.test.ts @@ -9,7 +9,14 @@ const overseasPanelSource = readFileSync(resolve(process.cwd(), 'src/modules/cus vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), put: vi.fn() }, - unwrapApiResponse: (response: { data: T }) => response.data, + unwrapApiResponse: (response: { data: T }) => { + const value = response.data as T & { code?: string; message?: string; data?: unknown } + if (value && typeof value === 'object' && 'code' in value) { + if (value.code !== '200') throw new Error(value.message || 'request failed') + return value.data as T + } + return value + }, })) const config = { id: 12, customerId: 9007199254740993n.toString(), customerName: '客户', currency: 'USD', tiers: [], version: 0, createdAt: null, updatedAt: null } @@ -46,6 +53,19 @@ describe('customer overseas storage fee api', () => { expect(result.version).toBe(1) }) + it('rejects a business error returned by the update endpoint', async () => { + vi.mocked(http.put).mockResolvedValue({ data: { code: 'A0400', message: '璇锋眰鏃犳晥', data: null } }) + + await expect(updateCustomerOverseasStorageFees(config.customerId, { + customerId: config.customerId, + customerName: '瀹㈡埛', + currency: 'USD', + version: 0, + tiers: [{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: null, unitPrice: 0 }], + })).rejects.toThrow('璇锋眰鏃犳晥') + expect(http.get).not.toHaveBeenCalled() + }) + it('normalizes an omitted end day as an open-ended tier', async () => { vi.mocked(http.get).mockResolvedValue({ data: { ...config, tiers: [{ id: 1, startDay: 91, unitPrice: 2, sortNo: 5, feeType: 'STORAGE', billingUnit: 'CBM' }] } }) const result = await getCustomerOverseasStorageFees(config.customerId) diff --git a/src/modules/customers/overseasStorageFee.ts b/src/modules/customers/overseasStorageFee.ts index f1816c2d..88e590d1 100644 --- a/src/modules/customers/overseasStorageFee.ts +++ b/src/modules/customers/overseasStorageFee.ts @@ -55,7 +55,8 @@ export async function getCustomerOverseasStorageFees(tenantId: string) { } export async function updateCustomerOverseasStorageFees(tenantId: string, request: OverseasStorageFeeUpdateRequest) { - await http.put>(path(tenantId), request) + const response = await http.put>(path(tenantId), request) + unwrapApiResponse(response) return getCustomerOverseasStorageFees(tenantId) } diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index 3e53ae0a..54d30f27 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -56,6 +56,28 @@ describe('finance api', () => { ]) }) + it('includes a supplier filter only for payable fee-pool requests', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] }) + + await listFinanceFeePool('10001', 'PAYABLE', '301') + + expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-pool', { + params: { customerId: '10001', direction: 'PAYABLE', supplierId: '301' }, + }) + }) + + it('preserves settlement mode from fee-pool candidates for client-side eligibility checks', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ + poolItemId: 'PAYABLE:42', customerId: 10001, direction: 'PAYABLE', sourceType: 'INBOUND_FEE', sourceId: 42, + sourceVersion: 1, feeItemName: '进仓费', amount: 33, currency: 'CNY', settlementMode: 'DIRECT_PAID', + status: 'AVAILABLE', createdAt: '2026-08-26T00:00:00', + }] }) + + const [candidate] = await listFinanceFeePool('10001', 'PAYABLE', '301') + + expect(candidate.settlementMode).toBe('DIRECT_PAID') + }) + it('uses the formal G1 account list path and fixed page DTO', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } }) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 368c4bf9..15ece978 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -111,12 +111,14 @@ export async function getFinanceCustomerAccountSummary(customerId: string): Prom } } -export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise { - const response = await http.get, ApiEnvelope>(`${basePath}/fee-pool`, { params: { customerId: customerId?.trim() || undefined, direction } }) +export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE', supplierId?: string): Promise { + const params: Record = { customerId: customerId?.trim() || undefined, direction } + if (supplierId?.trim()) params.supplierId = supplierId.trim() + const response = await http.get, ApiEnvelope>(`${basePath}/fee-pool`, { params }) return unwrapApiResponse(response).map((item) => { const poolItemId = stringValue(item.poolItemId) const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null) - return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) } + return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', settlementMode: item.settlementMode == null ? null : stringValue(item.settlementMode), status: stringValue(item.status), createdAt: stringValue(item.createdAt) } }) } diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts index 307e0bc5..3d3b20b7 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts @@ -129,6 +129,13 @@ describe('FinanceCustomerArapView G2 operations', () => { expect(adjustFinanceFeeSource).not.toHaveBeenCalled() }) + it('keeps the customer dropdown open while its option list is scrolled', async () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8') + expect(source).not.toContain("document.addEventListener('scroll', closeCustomerSelectOnScroll, true)") + expect(source).not.toContain('input?.blur()') + expect(source).toContain("class: 'finance-customer-select'") + }) + it('uses sourceSystem to suppress Firstleg editing when the source type is not in the legacy fallback list', async () => { const firstlegOtherType = { ...feeSummary, sourceType: 'ORDER_FEE', reviewStatus: 'DRAFT' as const } vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [firstlegOtherType], total: 1, pages: 1 }) diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue index 512fa642..8dac9f43 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.vue +++ b/src/modules/finance/pages/FinanceCustomerArapView.vue @@ -1,5 +1,5 @@ + + + + diff --git a/src/modules/notifications/routes.ts b/src/modules/notifications/routes.ts index 3b5a4e7e..f2f4dea6 100644 --- a/src/modules/notifications/routes.ts +++ b/src/modules/notifications/routes.ts @@ -1,6 +1,12 @@ import type { RouteRecordRaw } from 'vue-router' export const notificationRoutes = [ + { + path: 'notifications', + name: 'notification-inbox', + component: () => import('@/modules/notifications/pages/PlatformNotificationsView.vue'), + meta: { title: '我的消息', description: '查看当前平台账号的站内消息', permission: 'platform:notification:view' }, + }, { path: 'notifications/messages', name: 'notification-messages', diff --git a/src/modules/notifications/types.ts b/src/modules/notifications/types.ts index efe3b2f7..99dfc57a 100644 --- a/src/modules/notifications/types.ts +++ b/src/modules/notifications/types.ts @@ -8,6 +8,10 @@ export interface NotificationTask { id: string; taskNo: string; category: string export interface NotificationTaskRecipient { id: string; displayName: string; appCode: string; status: string; failureReason: string | null; deliveredAt: string | null } export interface NotificationTaskDetail extends Omit { attemptCount: number; lastError: string | null; recipients: NotificationTaskRecipient[] } +export type NotificationInboxStatus = 'UNREAD' | 'READ' +export interface NotificationInboxItem { id: string; category: string; title: string; summary: string; status: NotificationInboxStatus; sentAt: string; readAt: string | null } +export interface NotificationInboxDetail extends NotificationInboxItem { body: string; sourceType: string | null; sourceId: string | null } + export interface CreateNotificationRequest { tenantId: string; templateCode: string; recipientType: RecipientType; recipientIds?: string[]; roleCode?: string; variables: Record; scheduledAt: string | null; idempotencyKey: string; sourceEventId?: string | null } export interface CreateTemplateRequest { templateCode: string; templateName: string; category: string; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[] } export interface UpdateTemplateRequest extends Omit { version: number } diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index ea5f1b8a..c6db7ba5 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -24,6 +24,11 @@ describe('OceanBookingDetailView draft and release contract', () => { expect(source).not.toContain('cancelOceanBooking') }) + it('invalidates the cached booking list after deleting a draft', () => { + expect(source).toContain("workspace.bumpCacheEpoch('ocean-bookings')") + expect(source).toContain("await router.replace('/ocean-bookings')") + }) + it('saves incomplete DRAFT data and sends null for blank nullable fields', () => { expect(source).toContain('保存草稿') expect(source).toContain('channelCode: form.data.channelCode?.trim() || null') diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index 9732cbc2..699b6022 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -316,6 +316,7 @@ async function removeDraft() { actionSaving.value = true await deleteOceanBookingDraft(id.value, detail.value.version) ElMessage.success('订舱草稿已删除') + workspace.bumpCacheEpoch('ocean-bookings') await router.replace('/ocean-bookings') } catch (cause) { if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause) diff --git a/src/modules/trucking/components/TruckAppointmentForm.test.ts b/src/modules/trucking/components/TruckAppointmentForm.test.ts index ea62944e..c3c9d5a5 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.test.ts @@ -47,6 +47,16 @@ describe('TruckAppointmentForm interaction contract', () => { expect(source).not.toContain('const relationText = computed') }) + it('loads readonly detail options and preserves relation snapshots outside the first page', () => { + expect(source).toContain('onMounted(() => { void loadOptions() })') + expect(source).toContain('const selectedContainerFallback = computed') + expect(source).toContain('const selectedBookingFallback = computed') + expect(source).toContain('const selectedWarehouseFallback = computed') + expect(source).toContain('v-if="selectedContainerFallback"') + expect(source).toContain('v-if="selectedBookingFallback"') + expect(source).toContain('v-if="selectedWarehouseFallback"') + }) + it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => { expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约') expect(source).toContain('class="relation-note"') diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue index 4e970e6e..3374eeda 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.vue +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -53,6 +53,27 @@ const selectableCarriers = computed(() => carriers.value.filter((item) => ( item.status === 'ACTIVE' ))) const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null) +const selectedContainerFallback = computed(() => { + const id = draft.containerBookingId + const snapshot = props.resource + if (!id || containers.value.some((item) => String(item.id) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null + return { + id: String(id), + label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`, + } +}) +const selectedBookingFallback = computed(() => { + const id = draft.oceanBookingId + const snapshot = props.resource + if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null + return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' } +}) +const selectedWarehouseFallback = computed(() => { + const id = draft.warehouseId + const snapshot = props.resource + if (!id || selectableWarehouses.value.some((item) => String(item.id) === String(id)) || String(snapshot?.warehouseId) !== String(id)) return null + return { id: String(id), label: snapshot?.warehouseName || '当前仓库(需重新选择)' } +}) function customerLabel(customer: PlatformTenantResource) { return customer.tenantName } function selectCustomer(customerId: string) { const customer = selectableCustomers.value.find((item) => String(item.id) === customerId); draft.customerId = customer?.id ? String(customer.id) : null; draft.customerName = customer ? customerLabel(customer) : null; derivedCustomerSource.value = null; relationWarning.value = '' } function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null } @@ -115,7 +136,7 @@ async function loadOptions() { async function validate() { return formRef.value?.validate() } async function submitForm() { if (await formRef.value?.validate()) emit('submit') } defineExpose({ validate }) -onMounted(() => { if (!props.readonly) void loadOptions() }) +onMounted(() => { void loadOptions() }) watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.length) void loadOptions() }) @@ -131,9 +152,9 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
已从放舱订舱回填并锁定
可选关联 - - - + + + 运输与时间计划 diff --git a/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts b/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts index 6ba8a2b6..0b075f8b 100644 --- a/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts +++ b/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts @@ -52,4 +52,10 @@ describe('TruckAppointmentDetailView status contract', () => { expect(source).toContain('主详情数据不受影响') expect(source).toContain('v-if="relatedError"') }) + + it('reloads a cached detail after an error without showing stale resource data', () => { + expect(source).toContain("resource.value = null; history.value = []; relatedError.value = ''") + expect(source).toContain('onActivated(() => {') + expect(source).toContain('if (error.value || resource.value?.id !== id.value) void load()') + }) }) diff --git a/src/modules/trucking/pages/TruckAppointmentDetailView.vue b/src/modules/trucking/pages/TruckAppointmentDetailView.vue index 809d30ab..7538db56 100644 --- a/src/modules/trucking/pages/TruckAppointmentDetailView.vue +++ b/src/modules/trucking/pages/TruckAppointmentDetailView.vue @@ -1,5 +1,5 @@