From e742abd1cf80d1546666476e632b8eb942e31a0a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 13 Aug 2026 12:22:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 ++ .../PlatformQualificationFileController.java | 2 +- .../QualificationFileContractTest.java | 4 +- .../FinancePhase1OperationController.java | 6 +- .../FinanceSettlementController.java | 11 +- .../controller/PlatformFinanceController.java | 19 ++-- .../ReconciliationStatementController.java | 2 +- .../baiyue/finance/dto/FinanceReadModels.java | 12 +++ .../finance/service/FinanceQueryService.java | 29 +++++ .../CustomerAccountSummaryBehaviorTest.java | 100 ++++++++++++++++++ .../FinancePhase1PermissionContractTest.java | 6 +- .../FinancePlatformListContractTest.java | 4 +- .../FinanceSettlementBehaviorTest.java | 4 +- ...rmCustomerTransitStatisticsController.java | 31 ++++++ .../dto/CustomerTransitStatisticsDtos.java | 11 ++ .../CustomerTransitStatisticsMapper.java | 56 ++++++++++ .../CustomerTransitStatisticsService.java | 28 +++++ .../db/phase3-ocean-booking-schema.sql | 4 + .../db/phase3-smart-loading-schema.sql | 7 ++ .../db/phase3-truck-appointment-schema.sql | 4 + .../openapi/customer-statistics-v1.yaml | 35 ++++++ ...CustomerTransitStatisticsContractTest.java | 50 +++++++++ .../CustomerTransitStatisticsServiceTest.java | 44 ++++++++ gateway/src/main/resources/application.yml | 1 + ...rsonationExchangeSecurityContractTest.java | 20 ++++ .../controller/PlatformTenantController.java | 4 +- .../controller/ProfileResourceController.java | 4 +- .../main/resources/db/notification-center.sql | 4 +- iam/src/main/resources/openapi/iam-v1.yaml | 8 +- .../controller/IamOpenApiContractTest.java | 3 +- .../PlatformCustomerResourceContractTest.java | 8 +- .../PlatformTenantControllerContractTest.java | 2 +- ...tificationInboxPermissionContractTest.java | 27 +++++ .../NotificationSqlContractTest.java | 2 +- .../PlatformFirstLegOrderController.java | 11 ++ .../dto/TenantOrderStatisticsResponse.java | 14 +++ .../com/baiyue/order/mapper/OrderMapper.java | 25 ++++- .../order/repository/OrderRepository.java | 9 ++ .../service/OrderApplicationService.java | 26 +++++ order/src/main/resources/db/order.sql | 1 + .../src/main/resources/openapi/order-v1.yaml | 31 ++++++ ...OrderStatisticsControllerContractTest.java | 24 +++++ .../TenantOrderStatisticsSqlContractTest.java | 31 ++++++ .../TenantOrderStatisticsServiceTest.java | 68 ++++++++++++ scripts/admin-test-env-acceptance.mjs | 2 +- .../customer-operating-statistics.mjs | 39 +++++++ .../customer-system-contract.test.mjs | 26 +++++ scripts/local-db-full-acceptance.ps1 | 4 +- ...mCustomerOverseasStorageFeeController.java | 2 +- 49 files changed, 821 insertions(+), 50 deletions(-) create mode 100644 finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java create mode 100644 firstleg/src/main/resources/openapi/customer-statistics-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/ImpersonationExchangeSecurityContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java create mode 100644 order/src/main/java/com/baiyue/order/dto/TenantOrderStatisticsResponse.java create mode 100644 order/src/test/java/com/baiyue/order/controller/TenantOrderStatisticsControllerContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/repository/TenantOrderStatisticsSqlContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java create mode 100644 scripts/admin-test-env-modules/customer-operating-statistics.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f8835641e..baca8e5ba 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -14697,6 +14697,12 @@ WHERE r.tenant_id=0 AND r.status='ACTIVE' AND r.deleted=0 AND ((r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:notification:read','platform:notification:read-all')) OR (r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND p.permission_code IN('merchant:notification:read','merchant:notification:read-all'))); +-- 开发期不保留独立详情权限:列表权限是唯一只读权限。 +DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id +WHERE p.permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:firstleg:ocean-booking:view','platform:firstleg:truck-appointment:view','platform:supplier:view'); +UPDATE iam_permission SET status='INACTIVE',deleted=1 +WHERE permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:firstleg:ocean-booking:view','platform:firstleg:truck-appointment:view','platform:supplier:view'); + -- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。 SELECT 'PASS' AS migration_status, '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index 32b5fdfcc..46d5fb0a6 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -102,7 +102,7 @@ public class PlatformQualificationFileController { } @GetMapping("/{tenantId}/{fileName}") - @PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')") + @PreAuthorize("hasAuthority('platform:customer:list')") public ResponseEntity download( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable String fileName) { diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index a52aee208..719b3402d 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -82,11 +82,11 @@ class QualificationFileContractTest { } @Test - void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception { + void platformQualificationPreviewUsesTheCustomerListPermission() throws Exception { var method = PlatformQualificationFileController.class.getDeclaredMethod( "download", AuthenticatedUser.class, long.class, String.class); - assertEquals("hasAuthority('platform:customer:view')", method.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value()); } @Test diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java index bfd3616e7..a9fd3597f 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java @@ -27,7 +27,7 @@ public class FinancePhase1OperationController { public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; } @GetMapping("/accounts") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String keyword, @@ -36,7 +36,7 @@ public class FinancePhase1OperationController { } @GetMapping("/accounts/{customerId}/ledger") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") public Object listDerivedCustomerAccountLedger(@PathVariable long customerId, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @@ -47,7 +47,7 @@ public class FinancePhase1OperationController { } @GetMapping("/fee-sources/{feeSourceId}") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-arap:list','platform:finance:fee-source:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") public ApiResponse getFinanceFeeSource(@PathVariable long feeSourceId) { return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId)); } diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java index 54fdb92af..fd335a729 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java @@ -41,7 +41,7 @@ public class FinanceSettlementController { } @GetMapping("/receivable-settlements") - @PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ar:view')") + @PreAuthorize("hasAuthority('platform:finance:settlement-ar:list')") @Operation(summary = "查询正式应收结算单") public SettlementListEnvelope receivableSettlements( @Parameter(description = "可选客户筛选;省略时查询全部客户") @@ -50,7 +50,7 @@ public class FinanceSettlementController { } @GetMapping("/payable-settlements") - @PreAuthorize("hasAnyAuthority('platform:finance:settlement-ap:list','platform:finance:settlement-ap:view')") + @PreAuthorize("hasAuthority('platform:finance:settlement-ap:list')") @Operation(summary = "查询正式应付结算单") public SettlementListEnvelope payableSettlements( @Parameter(description = "可选客户筛选;省略时查询全部客户") @@ -77,17 +77,14 @@ public class FinanceSettlementController { } @GetMapping("/settlements/{settlementId}") - @PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ar:view','platform:finance:settlement-ap:list','platform:finance:settlement-ap:view')") + @PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ap:list')") public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long settlementId) { SettlementDetail detail = service.getSettlement(settlementId); String direction = detail.summary().direction(); String listPermission = "RECEIVABLE".equals(direction) ? "platform:finance:settlement-ar:list" : "platform:finance:settlement-ap:list"; - String legacyViewPermission = "RECEIVABLE".equals(direction) - ? "platform:finance:settlement-ar:view" : "platform:finance:settlement-ap:view"; - if (user == null || (!user.permissions().contains(listPermission) - && !user.permissions().contains(legacyViewPermission))) { + if (user == null || !user.permissions().contains(listPermission)) { throw new AccessDeniedException("无权查看该方向结算单"); } return success(detail); diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index 50dcc74ca..9c9580fea 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -45,14 +45,21 @@ public class PlatformFinanceController { } @GetMapping("/customer-accounts/{customerId}") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view','platform:finance:bill:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") @Operation(summary = "客户人工应收账户及流水") public ApiResponse customerAccount(@PathVariable long customerId) { return FinanceResponses.success(queryService.customerAccount(customerId)); } + @GetMapping("/customer-accounts/{customerId}/summary") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") + @Operation(summary = "客户正式应收应付汇总") + public ApiResponse customerAccountSummary(@PathVariable long customerId) { + return FinanceResponses.success(queryService.customerAccountSummary(customerId)); + } + @GetMapping("/fee-pool") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-arap:list','platform:finance:bill:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") @Operation(summary = "客户应收应付费用池") public ApiResponse feePool( @Parameter(description = "可选客户筛选;省略时查询全部客户") @@ -62,7 +69,7 @@ public class PlatformFinanceController { } @GetMapping("/statements") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") @Operation(summary = "客户对账单") public ApiResponse statements( @Parameter(description = "可选客户筛选;省略时查询全部客户") @@ -87,7 +94,7 @@ public class PlatformFinanceController { public ApiResponse terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); } @GetMapping("/bills") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") @Operation(summary = "查询平台账单") public ApiResponse bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); } @@ -115,11 +122,11 @@ public class PlatformFinanceController { } @GetMapping("/bills/{billId}") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") public ApiResponse bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); } @GetMapping("/bills/{billId}/revisions") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") public ApiResponse revisions(@PathVariable long billId, @RequestParam long customerId) { return FinanceResponses.success(queryService.billRevisions(customerId, billId)); } diff --git a/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java index 8280e06fe..a799e61ea 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java @@ -36,7 +36,7 @@ public class ReconciliationStatementController { } @GetMapping("/reconciliation-statements/{statementId}") - @PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") public ApiResponse getReconciliationStatement(@PathVariable long statementId) { return FinanceResponses.success(service.detailForPlatform(statementId)); } diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java index 6b702610b..855301b0f 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java @@ -3,6 +3,7 @@ package com.baiyue.finance.dto; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; /** Stable read projections used by the Finance pages. */ @@ -18,6 +19,17 @@ public final class FinanceReadModels { BigDecimal outstandingAmount, List entries) {} + public record CustomerAccountSummary( + long customerId, + String currency, + BigDecimal totalReceivable, + BigDecimal receivedAmount, + BigDecimal outstandingReceivable, + BigDecimal pendingBilling, + BigDecimal outstandingPayable, + Instant asOf, + LocalDateTime lastChangedAt) {} + public record AccountEntry( String entryType, long resourceId, diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index 60ff59afe..7ee0e7246 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -7,6 +7,7 @@ import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt; import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation; import com.baiyue.finance.dto.FinanceReadModels.BillRevision; import com.baiyue.finance.dto.FinanceReadModels.CustomerAccount; +import com.baiyue.finance.dto.FinanceReadModels.CustomerAccountSummary; import com.baiyue.finance.dto.FinanceReadModels.CostAllocation; import com.baiyue.finance.dto.FinanceReadModels.CostDetail; import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem; @@ -26,6 +27,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -58,6 +60,28 @@ public class FinanceQueryService { return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries)); } + public CustomerAccountSummary customerAccountSummary(long customerId) { + requireCustomer(customerId); + String sql = "SELECT " + + "COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.total_amount ELSE 0 END),0) total_receivable," + + "COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0) received_amount," + + "COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_receivable," + + "COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," + + "(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " + + "AND f.currency_code='CNY' AND f.status='AVAILABLE' " + + "AND JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.reviewStatus'))='APPROVED' " + + "AND JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.settlementIncluded'))='true' " + + "AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " + + "WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," + + "GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," + + "COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')) last_changed_at " + + "FROM fin_settlement s WHERE s.tenant_id=?"; + return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY, + money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"), + money(rs, "pending_billing"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")), + customerId, customerId, customerId); + } + public List feePool(Long customerId, String direction) { validateOptionalCustomer(customerId); String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT); @@ -200,6 +224,11 @@ public class FinanceQueryService { return value == null ? BigDecimal.ZERO : value; } + private static BigDecimal money(ResultSet rs, String column) throws SQLException { + BigDecimal value = rs.getBigDecimal(column); + return value == null ? BigDecimal.ZERO : value; + } + private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java new file mode 100644 index 000000000..2f64e8416 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -0,0 +1,100 @@ +package com.baiyue.finance; + +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.client.FirstlegCostSnapshotClient; +import com.baiyue.finance.controller.PlatformFinanceController; +import com.baiyue.finance.dto.FinanceReadModels.CustomerAccountSummary; +import com.baiyue.finance.service.FinanceQueryService; +import com.baiyue.finance.service.FinanceService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CustomerAccountSummaryBehaviorTest { + + @Test + void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "80.00"); + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation -> + ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + CustomerAccountSummary result = service.customerAccountSummary(9L); + + assertThat(result.customerId()).isEqualTo(9L); + assertThat(result.currency()).isEqualTo("CNY"); + assertThat(result.totalReceivable()).isEqualByComparingTo("300.00"); + assertThat(result.receivedAmount()).isEqualByComparingTo("140.00"); + assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00"); + assertThat(result.pendingBilling()).isEqualByComparingTo("25.00"); + assertThat(result.outstandingPayable()).isEqualByComparingTo("80.00"); + + var sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc).queryForObject(sql.capture(), any(RowMapper.class), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L)); + assertThat(sql.getValue()) + .contains("FROM fin_settlement s WHERE s.tenant_id=?") + .contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'") + .contains("s.direction='PAYABLE' AND s.status<>'VOIDED'") + .contains("f.customer_id=?", "f.status='AVAILABLE'", "'$.reviewStatus'", "'$.settlementIncluded'") + .contains("b.status<>'VOIDED'") + .doesNotContain("SUM(received_amount)", "FROM fin_receipt r"); + } + + @Test + void emptyCustomerReturnsZeroAmounts() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + ResultSet rs = summaryRow("0", "0", "0", "0", "0"); + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation -> + ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); + + CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L); + + assertThat(result.totalReceivable()).isZero(); + assertThat(result.receivedAmount()).isZero(); + assertThat(result.outstandingReceivable()).isZero(); + assertThat(result.pendingBilling()).isZero(); + assertThat(result.outstandingPayable()).isZero(); + } + + @Test + void controllerUsesListPermissionAndDelegatesSummary() throws Exception { + FinanceQueryService query = mock(FinanceQueryService.class); + PlatformFinanceController controller = new PlatformFinanceController(mock(FinanceService.class), + mock(FinanceFileClient.class), mock(FirstlegCostSnapshotClient.class), query); + + controller.customerAccountSummary(11L); + + verify(query).customerAccountSummary(11L); + PreAuthorize permission = PlatformFinanceController.class + .getMethod("customerAccountSummary", long.class).getAnnotation(PreAuthorize.class); + assertThat(permission.value()).isEqualTo("hasAuthority('platform:finance:customer-account:list')"); + } + + private static ResultSet summaryRow(String total, String paid, String outstanding, + String pending, String payable) throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total)); + when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid)); + when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding)); + when(rs.getBigDecimal("pending_billing")).thenReturn(new BigDecimal(pending)); + when(rs.getBigDecimal("outstanding_payable")).thenReturn(new BigDecimal(payable)); + when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0))); + return rs; + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java index b85ef3c7c..1f55643de 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java @@ -15,14 +15,14 @@ class FinancePhase1PermissionContractTest { @Test void freezesG1AndG2ControllerPermissionsAndHttpActions() throws Exception { assertEndpoint("listDerivedCustomerAccounts", GetMapping.class, - "platform:finance:customer-account:list", "platform:finance:account:view", + "platform:finance:customer-account:list", null, int.class, int.class, String.class, String.class); assertEndpoint("listDerivedCustomerAccountLedger", GetMapping.class, - "platform:finance:customer-account:list", "platform:finance:account:view", + "platform:finance:customer-account:list", null, long.class, int.class, int.class, String.class, java.time.LocalDate.class, java.time.LocalDate.class); assertEndpoint("getFinanceFeeSource", GetMapping.class, - "platform:finance:customer-arap:list", "platform:finance:fee-source:view", long.class); + "platform:finance:customer-arap:list", null, long.class); assertEndpoint("reviewFinanceFeeSource", PostMapping.class, "platform:finance:fee-source:review", null, com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class, diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java index 3da8cea2d..76e3d41b0 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java @@ -30,8 +30,8 @@ class FinancePlatformListContractTest { Map expected = Map.of( "feePool", "platform:finance:customer-arap:list", "statements", "platform:finance:customer-statement:list", - "receivableSettlements", "platform:finance:settlement-ar:view", - "payableSettlements", "platform:finance:settlement-ap:view"); + "receivableSettlements", "platform:finance:settlement-ar:list", + "payableSettlements", "platform:finance:settlement-ap:list"); for (var entry : expected.entrySet()) { assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).contains(entry.getValue()); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 26f185f47..ef9417176 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -54,7 +54,7 @@ class FinanceSettlementBehaviorTest { FinanceSettlementController controller = new FinanceSettlementController(controllerService); SettlementDetail payable = detail("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0); when(controllerService.getSettlement(88L)).thenReturn(payable); - AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:view")); + AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:list")); var response = controller.getSettlement(apViewer, 88L); @@ -62,7 +62,7 @@ class FinanceSettlementBehaviorTest { assertThat(response.data()).isSameAs(payable); assertThat(response.requestId()).isNotBlank(); assertThatThrownBy(() -> controller.getSettlement( - user(Set.of("platform:finance:settlement-ar:view")), 88L)).isInstanceOf(AccessDeniedException.class); + user(Set.of("platform:finance:settlement-ar:list")), 88L)).isInstanceOf(AccessDeniedException.class); } @Test diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java new file mode 100644 index 000000000..ca99b7399 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java @@ -0,0 +1,31 @@ +package com.baiyue.firstleg.customerstatistics.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; +import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Positive; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/platform/v1/customers") +public class PlatformCustomerTransitStatisticsController { + @Resource private CustomerTransitStatisticsService service; + + @GetMapping("/{customerId}/container-transit-summary") + @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse summary(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long customerId) { + FirstlegScopeGuard.platform(user, customerId); + return ApiResponse.success(service.summarize(customerId)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java new file mode 100644 index 000000000..e123871da --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.customerstatistics.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public final class CustomerTransitStatisticsDtos { + private CustomerTransitStatisticsDtos() {} + + public record TransitSummary(Long customerId, long containerCount, BigDecimal totalTeu, + long unknownContainerCount, LocalDateTime asOf, String definition) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java new file mode 100644 index 000000000..50e22037f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java @@ -0,0 +1,56 @@ +package com.baiyue.firstleg.customerstatistics.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Mapper +public interface CustomerTransitStatisticsMapper { + @Select(""" + SELECT #{customerId} AS customerId, + COUNT(*) AS containerCount, + COALESCE(SUM(teu), 0) AS totalTeu, + SUM(CASE WHEN teu IS NULL THEN 1 ELSE 0 END) AS unknownContainerCount, + CURRENT_TIMESTAMP(3) AS asOf + FROM ( + SELECT DISTINCT x.customer_id, x.container_booking_id, + CASE + WHEN UPPER(TRIM(x.container_type)) LIKE '20%' THEN 1.0 + WHEN UPPER(TRIM(x.container_type)) LIKE '40%' THEN 2.0 + WHEN UPPER(TRIM(x.container_type)) LIKE '45%' THEN 2.25 + ELSE NULL + END AS teu + FROM ( + SELECT b.customer_id, c.id AS container_booking_id, c.container_type + FROM fl_container_booking c + JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id + WHERE c.actual_sailing_at IS NOT NULL + AND c.actual_warehouse_arrival_at IS NULL + AND b.status NOT IN ('CANCELLED', 'FAILED') + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_plan p + JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id + WHERE p.container_booking_id = c.id AND i.deleted = 0 + ) + UNION ALL + SELECT i.account_id AS customer_id, c.id AS container_booking_id, c.container_type + FROM fl_container_booking c + JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id + JOIN fl_loading_plan p ON p.container_booking_id = c.id + JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + WHERE c.actual_sailing_at IS NOT NULL + AND c.actual_warehouse_arrival_at IS NULL + AND b.status NOT IN ('CANCELLED', 'FAILED') + AND i.account_id IS NOT NULL AND i.account_id > 0 + ) x + WHERE x.customer_id = #{customerId} + ) scoped + """) + SummaryRow summarize(@Param("customerId") long customerId); + + record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu, + Long unknownContainerCount, LocalDateTime asOf) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java new file mode 100644 index 000000000..485a9572b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java @@ -0,0 +1,28 @@ +package com.baiyue.firstleg.customerstatistics.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; +import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; + +@Service +public class CustomerTransitStatisticsService { + private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。"; + @Resource private CustomerTransitStatisticsMapper mapper; + + public TransitSummary summarize(long customerId) { + if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数"); + var row = mapper.summarize(customerId); + return new TransitSummary(customerId, + value(row == null ? null : row.containerCount()), + row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(), + value(row == null ? null : row.unknownContainerCount()), + row == null ? null : row.asOf(), DEFINITION); + } + + private static long value(Long value) { return value == null ? 0L : value; } +} diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index fd382dad0..d283ede56 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -256,4 +256,8 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); +DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id +WHERE p.permission_code='platform:firstleg:ocean-booking:view'; +UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE permission_code='platform:firstleg:ocean-booking:view'; + USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 50dcf8a08..1da47ad6f 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( KEY idx_container_booking_container (container_no), KEY idx_container_booking_warehouse (warehouse_id, updated_at, id), KEY idx_container_booking_voyage (voyage_no, updated_at), + KEY idx_container_booking_transit_summary (actual_sailing_at, actual_warehouse_arrival_at, ocean_booking_id, container_type), CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; @@ -1175,6 +1176,12 @@ WHERE role.tenant_id = 0 USE baiyue_firstleg; DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; + +DELETE rp FROM baiyue_iam.iam_role_permission rp +JOIN baiyue_iam.iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id +WHERE p.permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view'); +UPDATE baiyue_iam.iam_permission SET status='INACTIVE',deleted=1 +WHERE permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view'); DELIMITER $$ CREATE PROCEDURE assert_phase3_smart_loading_schema() BEGIN diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index 97299a87b..448083162 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -138,4 +138,8 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission AND permission.permission_code LIKE 'platform:firstleg:truck-appointment:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); +DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id +WHERE p.permission_code='platform:firstleg:truck-appointment:view'; +UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE permission_code='platform:firstleg:truck-appointment:view'; + USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/openapi/customer-statistics-v1.yaml b/firstleg/src/main/resources/openapi/customer-statistics-v1.yaml new file mode 100644 index 000000000..4a2573d57 --- /dev/null +++ b/firstleg/src/main/resources/openapi/customer-statistics-v1.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.3 +info: + title: Firstleg 客户统计 API + version: 1.0.0 +paths: + /api/platform/v1/customers/{customerId}/container-transit-summary: + get: + summary: 查询客户在途柜量(TEU) + description: 需要 platform:firstleg:container:list;按客户+柜次去重,整柜按订舱客户归属,拼柜按 active LoadingPlanItem.accountId 归属。 + parameters: + - {name: customerId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: 成功或业务失败,响应体 code 为字符串 200 时成功 + content: + application/json: + schema: {$ref: '#/components/schemas/TransitSummaryEnvelope'} +components: + schemas: + TransitSummaryEnvelope: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/TransitSummary'} + TransitSummary: + type: object + required: [customerId, containerCount, totalTeu, unknownContainerCount, asOf, definition] + properties: + customerId: {type: integer, format: int64, minimum: 1} + containerCount: {type: integer, format: int64, minimum: 0} + totalTeu: {type: number, format: decimal, multipleOf: 0.01, minimum: 0, description: 已知箱型TEU合计;未知箱型不计入} + unknownContainerCount: {type: integer, format: int64, minimum: 0} + asOf: {type: string, format: date-time} + definition: {type: string} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java new file mode 100644 index 000000000..179db670f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java @@ -0,0 +1,50 @@ +package com.baiyue.firstleg.customerstatistics; + +import com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController; +import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerTransitStatisticsContractTest { + @Test void exposesPlatformCustomerSummaryWithStablePermission() throws Exception { + assertThat(PlatformCustomerTransitStatisticsController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/customers"); + var method = PlatformCustomerTransitStatisticsController.class.getDeclaredMethod( + "summary", com.baiyue.common.security.AuthenticatedUser.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{customerId}/container-transit-summary"); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:container:list')"); + } + + @Test void queryEnforcesTransitScopeOwnershipDedupAndTeuRules() throws Exception { + var sql = Arrays.stream(CustomerTransitStatisticsMapper.class.getDeclaredMethods()) + .findFirst().orElseThrow().getAnnotation(org.apache.ibatis.annotations.Select.class).value(); + var statement = String.join("\n", sql); + assertThat(statement).contains("actual_sailing_at IS NOT NULL", "actual_warehouse_arrival_at IS NULL", + "status NOT IN ('CANCELLED', 'FAILED')", "DISTINCT x.customer_id, x.container_booking_id", + "b.customer_id", "i.account_id", "i.deleted = 0", "NOT EXISTS", + "x.customer_id = #{customerId}", "LIKE '20%'", "THEN 1.0", + "LIKE '40%'", "THEN 2.0", "LIKE '45%'", "THEN 2.25", "ELSE NULL"); + } + + @Test void schemaAddsTransitSummaryIndexAndDefinition() throws Exception { + var schema = new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(schema).contains("idx_container_booking_transit_summary"); + var openApi = new ClassPathResource("openapi/customer-statistics-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(openApi).contains("containerCount:", "format: int64", "totalTeu:", + "multipleOf: 0.01", "unknownContainerCount:", "format: date-time", "definition:"); + var service = CustomerTransitStatisticsService.class; + assertThat(service.getDeclaredFields()).anyMatch(f -> f.getName().equals("DEFINITION")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java new file mode 100644 index 000000000..e7028ebb9 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.customerstatistics; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomerTransitStatisticsServiceTest { + @Mock CustomerTransitStatisticsMapper mapper; + @InjectMocks CustomerTransitStatisticsService service; + + @Test void preservesDecimalTeuAndUnknownCount() { + var asOf = LocalDateTime.of(2026, 8, 13, 12, 0); + when(mapper.summarize(88L)).thenReturn(new CustomerTransitStatisticsMapper.SummaryRow( + 88L, 4L, new BigDecimal("6.25"), 1L, asOf)); + + var result = service.summarize(88L); + + assertThat(result.customerId()).isEqualTo(88L); + assertThat(result.containerCount()).isEqualTo(4); + assertThat(result.totalTeu()).isEqualByComparingTo("6.25"); + assertThat(result.unknownContainerCount()).isEqualTo(1); + assertThat(result.asOf()).isEqualTo(asOf); + assertThat(result.definition()).contains("20*=1", "40*=2", "45*=2.25", "未知箱型"); + verify(mapper).summarize(88L); + } + + @Test void rejectsInvalidCustomerBeforeQuery() { + assertThatThrownBy(() -> service.summarize(0)).isInstanceOf(BizException.class); + } +} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 6bdcb3863..8d7e45403 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -164,6 +164,7 @@ baiyue: - /api/auth/login - /api/auth/register - /api/auth/refresh + - /api/auth/impersonations/exchange - /openapi/v1/inbound-measurements/** - /actuator/health - /swagger-ui/** diff --git a/gateway/src/test/java/com/baiyue/gateway/config/ImpersonationExchangeSecurityContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/ImpersonationExchangeSecurityContractTest.java new file mode 100644 index 000000000..79799e03e --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/ImpersonationExchangeSecurityContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class ImpersonationExchangeSecurityContractTest { + @Test + void permitsAnonymousImpersonationCodeExchangeAtTheGateway() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("permit-paths:") + .contains("- /api/auth/impersonations/exchange"); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index bf840689c..0bf0ca592 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -69,7 +69,7 @@ public class PlatformTenantController { } @GetMapping("/tenants/{tenantId:\\d+}") - @PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')") + @PreAuthorize("hasAuthority('platform:customer:list')") @Operation(summary = "查询企业客户档案", description = "返回企业基础资料、销售负责人、账期、注册地址以及入驻和审批时间。") public ApiResponse tenant(@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { @@ -89,7 +89,7 @@ public class PlatformTenantController { } @GetMapping("/tenants/{tenantId:\\d+}/members") - @PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')") + @PreAuthorize("hasAuthority('platform:customer:list')") @Operation(summary = "查询企业客户成员及账号", description = "平台只读查询指定企业的全部成员,包含 OWNER 企业管理员,并返回登录账号。") public ApiResponse> members( @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java index cc1cebc2c..7d819fa3e 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java @@ -115,7 +115,7 @@ public class ProfileResourceController { } @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications") - @PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')") + @PreAuthorize("hasAuthority('platform:customer:list')") @Operation(summary = "查询企业客户资质", description = "只读查询企业客户资质元数据,不授予审核能力。") public ApiResponse> platformQualifications( @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { @@ -124,7 +124,7 @@ public class ProfileResourceController { } @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/addresses") - @PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')") + @PreAuthorize("hasAuthority('platform:customer:list')") @Operation(summary = "查询企业客户地址", description = "只读查询企业客户地址,不开放平台端写入。") public ApiResponse> platformAddresses( @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { diff --git a/iam/src/main/resources/db/notification-center.sql b/iam/src/main/resources/db/notification-center.sql index 829a681b9..2f8e73959 100644 --- a/iam/src/main/resources/db/notification-center.sql +++ b/iam/src/main/resources/db/notification-center.sql @@ -67,14 +67,14 @@ VALUES (5106009,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:retry','重试消息任务','/api/platform/v1/notification-tasks/{id}/retry','PUT',609,'ACTIVE',0), (5106011,0,'PLATFORM','BUTTON',5106001,'platform:notification:read','标记消息已读','/api/platform/v1/notifications/{id}/read','PUT',610,'ACTIVE',0), (5106012,0,'PLATFORM','BUTTON',5106001,'platform:notification:read-all','全部标记已读','/api/platform/v1/notifications/read-all','PUT',611,'ACTIVE',0) -ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; +ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) VALUES (5106010,0,'MERCHANT','API',0,'merchant:notification:view','查看我的消息','/api/merchant/v1/notifications','GET',900,'ACTIVE',0), (5106013,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read','标记消息已读','/api/merchant/v1/notifications/{id}/read','PUT',901,'ACTIVE',0), (5106014,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read-all','全部标记已读','/api/merchant/v1/notifications/read-all','PUT',902,'ACTIVE',0) -ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), status='ACTIVE', deleted=0; +ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 255c4c083..10709a991 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -89,7 +89,7 @@ paths: get: tags: [Platform Tenant / 平台租户] summary: 查询企业客户档案 - description: 需要独立的 `platform:customer:view` 权限,返回完整联系方式。 + description: 需要 `platform:customer:list` 权限,返回完整联系方式。 responses: '200': {$ref: '#/components/responses/TenantSuccess'} '403': {$ref: '#/components/responses/Forbidden'} @@ -113,7 +113,7 @@ paths: get: tags: [Platform Tenant / 平台租户] summary: 查询企业客户成员及账号 - description: 平台只读查询指定客户的全部企业成员,包含 OWNER 企业管理员并返回登录账号;需要 `platform:customer:view` 权限。 + description: 平台只读查询指定客户的全部企业成员,包含 OWNER 企业管理员并返回登录账号;需要 `platform:customer:list` 权限。 responses: '200': description: 企业成员查询成功 @@ -142,7 +142,7 @@ paths: get: tags: [Platform Tenant / 平台租户] summary: 查询企业客户地址 - description: 需要 `platform:customer:view` 权限;只读接口,不提供平台端地址写入。 + description: 需要 `platform:customer:list` 权限;只读接口,不提供平台端地址写入。 responses: '200': description: 企业客户地址查询成功 @@ -157,7 +157,7 @@ paths: get: tags: [Platform Tenant / 平台租户] summary: 查询企业客户资质 - description: 需要 `platform:customer:view` 权限;只读查询资质元数据,不授予审核权限。 + description: 需要 `platform:customer:list` 权限;只读查询资质元数据,不授予审核权限。 responses: '200': description: 企业客户资质查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 974474e81..921252509 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -31,7 +31,8 @@ class IamOpenApiContractTest { .contains("id: {type: string, pattern: '^\\d+$'") .contains("/api/platform/v1/iam/tenants/{tenantId}/addresses:") .contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications:") - .contains("platform:customer:view") + .contains("platform:customer:list") + .doesNotContain("platform:customer:view") .contains("TenantAddressListApiResponse:") .contains("TenantQualificationListApiResponse:") .contains("InternalUserListApiResponse:") diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java index c80c17374..fdcb270cb 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java @@ -13,17 +13,17 @@ class PlatformCustomerResourceContractTest { @Test void shouldHandleCase01() throws Exception { - assertReadOnlyViewContract("platformAddresses", + assertListPermissionContract("platformAddresses", "/api/platform/v1/iam/tenants/{tenantId}/addresses"); - assertReadOnlyViewContract("platformQualifications", + assertListPermissionContract("platformQualifications", "/api/platform/v1/iam/tenants/{tenantId}/qualifications"); } - private void assertReadOnlyViewContract(String methodName, String path) throws Exception { + private void assertListPermissionContract(String methodName, String path) throws Exception { Method method = ProfileResourceController.class.getMethod( methodName, long.class, AuthenticatedUser.class); assertEquals(path, method.getAnnotation(GetMapping.class).value()[0]); - assertEquals("hasAuthority('platform:customer:view')", + assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value()); } } diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index 309fc911a..461ddbbc1 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -52,7 +52,7 @@ class PlatformTenantControllerContractTest { void shouldHandleCase03() throws Exception { Method method = PlatformTenantController.class.getMethod("tenant", long.class, AuthenticatedUser.class); - assertEquals("hasAuthority('platform:customer:view')", + assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value()); } diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java new file mode 100644 index 000000000..776d2bdcc --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.iam.notification; + +import com.baiyue.iam.notification.controller.NotificationInboxController; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class NotificationInboxPermissionContractTest { + @Test + void separatesReadOperationsFromInboxViewing() { + assertPermission("list", "hasAuthority(#app + ':notification:view')"); + assertPermission("unread", "hasAuthority(#app + ':notification:view')"); + assertPermission("detail", "hasAuthority(#app + ':notification:view')"); + assertPermission("read", "hasAuthority(#app + ':notification:read')"); + assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')"); + } + + private static void assertPermission(String methodName, String expected) { + var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java index d1edd6e14..1823db26a 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java @@ -5,7 +5,7 @@ import java.nio.file.Files; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class NotificationSqlContractTest { - @Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","merchant:notification:view");} + @Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","platform:notification:read","platform:notification:read-all","merchant:notification:view","merchant:notification:read","merchant:notification:read-all").contains("'MENU',5106000,'platform:notification:template:list'","'MENU',5106000,'platform:notification:task:list'");} @Test void unifiedMigrationContainsNotificationBaselineAndSevenIndexAssertion() throws Exception { Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 7661d39c0..3c71451d7 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -9,6 +9,7 @@ import com.baiyue.order.dto.BatchOrderReviewResponse; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.TenantOrderCountResponse; +import com.baiyue.order.dto.TenantOrderStatisticsResponse; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; @@ -134,6 +135,16 @@ public class PlatformFirstLegOrderController { return ApiResponse.success(service.countByTenantIds(tenantIds)); } + @GetMapping("/tenant-statistics") + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "查询客户累计及本月运单数", description = "仅统计已提交的 MASTER 根运单;草稿和 CHILD 拆分单不计入,累计不受后续取消或驳回影响。月份按 Asia/Shanghai 半开区间计算。") + public ApiResponse tenantStatistics( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId) { + long scopedTenant = OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.tenantSubmittedStatistics(scopedTenant)); + } + @Operation(summary = "Tenant order detail / 租户订单详情", description = "Returns the complete order aggregate within the explicit target tenant. / 返回明确目标租户内的完整订单聚合。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/order/src/main/java/com/baiyue/order/dto/TenantOrderStatisticsResponse.java b/order/src/main/java/com/baiyue/order/dto/TenantOrderStatisticsResponse.java new file mode 100644 index 000000000..9388c721b --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/TenantOrderStatisticsResponse.java @@ -0,0 +1,14 @@ +package com.baiyue.order.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "TenantOrderStatisticsResponse", description = "平台客户已提交主运单统计") +public record TenantOrderStatisticsResponse( + @Schema(description = "客户 ID") Long tenantId, + @Schema(description = "历史累计已提交主运单数") Long totalSubmittedOrders, + @Schema(description = "当前上海时区月份已提交主运单数") Long currentMonthSubmittedOrders, + @Schema(description = "本月起始时间(含),ISO-8601") String monthStart, + @Schema(description = "下月起始时间(不含),ISO-8601") String nextMonthStart, + @Schema(description = "统计时区") String zoneId, + @Schema(description = "统计快照时间,ISO-8601") String asOf +) { } diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java index 391360af6..70d4c955d 100644 --- a/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java @@ -1,4 +1,25 @@ -package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /** +package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.time.LocalDateTime; +import java.util.Map; +/** * 订单主表 Mapper,提供 {@code ord_order} 的 MyBatis-Plus 基础数据访问能力。 */ -public interface OrderMapper extends BaseMapper{} +public interface OrderMapper extends BaseMapper{ + @Select(""" + SELECT COUNT(*) AS total_submitted_orders, + COALESCE(SUM(CASE WHEN submitted_at >= #{monthStart} + AND submitted_at < #{nextMonthStart} + THEN 1 ELSE 0 END), 0) AS current_month_submitted_orders + FROM ord_order + WHERE tenant_id = #{tenantId} + AND deleted = 0 + AND order_role = 'MASTER' + AND submitted_at IS NOT NULL + """) + Map selectTenantSubmittedStatistics( + @Param("tenantId") long tenantId, + @Param("monthStart") LocalDateTime monthStart, + @Param("nextMonthStart") LocalDateTime nextMonthStart); +} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index f86981e3e..142929cec 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -258,6 +258,15 @@ public class OrderRepository { )); } + /** 一次条件聚合客户已提交主运单,取消/驳回状态不会影响累计值。 */ + public Map tenantSubmittedStatistics(long tenantId, + LocalDateTime monthStart, + LocalDateTime nextMonthStart) { + Map aggregate = orderMapper.selectTenantSubmittedStatistics( + tenantId, monthStart, nextMonthStart); + return aggregate == null ? Map.of() : aggregate; + } + public List findCargo(long tenantId, long orderId) { return cargoMapper.selectList(new LambdaQueryWrapper() .eq(OrderCargoEntity::getTenantId, tenantId).eq(OrderCargoEntity::getOrderId, orderId) diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 1cbc6b5e4..af245c92a 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.TenantOrderCountResponse; +import com.baiyue.order.dto.TenantOrderStatisticsResponse; import com.baiyue.order.client.FirstlegInboundClient; import com.baiyue.order.entity.*; import com.baiyue.order.repository.OrderRepository; @@ -23,6 +24,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.List; +import java.time.*; import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; @@ -154,6 +156,30 @@ public class OrderApplicationService { .map(tenantId -> new TenantOrderCountResponse(tenantId, counts.getOrDefault(tenantId, 0L))) .toList(); } + + private static final ZoneId STATISTICS_ZONE = ZoneId.of("Asia/Shanghai"); + + public TenantOrderStatisticsResponse tenantSubmittedStatistics(long tenantId) { + return tenantSubmittedStatistics(tenantId, Clock.system(STATISTICS_ZONE)); + } + + TenantOrderStatisticsResponse tenantSubmittedStatistics(long tenantId, Clock clock) { + ZonedDateTime asOf = ZonedDateTime.now(clock).withZoneSameInstant(STATISTICS_ZONE); + YearMonth month = YearMonth.from(asOf); + ZonedDateTime monthStart = month.atDay(1).atStartOfDay(STATISTICS_ZONE); + ZonedDateTime nextMonthStart = month.plusMonths(1).atDay(1).atStartOfDay(STATISTICS_ZONE); + var aggregate = repository.tenantSubmittedStatistics(tenantId, + monthStart.toLocalDateTime(), nextMonthStart.toLocalDateTime()); + long total = number(aggregate.get("total_submitted_orders")); + long current = number(aggregate.get("current_month_submitted_orders")); + return new TenantOrderStatisticsResponse(tenantId, total, current, + monthStart.toOffsetDateTime().toString(), nextMonthStart.toOffsetDateTime().toString(), + STATISTICS_ZONE.getId(), asOf.toOffsetDateTime().toString()); + } + + private static long number(Object value) { + return value instanceof Number n ? n.longValue() : 0L; + } public OrderDetailResponse detail(long tenantId, long id) { OrderEntity order = require(tenantId, id); return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id), diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index e78cbd8c7..3bec81ee9 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( UNIQUE KEY uk_order_tenant_no (tenant_id, order_no), UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code), KEY idx_order_tenant_status_time (tenant_id, status, created_at), + KEY idx_order_tenant_role_submitted (tenant_id, order_role, submitted_at), KEY idx_order_tenant_customer (tenant_id, customer_id), KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index f594adbfb..4ecbb5e8e 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -231,6 +231,25 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:create 权限} + /api/platform/v1/orders/first-leg/tenant-statistics: + get: + tags: [Platform first-leg orders] + summary: 查询客户累计及本月运单数 + description: 仅统计 submitted_at 非空的 MASTER 根运单,草稿和 CHILD 拆分单不计入;累计不受取消或驳回影响;月份按 Asia/Shanghai 半开区间计算。 + parameters: + - {$ref: '#/components/parameters/TenantId'} + responses: + '200': + description: 成功;需要 platform:order:list 权限 + content: + application/json: + schema: + type: object + properties: + code: {type: string, example: '200'} + data: {$ref: '#/components/schemas/TenantOrderStatisticsResponse'} + '403': {description: 需要平台身份和 platform:order:list 权限} + /api/platform/v1/orders/first-leg/tenant-counts: get: summary: 批量查询客户运单数 @@ -327,6 +346,18 @@ paths: '403': {description: 需要 merchant:order:view 权限} components: + schemas: + TenantOrderStatisticsResponse: + type: object + required: [tenantId, totalSubmittedOrders, currentMonthSubmittedOrders, monthStart, nextMonthStart, zoneId, asOf] + properties: + tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: Long 按字符串序列化以避免 JavaScript 精度丢失} + totalSubmittedOrders: {type: string, pattern: '^[0-9]+$', description: Long 按字符串序列化} + currentMonthSubmittedOrders: {type: string, pattern: '^[0-9]+$', description: Long 按字符串序列化} + monthStart: {type: string, format: date-time} + nextMonthStart: {type: string, format: date-time} + zoneId: {type: string, example: Asia/Shanghai} + asOf: {type: string, format: date-time} securitySchemes: bearerAuth: type: http diff --git a/order/src/test/java/com/baiyue/order/controller/TenantOrderStatisticsControllerContractTest.java b/order/src/test/java/com/baiyue/order/controller/TenantOrderStatisticsControllerContractTest.java new file mode 100644 index 000000000..ff2622f56 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/TenantOrderStatisticsControllerContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.order.controller; + +import com.baiyue.order.dto.TenantOrderStatisticsResponse; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class TenantOrderStatisticsControllerContractTest { + @Test + void endpointUsesStablePathListPermissionAndResponseType() throws Exception { + Method method = PlatformFirstLegOrderController.class.getMethod("tenantStatistics", + com.baiyue.common.security.AuthenticatedUser.class, long.class); + + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/tenant-statistics"); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:order:list')"); + assertThat(method.getGenericReturnType().getTypeName()) + .contains(TenantOrderStatisticsResponse.class.getName()); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/TenantOrderStatisticsSqlContractTest.java b/order/src/test/java/com/baiyue/order/repository/TenantOrderStatisticsSqlContractTest.java new file mode 100644 index 000000000..ada884660 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/TenantOrderStatisticsSqlContractTest.java @@ -0,0 +1,31 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.OrderMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class TenantOrderStatisticsSqlContractTest { + @Test + void oneConditionalAggregateEnforcesTenantSubmittedMasterAndHalfOpenMonth() throws Exception { + Method method = OrderMapper.class.getMethod("selectTenantSubmittedStatistics", + long.class, java.time.LocalDateTime.class, java.time.LocalDateTime.class); + String sql = String.join(" ", method.getAnnotation(Select.class).value()) + .replaceAll("\\s+", " ").toLowerCase(); + + assertThat(sql) + .contains("count(*) as total_submitted_orders") + .contains("sum(case when submitted_at >= #{monthstart}") + .contains("submitted_at < #{nextmonthstart}") + .contains("tenant_id = #{tenantid}") + .contains("deleted = 0") + .contains("order_role = 'master'") + .contains("submitted_at is not null") + .doesNotContain("created_at") + .doesNotContain("status =") + .doesNotContain("status in"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java new file mode 100644 index 000000000..31779bf61 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java @@ -0,0 +1,68 @@ +package com.baiyue.order.service; + +import com.baiyue.order.dto.TenantOrderStatisticsResponse; +import com.baiyue.order.repository.OrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderStatisticsServiceTest.TestConfig.class}) +class TenantOrderStatisticsServiceTest { + @MockBean private OrderRepository repository; + @MockBean private OrderEligibilityGuard eligibilityGuard; + @MockBean private OrderMasterDataGuard masterDataGuard; + @MockBean private OrderPricingChannelGuard pricingChannelGuard; + @MockBean private ProductImageGuard productImageGuard; + @Resource private OrderApplicationService service; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } + + @Test + void usesShanghaiHalfOpenCalendarMonthAndReturnsAggregate() { + Clock clock = Clock.fixed(Instant.parse("2026-08-31T16:30:00Z"), ZoneId.of("UTC")); + when(repository.tenantSubmittedStatistics(88L, + LocalDateTime.of(2026, 9, 1, 0, 0), + LocalDateTime.of(2026, 10, 1, 0, 0))) + .thenReturn(Map.of("total_submitted_orders", 12L, + "current_month_submitted_orders", 3L)); + + TenantOrderStatisticsResponse response = service.tenantSubmittedStatistics(88L, clock); + + assertThat(response).isEqualTo(new TenantOrderStatisticsResponse( + 88L, 12L, 3L, "2026-09-01T00:00+08:00", "2026-10-01T00:00+08:00", + "Asia/Shanghai", "2026-09-01T00:30+08:00")); + verify(repository).tenantSubmittedStatistics(88L, + LocalDateTime.of(2026, 9, 1, 0, 0), + LocalDateTime.of(2026, 10, 1, 0, 0)); + } + + @Test + void returnsZeroWhenNoSubmittedMasterOrdersExist() { + Clock clock = Clock.fixed(Instant.parse("2026-01-15T00:00:00Z"), ZoneId.of("UTC")); + when(repository.tenantSubmittedStatistics(99L, + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 2, 1, 0, 0))).thenReturn(Map.of()); + + TenantOrderStatisticsResponse response = service.tenantSubmittedStatistics(99L, clock); + + assertThat(response.totalSubmittedOrders()).isZero(); + assertThat(response.currentMonthSubmittedOrders()).isZero(); + } +} diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index af8ff6432..c296f7a69 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -12,7 +12,7 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] +const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics'] const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', diff --git a/scripts/admin-test-env-modules/customer-operating-statistics.mjs b/scripts/admin-test-env-modules/customer-operating-statistics.mjs new file mode 100644 index 000000000..69a6f98fa --- /dev/null +++ b/scripts/admin-test-env-modules/customer-operating-statistics.mjs @@ -0,0 +1,39 @@ +const MODULE = 'customer-operating-statistics' +const OPTIONS = { scenario: 'customer-detail-operating-statistics', deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } +function decimal(value) { return typeof value === 'number' || (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) } +function nonNegative(value) { return decimal(value) && Number(value) >= 0 } + +export async function run(context) { + const customerId = id(context.context.customerId || context.createdResources.customer?.tenantId) + context.assert(MODULE, '取得客户详情统计客户隔离键', null, Boolean(customerId), `customerId=${customerId || 'missing'}`, OPTIONS) + if (!customerId) return + + const orderPath = `/api/platform/v1/orders/first-leg/tenant-statistics?tenantId=${encodeURIComponent(customerId)}` + const orderResponse = await context.request('GET', orderPath) + const orders = context.apiSuccess(MODULE, '查询累计与本月已提交主运单统计', orderResponse, `customerId=${customerId}`, OPTIONS) + context.assert(MODULE, '运单统计返回上海时区半开月份边界与客户隔离键', orderResponse, + id(orders?.tenantId) === customerId && nonNegative(orders?.totalSubmittedOrders) + && nonNegative(orders?.currentMonthSubmittedOrders) && orders?.zoneId === 'Asia/Shanghai' + && Boolean(orders?.monthStart) && Boolean(orders?.nextMonthStart) && Boolean(orders?.asOf), + `tenantId=${id(orders?.tenantId) || 'missing'}, zoneId=${orders?.zoneId || 'missing'}`, OPTIONS) + + const financePath = `/api/platform/finance/v1/customer-accounts/${encodeURIComponent(customerId)}/summary` + const financeResponse = await context.request('GET', financePath) + const finance = context.apiSuccess(MODULE, '查询非VOIDED正式结算应收汇总', financeResponse, `customerId=${customerId}`, OPTIONS) + context.assert(MODULE, '应收汇总保持CNY十进制金额与客户隔离键', financeResponse, + id(finance?.customerId) === customerId && finance?.currency === 'CNY' + && ['totalReceivable', 'receivedAmount', 'outstandingReceivable'].every((field) => nonNegative(finance?.[field])) + && Number(finance?.outstandingReceivable) <= Number(finance?.totalReceivable), + `customerId=${id(finance?.customerId) || 'missing'}, currency=${finance?.currency || 'missing'}`, OPTIONS) + + const transitPath = `/api/platform/v1/customers/${encodeURIComponent(customerId)}/container-transit-summary` + const transitResponse = await context.request('GET', transitPath) + const transit = context.apiSuccess(MODULE, '查询已开船未到仓在途柜量', transitResponse, `customerId=${customerId}`, OPTIONS) + context.assert(MODULE, '在途柜量保持TEU小数、未知柜型与客户隔离键', transitResponse, + id(transit?.customerId) === customerId && nonNegative(transit?.containerCount) + && nonNegative(transit?.totalTeu) && nonNegative(transit?.unknownContainerCount) + && Number(transit?.unknownContainerCount) <= Number(transit?.containerCount) && Boolean(transit?.definition), + `customerId=${id(transit?.customerId) || 'missing'}, totalTeu=${transit?.totalTeu ?? 'missing'}, unknown=${transit?.unknownContainerCount ?? 'missing'}`, OPTIONS) +} diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs index 8044764d5..759cb0ffc 100644 --- a/scripts/admin-test-env-modules/customer-system-contract.test.mjs +++ b/scripts/admin-test-env-modules/customer-system-contract.test.mjs @@ -7,6 +7,13 @@ import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8') const system = fs.readFileSync(path.join(here, 'system.mjs'), 'utf8') +const operatingStatistics = fs.readFileSync(path.join(here, 'customer-operating-statistics.mjs'), 'utf8') +const repositoryRoot = path.join(here, '..', '..') +const orderStatisticsTest = fs.readFileSync(path.join(repositoryRoot, 'order', 'src', 'test', 'java', 'com', 'baiyue', 'order', 'service', 'TenantOrderStatisticsServiceTest.java'), 'utf8') + + fs.readFileSync(path.join(repositoryRoot, 'order', 'src', 'test', 'java', 'com', 'baiyue', 'order', 'repository', 'TenantOrderStatisticsSqlContractTest.java'), 'utf8') +const financeStatisticsTest = fs.readFileSync(path.join(repositoryRoot, 'finance', 'src', 'test', 'java', 'com', 'baiyue', 'finance', 'CustomerAccountSummaryBehaviorTest.java'), 'utf8') +const transitStatisticsTest = fs.readFileSync(path.join(repositoryRoot, 'firstleg', 'src', 'test', 'java', 'com', 'baiyue', 'firstleg', 'customerstatistics', 'CustomerTransitStatisticsServiceTest.java'), 'utf8') + + fs.readFileSync(path.join(repositoryRoot, 'firstleg', 'src', 'test', 'java', 'com', 'baiyue', 'firstleg', 'customerstatistics', 'CustomerTransitStatisticsContractTest.java'), 'utf8') test('客户验收覆盖审核资质上传、回查和鉴权下载', () => { assert.match(acceptance, /files\/qualifications\/\$\{tenantId\}\/required-package/) @@ -30,3 +37,22 @@ test('客户验收覆盖客户维度操作日志列表和详情快照', () => { assert.match(acceptance, /JSON\.parse\(auditDetail\[field\]\)/) assert.match(acceptance, /snapshotsAreRedacted/) }) + +test('客户详情经营统计模块登记三项独立正式接口与精度字段', () => { + assert.match(acceptance, /'customer-operating-statistics'/) + assert.match(operatingStatistics, /orders\/first-leg\/tenant-statistics\?tenantId=/) + assert.match(operatingStatistics, /customer-accounts\/\$\{encodeURIComponent\(customerId\)\}\/summary/) + assert.match(operatingStatistics, /container-transit-summary/) + for (const field of ['currentMonthSubmittedOrders', 'outstandingReceivable', 'totalTeu', 'unknownContainerCount']) { + assert.match(operatingStatistics, new RegExp(field)) + } +}) + +test('后端统计契约测试覆盖客户详情已确认边界口径', () => { + for (const marker of ["order_role = 'master'", 'submitted_at is not null', 'monthstart', 'nextmonthstart', 'doesNotContain\\\("status ="\\\)']) assert.match(orderStatisticsTest, new RegExp(marker, 'i')) + for (const marker of ['VOIDED', 'totalReceivable', 'receivedAmount', 'outstandingReceivable']) assert.match(financeStatisticsTest, new RegExp(marker, 'i')) + for (const marker of ['actual_sailing_at', 'actual_warehouse_arrival_at', 'CANCELLED', 'FAILED', '20\\*=1', '40\\*=2', '45\\*=2.25', '2.25', 'unknown']) assert.match(transitStatisticsTest, new RegExp(marker, 'i')) + assert.match(orderStatisticsTest, /tenant_id|tenantSubmittedStatistics/) + assert.match(financeStatisticsTest, /customerId|customer_id/) + assert.match(transitStatisticsTest, /customerId|customer_id/) +}) diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index bcc48c924..4b1f9fde1 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop' $Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Workspace = (Resolve-Path (Join-Path $Repo '..')).Path $Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance' -$ExpectedSha256 = 'EB965FCBCE23B7A6E565530C13188B1C55FAB43B8FD4E3408DED91D57454A7DF' -$ExpectedLines = 14456 +$ExpectedSha256 = 'CA250057CAB91D248E31BCC5A66CADE72F81EB9906B19B8E3CB09BD60B112F29' +$ExpectedLines = 14708 if ($TargetHost -notin @('127.0.0.1', 'localhost')) { [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.') diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java index 1d209e060..365c65d93 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.*; public class PlatformCustomerOverseasStorageFeeController { @Resource private OverseasStorageFeeService service; @GetMapping - @PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')") + @PreAuthorize("hasAuthority('platform:customer:list')") public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId) { return ApiResponse.success(service.detailByTenant(tenantId)); } @PutMapping