This commit is contained in:
2026-08-17 17:49:03 +08:00
parent c71db4b481
commit 7c66097672
21 changed files with 1227 additions and 14 deletions
+74
View File
@@ -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:}
+74
View File
@@ -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:}
@@ -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
@@ -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<ExportBatchResponse> batch(String internalToken, String exportType,
@@ -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 { }
@@ -11,10 +11,10 @@ import java.util.regex.Pattern;
@Component
@ConfigurationProperties(prefix = "baiyue.file.export")
public class ExportDefinitionRegistry {
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service");
private static final Set<String> 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<String, Definition> definitions = Map.of();
@PostConstruct
+77
View File
@@ -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:
@@ -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);
}
}
@@ -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"))));
@@ -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<ExportBatchResponse> 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));
}
}
@@ -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<String> 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<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
private static final Set<String> LEDGER_TYPES = Set.of("FEE_INCLUDED", "FEE_EXCLUDED", "RECONCILIATION_CREATED",
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID");
private static final List<ExportColumn> 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<ExportColumn> 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<ExportColumn> 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<ExportColumn> 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<ExportColumn> 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<ExportColumn> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> feeRows(Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> statementRows(Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> settlementRows(String direction, Map<String, Object> 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<Map<String, Object>> settlementDetailRows(String direction, Map<String, Object> 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<String, Object> parameters = request.parameters() == null ? Map.of() : request.parameters();
Set<String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<ExportColumn> 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<String> 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<ExportColumn> 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<String, Object> row(Object... values) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> parameters, int offset, String snapshotToken) {}
private record BatchRows(List<Map<String, Object>> rows, long totalRows) {}
}
@@ -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<String> 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)));
}
}
@@ -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);
}
}
@@ -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<String, Object> parameters, String cursor, String snapshot) {
return new ExportBatchRequest("9001", "0", "PLATFORM", "7", 1, parameters, cursor, snapshot, 100);
}
}
@@ -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]
@@ -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<String, Object> document = stringMap(new Yaml().load(source));
Map<String, Object> paths = stringMap(document.get("paths"));
Map<String, Object> detailPath = stringMap(paths.get(
"/api/platform/v1/first-leg/inbounds/{inboundId}/measurement"));
assertThat(detailPath).containsKey("get");
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> 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<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult");
assertThat(weightProperties).containsKeys(
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt");
}
private static Map<String, Object> properties(Map<String, Object> schemas, String schemaName) {
return stringMap(stringMap(schemas.get(schemaName)).get("properties"));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
}
}
@@ -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");
@@ -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',
@@ -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
@@ -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 })
})
+22 -5
View File
@@ -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 <url> --count <1-${MAX_COUNT}>
node scripts/create-pending-review-orders.mjs --gateway <url> --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),