This commit is contained in:
2026-08-11 18:43:50 +08:00
parent e1487162a5
commit bc5d9a6215
25 changed files with 1424 additions and 28 deletions
@@ -1,5 +1,7 @@
package com.baiyue.finance.dto; package com.baiyue.finance.dto;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
@@ -27,7 +29,9 @@ public final class FinanceSettlementDtos {
String direction, long customerId, String customerName, Long supplierId, String direction, long customerId, String customerName, Long supplierId,
String supplierName, Long inboundId, String inboundCode, Long containerId, String supplierName, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName, 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, public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo, BigDecimal amount, String method, String referenceNo,
List<String> voucherFileIds, long createdBy, LocalDateTime createdAt, List<String> voucherFileIds, long createdBy, LocalDateTime createdAt,
@@ -35,7 +39,7 @@ public final class FinanceSettlementDtos {
public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount, public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount,
BigDecimal afterAmount, String reason, long operatorId, BigDecimal afterAmount, String reason, long operatorId,
LocalDateTime occurredAt) {} LocalDateTime occurredAt) {}
public record SettlementDetail(SettlementSummary summary, List<SettlementLine> lines, public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List<SettlementLine> lines,
List<SettlementTransaction> transactions, List<SettlementTransaction> transactions,
List<SettlementRevision> revisions) {} List<SettlementRevision> revisions) {}
public record SettlementDetailEnvelope(String code, String message, SettlementDetail data, public record SettlementDetailEnvelope(String code, String message, SettlementDetail data,
@@ -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<String> 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<String, Object> 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<Long> ids = request.feeSourceIds().stream().distinct().sorted().toList();
String placeholders = String.join(",", java.util.Collections.nCopies(ids.size(), "?"));
List<Object> args = new ArrayList<>();
args.addAll(ids);
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<SettlementLine> 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<SettlementTransaction> 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<Map<String, Object>> 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<SettlementRevision> 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<String, Object> 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<String> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> one(String sql, String message, Object... args) {
List<Map<String, Object>> 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<String, Object> 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<String> stringList(String value) {
if (!StringUtils.hasText(value)) return List.of();
try { return objectMapper.readValue(value, new TypeReference<List<String>>() {}); }
catch (Exception failure) { throw error("FIN_001", "财务凭证数据损坏"); }
}
private static String text(Map<String, Object> row, String key) {
Object value = row.get(key);
return value == null ? null : value.toString();
}
private static Number number(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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); }
}
@@ -109,7 +109,7 @@ public class ReconciliationStatementService {
Transition transition = transition(action, before.status()); Transition transition = transition(action, before.status());
if ("VOID".equals(action)) requireNoReceipt(before.customerId(), statementId); if ("VOID".equals(action)) requireNoReceipt(before.customerId(), statementId);
snapshot(before, request.reason(), operatorId); 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(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId,
transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion()); transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion());
if (changed != 1) throw error("FIN-REC-409", "对账单状态或版本已变化"); if (changed != 1) throw error("FIN-REC-409", "对账单状态或版本已变化");
@@ -134,7 +134,7 @@ public class ReconciliationStatementService {
(rs, row) -> payment(rs), customerId, statementId); (rs, row) -> payment(rs), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(), return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(), 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); bill.updatedAt(), bill.remark(), lines, payments);
} }
@@ -272,10 +272,10 @@ public class ReconciliationStatementService {
private static Transition transition(String action, String status) { private static Transition transition(String action, String status) {
return switch (action) { return switch (action) {
case "SUBMIT" -> requireTransition(status, "DRAFT", "PENDING_REVIEW"); 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 "REJECT" -> requireTransition(status, "PENDING_REVIEW", "REJECTED");
case "VOID" -> { 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"); yield new Transition(status, "VOIDED");
} }
default -> throw error("FIN-REQ-400", "对账单动作无效"); default -> throw error("FIN-REQ-400", "对账单动作无效");
@@ -287,6 +287,10 @@ public class ReconciliationStatementService {
return new Transition(expected, target); 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 { private static Source source(ResultSet rs) throws SQLException {
return new Source(rs.getLong("id"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), 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"), rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"),
@@ -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"); "CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code");
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot', CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash"); "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', CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at"); "DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash', 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'), 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_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)), 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 UPDATE fin_inbox_event
SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256)); SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256));
@@ -74,7 +87,8 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source' WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code', 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费用来源迁移失败:来源快照字段不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
@@ -20,6 +20,11 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希', source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', 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已作废', status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间', 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 '来源状态最后更新时间,数据修改时自动刷新', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
@@ -18,6 +18,9 @@ import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger; 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.eq;
import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -67,16 +71,46 @@ class FinancePhase1OperationBehaviorTest {
@Test @Test
void ledgerRejectsInvalidCustomerBeforeAnyQuery() { 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)) assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
.isInstanceOf(FinanceEventException.class) .isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
} }
@Test
void accountListReturnsFixedPagedDto() {
AccountJdbc jdbc = new AccountJdbc();
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
PageEnvelope<FinancePhase1Dtos.AccountSummary> 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<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
assertThat(result.data()).isEmpty();
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L);
}
@Test @Test
void rejectedFeeCannotBeIncludedInSettlement() throws Exception { void rejectedFeeCannotBeIncludedInSettlement() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2); 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", assertThatThrownBy(() -> service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 2))) new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 2)))
@@ -84,6 +118,46 @@ class FinancePhase1OperationBehaviorTest {
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-FEE-409"); .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<FeeSourceDetail> 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 @Test
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception { void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class); 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.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.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.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), (Object[]) any())).thenReturn(1); eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="), (Object[]) any())).thenReturn(1); when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), (Object[]) any())).thenReturn(1); eq("FEE_SOURCE_REVIEW"), eq("review-key"), anyString())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any())).thenReturn(1); when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="),
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, new ObjectMapper()); 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", FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0)); 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.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.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; 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 + "}"); when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"settlementIncluded\":" + included + ",\"operationVersion\":" + version + "}");
return rs; return rs;
} }
private static ObjectMapper objectMapper() {
return new ObjectMapper().findAndRegisterModules();
}
private static final class AccountJdbc extends JdbcTemplate {
private final List<Long> ledgerCustomerIds = new ArrayList<>();
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (sql.startsWith("SELECT COUNT(*)")) return requiredType.cast(1L);
return null;
}
@Override
@SuppressWarnings("unchecked")
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.startsWith("SELECT c.customer_id")) {
return (List<T>) 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();
}
}
} }
@@ -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<String, Object> before = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0);
Map<String, Object> 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<String, Object> 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<String> 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);
}
}
@@ -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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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;
}
}
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
@@ -38,6 +40,13 @@ public interface WarehousePalletLocationClient {
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request); @RequestBody SettlementRequest request);
@GetMapping("/occupied-resources")
ApiResponse<List<OccupiedResource>> 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, record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId, String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
@@ -75,4 +84,7 @@ public interface WarehousePalletLocationClient {
String operatorName, String requestId, List<Long> releasedReservationIds, String operatorName, String requestId, List<Long> releasedReservationIds,
List<SettlementTransferItem> remainingItems) {} List<SettlementTransferItem> remainingItems) {}
record SettlementResult(List<SelectiveReleaseResult> released, List<TransferResult> transferred) {} record SettlementResult(List<SelectiveReleaseResult> released, List<TransferResult> transferred) {}
record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId,
long palletId, long locationId, String locationCode, boolean sharedOverflow,
int allocatedPackageCount) {}
} }
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
import java.util.List; import java.util.List;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -48,6 +49,15 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.task(containerNo, warehouseId)); return ApiResponse.success(service.task(containerNo, warehouseId));
} }
@GetMapping("/tasks/{taskId}/inventory-snapshot")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<InventorySnapshot> 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") @PostMapping("/tasks/{taskId}/claim")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> claim(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Task> claim(@AuthenticationPrincipal AuthenticatedUser user,
@@ -60,6 +60,7 @@ public final class LoadingPickDtos {
@NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Long warehouseId,
@NotBlank @Size(max = 64) String containerNo, @NotBlank @Size(max = 64) String containerNo,
@NotNull @Min(0) Integer expectedVersion, @NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 64) String inventorySnapshotToken,
@NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items @NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items
) { ) {
} }
@@ -118,4 +119,18 @@ public final class LoadingPickDtos {
String workflowStatus, Integer version, Assignment assignment, String workflowStatus, Integer version, Assignment assignment,
Review review, List<PickItem> items) { Review review, List<PickItem> 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<InventoryResource> resources) {
}
public record InventorySnapshot(Long taskId, Integer taskVersion, String containerNo,
Long warehouseId, LocalDateTime capturedAt, String snapshotToken,
List<InboundInventory> inbounds) {
}
} }
@@ -1,7 +1,9 @@
package com.baiyue.firstleg.service; package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; 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.PickItem;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; 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.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; 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.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity; 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.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -43,10 +50,13 @@ import java.util.HashMap;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.time.LocalDateTime;
/** 装柜中 PDA 拣货任务及明细同步。 */ /** 装柜中 PDA 拣货任务及明细同步。 */
@Service @Service
public class LoadingPickService { public class LoadingPickService {
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
private static final String SOURCE_SERVICE = "firstleg-service";
@Resource @Resource
private LoadingPickRepository repository; private LoadingPickRepository repository;
@Resource @Resource
@@ -55,6 +65,10 @@ public class LoadingPickService {
private LoadingListService loadingListService; private LoadingListService loadingListService;
@Resource @Resource
private PdaPickSettlementService settlementService; private PdaPickSettlementService settlementService;
@Resource
private WarehousePalletLocationClient warehouse;
@Value("${baiyue.security.internal-service-token:test-internal-service-token}")
private String internalToken;
@Transactional @Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName, public void begin(long planId, String containerNo, long operatorId, String operatorName,
@@ -105,6 +119,12 @@ public class LoadingPickService {
return view(task, warehouseId); return view(task, warehouseId);
} }
public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) {
LoadingPickTaskEntity task = requiredTask(taskId);
List<LoadingPickItemEntity> items = warehouseScopedItems(task, warehouseId);
return buildInventorySnapshot(task, warehouseId, items);
}
@Transactional @Transactional
public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) { public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId()); String operationId = operation(request.operationId());
@@ -275,6 +295,7 @@ public class LoadingPickService {
} }
List<BatchLine> lines = validateBatchLines(task, request); List<BatchLine> lines = validateBatchLines(task, request);
validateInventorySnapshot(task, request, lines);
for (BatchLine line : lines) { for (BatchLine line : lines) {
applyBatchLine(taskId, line, operationId, operatorId, operatorName); applyBatchLine(taskId, line, operationId, operatorId, operatorName);
} }
@@ -554,6 +575,93 @@ public class LoadingPickService {
return result; return result;
} }
private List<LoadingPickItemEntity> warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) {
List<LoadingPickItemEntity> allItems = repository.items(task.getId());
List<LoadingPickItemEntity> 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<LoadingPickItemEntity> items) {
List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId)
.filter(Objects::nonNull).distinct().sorted().map(inboundId -> {
List<WarehousePalletLocationClient.OccupiedResource> current = occupiedResources(inboundId);
List<InventoryResource> 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<WarehousePalletLocationClient.OccupiedResource> occupiedResources(long inboundId) {
ApiResponse<List<WarehousePalletLocationClient.OccupiedResource>> 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<InboundInventory> 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<BatchLine> lines) {
List<LoadingPickItemEntity> allItems = warehouseScopedItems(task, request.warehouseId());
InventorySnapshot current = buildInventorySnapshot(task, request.warehouseId(), allItems);
if (!current.snapshotToken().equals(request.inventorySnapshotToken().trim())) {
throw conflict("库存已变化,请刷新后重试");
}
Map<Long, Integer> availableByInbound = current.inbounds().stream().collect(Collectors.toMap(
InboundInventory::inboundId, InboundInventory::availablePackageCount));
Map<Long, Integer> requestedByInbound = new HashMap<>();
Map<Long, BatchLine> 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, private void applyBatchLine(long taskId, BatchLine line, String batchOperationId,
long operatorId, String operatorName) { long operatorId, String operatorName) {
LoadingPickItemEntity item = line.item(); LoadingPickItemEntity item = line.item();
@@ -690,12 +798,13 @@ public class LoadingPickService {
private static String batchRequestHash(long taskId, BatchRequest request) { private static String batchRequestHash(long taskId, BatchRequest request) {
if (request == null || request.warehouseId() == null || !StringUtils.hasText(request.containerNo()) 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, "整批提交参数不完整"); throw new BizException(ResultCode.BAD_REQUEST, "整批提交参数不完整");
} }
StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|') StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|')
.append(request.warehouseId()).append('|').append(request.containerNo().trim()).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, request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId,
Comparator.nullsFirst(Long::compareTo))) Comparator.nullsFirst(Long::compareTo)))
.forEach(item -> { .forEach(item -> {
@@ -20,6 +20,16 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}} content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}}
responses: responses:
'200': {description: Claimed task or A0409 when another operator owns it} '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: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/assign:
post: post:
summary: Assign or reassign a task with optimistic locking summary: Assign or reassign a task with optimistic locking
@@ -91,11 +101,12 @@ components:
operationId: {type: string, maxLength: 128} operationId: {type: string, maxLength: 128}
BatchRequest: BatchRequest:
type: object type: object
required: [warehouseId, containerNo, expectedVersion, items] required: [warehouseId, containerNo, expectedVersion, inventorySnapshotToken, items]
properties: properties:
warehouseId: {type: integer, format: int64, minimum: 1} warehouseId: {type: integer, format: int64, minimum: 1}
containerNo: {type: string, maxLength: 64} containerNo: {type: string, maxLength: 64}
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
inventorySnapshotToken: {type: string, minLength: 64, maxLength: 64}
items: items:
type: array type: array
minItems: 1 minItems: 1
@@ -122,3 +133,36 @@ components:
expectedTaskVersion: {type: integer, minimum: 0} expectedTaskVersion: {type: integer, minimum: 0}
operationId: {type: string, maxLength: 128} operationId: {type: string, maxLength: 128}
remark: {type: string, maxLength: 500} 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}
@@ -15,6 +15,7 @@ class PdaConvergenceOpenApiContractTest {
assertThat(yaml) assertThat(yaml)
.contains("/api/platform/v1/first-leg/pda-loading/tasks:") .contains("/api/platform/v1/first-leg/pda-loading/tasks:")
.contains("/tasks/{taskId}/claim:") .contains("/tasks/{taskId}/claim:")
.contains("/tasks/{taskId}/inventory-snapshot:")
.contains("/tasks/{taskId}/assign:") .contains("/tasks/{taskId}/assign:")
.contains("/tasks/{taskId}/items/{itemId}/pick:") .contains("/tasks/{taskId}/items/{itemId}/pick:")
.contains("/tasks/{taskId}/items/{itemId}/partial:") .contains("/tasks/{taskId}/items/{itemId}/partial:")
@@ -24,6 +25,8 @@ class PdaConvergenceOpenApiContractTest {
.contains("/tasks/{taskId}/operations:") .contains("/tasks/{taskId}/operations:")
.contains("expectedAssignmentVersion") .contains("expectedAssignmentVersion")
.contains("BatchRequest") .contains("BatchRequest")
.contains("inventorySnapshotToken")
.contains("InventorySnapshot")
.contains("expectedTaskVersion"); .contains("expectedTaskVersion");
} }
} }
@@ -13,6 +13,7 @@ class PlatformLoadingPdaControllerContractTest {
@Test @Test
void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception { void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception {
authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, long.class); 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("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class);
authority("assign", "platform:firstleg:pda:assign", AuthenticatedUser.class, long.class, AssignRequest.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); authority("pick", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, PickRequest.class);
@@ -8,6 +8,8 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; 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.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity;
@@ -44,6 +46,8 @@ class LoadingPickServiceTest {
private LoadingListService loadingListService; private LoadingListService loadingListService;
@MockBean @MockBean
private PdaPickSettlementService settlementService; private PdaPickSettlementService settlementService;
@MockBean
private WarehousePalletLocationClient warehouse;
@Resource @Resource
private LoadingPickService service; private LoadingPickService service;
@@ -219,7 +223,11 @@ class LoadingPickServiceTest {
when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1); when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true); 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(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton"))); 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.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(items); when(repository.items(20L)).thenReturn(items);
when(repository.items(20L, 101L)).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(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 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"); 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 @Test
void rejectsBatchTaskVersionConflictBeforeWriting() { void rejectsBatchTaskVersionConflictBeforeWriting() {
LoadingPickTaskEntity task = task(); LoadingPickTaskEntity task = task();
task.setVersion(3); task.setVersion(3);
when(repository.taskForUpdate(20L)).thenReturn(task); 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))); new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator"))
@@ -200,12 +200,22 @@ export async function run(context) {
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushed?.version) + 1, claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushed?.version) + 1,
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS) `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 batchPath = `${PDA}/tasks/${taskId}/batch`
const batchItems = [{ itemId: id(pickItem.id), action: 'PICK', pickedPackageCount: 1, const batchItems = [{ itemId: id(pickItem.id), action: 'PICK', pickedPackageCount: 1,
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
version: Number(pickItem.version), reason: null }] version: Number(pickItem.version), reason: null }]
const staleBatchResponse = await request(context, 'POST', batchPath, { 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) }) }, { 'Idempotency-Key': `${context.marker}-pda-batch-stale`.slice(0, 128) })
context.addStep(MODULE, 'PDA batch 旧任务版本返回 A0409', staleBatchResponse, context.addStep(MODULE, 'PDA batch 旧任务版本返回 A0409', staleBatchResponse,
staleBatchResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleBatchResponse)), staleBatchResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleBatchResponse)),
@@ -222,7 +232,8 @@ export async function run(context) {
&& afterFailedItem?.itemStatus === pickItem.itemStatus, && afterFailedItem?.itemStatus === pickItem.itemStatus,
`taskVersion=${afterFailedBatch?.version ?? 'missing'}, itemVersion=${afterFailedItem?.version ?? 'missing'}, picked=${afterFailedItem?.pickedPackageCount ?? 'missing'}`, OPTIONS) `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 batchHeaders = { 'Idempotency-Key': `${context.marker}-pda-batch`.slice(0, 128) }
const pickResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders) const pickResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders)
const picked = context.apiSuccess(MODULE, '单次调用 PDA 原子 batch 完成拣货', pickResponse, '', OPTIONS) const picked = context.apiSuccess(MODULE, '单次调用 PDA 原子 batch 完成拣货', pickResponse, '', OPTIONS)
@@ -207,11 +207,22 @@ export async function run(context) {
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushedLoadingPlan?.version) + 1, claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushedLoadingPlan?.version) + 1,
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS) `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 batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
const batchPayload = { const batchPayload = {
warehouseId, containerNo, warehouseId, containerNo,
expectedVersion: Number(claimed?.version), expectedVersion: Number(claimed?.version),
inventorySnapshotToken: inventory.snapshotToken,
items: [{ items: [{
itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1, itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1,
releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version), releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version),
@@ -13,10 +13,12 @@ import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestParam;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
@@ -81,6 +83,15 @@ public class InternalPalletLocationController {
return ApiResponse.success(service.settle(request)); return ApiResponse.success(service.settle(request));
} }
@GetMapping("/occupied-resources")
@Operation(summary = "查询业务当前占用资源", description = "PDA提交前使用的只读实时库存事实")
public ApiResponse<List<LocationDtos.OccupiedResource>> 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") @PostMapping("/rebind-temporary-location")
@Operation(summary = "A0临时库位换绑", description = "将一条具体A0托盘占用换绑到同仓空闲普通库位") @Operation(summary = "A0临时库位换绑", description = "将一条具体A0托盘占用换绑到同仓空闲普通库位")
public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation( public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation(
@@ -458,6 +458,19 @@ public class LocationResourceService {
.toList(); .toList();
} }
/** PDA 提交前读取当前占用事实;只读快照不持有数据库行锁。 */
@Transactional(readOnly = true)
public List<OccupiedResource> 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 的一条托盘占用换绑到同仓空闲普通库位。 */ /** 将 A0 的一条托盘占用换绑到同仓空闲普通库位。 */
@Transactional @Transactional
public LocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation( public LocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation(
@@ -153,9 +153,6 @@ public class OverseasStorageFeeService {
.thenComparingInt(OverseasStorageFeeDtos.TierRequest::startDay)); .thenComparingInt(OverseasStorageFeeDtos.TierRequest::startDay));
for (String feeType : List.of("STORAGE", "RETURN_STORAGE")) { for (String feeType : List.of("STORAGE", "RETURN_STORAGE")) {
List<OverseasStorageFeeDtos.TierRequest> group = tiers.stream().filter(t -> feeType.equals(t.feeType())).toList(); List<OverseasStorageFeeDtos.TierRequest> 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; int expectedStart = 1;
for (int i = 0; i < group.size(); i++) { for (int i = 0; i < group.size(); i++) {
OverseasStorageFeeDtos.TierRequest tier = group.get(i); OverseasStorageFeeDtos.TierRequest tier = group.get(i);
@@ -538,6 +538,11 @@ public class PalletLocationAllocationService {
return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId)); return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId));
} }
public List<LocationDtos.OccupiedResource> occupiedResources(long tenantId, String sourceService,
long businessId) {
return locationResourceService.findOccupiedResources(tenantId, sourceService, businessId);
}
private static PalletAllocationDtos.BatchResult fromActive(List<LocationDtos.ActiveResource> active, private static PalletAllocationDtos.BatchResult fromActive(List<LocationDtos.ActiveResource> active,
PalletVolumeSplitter.SplitResult split) { PalletVolumeSplitter.SplitResult split) {
if (active.size() != split.palletCount()) { if (active.size() != split.palletCount()) {
@@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletAllocationDtos; import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.service.PalletLocationAllocationService; import com.baiyue.warehouse.service.PalletLocationAllocationService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -65,6 +66,18 @@ class InternalPalletLocationControllerTest {
verify(service).settle(request); verify(service).settle(request);
} }
@Test
void readsOccupiedResourcesOnlyForInternalCaller() {
List<LocationDtos.OccupiedResource> 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() { private static PalletAllocationDtos.TransferRequest request() {
return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L, return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L,
"RK-001-S1", "transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5))); "RK-001-S1", "transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5)));
@@ -49,6 +49,27 @@ class LocationResourceServiceTest {
assertThat(service.findActiveResources(0L, "firstleg-service", 901L)).isEmpty(); 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 @Test
void shouldHandleCase01() { void shouldHandleCase01() {
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
@@ -53,6 +53,26 @@ class OverseasStorageFeeServiceTest {
verify(repository, never()).insertRule(any()); 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<OverseasStorageFeeDtos.TierRequest> 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 @Test
void rejectsDuplicateCustomer() { void rejectsDuplicateCustomer() {
when(repository.existsCustomer(10001L, null)).thenReturn(true); when(repository.existsCustomer(10001L, null)).thenReturn(true);
@@ -124,6 +144,11 @@ class OverseasStorageFeeServiceTest {
return new OverseasStorageFeeDtos.TierRequest(start, end, new BigDecimal(price)); 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() { private static OverseasStorageFeeRuleEntity rule() {
OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity(); OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity();
rule.setId(10L); rule.setId(10L);