优化
This commit is contained in:
@@ -21,3 +21,77 @@ baiyue:
|
|||||||
max-rows: 200000
|
max-rows: 200000
|
||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
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-rows: 200000
|
||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
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:}
|
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),
|
(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),
|
(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),
|
(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;
|
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');
|
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');
|
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');
|
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)
|
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'
|
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';
|
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
|
INSERT INTO baiyue_iam.iam_permission
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ public class ExportProviderClient {
|
|||||||
@Resource private OrderExportProviderClient order;
|
@Resource private OrderExportProviderClient order;
|
||||||
@Resource private WarehouseExportProviderClient warehouse;
|
@Resource private WarehouseExportProviderClient warehouse;
|
||||||
@Resource private FirstlegExportProviderClient firstleg;
|
@Resource private FirstlegExportProviderClient firstleg;
|
||||||
|
@Resource private FinanceExportProviderClient finance;
|
||||||
@Resource
|
@Resource
|
||||||
private MeterRegistry meters;
|
private MeterRegistry meters;
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
void initializeClients() {
|
void initializeClients() {
|
||||||
this.clients = Map.of("iam-service", iam, "order-service", order,
|
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,
|
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
|
@Component
|
||||||
@ConfigurationProperties(prefix = "baiyue.file.export")
|
@ConfigurationProperties(prefix = "baiyue.file.export")
|
||||||
public class ExportDefinitionRegistry {
|
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 TYPE = Pattern.compile("[A-Z0-9_]+");
|
||||||
private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches");
|
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();
|
private Map<String, Definition> definitions = Map.of();
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ spring:
|
|||||||
firstlegExportProviderClient:
|
firstlegExportProviderClient:
|
||||||
connect-timeout: 3000
|
connect-timeout: 3000
|
||||||
read-timeout: 15000
|
read-timeout: 15000
|
||||||
|
financeExportProviderClient:
|
||||||
|
connect-timeout: 3000
|
||||||
|
read-timeout: 15000
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 10MB
|
max-file-size: 10MB
|
||||||
@@ -53,6 +56,8 @@ baiyue:
|
|||||||
url: ${IAM_SERVICE_URL:}
|
url: ${IAM_SERVICE_URL:}
|
||||||
integration:
|
integration:
|
||||||
url: ${INTEGRATION_SERVICE_URL:}
|
url: ${INTEGRATION_SERVICE_URL:}
|
||||||
|
finance:
|
||||||
|
url: ${FINANCE_SERVICE_URL:}
|
||||||
file:
|
file:
|
||||||
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
|
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
|
||||||
price-book:
|
price-book:
|
||||||
@@ -96,6 +101,78 @@ baiyue:
|
|||||||
max-rows: 200000
|
max-rows: 200000
|
||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
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
|
management.endpoints.web.exposure.include: health,info,prometheus
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class ExportProviderClientTest {
|
|||||||
@MockBean private OrderExportProviderClient order;
|
@MockBean private OrderExportProviderClient order;
|
||||||
@MockBean private WarehouseExportProviderClient warehouse;
|
@MockBean private WarehouseExportProviderClient warehouse;
|
||||||
@MockBean private FirstlegExportProviderClient firstleg;
|
@MockBean private FirstlegExportProviderClient firstleg;
|
||||||
|
@MockBean private FinanceExportProviderClient finance;
|
||||||
@Resource private ExportProviderClient client;
|
@Resource private ExportProviderClient client;
|
||||||
|
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
@@ -44,6 +45,19 @@ class ExportProviderClientTest {
|
|||||||
|
|
||||||
assertSame(response,actual);
|
assertSame(response,actual);
|
||||||
verify(order).batch("token","ORDER_LIST",request);
|
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")));
|
ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches")));
|
||||||
assertDoesNotThrow(registry::validate);
|
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() {
|
@Test void rejectsSsrfAndPathTypeMismatch() {
|
||||||
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
|
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
|
||||||
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches"))));
|
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}
|
requestId: {type: string, nullable: true}
|
||||||
traceId: {type: string, nullable: true}
|
traceId: {type: string, nullable: true}
|
||||||
'403': {description: 无查看权限}
|
'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:
|
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:
|
||||||
post:
|
post:
|
||||||
summary: 平台模拟单箱设备扫描
|
summary: 平台模拟单箱设备扫描
|
||||||
@@ -1204,6 +1224,96 @@ components:
|
|||||||
confirmedBy: {type: string, nullable: true}
|
confirmedBy: {type: string, nullable: true}
|
||||||
confirmedAt: {type: string, format: date-time, nullable: true}
|
confirmedAt: {type: string, format: date-time, nullable: true}
|
||||||
version: {type: integer, minimum: 0}
|
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:
|
ConfirmWeightResultRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [version]
|
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 org.springframework.test.context.TestPropertySource;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -136,6 +137,45 @@ class InboundMeasurementServiceTest {
|
|||||||
verify(repository, never()).updateInbound(any());
|
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
|
@Test
|
||||||
void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() {
|
void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() {
|
||||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
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),
|
(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),
|
(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),
|
(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
|
ON DUPLICATE KEY UPDATE
|
||||||
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
|
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
|
||||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
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 (
|
AND p.permission_code IN (
|
||||||
'platform:finance:customer-account:list','platform:finance:customer-arap:list',
|
'platform:finance:customer-account:list','platform:finance:customer-arap:list',
|
||||||
'platform:finance:customer-statement:list','platform:finance:settlement-ar: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',
|
'firstleg:inbound:measurement-review','platform:supplier:list',
|
||||||
'platform:supplier:create','platform:supplier:edit','platform:supplier:status',
|
'platform:supplier:create','platform:supplier:edit','platform:supplier:status',
|
||||||
'platform:domestic-container:list','platform:domestic-container:inbound:create',
|
'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:customer-statement:list", "platform:finance:settlement-ar:list",
|
||||||
"platform:finance:settlement-ap: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
|
@Test
|
||||||
void declaresFinancePageMenusAndReparentsActions() throws Exception {
|
void declaresFinancePageMenusAndReparentsActions() throws Exception {
|
||||||
@@ -23,6 +28,7 @@ class AdminPrototypeMenuPermissionSqlContractTest {
|
|||||||
|
|
||||||
for (int index = 0; index < FINANCE_MENUS.length; index++) {
|
for (int index = 0; index < FINANCE_MENUS.length; index++) {
|
||||||
assertThat(sql).contains("(" + (5105040 + index) + ",0,'PLATFORM','MENU',5105000,'" + FINANCE_MENUS[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)
|
assertThat(sql)
|
||||||
.contains("'platform:finance:fee-source:review' code, 5105041 parent_id")
|
.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-ar:view', 5105043")
|
||||||
.doesNotContain("SELECT 'platform:finance:settlement-ap:view', 5105044")
|
.doesNotContain("SELECT 'platform:finance:settlement-ap:view', 5105044")
|
||||||
.contains("role_code='PLATFORM_ADMIN'");
|
.contains("role_code='PLATFORM_ADMIN'");
|
||||||
|
|
||||||
|
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
|
||||||
|
for (String permission : FINANCE_EXPORTS) assertThat(unified).contains(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
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')
|
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}`
|
const secondKey = `${diversified[1].channel.channelCode}|${diversified[1].pricingTaxMode}`
|
||||||
assert.notEqual(firstKey, secondKey)
|
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 = {
|
const CONFIG = {
|
||||||
GATEWAY: 'http://8.163.30.128:9000',
|
GATEWAY: 'http://8.163.30.128:9000',
|
||||||
USERNAME: '17512095155',
|
USERNAME: '17512095155',
|
||||||
PASSWORD: 'd6KW2ZC4vv44WJ4a',
|
PASSWORD: 'M4mmp4YWcH5DiYPT',
|
||||||
COUNT: 50,
|
COUNT: 50,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseArguments(values) {
|
export function parseArguments(values) {
|
||||||
const options = {}
|
const options = {}
|
||||||
|
const booleanOptions = new Set(['help', 'same-channel'])
|
||||||
for (let index = 0; index < values.length; index += 1) {
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
const value = values[index]
|
const value = values[index]
|
||||||
if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`)
|
if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`)
|
||||||
const [rawKey, inlineValue] = value.slice(2).split('=', 2)
|
const [rawKey, inlineValue] = value.slice(2).split('=', 2)
|
||||||
if (rawKey === 'help') {
|
if (booleanOptions.has(rawKey)) {
|
||||||
options.help = true
|
if (inlineValue !== undefined) throw new Error(`--${rawKey} does not accept a value`)
|
||||||
|
options[rawKey] = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const next = inlineValue ?? values[index + 1]
|
const next = inlineValue ?? values[index + 1]
|
||||||
@@ -123,6 +125,19 @@ export function diversifyRoutes(routes) {
|
|||||||
return result
|
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() {
|
export function randomOrderOptions() {
|
||||||
return {
|
return {
|
||||||
tradeTerm: pickRandom(TRADE_TERMS),
|
tradeTerm: pickRandom(TRADE_TERMS),
|
||||||
@@ -324,7 +339,7 @@ export function buildOrderPayload({ marker, index, profile, master, product, pac
|
|||||||
|
|
||||||
function printHelp() {
|
function printHelp() {
|
||||||
console.log(`Usage:
|
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:
|
Environment:
|
||||||
CLIENT_TEST_GATEWAY Test environment Gateway URL
|
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 routePool = await loadPricedRoutes(gateway, timeoutMs, token)
|
||||||
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
|
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
|
||||||
const randomizedProducts = shuffled(PRODUCT_CATALOG)
|
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) {
|
for (let index = 1; index <= count; index += 1) {
|
||||||
const master = randomizedRoutes[(index - 1) % randomizedRoutes.length]
|
const master = randomizedRoutes[(index - 1) % randomizedRoutes.length]
|
||||||
@@ -446,6 +462,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|||||||
tenantId: String(profile.id),
|
tenantId: String(profile.id),
|
||||||
requestedCount: count,
|
requestedCount: count,
|
||||||
createdCount: orders.length,
|
createdCount: orders.length,
|
||||||
|
sameChannel,
|
||||||
startedAt,
|
startedAt,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
productImageFileId: String(productImageFileId),
|
productImageFileId: String(productImageFileId),
|
||||||
|
|||||||
Reference in New Issue
Block a user