diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml index d1ba97791..4bcaee546 100644 --- a/config/nacos/cn/file-service.yml +++ b/config/nacos/cn/file-service.yml @@ -21,3 +21,77 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 500 + + FINANCE_CUSTOMER_ACCOUNT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LEDGER: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_FEE_SOURCE_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches + required-permission: platform:finance:customer-arap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECONCILIATION_STATEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches + required-permission: platform:finance:customer-statement:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 +baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:} diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml index 2fc6dd0a5..bbad36219 100644 --- a/config/nacos/local/file-service.yml +++ b/config/nacos/local/file-service.yml @@ -17,4 +17,78 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 500 + + FINANCE_CUSTOMER_ACCOUNT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LEDGER: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_FEE_SOURCE_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches + required-permission: platform:finance:customer-arap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECONCILIATION_STATEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches + required-permission: platform:finance:customer-statement:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 +baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:} baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d6f25fe28..c8c5d20ed 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15142,14 +15142,19 @@ VALUES (5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0), (5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0), (5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0), - (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0) + (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0), + (5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0), + (5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0), + (5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0), + (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), + (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,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), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'); DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view'); UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view'); INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM' -WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list'); +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export'); UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:settlement',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; INSERT INTO baiyue_iam.iam_permission diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java index 3bd7ddf27..4fcb7d5df 100644 --- a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java +++ b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java @@ -20,12 +20,14 @@ public class ExportProviderClient { @Resource private OrderExportProviderClient order; @Resource private WarehouseExportProviderClient warehouse; @Resource private FirstlegExportProviderClient firstleg; + @Resource private FinanceExportProviderClient finance; @Resource private MeterRegistry meters; @PostConstruct void initializeClients() { this.clients = Map.of("iam-service", iam, "order-service", order, - "warehouse-service", warehouse, "firstleg-service", firstleg); + "warehouse-service", warehouse, "firstleg-service", firstleg, + "finance-service", finance); } public ApiResponse batch(String internalToken, String exportType, diff --git a/file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java new file mode 100644 index 000000000..63f2e8942 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java @@ -0,0 +1,7 @@ +package com.baiyue.file.client; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient(name="finance-service", contextId="financeExportProviderClient", + url="${baiyue.clients.finance.url:}", configuration=ExportProviderFeignConfiguration.class) +interface FinanceExportProviderClient extends ExportProviderFeignClient { } diff --git a/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java index 3b6bc0713..bec3b2a44 100644 --- a/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java +++ b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java @@ -11,10 +11,10 @@ import java.util.regex.Pattern; @Component @ConfigurationProperties(prefix = "baiyue.file.export") public class ExportDefinitionRegistry { - private static final Set SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service"); + private static final Set SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service"); private static final Pattern TYPE = Pattern.compile("[A-Z0-9_]+"); private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches"); - private static final Pattern PERMISSION = Pattern.compile("[a-z]+:[a-z-]+:[a-z-]+"); + private static final Pattern PERMISSION = Pattern.compile("[a-z]+(?::[a-z-]+){2,}"); private Map definitions = Map.of(); @PostConstruct diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 79ac974ff..015e69f9b 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -35,6 +35,9 @@ spring: firstlegExportProviderClient: connect-timeout: 3000 read-timeout: 15000 + financeExportProviderClient: + connect-timeout: 3000 + read-timeout: 15000 servlet: multipart: max-file-size: 10MB @@ -53,6 +56,8 @@ baiyue: url: ${IAM_SERVICE_URL:} integration: url: ${INTEGRATION_SERVICE_URL:} + finance: + url: ${FINANCE_SERVICE_URL:} file: storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads} price-book: @@ -96,6 +101,78 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LEDGER: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_FEE_SOURCE_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches + required-permission: platform:finance:customer-arap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECONCILIATION_STATEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches + required-permission: platform:finance:customer-statement:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 management.endpoints.web.exposure.include: health,info,prometheus logging: diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java index 0fa8ba91a..fd86f9e01 100644 --- a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java +++ b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java @@ -24,6 +24,7 @@ class ExportProviderClientTest { @MockBean private OrderExportProviderClient order; @MockBean private WarehouseExportProviderClient warehouse; @MockBean private FirstlegExportProviderClient firstleg; + @MockBean private FinanceExportProviderClient finance; @Resource private ExportProviderClient client; @Configuration(proxyBeanMethods = false) @@ -44,6 +45,19 @@ class ExportProviderClientTest { assertSame(response,actual); verify(order).batch("token","ORDER_LIST",request); - verifyNoInteractions(iam,warehouse,firstleg); + verifyNoInteractions(iam,warehouse,firstleg,finance); + } + + @Test void routesFinanceExportsToFinanceService() { + var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L)); + when(finance.batch(anyString(),anyString(),any())).thenReturn(response); + var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("finance-service"); + ExportBatchRequest request=new ExportBatchRequest("9","0","PLATFORM","7",1, Map.of(),null,null,500); + + var actual=client.batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",definition,request); + + assertSame(response,actual); + verify(finance).batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",request); + verifyNoInteractions(iam,order,warehouse,firstleg); } } diff --git a/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java index 3fd4e4ffa..8e8eef784 100644 --- a/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java +++ b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java @@ -12,6 +12,13 @@ class ExportDefinitionRegistryTest { ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches"))); assertDoesNotThrow(registry::validate); } + @Test void acceptsTheControlledFinanceProvider() { + ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); + var finance=definition("finance-service","/internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches"); + finance.setRequiredPermission("platform:finance:customer-statement:export"); + registry.setDefinitions(Map.of("FINANCE_RECONCILIATION_STATEMENT_LIST", finance)); + assertDoesNotThrow(registry::validate); + } @Test void rejectsSsrfAndPathTypeMismatch() { ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches")))); diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceExportProviderController.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceExportProviderController.java new file mode 100644 index 000000000..9ccc627b9 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceExportProviderController.java @@ -0,0 +1,51 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.finance.service.FinanceExportProviderService; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Internal export provider consumed only by File Service. */ +@RestController +@RequestMapping("/internal/exports/v1") +public class FinanceExportProviderController { + private final FinanceExportProviderService service; + private final String configuredToken; + private byte[] internalToken; + + public FinanceExportProviderController(FinanceExportProviderService service, + @Value("${baiyue.security.internal-service-token}") String configuredToken) { + this.service = service; + this.configuredToken = configuredToken; + } + + @PostConstruct + void initializeToken() { + byte[] configured = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); + if (configured.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + internalToken = configured; + } + + @PostMapping("/{exportType}/batches") + public ApiResponse batch( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable String exportType, + @RequestBody ExportBatchRequest request) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(internalToken, supplied)) throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token"); + return ApiResponse.success(service.exportBatch(exportType, request)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java new file mode 100644 index 000000000..6307427df --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -0,0 +1,472 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.common.core.export.ExportContracts.ExportColumn; +import com.baiyue.common.core.export.ExportContracts.ExportColumnType; +import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; +import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; +import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** Provides versioned, read-only Finance export batches to File Service. */ +@Service +public class FinanceExportProviderService { + public static final String CUSTOMER_ACCOUNT_LIST = "FINANCE_CUSTOMER_ACCOUNT_LIST"; + public static final String CUSTOMER_ACCOUNT_LEDGER = "FINANCE_CUSTOMER_ACCOUNT_LEDGER"; + public static final String FEE_SOURCE_LIST = "FINANCE_FEE_SOURCE_LIST"; + public static final String RECONCILIATION_STATEMENT_LIST = "FINANCE_RECONCILIATION_STATEMENT_LIST"; + public static final String RECEIVABLE_SETTLEMENT_LIST = "FINANCE_RECEIVABLE_SETTLEMENT_LIST"; + public static final String RECEIVABLE_SETTLEMENT_DETAIL = "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL"; + public static final String PAYABLE_SETTLEMENT_LIST = "FINANCE_PAYABLE_SETTLEMENT_LIST"; + public static final String PAYABLE_SETTLEMENT_DETAIL = "FINANCE_PAYABLE_SETTLEMENT_DETAIL"; + + private static final int SCHEMA_VERSION = 1; + private static final int MAX_BATCH_SIZE = 500; + private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512; + private static final Set TYPES = Set.of(CUSTOMER_ACCOUNT_LIST, CUSTOMER_ACCOUNT_LEDGER, + FEE_SOURCE_LIST, RECONCILIATION_STATEMENT_LIST, RECEIVABLE_SETTLEMENT_LIST, + RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL); + private static final Set SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED"); + private static final Set LEDGER_TYPES = Set.of("FEE_INCLUDED", "FEE_EXCLUDED", "RECONCILIATION_CREATED", + "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID"); + + private static final List ACCOUNT_COLUMNS = List.of( + column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28), + column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18), + column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16), + column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16), + column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21)); + private static final List LEDGER_COLUMNS = List.of( + column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21), + column("businessType", "业务类型", ExportColumnType.STRING, 18), column("documentNo", "关联单据", ExportColumnType.STRING, 24), + column("receivableChange", "应收变动(CNY)", ExportColumnType.DECIMAL, 18), column("payableChange", "应付变动(CNY)", ExportColumnType.DECIMAL, 18), + column("outstandingReceivableAfter", "变动后未收(CNY)", ExportColumnType.DECIMAL, 20), column("operatorName", "操作人", ExportColumnType.STRING, 16), + column("remark", "备注", ExportColumnType.STRING, 28)); + private static final List FEE_COLUMNS = List.of( + column("customerName", "客户", ExportColumnType.STRING, 28), column("direction", "收付方向", ExportColumnType.STRING, 14), + column("sourceType", "来源类型", ExportColumnType.STRING, 18), column("sourceFeeId", "来源单据ID", ExportColumnType.STRING, 22), + column("sourceVersion", "来源版本", ExportColumnType.INTEGER, 12), column("inboundCode", "入仓号", ExportColumnType.STRING, 20), + column("containerNo", "柜次号", ExportColumnType.STRING, 20), column("businessDate", "业务发生日", ExportColumnType.DATE, 14), + column("feeItemName", "费用项目", ExportColumnType.STRING, 22), column("quantity", "计费量", ExportColumnType.DECIMAL, 14), + column("unitPrice", "单价(CNY)", ExportColumnType.DECIMAL, 14), column("amount", "金额(CNY)", ExportColumnType.DECIMAL, 16), + column("reviewStatus", "审核状态", ExportColumnType.STRING, 14), column("settlementIncluded", "参与结算", ExportColumnType.BOOLEAN, 12), + column("sourceStatus", "来源状态", ExportColumnType.STRING, 14), column("statementGenerated", "已生成对账单", ExportColumnType.BOOLEAN, 16)); + private static final List STATEMENT_COLUMNS = List.of( + column("statementNo", "对账单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28), + column("periodStart", "结算起始日", ExportColumnType.DATE, 14), column("periodEnd", "结算结束日", ExportColumnType.DATE, 14), + column("creditDays", "账期(天)", ExportColumnType.INTEGER, 12), column("currency", "货币", ExportColumnType.STRING, 10), + column("totalReceivable", "总应收金额(CNY)", ExportColumnType.DECIMAL, 20), column("receivedAmount", "已收金额(CNY)", ExportColumnType.DECIMAL, 18), + column("outstandingAmount", "未收金额(CNY)", ExportColumnType.DECIMAL, 18), column("settled", "是否结清", ExportColumnType.BOOLEAN, 12), + column("status", "审核状态", ExportColumnType.STRING, 14), column("remark", "备注", ExportColumnType.STRING, 28), + column("createdByName", "创建人", ExportColumnType.STRING, 16), column("createdAt", "创建时间", ExportColumnType.DATETIME, 21), + column("updatedByName", "更新人", ExportColumnType.STRING, 16), column("updatedAt", "更新时间", ExportColumnType.DATETIME, 21)); + private static final List SETTLEMENT_LIST_COLUMNS = List.of( + column("settlementNo", "结算单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28), + column("supplierName", "供应商", ExportColumnType.STRING, 24), column("currency", "货币", ExportColumnType.STRING, 10), + column("totalAmount", "总金额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20), + column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("businessPeriod", "业务期间", ExportColumnType.STRING, 18), + column("generatedDate", "生成日期", ExportColumnType.DATE, 14), column("dueDate", "到期日期", ExportColumnType.DATE, 14), + column("status", "状态", ExportColumnType.STRING, 16)); + private static final List SETTLEMENT_DETAIL_COLUMNS = List.of( + column("settlementNo", "结算单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28), + column("supplierName", "供应商", ExportColumnType.STRING, 24), column("feeItemName", "费用项目", ExportColumnType.STRING, 22), + column("inboundCode", "入仓号", ExportColumnType.STRING, 20), column("containerNo", "柜次号", ExportColumnType.STRING, 20), + column("businessDate", "业务发生日", ExportColumnType.DATE, 14), column("lineAmount", "明细金额(CNY)", ExportColumnType.DECIMAL, 18), + column("totalAmount", "结算总额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20), + column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("status", "状态", ExportColumnType.STRING, 16)); + + private final FinanceCustomerAccountService accountService; + private final FinancePhase1OperationService operationService; + private final FinanceQueryService queryService; + private final ReconciliationStatementService reconciliationService; + private final FinanceSettlementService settlementService; + private final ObjectMapper objectMapper; + private final String internalServiceToken; + private byte[] snapshotSigningKey; + + public FinanceExportProviderService(FinanceCustomerAccountService accountService, + FinancePhase1OperationService operationService, + FinanceQueryService queryService, + ReconciliationStatementService reconciliationService, + FinanceSettlementService settlementService, + ObjectMapper objectMapper, + @Value("${baiyue.security.internal-service-token}") String internalServiceToken) { + this.accountService = accountService; + this.operationService = operationService; + this.queryService = queryService; + this.reconciliationService = reconciliationService; + this.settlementService = settlementService; + this.objectMapper = objectMapper; + this.internalServiceToken = internalServiceToken; + } + + @PostConstruct + void initializeSigningKey() { + snapshotSigningKey = internalServiceToken.getBytes(StandardCharsets.UTF_8); + } + + public ExportBatchResponse exportBatch(String exportType, ExportBatchRequest request) { + String type = normalizeType(exportType); + ValidatedRequest validated = validate(type, request); + boolean firstBatch = validated.snapshotToken() == null; + String snapshot = firstBatch ? signSnapshot(type, validated) : verifySnapshot(type, validated); + BatchRows batch = rows(type, validated); + String nextCursor = String.valueOf(validated.offset() + batch.rows().size()); + return new ExportBatchResponse(SCHEMA_VERSION, schemaHash(columns(type)), snapshot, + firstBatch ? columns(type) : List.of(), batch.rows(), nextCursor, + validated.offset() + batch.rows().size() < batch.totalRows(), firstBatch ? batch.totalRows() : null); + } + + private BatchRows rows(String type, ValidatedRequest request) { + int offset = request.offset(); + int limit = request.source().limit(); + Map p = request.parameters(); + if (CUSTOMER_ACCOUNT_LIST.equals(type)) { + int page = offset / limit + 1; + var result = accountService.list(page, limit, null, text(p, "status"), longValue(p, "customerId", false)); + return new BatchRows(result.data().stream().map(this::accountRow).toList(), result.page().total()); + } + if (CUSTOMER_ACCOUNT_LEDGER.equals(type)) { + int page = offset / limit + 1; + var result = operationService.listDerivedCustomerAccountLedger(longValue(p, "customerId", true), page, limit, + text(p, "businessType"), date(p, "startDate"), date(p, "endDate")); + return new BatchRows(result.data().stream().map(this::ledgerRow).toList(), result.page().total()); + } + List> all = switch (type) { + case FEE_SOURCE_LIST -> feeRows(p); + case RECONCILIATION_STATEMENT_LIST -> statementRows(p); + case RECEIVABLE_SETTLEMENT_LIST -> settlementRows("RECEIVABLE", p); + case PAYABLE_SETTLEMENT_LIST -> settlementRows("PAYABLE", p); + case RECEIVABLE_SETTLEMENT_DETAIL -> settlementDetailRows("RECEIVABLE", p); + case PAYABLE_SETTLEMENT_DETAIL -> settlementDetailRows("PAYABLE", p); + default -> throw invalid("Unsupported Finance export type"); + }; + int from = Math.min(offset, all.size()); + int to = Math.min(from + limit, all.size()); + return new BatchRows(List.copyOf(all.subList(from, to)), (long) all.size()); + } + + private List> feeRows(Map p) { + Long customerId = longValue(p, "customerId", false); + String direction = text(p, "direction"); + String status = text(p, "status"); + String reviewStatus = text(p, "reviewStatus"); + Boolean included = bool(p, "settlementIncluded"); + Boolean generated = bool(p, "statementGenerated"); + LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo"); + String keyword = lower(text(p, "keyword")); + List> rows = new ArrayList<>(); + for (FeePoolItem item : queryService.feePool(customerId, direction)) { + FeeSourceDetail detail = detail(item); + String sourceStatus = detail == null ? item.status() : detail.sourceStatus(); + String actualReview = detail == null ? null : detail.reviewStatus(); + Boolean actualIncluded = detail == null ? null : detail.settlementIncluded(); + Boolean actualGenerated = detail == null ? null : detail.statementGenerated(); + LocalDate businessDate = detail == null || detail.businessDate() == null + ? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate(); + if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue; + if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue; + if (included != null && !included.equals(actualIncluded)) continue; + if (generated != null && !generated.equals(actualGenerated)) continue; + if (from != null && (businessDate == null || businessDate.isBefore(from))) continue; + if (to != null && (businessDate == null || businessDate.isAfter(to))) continue; + String searchable = lower(String.join(" ", value(item.poolItemId()), value(item.sourceId()), value(item.feeItemName()), + detail == null ? "" : value(detail.inboundCode()), detail == null ? "" : value(detail.containerNo()))); + if (keyword != null && !searchable.contains(keyword)) continue; + rows.add(feeRow(item, detail, businessDate)); + } + return rows; + } + + private FeeSourceDetail detail(FeePoolItem item) { + if ("MANUAL_COST".equals(item.sourceType())) return null; + String[] parts = item.poolItemId().split(":", -1); + if (parts.length != 2) return null; + try { return operationService.getFinanceFeeSource(Long.parseLong(parts[1])); } + catch (RuntimeException ignored) { return null; } + } + + private List> statementRows(Map p) { + Long customerId = longValue(p, "customerId", false); + String status = text(p, "status"), billNo = lower(text(p, "billNo")), containerNo = lower(text(p, "containerNo")); + LocalDate periodStartFrom = date(p, "periodStartFrom"), periodStartTo = date(p, "periodStartTo"); + LocalDate periodEndFrom = date(p, "periodEndFrom"), periodEndTo = date(p, "periodEndTo"); + LocalDate lastPaymentFrom = date(p, "lastPaymentFrom"), lastPaymentTo = date(p, "lastPaymentTo"); + LocalDate createdFrom = date(p, "createdFrom"), createdTo = date(p, "createdTo"); + Boolean settled = bool(p, "settled"); + List> rows = new ArrayList<>(); + queryService.statements(customerId, false).forEach(summary -> { + ReconciliationDetail detail = reconciliationService.detailForPlatform(summary.id()); + LocalDate lastPayment = detail.paymentHistory().stream().map(row -> row.transactionDate()).max(LocalDate::compareTo).orElse(null); + boolean containerMatches = containerNo == null || detail.lines().stream() + .map(line -> feeSourceContainerNo(line.feeSourceId())) + .anyMatch(value -> value != null && value.toLowerCase(Locale.ROOT).contains(containerNo)); + if (status != null && !status.equalsIgnoreCase(detail.status())) return; + if (billNo != null && !detail.statementNo().toLowerCase(Locale.ROOT).contains(billNo)) return; + if (!containerMatches || (settled != null && settled != detail.settled())) return; + if (!between(detail.periodStart(), periodStartFrom, periodStartTo) || !between(detail.periodEnd(), periodEndFrom, periodEndTo)) return; + if ((lastPaymentFrom != null || lastPaymentTo != null) && !between(lastPayment, lastPaymentFrom, lastPaymentTo)) return; + if (!between(detail.createdAt().toLocalDate(), createdFrom, createdTo)) return; + rows.add(statementRow(detail)); + }); + return rows; + } + + private String feeSourceContainerNo(long feeSourceId) { + try { + return operationService.getFinanceFeeSource(feeSourceId).containerNo(); + } catch (RuntimeException ignored) { + return null; + } + } + + private List> settlementRows(String direction, Map p) { + Long customerId = longValue(p, "customerId", false); + String status = text(p, "status"), no = lower(text(p, "settlementNo")), supplier = lower(text(p, "supplierName")); + return settlementService.listSettlements(direction, customerId).stream() + .filter(row -> status == null || status.equalsIgnoreCase(row.status())) + .filter(row -> no == null || row.settlementNo().toLowerCase(Locale.ROOT).contains(no)) + .filter(row -> supplier == null || lower(row.supplierName()) != null && lower(row.supplierName()).contains(supplier)) + .map(this::settlementRow).toList(); + } + + private List> settlementDetailRows(String direction, Map p) { + SettlementDetail detail = settlementService.getSettlement(longValue(p, "settlementId", true)); + if (!direction.equals(detail.summary().direction())) throw invalid("结算单方向与导出类型不匹配"); + if (detail.lines().isEmpty()) return List.of(settlementDetailRow(detail.summary(), null)); + return detail.lines().stream().map(line -> settlementDetailRow(detail.summary(), line)).toList(); + } + + private ValidatedRequest validate(String type, ExportBatchRequest request) { + if (request == null) throw invalid("Export batch request is required"); + positive(request.taskId(), "taskId"); positive(request.requestedBy(), "requestedBy"); + if (!"0".equals(request.tenantId()) || !"PLATFORM".equals(request.appCode())) throw invalid("Finance export only supports PLATFORM tenant 0"); + if (request.dispatchVersion() <= 0 || request.limit() < 1 || request.limit() > MAX_BATCH_SIZE) throw invalid("Finance export batch limit is invalid"); + Map parameters = request.parameters() == null ? Map.of() : request.parameters(); + Set allowed = allowedParameters(type); + if (!allowed.containsAll(parameters.keySet())) throw invalid(type + " contains unsupported parameters"); + validateParameters(type, parameters); + String snapshot = normalize(request.snapshotToken()); + if (snapshot != null && snapshot.length() > MAX_SNAPSHOT_TOKEN_LENGTH) throw invalid("snapshotToken is too long"); + int offset = integerCursor(request.cursor()); + if (snapshot == null && offset != 0) throw invalid("cursor requires a snapshotToken"); + return new ValidatedRequest(request, Map.copyOf(parameters), offset, snapshot); + } + + private void validateParameters(String type, Map p) { + Long customerId = longValue(p, "customerId", false); + if (customerId != null && customerId <= 0) throw invalid("customerId must be positive"); + String status = text(p, "status"); + if (CUSTOMER_ACCOUNT_LIST.equals(type) && status != null && !Set.of("ACTIVE", "DISABLED").contains(status)) throw invalid("account status is invalid"); + if (CUSTOMER_ACCOUNT_LEDGER.equals(type)) { + longValue(p, "customerId", true); + String businessType = text(p, "businessType"); + if (businessType != null && !LEDGER_TYPES.contains(businessType)) throw invalid("ledger businessType is invalid"); + range(p, "startDate", "endDate"); + } + if (FEE_SOURCE_LIST.equals(type)) { + String direction = text(p, "direction"); + if (direction != null && !Set.of("RECEIVABLE", "PAYABLE").contains(direction)) throw invalid("fee direction is invalid"); + String review = text(p, "reviewStatus"); + if (review != null && !Set.of("PENDING", "APPROVED", "REJECTED").contains(review)) throw invalid("reviewStatus is invalid"); + bool(p, "settlementIncluded"); bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo"); + max(text(p, "keyword"), 128, "keyword"); max(status, 64, "status"); + } + if (RECONCILIATION_STATEMENT_LIST.equals(type)) { + bool(p, "settled"); range(p, "periodStartFrom", "periodStartTo"); range(p, "periodEndFrom", "periodEndTo"); + range(p, "lastPaymentFrom", "lastPaymentTo"); range(p, "createdFrom", "createdTo"); + max(text(p, "billNo"), 128, "billNo"); max(text(p, "containerNo"), 128, "containerNo"); + } + if (type.contains("SETTLEMENT")) { + if (type.endsWith("_DETAIL")) longValue(p, "settlementId", true); + else { + if (status != null && !SETTLEMENT_STATUSES.contains(status)) throw invalid("settlement status is invalid"); + max(text(p, "settlementNo"), 128, "settlementNo"); max(text(p, "supplierName"), 128, "supplierName"); + } + } + } + + private String signSnapshot(String type, ValidatedRequest request) { + return "v1." + Base64.getUrlEncoder().withoutPadding().encodeToString(hmac(snapshotSubject(type, request))); + } + + private String verifySnapshot(String type, ValidatedRequest request) { + String expected = signSnapshot(type, request); + if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), request.snapshotToken().getBytes(StandardCharsets.UTF_8))) { + throw invalid("snapshotToken does not match this export task"); + } + return request.snapshotToken(); + } + + private String snapshotSubject(String type, ValidatedRequest request) { + try { + ExportBatchRequest source = request.source(); + return String.join("\n", type, source.taskId(), source.tenantId(), source.appCode(), source.requestedBy(), + String.valueOf(source.dispatchVersion()), objectMapper.writeValueAsString(new TreeMap<>(request.parameters()))); + } catch (Exception exception) { throw invalid("Finance export parameters cannot be serialized"); } + } + + private byte[] hmac(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(snapshotSigningKey, "HmacSHA256")); + return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); } + } + + private Map accountRow(AccountSummary value) { + return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(), + "contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(), + "receivedInPeriod", value.receivedInPeriod(), "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt()); + } + private Map ledgerRow(LedgerEntry value) { + return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(), + "documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(), + "outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark()); + } + private Map feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) { + return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(), + "sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(), + "containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(), + "quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(), + "reviewStatus", detail == null ? null : detail.reviewStatus(), "settlementIncluded", detail == null ? null : detail.settlementIncluded(), + "sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated()); + } + private Map statementRow(ReconciliationDetail value) { + return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(), + "periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(), + "receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(), + "remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt()); + } + private Map settlementRow(SettlementSummary value) { + return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(), + "currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(), + "businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", value.status()); + } + private Map settlementDetailRow(SettlementSummary summary, SettlementLine line) { + return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(), + "feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(), + "containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(), + "lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(), + "outstandingAmount", summary.outstandingAmount(), "status", summary.status()); + } + + private static List columns(String type) { + return switch (type) { + case CUSTOMER_ACCOUNT_LIST -> ACCOUNT_COLUMNS; + case CUSTOMER_ACCOUNT_LEDGER -> LEDGER_COLUMNS; + case FEE_SOURCE_LIST -> FEE_COLUMNS; + case RECONCILIATION_STATEMENT_LIST -> STATEMENT_COLUMNS; + case RECEIVABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_LIST -> SETTLEMENT_LIST_COLUMNS; + case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> SETTLEMENT_DETAIL_COLUMNS; + default -> throw invalid("Unsupported Finance export type"); + }; + } + private static Set allowedParameters(String type) { + return switch (type) { + case CUSTOMER_ACCOUNT_LIST -> Set.of("customerId", "status"); + case CUSTOMER_ACCOUNT_LEDGER -> Set.of("customerId", "businessType", "startDate", "endDate"); + case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "settlementIncluded", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword"); + case RECONCILIATION_STATEMENT_LIST -> Set.of("customerId", "status", "billNo", "containerNo", "periodStartFrom", "periodStartTo", "periodEndFrom", "periodEndTo", "settled", "lastPaymentFrom", "lastPaymentTo", "createdFrom", "createdTo"); + case RECEIVABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo"); + case PAYABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo", "supplierName"); + case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> Set.of("settlementId"); + default -> throw invalid("Unsupported Finance export type"); + }; + } + private static String normalizeType(String value) { + String type = normalize(value); + if (type == null || !TYPES.contains(type.toUpperCase(Locale.ROOT))) throw invalid("Unsupported Finance export type"); + return type.toUpperCase(Locale.ROOT); + } + private static ExportColumn column(String key, String title, ExportColumnType type, int width) { return new ExportColumn(key, title, type, width); } + private static String schemaHash(List columns) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (ExportColumn column : columns) digest.update((column.key() + "\u001f" + column.title() + "\u001f" + column.type() + "\u001f" + column.width() + "\n").getBytes(StandardCharsets.UTF_8)); + return "sha256:" + java.util.HexFormat.of().formatHex(digest.digest()); + } catch (GeneralSecurityException exception) { throw new ExceptionInInitializerError(exception); } + } + private static Map row(Object... values) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) row.put(String.valueOf(values[i]), values[i + 1]); + return row; + } + private static int integerCursor(String value) { + String normalized = normalize(value); + if (normalized == null) return 0; + try { int cursor = Integer.parseInt(normalized); if (cursor < 0) throw invalid("cursor must not be negative"); return cursor; } + catch (NumberFormatException exception) { throw invalid("cursor must be a numeric offset"); } + } + private static Long longValue(Map values, String key, boolean required) { + Object value = values.get(key); + if (value == null || value.toString().isBlank()) { if (required) throw invalid(key + " is required"); return null; } + if (!(value instanceof String)) throw invalid(key + " must be a string ID"); + try { long result = Long.parseLong(value.toString()); if (result <= 0) throw invalid(key + " must be positive"); return result; } + catch (NumberFormatException exception) { throw invalid(key + " must be a positive ID"); } + } + private static String text(Map values, String key) { + Object value = values.get(key); + if (value == null) return null; + if (!(value instanceof String text)) throw invalid(key + " must be a string"); + return normalize(text); + } + private static Boolean bool(Map values, String key) { + Object value = values.get(key); + if (value == null) return null; + if (!(value instanceof Boolean result)) throw invalid(key + " must be a boolean"); + return result; + } + private static LocalDate date(Map values, String key) { + String value = text(values, key); + if (value == null) return null; + try { return LocalDate.parse(value); } + catch (RuntimeException exception) { throw invalid(key + " must use YYYY-MM-DD"); } + } + private static void range(Map values, String fromKey, String toKey) { + LocalDate from = date(values, fromKey), to = date(values, toKey); + if (from != null && to != null && from.isAfter(to)) throw invalid(fromKey + " cannot be after " + toKey); + } + private static boolean between(LocalDate value, LocalDate from, LocalDate to) { + if (value == null) return from == null && to == null; + return (from == null || !value.isBefore(from)) && (to == null || !value.isAfter(to)); + } + private static void positive(String value, String field) { + try { if (value == null || Long.parseLong(value) <= 0) throw invalid(field + " must be a positive ID"); } + catch (NumberFormatException exception) { throw invalid(field + " must be a positive ID"); } + } + private static void max(String value, int max, String field) { if (value != null && value.length() > max) throw invalid(field + " is too long"); } + private static String lower(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); } + private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String value(Object value) { return value == null ? "" : value.toString(); } + private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); } + + private record ValidatedRequest(ExportBatchRequest source, Map parameters, int offset, String snapshotToken) {} + private record BatchRows(List> rows, long totalRows) {} +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java new file mode 100644 index 000000000..a76748f7a --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java @@ -0,0 +1,50 @@ +package com.baiyue.finance; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceExportContractTest { + private static final List TYPES = List.of( + "FINANCE_CUSTOMER_ACCOUNT_LIST", "FINANCE_CUSTOMER_ACCOUNT_LEDGER", "FINANCE_FEE_SOURCE_LIST", + "FINANCE_RECONCILIATION_STATEMENT_LIST", "FINANCE_RECEIVABLE_SETTLEMENT_LIST", + "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL", "FINANCE_PAYABLE_SETTLEMENT_LIST", "FINANCE_PAYABLE_SETTLEMENT_DETAIL"); + + @Test + void keepsOpenApiAndAllFileDefinitionsOnTheSameEightTypes() throws Exception { + String openApi = Files.readString(resolve("docs", "财务管理", "openapi", "finance-phase1-five-pages-v1.yaml"), StandardCharsets.UTF_8); + String application = Files.readString(resolve("baiyue-firstleg", "file", "src", "main", "resources", "application.yml"), StandardCharsets.UTF_8); + String local = Files.readString(resolve("baiyue-firstleg", "config", "nacos", "local", "file-service.yml"), StandardCharsets.UTF_8); + String cn = Files.readString(resolve("baiyue-firstleg", "config", "nacos", "cn", "file-service.yml"), StandardCharsets.UTF_8); + + Yaml yaml = new Yaml(); + assertThat((Object) yaml.load(openApi)).isNotNull(); + assertThat((Object) yaml.load(application)).isNotNull(); + assertThat((Object) yaml.load(local)).isNotNull(); + assertThat((Object) yaml.load(cn)).isNotNull(); + + assertThat(openApi).contains("/internal/exports/v1/{exportType}/batches:") + .contains("customerId?, status?, settlementNo?") + .contains("所有 DETAIL 仅 settlementId") + .doesNotContain("DETAIL: settlementId, direction"); + for (String type : TYPES) { + assertThat(openApi).contains("- " + type); + assertThat(application).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); + assertThat(local).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); + assertThat(cn).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); + } + } + + private static Path resolve(String... segments) { + Path repository = Path.of("").toAbsolutePath().normalize(); + while (repository != null && !Files.exists(repository.resolve("apps").resolve("admin-web"))) repository = repository.getParent(); + if (repository == null) throw new IllegalStateException("repository root not found"); + return repository.resolve(Path.of(segments[0], java.util.Arrays.copyOfRange(segments, 1, segments.length))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/controller/FinanceExportProviderControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/FinanceExportProviderControllerTest.java new file mode 100644 index 000000000..46c77df04 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/FinanceExportProviderControllerTest.java @@ -0,0 +1,37 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.finance.service.FinanceExportProviderService; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceExportProviderControllerTest { + @Test + void exposesOnlyTheInternalVersionedProviderAndRequiresTheServiceToken() { + FinanceExportProviderService service = mock(FinanceExportProviderService.class); + String token = "12345678901234567890123456789012"; + FinanceExportProviderController controller = new FinanceExportProviderController(service, token); + controller.initializeToken(); + ExportBatchRequest request = new ExportBatchRequest("9", "0", "PLATFORM", "7", 1, Map.of(), null, null, 100); + ExportBatchResponse response = new ExportBatchResponse(1, "sha256:test", "snapshot", List.of(), List.of(), "0", false, 0L); + when(service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request)).thenReturn(response); + + assertThat(FinanceExportProviderController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/internal/exports/v1"); + assertThat(controller.batch(token, FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request).data()).isSameAs(response); + verify(service).exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request); + assertThatThrownBy(() -> controller.batch("wrong", FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request)) + .isInstanceOf(BizException.class); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java new file mode 100644 index 000000000..9fe8dfd43 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -0,0 +1,93 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; +import com.baiyue.finance.dto.FinanceReadModels.Statement; +import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FinanceExportProviderServiceTest { + @Mock FinanceCustomerAccountService accountService; + @Mock FinancePhase1OperationService operationService; + @Mock FinanceQueryService queryService; + @Mock ReconciliationStatementService reconciliationService; + @Mock FinanceSettlementService settlementService; + private FinanceExportProviderService service; + + @BeforeEach + void setUp() { + service = new FinanceExportProviderService(accountService, operationService, queryService, + reconciliationService, settlementService, new ObjectMapper(), "12345678901234567890123456789012"); + service.initializeSigningKey(); + } + + @Test + void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() { + Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"), + null, new BigDecimal("300"), new BigDecimal("100"), new BigDecimal("200"), "CNY", "ISSUED", 3, + 7L, "创建人A", LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00")); + ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", 21L, 31L, "运费", + new BigDecimal("320"), new BigDecimal("300"), "CNY", "优惠调整"); + ReconciliationDetail detail = new ReconciliationDetail(501L, "REC-501", 10001L, "客户A", + LocalDate.parse("2026-08-01"), LocalDate.parse("2026-08-31"), 30, "CNY", new BigDecimal("300"), + new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3, 7L, "创建人A", + LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"), + "八月账单", List.of(line), List.of(new PaymentHistory(701L, "RECEIPT", LocalDate.parse("2026-08-20"), + new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of()); + FeeSourceDetail source = new FeeSourceDetail(81L, "CONTAINER_FEE", 9001L, 2, "RECEIVABLE", 10001L, + "客户A", null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费", + BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED", true, + "AVAILABLE", true, null, null, "hash", 2, List.of()); + when(queryService.statements(10001L, false)).thenReturn(List.of(summary)); + when(reconciliationService.detailForPlatform(501L)).thenReturn(detail); + when(operationService.getFinanceFeeSource(81L)).thenReturn(source); + + var first = service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST, + request(Map.of("customerId", "10001", "containerNo", "CONT-31"), null, null)); + var continued = service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST, + request(Map.of("customerId", "10001", "containerNo", "CONT-31"), first.nextCursor(), first.snapshotToken())); + + assertThat(first.columns()).isNotEmpty(); + assertThat(first.rows()).hasSize(1); + assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A"); + assertThat(first.totalRows()).isEqualTo(1L); + assertThat(continued.columns()).isEmpty(); + assertThat(continued.rows()).isEmpty(); + assertThat(continued.snapshotToken()).isEqualTo(first.snapshotToken()); + } + + @Test + void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() { + assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, + request(Map.of("page", 1), null, null))).isInstanceOf(BizException.class); + + var first = service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST, + request(Map.of("status", "ISSUED"), null, null)); + assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST, + request(Map.of("status", "VOIDED"), first.nextCursor(), first.snapshotToken()))) + .isInstanceOf(BizException.class).hasMessageContaining("snapshotToken"); + } + + private ExportBatchRequest request(Map parameters, String cursor, String snapshot) { + return new ExportBatchRequest("9001", "0", "PLATFORM", "7", 1, parameters, cursor, snapshot, 100); + } +} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 9a6f182ab..351209fde 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -558,6 +558,26 @@ paths: requestId: {type: string, nullable: true} traceId: {type: string, nullable: true} '403': {description: 无查看权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/measurement: + get: + summary: 查询入库测量详情 + description: >- + 需要 platform:firstleg:inbound:measure:view 权限。尺寸测量图片只随 items 返回, + 由稳定的 item.id(measurementItemId)与 intelligentImageFileId 关联;称重图片只随 + weightResults 返回,由稳定的 weightResult.id 与 fileId 关联,并携带确认状态及服务端确认的 + grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。 + actualGrossWeightKg 是服务端权威的货物总重量;存在已确认称重结果时为所有已确认 + netWeightKg 之和,消费者不得根据图片或 OCR 值自行重算。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: {$ref: '#/components/schemas/InboundMeasurementDetailEnvelope'} + '403': {description: 无入库测量查看权限} /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans: post: summary: 平台模拟单箱设备扫描 @@ -1204,6 +1224,96 @@ components: confirmedBy: {type: string, nullable: true} confirmedAt: {type: string, format: date-time, nullable: true} version: {type: integer, minimum: 0} + InboundMeasurementItem: + type: object + description: >- + 单条尺寸测量明细。id 是稳定 measurementItemId;intelligentImageFileId 仅表示该明细的 + 尺寸测量原图,不得与 weightResults.fileId 混用。 + required: [id] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$', description: 稳定 measurementItemId} + inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + itemNo: {type: string, nullable: true} + boxSpecification: {type: string, nullable: true} + packageCount: {type: integer, nullable: true} + lengthCm: {type: number, format: double, nullable: true} + widthCm: {type: number, format: double, nullable: true} + heightCm: {type: number, format: double, nullable: true} + girthCm: {type: number, format: double, nullable: true} + unitWeightKg: {type: number, format: double, nullable: true} + unitVolumeCbm: {type: number, format: double, nullable: true} + totalWeightKg: {type: number, format: double, nullable: true} + totalVolumeCbm: {type: number, format: double, nullable: true} + volumeWeightKg: {type: number, format: double, nullable: true} + intelligentImageFileId: + type: string + pattern: '^[1-9][0-9]*$' + nullable: true + description: 当前尺寸测量明细的 File 文件 ID;图片类型固定为 DIMENSION + overLimitWarning: {type: string, nullable: true} + outerLengthCm: {type: number, format: double, nullable: true} + outerWidthCm: {type: number, format: double, nullable: true} + outerHeightCm: {type: number, format: double, nullable: true} + remark: {type: string, nullable: true} + boxCode: {type: string, nullable: true} + scannedShipmentId: {type: string, nullable: true} + confirmedPackageCount: {type: integer, nullable: true} + machineNo: {type: string, nullable: true} + deviceMeasuredAt: {type: string, format: date-time, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} + InboundMeasurementDetail: + type: object + description: >- + 入库测量权威详情。尺寸证据天然归属 items,称重证据天然归属 weightResults,响应不提供统一 images。 + required: [inboundId, inboundVersion, items, weightResults] + properties: + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundCode: {type: string, nullable: true} + companyName: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + processStatus: {type: string, nullable: true} + measureReviewStatus: {type: string, nullable: true} + recommendedPalletType: {type: string, nullable: true} + reviewReason: {type: string, nullable: true} + reviewerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + reviewerName: {type: string, nullable: true} + measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + sourceType: {type: string, nullable: true} + measurementStatus: {type: string, nullable: true} + inboundVersion: {type: integer, minimum: 0} + version: {type: integer, minimum: 0, nullable: true} + originalDocumentNo: {type: string, nullable: true} + originalGrossWeightKg: {type: number, format: double, nullable: true} + originalVolumeCbm: {type: number, format: double, nullable: true} + originalPackageCount: {type: integer, nullable: true} + actualPackageCount: {type: integer, nullable: true} + actualGrossWeightKg: + type: number + format: double + nullable: true + multipleOf: 0.001 + description: 服务端权威货物总重量 KG;存在已确认称重结果时为所有 CONFIRMED netWeightKg 之和 + actualVolumeCbm: {type: number, format: double, nullable: true} + volumeWeightKg: {type: number, format: double, nullable: true} + actualGoodsName: {type: string, nullable: true} + items: + type: array + description: DIMENSION 尺寸测量明细及其图片证据 + items: {$ref: '#/components/schemas/InboundMeasurementItem'} + weightResults: + type: array + description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实 + items: {$ref: '#/components/schemas/InboundWeightResult'} + InboundMeasurementDetailEnvelope: + type: object + required: [code, message] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/InboundMeasurementDetail'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} ConfirmWeightResultRequest: type: object required: [version] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java new file mode 100644 index 000000000..549c697f9 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -0,0 +1,48 @@ +package com.baiyue.firstleg.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.yaml.snakeyaml.Yaml; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundMeasurementEvidenceOpenApiContractTest { + + @Test + void measurementDetailSeparatesDimensionAndWeightEvidenceByStableRecord() throws Exception { + String source = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map document = stringMap(new Yaml().load(source)); + Map paths = stringMap(document.get("paths")); + Map detailPath = stringMap(paths.get( + "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement")); + assertThat(detailPath).containsKey("get"); + + Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); + Map detailProperties = properties(schemas, "InboundMeasurementDetail"); + assertThat(detailProperties).containsKeys("actualGrossWeightKg", "items", "weightResults"); + assertThat(stringMap(detailProperties.get("items"))) + .containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据"); + assertThat(stringMap(detailProperties.get("weightResults"))) + .containsEntry("description", "WEIGHT 称重结果及其图片、确认状态和服务端重量事实"); + assertThat(detailProperties).doesNotContainKey("images"); + + Map itemProperties = properties(schemas, "InboundMeasurementItem"); + assertThat(itemProperties).containsKeys("id", "intelligentImageFileId"); + Map weightProperties = properties(schemas, "InboundWeightResult"); + assertThat(weightProperties).containsKeys( + "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt"); + } + + private static Map properties(Map schemas, String schemaName) { + return stringMap(stringMap(schemas.get(schemaName)).get("properties")); + } + + @SuppressWarnings("unchecked") + private static Map stringMap(Object value) { + return (Map) value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 231d508d8..b17f5a602 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -26,6 +26,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.TestPropertySource; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -136,6 +137,45 @@ class InboundMeasurementServiceTest { verify(repository, never()).updateInbound(any()); } + @Test + void detailKeepsDimensionAndWeightEvidenceOnTheirStableBusinessRecords() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setActualGrossWeightKg(new BigDecimal("11.500")); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setVersion(4); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(40L); + item.setTenantId(1L); + item.setMeasurementId(30L); + item.setSourceImageFileId(140L); + InboundWeightResultEntity weight = weight(50L, "30.000", "KG", "CONFIRMED", 2); + weight.setGrossWeightKg(new BigDecimal("30.000")); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + weight.setNetWeightKg(new BigDecimal("11.500")); + weight.setConfirmedAt(LocalDateTime.of(2026, 8, 17, 10, 30)); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("11.500"); + assertThat(result.items()).singleElement().satisfies(dimension -> { + assertThat(dimension.id()).isEqualTo(40L); + assertThat(dimension.intelligentImageFileId()).isEqualTo(140L); + }); + assertThat(result.weightResults()).singleElement().satisfies(confirmedWeight -> { + assertThat(confirmedWeight.id()).isEqualTo(50L); + assertThat(confirmedWeight.fileId()).isEqualTo(150L); + assertThat(confirmedWeight.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(confirmedWeight.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(confirmedWeight.netWeightKg()).isEqualByComparingTo("11.500"); + assertThat(confirmedWeight.confirmedAt()).isNotNull(); + }); + } + @Test void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index d3b3c5038..964d8e5dc 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -21,7 +21,12 @@ VALUES (5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0), (5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0), (5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0), - (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0) + (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0), + (5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0), + (5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0), + (5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0), + (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), + (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,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), @@ -54,7 +59,10 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) AND p.permission_code IN ( 'platform:finance:customer-account:list','platform:finance:customer-arap:list', 'platform:finance:customer-statement:list','platform:finance:settlement-ar:list', - 'platform:finance:settlement-ap:list','platform:firstleg:inbound:measure:view', + 'platform:finance:settlement-ap:list','platform:finance:customer-account:export', + 'platform:finance:customer-arap:export','platform:finance:customer-statement:export', + 'platform:finance:settlement-ar:export','platform:finance:settlement-ap:export', + 'platform:firstleg:inbound:measure:view', 'firstleg:inbound:measurement-review','platform:supplier:list', 'platform:supplier:create','platform:supplier:edit','platform:supplier:status', 'platform:domestic-container:list','platform:domestic-container:inbound:create', diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index fe3314af1..cfedd122b 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -15,6 +15,11 @@ class AdminPrototypeMenuPermissionSqlContractTest { "platform:finance:customer-statement:list", "platform:finance:settlement-ar:list", "platform:finance:settlement-ap:list" }; + private static final String[] FINANCE_EXPORTS = { + "platform:finance:customer-account:export", "platform:finance:customer-arap:export", + "platform:finance:customer-statement:export", "platform:finance:settlement-ar:export", + "platform:finance:settlement-ap:export" + }; @Test void declaresFinancePageMenusAndReparentsActions() throws Exception { @@ -23,6 +28,7 @@ class AdminPrototypeMenuPermissionSqlContractTest { for (int index = 0; index < FINANCE_MENUS.length; index++) { assertThat(sql).contains("(" + (5105040 + index) + ",0,'PLATFORM','MENU',5105000,'" + FINANCE_MENUS[index] + "'"); + assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); } assertThat(sql) .contains("'platform:finance:fee-source:review' code, 5105041 parent_id") @@ -36,6 +42,9 @@ class AdminPrototypeMenuPermissionSqlContractTest { .doesNotContain("SELECT 'platform:finance:settlement-ar:view', 5105043") .doesNotContain("SELECT 'platform:finance:settlement-ap:view', 5105044") .contains("role_code='PLATFORM_ADMIN'"); + + String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); + for (String permission : FINANCE_EXPORTS) assertThat(unified).contains(permission); } @Test diff --git a/scripts/create-pending-review-orders-contract.test.mjs b/scripts/create-pending-review-orders-contract.test.mjs index 14b95da78..ac4c7a2d3 100644 --- a/scripts/create-pending-review-orders-contract.test.mjs +++ b/scripts/create-pending-review-orders-contract.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, randomOrderOptions, validateCount } from './create-pending-review-orders.mjs' +import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, randomOrderOptions, selectSameChannelRoutes, validateCount } from './create-pending-review-orders.mjs' const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8') @@ -101,3 +101,21 @@ test('interleaves different logistics combinations before repeating one', () => const secondKey = `${diversified[1].channel.channelCode}|${diversified[1].pricingTaxMode}` assert.notEqual(firstKey, secondKey) }) + +test('selects routes from exactly one logistics channel', () => { + const route = (receivingId, channelCode, pricingTaxMode, destinationCode) => ({ + receiving: { id: receivingId }, channel: { channelCode }, pricingTaxMode, + destination: { warehouseCode: destinationCode }, + }) + const selected = selectSameChannelRoutes([ + route('1', 'SEA-A', 'TAX_INCLUDED', 'A1'), + route('1', 'SEA-A', 'TAX_INCLUDED', 'A2'), + route('1', 'SEA-B', 'TAX_EXCLUDED', 'B1'), + ]) + assert.equal(selected.length, 2) + assert.equal(new Set(selected.map((item) => `${item.receiving.id}|${item.channel.channelCode}|${item.pricingTaxMode}`)).size, 1) +}) + +test('parses same-channel as a boolean flag', () => { + assert.deepEqual(parseArguments(['--count', '10', '--same-channel']), { count: '10', 'same-channel': true }) +}) diff --git a/scripts/create-pending-review-orders.mjs b/scripts/create-pending-review-orders.mjs index e194efa4b..4da72837d 100644 --- a/scripts/create-pending-review-orders.mjs +++ b/scripts/create-pending-review-orders.mjs @@ -33,18 +33,20 @@ export const PRODUCT_CATALOG = [ const CONFIG = { GATEWAY: 'http://8.163.30.128:9000', USERNAME: '17512095155', - PASSWORD: 'd6KW2ZC4vv44WJ4a', + PASSWORD: 'M4mmp4YWcH5DiYPT', COUNT: 50, } export function parseArguments(values) { const options = {} + const booleanOptions = new Set(['help', 'same-channel']) for (let index = 0; index < values.length; index += 1) { const value = values[index] if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`) const [rawKey, inlineValue] = value.slice(2).split('=', 2) - if (rawKey === 'help') { - options.help = true + if (booleanOptions.has(rawKey)) { + if (inlineValue !== undefined) throw new Error(`--${rawKey} does not accept a value`) + options[rawKey] = true continue } const next = inlineValue ?? values[index + 1] @@ -123,6 +125,19 @@ export function diversifyRoutes(routes) { return result } +export function selectSameChannelRoutes(routes) { + const grouped = new Map() + for (const route of routes) { + const key = logisticsKey(route) + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key).push(route) + } + const candidates = [...grouped.values()] + if (candidates.length === 0) throw new Error('No priced route is available for same-channel orders') + const maxDestinationCount = Math.max(...candidates.map((group) => group.length)) + return shuffled(pickRandom(candidates.filter((group) => group.length === maxDestinationCount))) +} + export function randomOrderOptions() { return { tradeTerm: pickRandom(TRADE_TERMS), @@ -324,7 +339,7 @@ export function buildOrderPayload({ marker, index, profile, master, product, pac function printHelp() { console.log(`Usage: - node scripts/create-pending-review-orders.mjs --gateway --count <1-${MAX_COUNT}> + node scripts/create-pending-review-orders.mjs --gateway --count <1-${MAX_COUNT}> [--same-channel] Environment: CLIENT_TEST_GATEWAY Test environment Gateway URL @@ -374,7 +389,8 @@ export async function main(argv = process.argv.slice(2)) { const routePool = await loadPricedRoutes(gateway, timeoutMs, token) const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker) const randomizedProducts = shuffled(PRODUCT_CATALOG) - const randomizedRoutes = diversifyRoutes(routePool) + const sameChannel = args['same-channel'] === true + const randomizedRoutes = sameChannel ? selectSameChannelRoutes(routePool) : diversifyRoutes(routePool) for (let index = 1; index <= count; index += 1) { const master = randomizedRoutes[(index - 1) % randomizedRoutes.length] @@ -446,6 +462,7 @@ export async function main(argv = process.argv.slice(2)) { tenantId: String(profile.id), requestedCount: count, createdCount: orders.length, + sameChannel, startedAt, completedAt: new Date().toISOString(), productImageFileId: String(productImageFileId),