优化
This commit is contained in:
+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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user