diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java index ef2bdbd0e..0aca23747 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java @@ -1,5 +1,7 @@ package com.baiyue.finance.dto; +import com.fasterxml.jackson.annotation.JsonUnwrapped; + import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; @@ -27,7 +29,9 @@ public final class FinanceSettlementDtos { String direction, long customerId, String customerName, Long supplierId, String supplierName, Long inboundId, String inboundCode, Long containerId, String containerNo, LocalDate businessDate, String feeItemName, - BigDecimal amount, String currency, String sourceStatus) {} + BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency, + String reviewStatus, boolean settlementIncluded, String sourceStatus, + boolean statementGenerated) {} public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate, BigDecimal amount, String method, String referenceNo, List voucherFileIds, long createdBy, LocalDateTime createdAt, @@ -35,7 +39,7 @@ public final class FinanceSettlementDtos { public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount, BigDecimal afterAmount, String reason, long operatorId, LocalDateTime occurredAt) {} - public record SettlementDetail(SettlementSummary summary, List lines, + public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List lines, List transactions, List revisions) {} public record SettlementDetailEnvelope(String code, String message, SettlementDetail data, diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java new file mode 100644 index 000000000..3062a9e77 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -0,0 +1,486 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +public class FinanceSettlementService { + private static final String CNY = "CNY"; + private static final Set PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER"); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final FinanceFileClient fileClient; + + @Value("${baiyue.security.internal-service-token:}") + private String fileToken; + + public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceFileClient fileClient) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.fileClient = fileClient; + } + + @Transactional + public SettlementDetail createReceivableSettlement(long operatorId, String key, + CreateReceivableSettlementRequest request) { + if (request == null || request.reconciliationStatementId() <= 0) { + throw error("FIN_400", "对账单ID无效"); + } + requireCny(request.currency()); + Map bill = one( + "SELECT id,tenant_id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount " + + "FROM fin_bill WHERE id=? FOR UPDATE", + "对账单不存在", request.reconciliationStatementId()); + long customerId = number(bill, "tenant_id").longValue(); + if (!CNY.equals(text(bill, "currency_code"))) throw error("FIN_400", "对账单币种必须为CNY"); + if (!"ISSUED".equals(text(bill, "status"))) throw error("FIN_409", "只有已出账对账单可生成应收结算单"); + BigDecimal total = decimal(bill, "total_amount"); + if (total.signum() <= 0) throw error("FIN_400", "应收结算金额必须大于0"); + Long replay = reserve(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, "POST", + "/api/platform/finance/v1/receivable-settlements", request); + if (replay != null) return getSettlement(replay); + Integer occupied = jdbc.queryForObject( + "SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' " + + "AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class, + customerId, request.reconciliationStatementId()); + if (occupied != null && occupied > 0) throw error("FIN_409", "对账单已生成有效应收结算单"); + Integer lineCount = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?", + Integer.class, customerId, request.reconciliationStatementId()); + if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细"); + + LocalDate generatedDate = LocalDate.now(); + long settlementId = insertSettlement(customerId, documentNo("AR"), "RECEIVABLE", + text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(), + date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total, + generatedDate, date(bill, "due_date"), operatorId); + int inserted = jdbc.update( + "INSERT INTO fin_settlement_line(tenant_id,settlement_id,fee_source_id,amount,currency_code,created_at) " + + "SELECT bl.tenant_id,?,f.id,bl.amount,bl.currency_code,CURRENT_TIMESTAMP(3) FROM fin_bill_line bl " + + "JOIN fin_fee_source f ON f.customer_id=bl.tenant_id AND f.source_fee_id=bl.source_id " + + "AND f.source_fee_version=bl.source_version WHERE bl.tenant_id=? AND bl.bill_id=?", + settlementId, customerId, request.reconciliationStatementId()); + if (inserted != lineCount) throw error("FIN_409", "对账单费用来源快照不完整"); + audit(customerId, settlementId, "CREATE", null, null, settlementSnapshot(total, BigDecimal.ZERO), operatorId); + complete(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, settlementId); + return getSettlement(settlementId); + } + + @Transactional + public SettlementDetail createPayableSettlement(long operatorId, String key, + CreatePayableSettlementRequest request) { + validatePayableRequest(request); + long customerId = request.customerId(); + Long replay = reserve(customerId, operatorId, "SETTLEMENT_AP_CREATE", key, "POST", + "/api/platform/finance/v1/payable-settlements", request); + if (replay != null) return getSettlement(replay); + List ids = request.feeSourceIds().stream().distinct().sorted().toList(); + String placeholders = String.join(",", java.util.Collections.nCopies(ids.size(), "?")); + List args = new ArrayList<>(); + args.addAll(ids); + List> sources = jdbc.queryForList( + "SELECT id,customer_id,direction,currency_code,status,amount,source_snapshot," + + "JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus')) review_status," + + "JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded')) settlement_included," + + "CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," + + "JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name " + + "FROM fin_fee_source WHERE id IN (" + placeholders + ") FOR UPDATE", args.toArray()); + if (sources.size() != ids.size() || sources.stream().anyMatch(row -> number(row, "customer_id").longValue() != customerId)) { + throw error("FIN_404", "费用来源不存在或不属于客户"); + } + for (Map source : sources) { + Long supplierId = nullableNumber(source, "supplier_id"); + if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商"); + if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status")) + || !"APPROVED".equals(text(source, "review_status")) || !truth(source.get("settlement_included"))) { + throw error("FIN_409", "费用来源未审核、未参与结算或状态不可用"); + } + if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY"); + Integer used = jdbc.queryForObject( + "SELECT COUNT(*) FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id " + + "WHERE sl.fee_source_id=? AND s.status<>'VOIDED'", Integer.class, number(source, "id").longValue()); + if (used != null && used > 0) throw error("FIN_409", "费用来源已被有效结算单占用"); + } + BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add); + if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0"); + String supplierName = sources.stream().map(row -> text(row, "supplier_name")) + .filter(StringUtils::hasText).findFirst().orElse("供应商" + request.supplierId()); + String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName")) + .filter(StringUtils::hasText).findFirst().orElse("客户" + customerId); + LocalDate generatedDate = LocalDate.now(); + long settlementId = insertSettlement(customerId, documentNo("AP"), "PAYABLE", customerName, + request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")), + CNY, total, generatedDate, request.dueDate(), operatorId); + for (Map source : sources) { + jdbc.update("INSERT INTO fin_settlement_line(tenant_id,settlement_id,fee_source_id,amount,currency_code,created_at) " + + "VALUES(?,?,?,?,?,CURRENT_TIMESTAMP(3))", customerId, settlementId, + number(source, "id").longValue(), decimal(source, "amount"), CNY); + } + audit(customerId, settlementId, "CREATE", null, null, settlementSnapshot(total, BigDecimal.ZERO), operatorId); + complete(customerId, operatorId, "SETTLEMENT_AP_CREATE", key, settlementId); + return getSettlement(settlementId); + } + + public SettlementDetail getSettlement(long settlementId) { + if (settlementId <= 0) throw error("FIN_404", "结算单不存在"); + Map row = one( + "SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id,supplier_name_snapshot," + + "reconciliation_statement_id,business_period,currency_code,total_amount,paid_amount,outstanding_amount," + + "generated_date,due_date,payment_status,status,version FROM fin_settlement WHERE id=?", + "结算单不存在", settlementId); + SettlementSummary summary = summary(row); + List lines = jdbc.queryForList( + "SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," + + "f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " + + "FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " + + "WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId) + .stream().map(source -> line(source, summary)).toList(); + List transactions = jdbc.queryForList( + "SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_at,remark " + + "FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id", + summary.customerId(), settlementId).stream().map(this::transaction).toList(); + List> audits = jdbc.queryForList( + "SELECT action,reason,before_data,after_data,operator_id,created_at FROM fin_audit_log " + + "WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id", + summary.customerId(), settlementId); + List revisions = new ArrayList<>(); + for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index))); + return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions)); + } + + @Transactional + public SettlementDetail createSettlementPayment(long settlementId, long operatorId, String key, + CreateSettlementPaymentRequest request) { + validatePaymentRequest(request); + Map settlement = one( + "SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version " + + "FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId); + long customerId = number(settlement, "tenant_id").longValue(); + if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "仅应付结算单可登记付款"); + if (!List.of("ISSUED", "PARTIALLY_PAID").contains(text(settlement, "status"))) { + throw error("FIN_409", "结算单状态不允许付款"); + } + if (!CNY.equals(text(settlement, "currency_code"))) throw error("FIN_400", "结算单币种必须为CNY"); + if (number(settlement, "version").intValue() != request.expectedVersion()) throw error("FIN_409", "结算单版本冲突"); + BigDecimal outstanding = decimal(settlement, "outstanding_amount"); + if (request.amount().compareTo(outstanding) > 0) throw error("FIN_409", "付款金额不得超过未付余额"); + Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST", + "/api/platform/finance/v1/settlements/" + settlementId + "/transactions", request); + if (replay != null) return getSettlement(settlementId); + validateVoucherFiles(customerId, request.voucherFileIds()); + + insertPayment(customerId, settlementId, request, operatorId); + BigDecimal paid = decimal(settlement, "paid_amount").add(request.amount()); + BigDecimal remaining = outstanding.subtract(request.amount()); + String status = remaining.signum() == 0 ? "PAID" : "PARTIALLY_PAID"; + int changed = jdbc.update("UPDATE fin_settlement SET paid_amount=?,outstanding_amount=?,payment_status=?,status=?," + + "updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND version=?", + paid, remaining, status, status, operatorId, settlementId, customerId, request.expectedVersion()); + if (changed != 1) throw error("FIN_409", "结算单版本冲突"); + audit(customerId, settlementId, "PAYMENT", request.remark(), + settlementSnapshot(decimal(settlement, "total_amount"), decimal(settlement, "paid_amount")), + settlementSnapshot(decimal(settlement, "total_amount"), paid), operatorId); + complete(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, settlementId); + return getSettlement(settlementId); + } + + private long insertSettlement(long customerId, String no, String direction, String customerName, + Long supplierId, String supplierName, Long reconciliationId, + String businessPeriod, String currency, BigDecimal total, + LocalDate generatedDate, LocalDate dueDate, long operatorId) { + KeyHolder holder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO fin_settlement(tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id," + + "supplier_name_snapshot,reconciliation_statement_id,business_period,currency_code,total_amount," + + "paid_amount,outstanding_amount,generated_date,due_date,payment_status,status,version,created_by," + + "updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?,'UNPAID','ISSUED',0,?,?," + + "CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))", Statement.RETURN_GENERATED_KEYS); + statement.setLong(1, customerId); statement.setString(2, no); statement.setString(3, direction); + statement.setString(4, customerName); statement.setObject(5, supplierId); statement.setString(6, supplierName); + statement.setObject(7, reconciliationId); statement.setString(8, businessPeriod); statement.setString(9, currency); + statement.setBigDecimal(10, total); statement.setBigDecimal(11, total); statement.setDate(12, Date.valueOf(generatedDate)); + statement.setDate(13, Date.valueOf(dueDate)); statement.setLong(14, operatorId); statement.setLong(15, operatorId); + return statement; + }, holder); + if (holder.getKey() == null) throw error("FIN_001", "结算单创建后未返回主键"); + return holder.getKey().longValue(); + } + + private void insertPayment(long customerId, long settlementId, CreateSettlementPaymentRequest request, + long operatorId) { + jdbc.update("INSERT INTO fin_settlement_payment(tenant_id,settlement_id,transaction_type,transaction_no," + + "transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_at) " + + "VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))", + customerId, settlementId, documentNo("PAY"), request.transactionDate(), request.amount(), CNY, + request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()), operatorId); + } + + private void validatePayableRequest(CreatePayableSettlementRequest request) { + if (request == null || request.customerId() <= 0 || request.supplierId() <= 0 || request.dueDate() == null + || request.feeSourceIds() == null || request.feeSourceIds().isEmpty() + || request.feeSourceIds().stream().anyMatch(id -> id == null || id <= 0)) { + throw error("FIN_400", "应付结算请求字段不完整"); + } + if (new HashSet<>(request.feeSourceIds()).size() != request.feeSourceIds().size()) { + throw error("FIN_400", "费用来源ID不能重复"); + } + requireCny(request.currency()); + } + + private void validatePaymentRequest(CreateSettlementPaymentRequest request) { + if (request == null || request.transactionDate() == null || request.amount() == null || request.amount().signum() <= 0 + || request.expectedVersion() == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.method()) + || request.voucherFileIds() == null || request.voucherFileIds().isEmpty()) { + throw error("FIN_400", "付款请求字段不完整或金额无效"); + } + requireCny(request.currency()); + if (!PAYMENT_METHODS.contains(request.method())) throw error("FIN_400", "付款方式无效"); + if (new HashSet<>(request.voucherFileIds()).size() != request.voucherFileIds().size()) { + throw error("FIN_400", "付款凭证ID不能重复"); + } + if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "付款备注不能超过500字"); + } + + private void validateVoucherFiles(long customerId, List fileIds) { + if (fileClient == null || !StringUtils.hasText(fileToken)) throw error("FIN_503", "File服务校验能力不可用"); + for (String raw : fileIds) { + long fileId; + try { fileId = Long.parseLong(raw); } catch (RuntimeException invalid) { throw error("FIN_400", "付款凭证文件ID无效"); } + FinanceFileClient.Metadata metadata; + try { + var response = fileClient.metadata(fileToken, customerId, customerId, fileId); + metadata = response == null ? null : response.data(); + } catch (RuntimeException unavailable) { + throw error("FIN_503", "File服务校验不可用"); + } + if (metadata == null || metadata.fileId() == null || metadata.fileId() != fileId + || metadata.tenantId() == null || metadata.tenantId() != customerId + || !"FINANCE_VOUCHER".equals(metadata.businessType()) + || !String.valueOf(customerId).equals(metadata.businessId()) + || !StringUtils.hasText(metadata.contentType()) + || !metadata.contentType().toLowerCase(Locale.ROOT).startsWith("image/")) { + throw error("FIN_400", "付款凭证不存在、客户归属不匹配或文件类型无效"); + } + } + } + + private Long reserve(long customerId, long operatorId, String operation, String key, + String method, String path, Object body) { + if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key"); + String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body)); + List> rows = jdbc.queryForList( + "SELECT request_hash,status,result_resource_id FROM fin_idempotency_record " + + "WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", + customerId, operatorId, operation, key); + if (!rows.isEmpty()) { + Map row = rows.get(0); + if (!requestHash.equals(text(row, "request_hash"))) throw error("FIN_409", "Idempotency-Key已用于不同请求"); + if ("SUCCEEDED".equals(text(row, "status")) && row.get("result_resource_id") != null) { + return number(row, "result_resource_id").longValue(); + } + throw error("FIN_409", "相同请求正在处理中或等待恢复"); + } + jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) " + + "VALUES(?,?,?,?,?,'PROCESSING')", customerId, operatorId, operation, key, requestHash); + return null; + } + + private void complete(long customerId, long operatorId, String operation, String key, long resourceId) { + jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? " + + "AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", + resourceId, customerId, operatorId, operation, key); + } + + private void audit(long customerId, long resourceId, String action, String reason, + Object before, Object after, long operatorId) { + jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data," + + "operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + customerId, "FIN_SETTLEMENT", resourceId, action, reason, before == null ? null : json(before), + json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId")); + } + + private SettlementSummary summary(Map row) { + return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"), + text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"), + nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"), + nullableNumber(row, "reconciliation_statement_id"), text(row, "business_period"), + text(row, "currency_code"), decimal(row, "total_amount"), decimal(row, "paid_amount"), + decimal(row, "outstanding_amount"), date(row, "generated_date"), date(row, "due_date"), + text(row, "payment_status"), text(row, "status"), number(row, "version").intValue()); + } + + private SettlementLine line(Map row, SettlementSummary settlement) { + JsonNode snapshot = parseTree(text(row, "source_snapshot")); + BigDecimal quantity = jsonDecimalOrNull(snapshot, "chargeableQuantity"); + BigDecimal amount = decimal(row, "amount"); + BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null + : amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP); + return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"), + number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(), + text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"), + jsonLong(snapshot, "supplierId"), firstText(jsonText(snapshot, "supplierName"), settlement.supplierName()), + nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"), + jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"), + quantity, unitPrice, amount, text(row, "currency_code"), firstText(jsonText(snapshot, "reviewStatus"), "PENDING"), + jsonBoolean(snapshot, "settlementIncluded"), text(row, "status"), true); + } + + private SettlementTransaction transaction(Map row) { + return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"), + date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"), + stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(), + time(row, "created_at"), text(row, "remark")); + } + + private SettlementRevision revision(int revisionNo, Map row) { + JsonNode before = parseTree(text(row, "before_data")); + JsonNode after = parseTree(text(row, "after_data")); + return new SettlementRevision(revisionNo, text(row, "action"), jsonDecimal(before, "totalAmount"), + jsonDecimal(after, "totalAmount"), text(row, "reason"), number(row, "operator_id").longValue(), + time(row, "created_at")); + } + + private Map one(String sql, String message, Object... args) { + List> rows = jdbc.queryForList(sql, args); + if (rows.isEmpty()) throw error("FIN_404", message); + return rows.get(0); + } + + private static void requireCny(String currency) { + if (!CNY.equals(currency)) throw error("FIN_400", "一期仅支持CNY"); + } + + private static Map settlementSnapshot(BigDecimal totalAmount, BigDecimal paidAmount) { + return Map.of("totalAmount", totalAmount, "paidAmount", paidAmount, + "outstandingAmount", totalAmount.subtract(paidAmount)); + } + + private String documentNo(String prefix) { + return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + + UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT); + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json(value).getBytes(StandardCharsets.UTF_8))); + } catch (Exception failure) { + throw error("FIN_001", "幂等请求哈希失败"); + } + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception failure) { throw error("FIN_001", "财务数据序列化失败"); } + } + + private JsonNode parseTree(String value) { + if (!StringUtils.hasText(value)) return objectMapper.createObjectNode(); + try { return objectMapper.readTree(value); } + catch (Exception failure) { throw error("FIN_001", "财务快照数据损坏"); } + } + + private List stringList(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { return objectMapper.readValue(value, new TypeReference>() {}); } + catch (Exception failure) { throw error("FIN_001", "财务凭证数据损坏"); } + } + + private static String text(Map row, String key) { + Object value = row.get(key); + return value == null ? null : value.toString(); + } + private static Number number(Map row, String key) { + Object value = row.get(key); + if (value instanceof Number number) return number; + if (value == null) throw error("FIN_001", "财务字段缺失:" + key); + return new BigDecimal(value.toString()); + } + private static Long nullableNumber(Map row, String key) { + Object value = row.get(key); + return value == null ? null : (value instanceof Number number ? number.longValue() : Long.valueOf(value.toString())); + } + private static BigDecimal decimal(Map row, String key) { + Object value = row.get(key); + if (value == null) return BigDecimal.ZERO; + return value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString()); + } + private static LocalDate date(Map row, String key) { + Object value = row.get(key); + if (value instanceof Date date) return date.toLocalDate(); + if (value instanceof LocalDate date) return date; + return LocalDate.parse(value.toString()); + } + private static LocalDateTime time(Map row, String key) { + Object value = row.get(key); + if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime(); + if (value instanceof LocalDateTime time) return time; + return LocalDateTime.parse(value.toString()); + } + private static String blank(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static boolean truth(Object value) { return value != null && Boolean.parseBoolean(value.toString()); } + private static String jsonText(JsonNode node, String key) { + JsonNode value = node.path(key); + return value.isMissingNode() || value.isNull() ? null : value.asText(); + } + private static Long jsonLong(JsonNode node, String key) { + JsonNode value = node.path(key); + return value.isIntegralNumber() ? value.longValue() : null; + } + private static LocalDate jsonDate(JsonNode node, String key) { + String value = jsonText(node, key); + return StringUtils.hasText(value) ? LocalDate.parse(value) : null; + } + private static BigDecimal jsonDecimal(JsonNode node, String key) { + JsonNode value = node.path(key); + if (value.isNumber()) return value.decimalValue(); + if (value.isTextual() && StringUtils.hasText(value.asText())) return new BigDecimal(value.asText()); + return BigDecimal.ZERO; + } + private static BigDecimal jsonDecimalOrNull(JsonNode node, String key) { + JsonNode value = node.path(key); + if (value.isNumber()) return value.decimalValue(); + if (value.isTextual() && StringUtils.hasText(value.asText())) return new BigDecimal(value.asText()); + return null; + } + private static boolean jsonBoolean(JsonNode node, String key) { return node.path(key).asBoolean(false); } + private static String firstText(String first, String fallback) { return StringUtils.hasText(first) ? first : fallback; } + private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index c9e95bb4f..859c4ae9e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -109,7 +109,7 @@ public class ReconciliationStatementService { Transition transition = transition(action, before.status()); if ("VOID".equals(action)) requireNoReceipt(before.customerId(), statementId); snapshot(before, request.reason(), operatorId); - int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='APPROVED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='APPROVED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?", + int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?", transition.to(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId, transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion()); if (changed != 1) throw error("FIN-REC-409", "对账单状态或版本已变化"); @@ -134,7 +134,7 @@ public class ReconciliationStatementService { (rs, row) -> payment(rs), customerId, statementId); return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(), metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(), - bill.outstanding(), bill.outstanding().signum() == 0, bill.status(), bill.version(), bill.createdAt(), + bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), bill.createdAt(), bill.updatedAt(), bill.remark(), lines, payments); } @@ -272,10 +272,10 @@ public class ReconciliationStatementService { private static Transition transition(String action, String status) { return switch (action) { case "SUBMIT" -> requireTransition(status, "DRAFT", "PENDING_REVIEW"); - case "APPROVE" -> requireTransition(status, "PENDING_REVIEW", "APPROVED"); + case "APPROVE" -> requireTransition(status, "PENDING_REVIEW", "ISSUED"); case "REJECT" -> requireTransition(status, "PENDING_REVIEW", "REJECTED"); case "VOID" -> { - if (!Set.of("DRAFT", "PENDING_REVIEW", "REJECTED", "APPROVED").contains(status)) throw error("FIN-REC-409", "当前状态不允许作废"); + if (!Set.of("DRAFT", "PENDING_REVIEW", "REJECTED", "ISSUED").contains(status)) throw error("FIN-REC-409", "当前状态不允许作废"); yield new Transition(status, "VOIDED"); } default -> throw error("FIN-REQ-400", "对账单动作无效"); @@ -287,6 +287,10 @@ public class ReconciliationStatementService { return new Transition(expected, target); } + private static String reconciliationStatus(String billStatus) { + return "ISSUED".equals(billStatus) ? "APPROVED" : billStatus; + } + private static Source source(ResultSet rs) throws SQLException { return new Source(rs.getLong("id"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"), diff --git a/finance/src/main/resources/db/fee-source-event-migration.sql b/finance/src/main/resources/db/fee-source-event-migration.sql index 56ab96e0d..f9d2487eb 100644 --- a/finance/src/main/resources/db/fee-source-event-migration.sql +++ b/finance/src/main/resources/db/fee-source-event-migration.sql @@ -33,6 +33,16 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_hash', "CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code"); CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot', "JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash"); +CALL finance_fee_source_add_column('fin_fee_source', 'review_status', + "VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot"); +CALL finance_fee_source_add_column('fin_fee_source', 'review_reason', + "VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status"); +CALL finance_fee_source_add_column('fin_fee_source', 'settlement_included', + "TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是' AFTER review_reason"); +CALL finance_fee_source_add_column('fin_fee_source', 'settlement_reason', + "VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明' AFTER settlement_included"); +CALL finance_fee_source_add_column('fin_fee_source', 'operation_version', + "INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号' AFTER settlement_reason"); CALL finance_fee_source_add_column('fin_fee_source', 'updated_at', "DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at"); CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash', @@ -51,7 +61,10 @@ UPDATE fin_fee_source fee_item_code = COALESCE(fee_item_code, 'LEGACY'), source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)), source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)), - updated_at = COALESCE(updated_at, created_at); + updated_at = COALESCE(updated_at, created_at), + review_status = COALESCE(review_status, 'PENDING'), + settlement_included = COALESCE(settlement_included, 0), + operation_version = COALESCE(operation_version, 0); UPDATE fin_inbox_event SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256)); @@ -74,7 +87,8 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source' AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code', - 'source_hash','source_snapshot','updated_at')) <> 8 THEN + 'source_hash','source_snapshot','review_status','review_reason', + 'settlement_included','settlement_reason','operation_version','updated_at')) <> 13 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 9e60d2adb..06088119a 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -20,6 +20,11 @@ CREATE TABLE IF NOT EXISTS fin_fee_source ( currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希', source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', + review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', + review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因', + settlement_included TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是', + settlement_reason VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明', + operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号', status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新', diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index fd53990b2..6b0338f65 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -18,6 +18,9 @@ import org.springframework.jdbc.core.RowMapper; import java.math.BigDecimal; import java.sql.ResultSet; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -29,6 +32,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -67,16 +71,46 @@ class FinancePhase1OperationBehaviorTest { @Test void ledgerRejectsInvalidCustomerBeforeAnyQuery() { - FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), new ObjectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper()); assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null)) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); } + @Test + void accountListReturnsFixedPagedDto() { + AccountJdbc jdbc = new AccountJdbc(); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + PageEnvelope result = + service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE"); + + assertThat(result.data()).singleElement().satisfies(account -> { + assertThat(account.customerId()).isEqualTo(9L); + assertThat(account.customerName()).isEqualTo("客户9"); + assertThat(account.outstandingReceivable()).isEqualByComparingTo("100.00"); + }); + assertThat(result.page().total()).isEqualTo(1); + assertThat(result.requestId()).isNotBlank(); + assertThat(result.traceId()).isNotBlank(); + } + + @Test + void ledgerQueriesEveryDerivedSourceWithinCustomerScope() { + AccountJdbc jdbc = new AccountJdbc(); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + PageEnvelope result = + service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); + + assertThat(result.data()).isEmpty(); + assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L); + } + @Test void rejectedFeeCannotBeIncludedInSettlement() throws Exception { JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, new ObjectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); assertThatThrownBy(() -> service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key", new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 2))) @@ -84,6 +118,46 @@ class FinancePhase1OperationBehaviorTest { .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-FEE-409"); } + @Test + void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception { + FinancePhase1OperationService service = new FinancePhase1OperationService( + feeSourceJdbc("APPROVED", true, 2), objectMapper()); + + FeeSourceDetail detail = service.getFinanceFeeSource(88L); + + assertThat(detail.feeSourceId()).isEqualTo(88L); + assertThat(detail.customerId()).isEqualTo(9L); + assertThat(detail.currency()).isEqualTo("CNY"); + assertThat(detail.reviewStatus()).isEqualTo("APPROVED"); + assertThat(detail.settlementIncluded()).isTrue(); + } + + @Test + void approvedFeeCanBeIncludedWithExpectedVersion() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) + ? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation)); + AtomicInteger reads = new AtomicInteger(); + when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + boolean included = reads.getAndIncrement() > 0; + return List.of(mapper.mapRow(resultSet("APPROVED", included, included ? 2 : 1), 0)); + }); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(700L), eq(3))).thenReturn(false); + when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), + eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + FeeSourceDetail result = service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key", + new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 1)); + + assertThat(result.settlementIncluded()).isTrue(); + assertThat(result.operationVersion()).isEqualTo(2); + verify(jdbc).update(startsWith("UPDATE fin_fee_source SET settlement_included="), + eq(true), eq("进入结算"), eq(88L), eq(1)); + } + @Test void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); @@ -96,12 +170,18 @@ class FinancePhase1OperationBehaviorTest { }); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(700L), eq(3))).thenReturn(false); when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of()); - when(jdbc.queryForList(startsWith("SELECT request_hash"), any(), any(), any(), any())).thenReturn(List.of()); - when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), (Object[]) any())).thenReturn(1); - when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="), (Object[]) any())).thenReturn(1); - when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), (Object[]) any())).thenReturn(1); - when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any())).thenReturn(1); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, new ObjectMapper()); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), + eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of()); + when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), + eq("FEE_SOURCE_REVIEW"), eq("review-key"), anyString())).thenReturn(1); + when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="), + eq("APPROVED"), eq("核对无误"), eq(88L), eq(0))).thenReturn(1); + when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), + eq(88L), eq("FEE_SOURCE_REVIEW_APPROVE"), eq("核对无误"), anyString(), anyString(), + eq(71L), eq("operator-71"), any(), any())).thenReturn(1); + when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), + eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key", new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0)); @@ -119,7 +199,8 @@ class FinancePhase1OperationBehaviorTest { }); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(700L), eq(3))).thenReturn(false); when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of()); - when(jdbc.queryForList(anyString(), any(), any(), any(), any())).thenReturn(List.of()); + lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), + eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of()); return jdbc; } @@ -146,4 +227,35 @@ class FinancePhase1OperationBehaviorTest { when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"settlementIncluded\":" + included + ",\"operationVersion\":" + version + "}"); return rs; } + + private static ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + + private static final class AccountJdbc extends JdbcTemplate { + private final List ledgerCustomerIds = new ArrayList<>(); + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + if (sql.startsWith("SELECT COUNT(*)")) return requiredType.cast(1L); + return null; + } + + @Override + @SuppressWarnings("unchecked") + public List query(String sql, RowMapper rowMapper, Object... args) { + if (sql.startsWith("SELECT c.customer_id")) { + return (List) List.of(new FinancePhase1Dtos.AccountSummary( + 9L, "9", "客户9", null, null, new BigDecimal("100.00"), + new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, + "ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0))); + } + if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no") + || sql.startsWith("SELECT id,cost_no")) { + ledgerCustomerIds.add(((Number) Arrays.asList(args).get(0)).longValue()); + return List.of(); + } + return List.of(); + } + } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java new file mode 100644 index 000000000..18331c671 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -0,0 +1,181 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.controller.FinanceSettlementController; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceSettlementService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FinanceSettlementBehaviorTest { + @Mock FinanceSettlementService controllerService; + + @Test + void controllerReturnsFixedEnvelopeAndEnforcesDirectionPermission() { + FinanceSettlementController controller = new FinanceSettlementController(controllerService); + SettlementDetail payable = detail("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0); + when(controllerService.getSettlement(88L)).thenReturn(payable); + AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:view")); + + var response = controller.getSettlement(apViewer, 88L); + + assertThat(response.code()).isEqualTo("200"); + assertThat(response.data()).isSameAs(payable); + assertThat(response.requestId()).isNotBlank(); + assertThatThrownBy(() -> controller.getSettlement( + user(Set.of("platform:finance:settlement-ar:view")), 88L)).isInstanceOf(AccessDeniedException.class); + } + + @Test + void arCreationRejectsNonCnyBeforeDatabaseAccess() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.createReceivableSettlement(7L, "ar-key", + new CreateReceivableSettlementRequest(9L, "USD")), "FIN_400"); + + verify(jdbc, never()).queryForList(anyString(), (Object[]) any()); + } + + @Test + void payableCreationRejectsDuplicateSourcesBeforeIdempotencyReservation() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.createPayableSettlement(7L, "ap-key", + new CreatePayableSettlementRequest(10L, 20L, List.of(3L, 3L), LocalDate.now(), "CNY")), "FIN_400"); + + verify(jdbc, never()).update(startsWith("INSERT INTO fin_idempotency_record"), (Object[]) any()); + } + + @Test + void paymentFailsClosedWhenFileServiceIsUnavailable() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))) + .thenReturn(List.of(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0))); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of()); + when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenThrow(new IllegalStateException("down")); + FinanceSettlementService service = service(jdbc, fileClient); + + assertCode(() -> service.createSettlementPayment(88L, 7L, "pay-key", + payment(new BigDecimal("40.00"), 0)), "FIN_503"); + + verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any()); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET paid_amount"), (Object[]) any()); + } + + @Test + void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) + ? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class); + Map before = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0); + Map after = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), + new BigDecimal("60.00"), 1); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))).thenReturn(List.of(before)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(88L))).thenReturn(List.of(after)); + when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.of( + "id", 501L, "transaction_type", "PAYMENT", "transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11)), + "amount", new BigDecimal("40.00"), "method", "BANK_TRANSFER", "reference_no", "BANK-1", + "voucher_file_ids", "[\"91\"]", "created_by", 7L, + "created_at", Timestamp.valueOf(LocalDateTime.of(2026, 8, 11, 10, 0)), "remark", "首笔付款"))); + when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(10L), eq(88L))).thenReturn(List.of()); + when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L))); + FinanceSettlementService service = service(jdbc, fileClient); + + SettlementDetail result = service.createSettlementPayment(88L, 7L, "pay-key", + payment(new BigDecimal("40.00"), 0)); + + assertThat(result.summary().status()).isEqualTo("PARTIALLY_PAID"); + assertThat(result.summary().paidAmount()).isEqualByComparingTo("40.00"); + assertThat(result.summary().outstandingAmount()).isEqualByComparingTo("60.00"); + assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType()) + .isEqualTo("PAYMENT"); + verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"), + any(), any(), any(), any(), any(), any(), any(), any()); + } + + private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) { + FinanceSettlementService service = new FinanceSettlementService( + jdbc, new ObjectMapper().findAndRegisterModules(), fileClient); + ReflectionTestUtils.setField(service, "fileToken", "internal-token"); + return service; + } + + private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) { + return new CreateSettlementPaymentRequest(LocalDate.of(2026, 8, 11), amount, "CNY", "BANK_TRANSFER", + "BANK-1", List.of("91"), version, "首笔付款"); + } + + private static Map settlementRow(String direction, String status, BigDecimal paid, + BigDecimal outstanding, int version) { + return new java.util.HashMap<>(Map.ofEntries( + Map.entry("id", 88L), Map.entry("tenant_id", 10L), Map.entry("settlement_no", "AP-20260811-001"), + Map.entry("direction", direction), Map.entry("customer_name_snapshot", "客户10"), + Map.entry("supplier_id", 20L), Map.entry("supplier_name_snapshot", "供应商20"), + Map.entry("business_period", "2026-08"), Map.entry("currency_code", "CNY"), + Map.entry("total_amount", new BigDecimal("100.00")), Map.entry("paid_amount", paid), + Map.entry("outstanding_amount", outstanding), Map.entry("generated_date", Date.valueOf("2026-08-11")), + Map.entry("due_date", Date.valueOf("2026-09-10")), + Map.entry("payment_status", paid.signum() == 0 ? "UNPAID" : status), Map.entry("status", status), + Map.entry("version", version))); + } + + private static SettlementDetail detail(String direction, String status, BigDecimal paid, + BigDecimal outstanding, int version) { + return new SettlementDetail(new SettlementSummary(88L, "SET-88", direction, 10L, "客户10", 20L, + "供应商20", null, "2026-08", "CNY", paid.add(outstanding), paid, outstanding, + LocalDate.of(2026, 8, 11), LocalDate.of(2026, 9, 10), paid.signum() == 0 ? "UNPAID" : status, + status, version), List.of(), List.of(), List.of()); + } + + private static AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, null, null, null, "finance", "ADMIN", "WEB", permissions); + } + + private static void assertCode(org.assertj.core.api.ThrowableAssert.ThrowingCallable call, String code) { + assertThatThrownBy(call).isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo(code); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java new file mode 100644 index 000000000..983b13b88 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -0,0 +1,257 @@ +package com.baiyue.finance; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.controller.ReconciliationStatementController; +import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest; +import com.baiyue.finance.dto.ReconciliationStatementDtos.DocumentActionRequest; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.ReconciliationStatementService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.KeyHolder; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.RETURNS_DEFAULTS; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReconciliationStatementBehaviorTest { + @Mock ReconciliationStatementService service; + + @Test + void controllerUsesAuthenticatedOperatorAndStableTraceResponse() { + ReconciliationStatementController controller = new ReconciliationStatementController(service); + AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of()); + CreateReconciliationRequest create = new CreateReconciliationRequest(9L, "SELECTED", List.of(88L), + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账"); + DocumentActionRequest action = new DocumentActionRequest("SUBMIT", "提交审核", 0); + + var created = controller.createReconciliationStatement(user, "create-key", create); + var acted = controller.actionReconciliationStatement(user, 501L, "action-key", action); + + verify(service).create(71L, "create-key", create); + verify(service).action(71L, 501L, "action-key", action); + assertThat(created.requestId()).isNotBlank(); + assertThat(created.traceId()).isNotBlank(); + assertThat(acted.requestId()).isNotBlank(); + assertThat(acted.traceId()).isNotBlank(); + } + + @Test + void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "SELECTED", List.of(88L, 88L), + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null); + + assertThatThrownBy(() -> target.create(71L, "create-key", request)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); + verifyNoInteractions(jdbc); + } + + @Test + void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception { + JdbcTemplate jdbc = createJdbc(); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "SELECTED", List.of(88L), + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账"); + + var result = target.create(71L, "create-key", request); + + assertThat(result.statementId()).isEqualTo(501L); + assertThat(result.statementNo()).isEqualTo("REC-00000000000000000501"); + assertThat(result.currency()).isEqualTo("CNY"); + assertThat(result.totalReceivable()).isEqualByComparingTo("100.00"); + assertThat(result.lines()).hasSize(1); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)); + } + + @Test + void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception { + JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key"); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + var result = target.action(71L, 501L, "submit-key", + new DocumentActionRequest("SUBMIT", "提交审核", 0)); + + assertThat(result.customerId()).isEqualTo(9L); + assertThat(result.status()).isEqualTo("PENDING_REVIEW"); + assertThat(result.version()).isEqualTo(1); + assertThat(result.periodStart()).isEqualTo(LocalDate.of(2026, 8, 1)); + verify(jdbc).update(anyString(), eq(9L), eq(501L), eq(0), eq("提交审核"), anyString(), anyString(), eq(71L)); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), + eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq(71L), + eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq(71L), eq("PENDING_REVIEW"), + eq("提交审核"), eq(71L), eq(501L), eq(9L), eq("DRAFT"), eq(0)); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), (Object[]) any()); + } + + @Test + void approveFreezesInternalBillAndReturnsContractStatus() throws Exception { + JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "ISSUED", "approve-key"); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + var result = target.action(71L, 501L, "approve-key", + new DocumentActionRequest("APPROVE", "审核通过", 1)); + + assertThat(result.status()).isEqualTo("APPROVED"); + assertThat(result.version()).isEqualTo(2); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)); + } + + @Test + void sameIdempotencyKeyWithDifferentActionBodyIsRejected() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(billResultSet("DRAFT", 0), 0)); + }); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of( + "request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L, + "updated_at", LocalDateTime.now()))); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + assertThatThrownBy(() -> target.action(71L, 501L, "used-key", + new DocumentActionRequest("SUBMIT", "提交审核", 0))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-IDEM-409"); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_bill_revision"), (Object[]) any()); + } + + private static JdbcTemplate actionJdbc(String beforeStatus, String afterStatus, String key) throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> { + if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1; + return RETURNS_DEFAULTS.answer(invocation); + }); + when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(billResultSet(beforeStatus, "PENDING_REVIEW".equals(beforeStatus) ? 1 : 0), 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(billResultSet(afterStatus, "ISSUED".equals(afterStatus) ? 2 : 1), 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT after_data"), any(RowMapper.class), eq(9L), eq(501L))) + .thenReturn(List.of("{\"generationMode\":\"SELECTED\",\"periodStart\":\"2026-08-01\",\"periodEnd\":\"2026-08-31\"}")); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT f.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq(key))).thenReturn(List.of()); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT * FROM fin_bill_line"), eq(9L), eq(501L))).thenReturn(List.of()); + return jdbc; + } + + private static JdbcTemplate createJdbc() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> { + if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1; + return RETURNS_DEFAULTS.answer(invocation); + }); + LocalDate start = LocalDate.of(2026, 8, 1); + LocalDate end = LocalDate.of(2026, 8, 31); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getString("customer_name_snapshot")).thenReturn("客户9"); + when(rs.getInt("credit_days")).thenReturn(30); + return List.of(mapper.mapRow(rs, 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id FROM fin_subject"), any(RowMapper.class))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong(1)).thenReturn(1L); + return List.of(mapper.mapRow(rs, 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class), + eq(9L), eq(start), eq(end), eq(88L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(sourceResultSet(), 0)); + }); + org.mockito.Mockito.lenient().doAnswer(invocation -> { + KeyHolder holder = invocation.getArgument(1); + holder.getKeyList().add(Map.of("GENERATED_KEY", 501L)); + return 1; + }).when(jdbc).update(any(PreparedStatementCreator.class), any(KeyHolder.class)); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = billResultSet("DRAFT", 0); + when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("100.00")); + when(rs.getBigDecimal("outstanding_amount")).thenReturn(new BigDecimal("100.00")); + return List.of(mapper.mapRow(rs, 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT after_data"), any(RowMapper.class), eq(9L), eq(501L))) + .thenReturn(List.of("{\"generationMode\":\"SELECTED\",\"periodStart\":\"2026-08-01\",\"periodEnd\":\"2026-08-31\"}")); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT f.id"), any(RowMapper.class), eq(9L), eq(501L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(sourceResultSet(), 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("create-key"))).thenReturn(List.of()); + return jdbc; + } + + private static ResultSet sourceResultSet() throws Exception { + ResultSet rs = mock(ResultSet.class, org.mockito.Mockito.withSettings().lenient()); + when(rs.getLong("id")).thenReturn(88L); + when(rs.getLong("source_fee_id")).thenReturn(700L); + when(rs.getInt("source_fee_version")).thenReturn(3); + when(rs.getString("source_type")).thenReturn("INBOUND_FEE"); + when(rs.getString("source_no")).thenReturn("IN-202608-1"); + when(rs.getString("direction")).thenReturn("RECEIVABLE"); + when(rs.getLong("customer_id")).thenReturn(9L); + when(rs.getLong("order_id")).thenReturn(300L); + when(rs.getLong("inbound_id")).thenReturn(400L); + when(rs.getLong("container_id")).thenReturn(0L); + when(rs.getString("fee_item_code")).thenReturn("FREIGHT"); + when(rs.getString("fee_item_name")).thenReturn("头程运费"); + when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00")); + when(rs.getString("currency_code")).thenReturn("CNY"); + when(rs.getString("status")).thenReturn("LOCKED"); + return rs; + } + + private static ResultSet billResultSet(String status, int version) throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(501L); + when(rs.getLong("tenant_id")).thenReturn(9L); + when(rs.getString("bill_no")).thenReturn("REC-00000000000000000501"); + when(rs.getString("customer_name_snapshot")).thenReturn("客户9"); + when(rs.getString("status")).thenReturn(status); + when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00")); + when(rs.getBigDecimal("received_amount")).thenReturn(BigDecimal.ZERO); + when(rs.getBigDecimal("outstanding_amount")).thenReturn(new BigDecimal("300.00")); + when(rs.getInt("version")).thenReturn(version); + when(rs.getInt("credit_days")).thenReturn(30); + when(rs.getString("remark")).thenReturn("八月对账"); + Timestamp timestamp = Timestamp.valueOf("2026-08-11 10:00:00"); + when(rs.getTimestamp("created_at")).thenReturn(timestamp); + when(rs.getTimestamp("updated_at")).thenReturn(timestamp); + return rs; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java index 7e72bf142..0dc4222f3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.client; import com.baiyue.common.core.api.ApiResponse; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -38,6 +40,13 @@ public interface WarehousePalletLocationClient { ApiResponse settle(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody SettlementRequest request); + @GetMapping("/occupied-resources") + ApiResponse> occupiedResources( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("tenantId") long tenantId, + @RequestParam("sourceService") String sourceService, + @RequestParam("businessId") long businessId); + record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId, String inboundCode, String idempotencyKey, long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType, @@ -75,4 +84,7 @@ public interface WarehousePalletLocationClient { String operatorName, String requestId, List releasedReservationIds, List remainingItems) {} record SettlementResult(List released, List transferred) {} + record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId, + long palletId, long locationId, String locationCode, boolean sharedOverflow, + int allocatedPackageCount) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index e42b00fc8..9a7f5ffbd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; import java.util.List; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.service.FirstlegScopeGuard; @@ -48,6 +49,15 @@ public class PlatformLoadingPdaController { return ApiResponse.success(service.task(containerNo, warehouseId)); } + @GetMapping("/tasks/{taskId}/inventory-snapshot") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse inventorySnapshot(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.inventorySnapshot(taskId, warehouseId)); + } + @PostMapping("/tasks/{taskId}/claim") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") public ApiResponse claim(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index e23e5f5ff..536fd8f9a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -60,6 +60,7 @@ public final class LoadingPickDtos { @NotNull @Min(1) Long warehouseId, @NotBlank @Size(max = 64) String containerNo, @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 64) String inventorySnapshotToken, @NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items ) { } @@ -118,4 +119,18 @@ public final class LoadingPickDtos { String workflowStatus, Integer version, Assignment assignment, Review review, List items) { } + + public record InventoryResource(Long reservationId, Long occupancyId, Long palletId, + Long locationId, String locationCode, Boolean sharedOverflow, + Integer availablePackageCount) { + } + + public record InboundInventory(Long inboundId, Integer availablePackageCount, + List resources) { + } + + public record InventorySnapshot(Long taskId, Integer taskVersion, String containerNo, + Long warehouseId, LocalDateTime capturedAt, String snapshotToken, + List inbounds) { + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 935829b4f..0919e9f0c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -1,7 +1,9 @@ package com.baiyue.firstleg.service; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; @@ -15,6 +17,9 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource; +import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory; +import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; @@ -26,6 +31,8 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import jakarta.annotation.Resource; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,10 +50,13 @@ import java.util.HashMap; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import java.time.LocalDateTime; /** 装柜中 PDA 拣货任务及明细同步。 */ @Service public class LoadingPickService { + private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L; + private static final String SOURCE_SERVICE = "firstleg-service"; @Resource private LoadingPickRepository repository; @Resource @@ -55,6 +65,10 @@ public class LoadingPickService { private LoadingListService loadingListService; @Resource private PdaPickSettlementService settlementService; + @Resource + private WarehousePalletLocationClient warehouse; + @Value("${baiyue.security.internal-service-token:test-internal-service-token}") + private String internalToken; @Transactional public void begin(long planId, String containerNo, long operatorId, String operatorName, @@ -105,6 +119,12 @@ public class LoadingPickService { return view(task, warehouseId); } + public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) { + LoadingPickTaskEntity task = requiredTask(taskId); + List items = warehouseScopedItems(task, warehouseId); + return buildInventorySnapshot(task, warehouseId, items); + } + @Transactional public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) { String operationId = operation(request.operationId()); @@ -275,6 +295,7 @@ public class LoadingPickService { } List lines = validateBatchLines(task, request); + validateInventorySnapshot(task, request, lines); for (BatchLine line : lines) { applyBatchLine(taskId, line, operationId, operatorId, operatorName); } @@ -554,6 +575,93 @@ public class LoadingPickService { return result; } + private List warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) { + List allItems = repository.items(task.getId()); + List scopedItems = repository.items(task.getId(), warehouseId); + if (allItems.isEmpty() || scopedItems.size() != allItems.size()) { + throw conflict("PDA任务柜次、仓库或客户归属不一致"); + } + return scopedItems; + } + + private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId, + List items) { + List inbounds = items.stream().map(LoadingPickItemEntity::getInboundId) + .filter(Objects::nonNull).distinct().sorted().map(inboundId -> { + List current = occupiedResources(inboundId); + List resources = current.stream() + .filter(resource -> resource.warehouseId() == warehouseId) + .sorted(Comparator.comparingLong(WarehousePalletLocationClient.OccupiedResource::reservationId) + .thenComparingLong(WarehousePalletLocationClient.OccupiedResource::occupancyId)) + .map(resource -> new InventoryResource(resource.reservationId(), resource.occupancyId(), + resource.palletId(), resource.locationId(), resource.locationCode(), + resource.sharedOverflow(), resource.allocatedPackageCount())) + .toList(); + int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum(); + return new InboundInventory(inboundId, available, resources); + }).toList(); + String token = inventoryToken(task, warehouseId, inbounds); + return new InventorySnapshot(task.getId(), task.getVersion(), task.getContainerNo(), warehouseId, + LocalDateTime.now(), token, inbounds); + } + + private List occupiedResources(long inboundId) { + ApiResponse> response; + try { + response = warehouse.occupiedResources(internalToken, PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID, + SOURCE_SERVICE, inboundId); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储实时库存查询失败,请刷新后重试"); + } + if (response == null || !response.successful() || response.data() == null) { + ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()); + throw new BizException(code, response == null || response.message() == null + ? "仓储实时库存查询失败,请刷新后重试" : response.message()); + } + return response.data(); + } + + private static String inventoryToken(LoadingPickTaskEntity task, long warehouseId, + List inbounds) { + StringBuilder canonical = new StringBuilder("INVENTORY|").append(task.getId()).append('|') + .append(task.getVersion()).append('|').append(warehouseId); + for (InboundInventory inbound : inbounds) { + canonical.append('|').append(inbound.inboundId()).append('|').append(inbound.availablePackageCount()); + for (InventoryResource resource : inbound.resources()) { + canonical.append('|').append(resource.reservationId()).append('|').append(resource.occupancyId()) + .append('|').append(resource.locationId()).append('|').append(resource.availablePackageCount()); + } + } + return hash(canonical.toString()); + } + + private void validateInventorySnapshot(LoadingPickTaskEntity task, BatchRequest request, + List lines) { + List allItems = warehouseScopedItems(task, request.warehouseId()); + InventorySnapshot current = buildInventorySnapshot(task, request.warehouseId(), allItems); + if (!current.snapshotToken().equals(request.inventorySnapshotToken().trim())) { + throw conflict("库存已变化,请刷新后重试"); + } + Map availableByInbound = current.inbounds().stream().collect(Collectors.toMap( + InboundInventory::inboundId, InboundInventory::availablePackageCount)); + Map requestedByInbound = new HashMap<>(); + Map lineByItem = lines.stream().collect(Collectors.toMap( + line -> line.item().getId(), line -> line)); + for (LoadingPickItemEntity item : allItems) { + BatchLine line = lineByItem.get(item.getId()); + int picked = line == null ? count(item.getPickedPackageCount()) + : "PICK".equals(line.action()) + ? count(item.getPickedPackageCount()) + line.request().pickedPackageCount() + : "PARTIAL".equals(line.action()) ? line.request().finalPickedPackageCount() : 0; + requestedByInbound.merge(item.getInboundId(), picked, Integer::sum); + } + requestedByInbound.forEach((inboundId, requested) -> { + if (requested > availableByInbound.getOrDefault(inboundId, 0)) { + throw conflict("库存已变化,请刷新后重试"); + } + }); + } + private void applyBatchLine(long taskId, BatchLine line, String batchOperationId, long operatorId, String operatorName) { LoadingPickItemEntity item = line.item(); @@ -690,12 +798,13 @@ public class LoadingPickService { private static String batchRequestHash(long taskId, BatchRequest request) { if (request == null || request.warehouseId() == null || !StringUtils.hasText(request.containerNo()) - || request.expectedVersion() == null || request.items() == null || request.items().isEmpty()) { + || request.expectedVersion() == null || !StringUtils.hasText(request.inventorySnapshotToken()) + || request.items() == null || request.items().isEmpty()) { throw new BizException(ResultCode.BAD_REQUEST, "整批提交参数不完整"); } StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|') .append(request.warehouseId()).append('|').append(request.containerNo().trim()).append('|') - .append(request.expectedVersion()); + .append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim()); request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId, Comparator.nullsFirst(Long::compareTo))) .forEach(item -> { diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 22f7edc2c..433f2f6a1 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -20,6 +20,16 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}} responses: '200': {description: Claimed task or A0409 when another operator owns it} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/inventory-snapshot: + get: + summary: Read the current Warehouse inventory snapshot before atomic submission + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: Current occupied resources grouped by inbound order with a stable snapshot token + content: {application/json: {schema: {$ref: '#/components/schemas/InventorySnapshot'}}} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/assign: post: summary: Assign or reassign a task with optimistic locking @@ -91,11 +101,12 @@ components: operationId: {type: string, maxLength: 128} BatchRequest: type: object - required: [warehouseId, containerNo, expectedVersion, items] + required: [warehouseId, containerNo, expectedVersion, inventorySnapshotToken, items] properties: warehouseId: {type: integer, format: int64, minimum: 1} containerNo: {type: string, maxLength: 64} expectedVersion: {type: integer, minimum: 0} + inventorySnapshotToken: {type: string, minLength: 64, maxLength: 64} items: type: array minItems: 1 @@ -122,3 +133,36 @@ components: expectedTaskVersion: {type: integer, minimum: 0} operationId: {type: string, maxLength: 128} remark: {type: string, maxLength: 500} + InventorySnapshot: + type: object + required: [taskId, taskVersion, containerNo, warehouseId, capturedAt, snapshotToken, inbounds] + properties: + taskId: {type: integer, format: int64} + taskVersion: {type: integer, minimum: 0} + containerNo: {type: string} + warehouseId: {type: integer, format: int64} + capturedAt: {type: string, format: date-time} + snapshotToken: {type: string, minLength: 64, maxLength: 64} + inbounds: + type: array + items: {$ref: '#/components/schemas/InboundInventory'} + InboundInventory: + type: object + required: [inboundId, availablePackageCount, resources] + properties: + inboundId: {type: integer, format: int64} + availablePackageCount: {type: integer, minimum: 0} + resources: + type: array + items: {$ref: '#/components/schemas/InventoryResource'} + InventoryResource: + type: object + required: [reservationId, occupancyId, palletId, locationId, sharedOverflow, availablePackageCount] + properties: + reservationId: {type: integer, format: int64} + occupancyId: {type: integer, format: int64} + palletId: {type: integer, format: int64} + locationId: {type: integer, format: int64} + locationCode: {type: string, nullable: true} + sharedOverflow: {type: boolean} + availablePackageCount: {type: integer, minimum: 1} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index a665f0b08..fae9ad829 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -15,6 +15,7 @@ class PdaConvergenceOpenApiContractTest { assertThat(yaml) .contains("/api/platform/v1/first-leg/pda-loading/tasks:") .contains("/tasks/{taskId}/claim:") + .contains("/tasks/{taskId}/inventory-snapshot:") .contains("/tasks/{taskId}/assign:") .contains("/tasks/{taskId}/items/{itemId}/pick:") .contains("/tasks/{taskId}/items/{itemId}/partial:") @@ -24,6 +25,8 @@ class PdaConvergenceOpenApiContractTest { .contains("/tasks/{taskId}/operations:") .contains("expectedAssignmentVersion") .contains("BatchRequest") + .contains("inventorySnapshotToken") + .contains("InventorySnapshot") .contains("expectedTaskVersion"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index 156dbddbf..7d0cb03f8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -13,6 +13,7 @@ class PlatformLoadingPdaControllerContractTest { @Test void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception { authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, long.class); + authority("inventorySnapshot", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class); authority("assign", "platform:firstleg:pda:assign", AuthenticatedUser.class, long.class, AssignRequest.class); authority("pick", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, PickRequest.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index e989d2083..8ecee2989 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -8,6 +8,8 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; @@ -44,6 +46,8 @@ class LoadingPickServiceTest { private LoadingListService loadingListService; @MockBean private PdaPickSettlementService settlementService; + @MockBean + private WarehousePalletLocationClient warehouse; @Resource private LoadingPickService service; @@ -219,7 +223,11 @@ class LoadingPickServiceTest { when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.touchTask(20L, 0)).thenReturn(1); when(repository.allPlanItemsPicked(10L)).thenReturn(true); - BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, List.of( + when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( + ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( + 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); + String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); + BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, snapshotToken, List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton"))); @@ -249,7 +257,7 @@ class LoadingPickServiceTest { when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L)).thenReturn(items); when(repository.items(20L, 101L)).thenReturn(items); - BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, List.of( + BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null))); @@ -262,12 +270,36 @@ class LoadingPickServiceTest { verify(settlementService, never()).settle(20L, 9L, "operator"); } + @Test + void rejectsChangedInventorySnapshotBeforeWritingAnyBatchLine() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity picked = item(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(List.of(picked)); + when(repository.items(20L, 101L)).thenReturn(List.of(picked)); + when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( + ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( + 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); + BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, "0".repeat(64), List.of( + new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null))); + + assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("库存已变化"); + + verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(), + any(Integer.class), any(Long.class)); + verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); + verify(settlementService, never()).settle(20L, 9L, "operator"); + } + @Test void rejectsBatchTaskVersionConflictBeforeWriting() { LoadingPickTaskEntity task = task(); task.setVersion(3); when(repository.taskForUpdate(20L)).thenReturn(task); - BatchRequest request = new BatchRequest(101L, "CSLU1234567", 2, List.of( + BatchRequest request = new BatchRequest(101L, "CSLU1234567", 2, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index d3ff65a11..dd81baec1 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -200,12 +200,22 @@ export async function run(context) { claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushed?.version) + 1, `status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS) + const inventoryResponse = await context.request('GET', + `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS) + context.assert(MODULE, '实时库存快照包含稳定令牌和可用箱数', inventoryResponse, + typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64 + && inventory?.inbounds?.some?.((item) => Number(item.availablePackageCount) >= 1), + `token=${inventory?.snapshotToken ? 'present' : 'missing'}, inboundCount=${inventory?.inbounds?.length ?? 0}`, OPTIONS) + if (!inventory?.snapshotToken) return + const batchPath = `${PDA}/tasks/${taskId}/batch` const batchItems = [{ itemId: id(pickItem.id), action: 'PICK', pickedPackageCount: 1, finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], version: Number(pickItem.version), reason: null }] const staleBatchResponse = await request(context, 'POST', batchPath, { - warehouseId, containerNo, expectedVersion: Number(task.version) + 1, items: batchItems, + warehouseId, containerNo, expectedVersion: Number(task.version) + 1, + inventorySnapshotToken: inventory.snapshotToken, items: batchItems, }, { 'Idempotency-Key': `${context.marker}-pda-batch-stale`.slice(0, 128) }) context.addStep(MODULE, 'PDA batch 旧任务版本返回 A0409', staleBatchResponse, staleBatchResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleBatchResponse)), @@ -222,7 +232,8 @@ export async function run(context) { && afterFailedItem?.itemStatus === pickItem.itemStatus, `taskVersion=${afterFailedBatch?.version ?? 'missing'}, itemVersion=${afterFailedItem?.version ?? 'missing'}, picked=${afterFailedItem?.pickedPackageCount ?? 'missing'}`, OPTIONS) - const batchPayload = { warehouseId, containerNo, expectedVersion: Number(task.version), items: batchItems } + const batchPayload = { warehouseId, containerNo, expectedVersion: Number(task.version), + inventorySnapshotToken: inventory.snapshotToken, items: batchItems } const batchHeaders = { 'Idempotency-Key': `${context.marker}-pda-batch`.slice(0, 128) } const pickResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders) const picked = context.apiSuccess(MODULE, '单次调用 PDA 原子 batch 完成拣货', pickResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 3df3ddbe8..e877f5ac2 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -207,11 +207,22 @@ export async function run(context) { claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushedLoadingPlan?.version) + 1, `status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS) + const inventoryResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS) + context.assert(MODULE, '实时库存快照包含当前入库单和稳定令牌', inventoryResponse, + typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64 + && inventory?.inbounds?.some?.((item) => id(item.inboundId) === inboundId + && Number(item.availablePackageCount) >= 1), + `token=${inventory?.snapshotToken ? 'present' : 'missing'}, inboundCount=${inventory?.inbounds?.length ?? 0}`, OPTIONS) + if (!inventory?.snapshotToken) return + const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch` const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) const batchPayload = { warehouseId, containerNo, expectedVersion: Number(claimed?.version), + inventorySnapshotToken: inventory.snapshotToken, items: [{ itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1, releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version), diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java index 0fe31ec32..7e38c63c3 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -13,10 +13,12 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; 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 org.springframework.web.bind.annotation.RequestParam; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -81,6 +83,15 @@ public class InternalPalletLocationController { return ApiResponse.success(service.settle(request)); } + @GetMapping("/occupied-resources") + @Operation(summary = "查询业务当前占用资源", description = "PDA提交前使用的只读实时库存事实") + public ApiResponse> occupiedResources( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam long tenantId, @RequestParam String sourceService, @RequestParam long businessId) { + verify(token); + return ApiResponse.success(service.occupiedResources(tenantId, sourceService, businessId)); + } + @PostMapping("/rebind-temporary-location") @Operation(summary = "A0临时库位换绑", description = "将一条具体A0托盘占用换绑到同仓空闲普通库位") public ApiResponse rebindTemporaryLocation( diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java index 83116bc8e..c3d56bedc 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java @@ -458,6 +458,19 @@ public class LocationResourceService { .toList(); } + /** PDA 提交前读取当前占用事实;只读快照不持有数据库行锁。 */ + @Transactional(readOnly = true) + public List findOccupiedResources(long tenantId, String sourceService, long businessId) { + return repository.findOccupanciesByBusiness(tenantId, sourceService, businessId).stream() + .filter(item -> "OCCUPIED".equals(item.getOccupancyStatus())) + .sorted(Comparator.comparingLong(LocationOccupancyEntity::getReservationId) + .thenComparingLong(LocationOccupancyEntity::getId)) + .map(item -> new OccupiedResource(item.getReservationId(), item.getId(), 0, + item.getWarehouseId(), item.getPalletId(), item.getLocationId(), item.getLocationCode(), + Boolean.TRUE.equals(item.getSharedOverflow()), requireAllocatedPackageCount(item))) + .toList(); + } + /** 将 A0 的一条托盘占用换绑到同仓空闲普通库位。 */ @Transactional public LocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation( diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java index 83ece5c6f..00e03b549 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java @@ -153,9 +153,6 @@ public class OverseasStorageFeeService { .thenComparingInt(OverseasStorageFeeDtos.TierRequest::startDay)); for (String feeType : List.of("STORAGE", "RETURN_STORAGE")) { List group = tiers.stream().filter(t -> feeType.equals(t.feeType())).toList(); - if (source.size() == 9 && (("STORAGE".equals(feeType) && group.size() != 5) - || ("RETURN_STORAGE".equals(feeType) && group.size() != 4))) - throw new BizException(ResultCode.VALIDATION_FAILED, "仓租必须5档,退货仓储费必须4档"); int expectedStart = 1; for (int i = 0; i < group.size(); i++) { OverseasStorageFeeDtos.TierRequest tier = group.get(i); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java index beed201f1..0679b4a73 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -538,6 +538,11 @@ public class PalletLocationAllocationService { return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId)); } + public List occupiedResources(long tenantId, String sourceService, + long businessId) { + return locationResourceService.findOccupiedResources(tenantId, sourceService, businessId); + } + private static PalletAllocationDtos.BatchResult fromActive(List active, PalletVolumeSplitter.SplitResult split) { if (active.size() != split.palletCount()) { diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletLocationControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletLocationControllerTest.java index e95f09009..a3a2be72f 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletLocationControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletLocationControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.PalletAllocationDtos; +import com.baiyue.warehouse.dto.LocationDtos; import com.baiyue.warehouse.service.PalletLocationAllocationService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -65,6 +66,18 @@ class InternalPalletLocationControllerTest { verify(service).settle(request); } + @Test + void readsOccupiedResourcesOnlyForInternalCaller() { + List expected = List.of(new LocationDtos.OccupiedResource( + 101L, 201L, 0, 20L, 30L, 40L, "A-01", false, 5)); + when(service.occupiedResources(0L, "firstleg-service", 88L)).thenReturn(expected); + + var response = controller.occupiedResources(INTERNAL_TOKEN, 0L, "firstleg-service", 88L); + + assertThat(response.data()).isEqualTo(expected); + verify(service).occupiedResources(0L, "firstleg-service", 88L); + } + private static PalletAllocationDtos.TransferRequest request() { return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1", "transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5))); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java index d6c09f91a..1e7958579 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java @@ -49,6 +49,27 @@ class LocationResourceServiceTest { assertThat(service.findActiveResources(0L, "firstleg-service", 901L)).isEmpty(); } + @Test + void returnsOnlyCurrentOccupiedResourcesWithAllocatedPackageCounts() { + LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L); + active.setTenantId(0L); + active.setSourceService("firstleg-service"); + active.setBusinessId(901L); + active.setAllocatedPackageCount(7); + LocationOccupancyEntity released = occupancy(202L, 102L, 10L, "A2", 502L); + released.setOccupancyStatus("RELEASED"); + when(repository.findOccupanciesByBusiness(0L, "firstleg-service", 901L)) + .thenReturn(List.of(released, active)); + + var result = service.findOccupiedResources(0L, "firstleg-service", 901L); + + assertThat(result).singleElement().satisfies(item -> { + assertThat(item.reservationId()).isEqualTo(101L); + assertThat(item.occupancyId()).isEqualTo(201L); + assertThat(item.allocatedPackageCount()).isEqualTo(7); + }); + } + @Test void shouldHandleCase01() { when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java index 59b287054..729ade0a6 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java @@ -53,6 +53,26 @@ class OverseasStorageFeeServiceTest { verify(repository, never()).insertRule(any()); } + @Test + void acceptsVariableTierCountsEvenWhenTotalIsNine() { + doAnswer(invocation -> { + OverseasStorageFeeRuleEntity rule = invocation.getArgument(0); + rule.setId(10L); + return null; + }).when(repository).insertRule(any()); + List tiers = List.of( + typedTier("STORAGE", "CBM", 1, 10), typedTier("STORAGE", "CBM", 11, 20), + typedTier("STORAGE", "CBM", 21, 30), typedTier("STORAGE", "CBM", 31, null), + typedTier("RETURN_STORAGE", "PALLET", 1, 10), typedTier("RETURN_STORAGE", "PALLET", 11, 20), + typedTier("RETURN_STORAGE", "PALLET", 21, 30), typedTier("RETURN_STORAGE", "PALLET", 31, 40), + typedTier("RETURN_STORAGE", "PALLET", 41, null)); + + Long id = service.create(new OverseasStorageFeeDtos.CreateRequest(10001L, "示例客户", "USD", tiers)); + + assertThat(id).isEqualTo(10L); + verify(repository).insertTiers(org.mockito.ArgumentMatchers.argThat(list -> list.size() == 9)); + } + @Test void rejectsDuplicateCustomer() { when(repository.existsCustomer(10001L, null)).thenReturn(true); @@ -124,6 +144,11 @@ class OverseasStorageFeeServiceTest { return new OverseasStorageFeeDtos.TierRequest(start, end, new BigDecimal(price)); } + private static OverseasStorageFeeDtos.TierRequest typedTier(String feeType, String billingUnit, + int start, Integer end) { + return new OverseasStorageFeeDtos.TierRequest(feeType, billingUnit, start, end, BigDecimal.ONE); + } + private static OverseasStorageFeeRuleEntity rule() { OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity(); rule.setId(10L);