优化
This commit is contained in:
@@ -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:}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))));
|
||||
|
||||
+51
@@ -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)));
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+93
@@ -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]
|
||||
|
||||
+48
@@ -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',
|
||||
|
||||
+9
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user