优化
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package com.baiyue.finance.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonUnwrapped;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
@@ -27,7 +29,9 @@ public final class FinanceSettlementDtos {
|
||||
String direction, long customerId, String customerName, Long supplierId,
|
||||
String supplierName, Long inboundId, String inboundCode, Long containerId,
|
||||
String containerNo, LocalDate businessDate, String feeItemName,
|
||||
BigDecimal amount, String currency, String sourceStatus) {}
|
||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
||||
String reviewStatus, boolean settlementIncluded, String sourceStatus,
|
||||
boolean statementGenerated) {}
|
||||
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
||||
BigDecimal amount, String method, String referenceNo,
|
||||
List<String> voucherFileIds, long createdBy, LocalDateTime createdAt,
|
||||
@@ -35,7 +39,7 @@ public final class FinanceSettlementDtos {
|
||||
public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount,
|
||||
BigDecimal afterAmount, String reason, long operatorId,
|
||||
LocalDateTime occurredAt) {}
|
||||
public record SettlementDetail(SettlementSummary summary, List<SettlementLine> lines,
|
||||
public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List<SettlementLine> lines,
|
||||
List<SettlementTransaction> transactions,
|
||||
List<SettlementRevision> revisions) {}
|
||||
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); }
|
||||
}
|
||||
+8
-4
@@ -109,7 +109,7 @@ public class ReconciliationStatementService {
|
||||
Transition transition = transition(action, before.status());
|
||||
if ("VOID".equals(action)) requireNoReceipt(before.customerId(), statementId);
|
||||
snapshot(before, request.reason(), operatorId);
|
||||
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='APPROVED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='APPROVED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?",
|
||||
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?",
|
||||
transition.to(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId,
|
||||
transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion());
|
||||
if (changed != 1) throw error("FIN-REC-409", "对账单状态或版本已变化");
|
||||
@@ -134,7 +134,7 @@ public class ReconciliationStatementService {
|
||||
(rs, row) -> payment(rs), customerId, statementId);
|
||||
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
|
||||
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
|
||||
bill.outstanding(), bill.outstanding().signum() == 0, bill.status(), bill.version(), bill.createdAt(),
|
||||
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), bill.createdAt(),
|
||||
bill.updatedAt(), bill.remark(), lines, payments);
|
||||
}
|
||||
|
||||
@@ -272,10 +272,10 @@ public class ReconciliationStatementService {
|
||||
private static Transition transition(String action, String status) {
|
||||
return switch (action) {
|
||||
case "SUBMIT" -> requireTransition(status, "DRAFT", "PENDING_REVIEW");
|
||||
case "APPROVE" -> requireTransition(status, "PENDING_REVIEW", "APPROVED");
|
||||
case "APPROVE" -> requireTransition(status, "PENDING_REVIEW", "ISSUED");
|
||||
case "REJECT" -> requireTransition(status, "PENDING_REVIEW", "REJECTED");
|
||||
case "VOID" -> {
|
||||
if (!Set.of("DRAFT", "PENDING_REVIEW", "REJECTED", "APPROVED").contains(status)) throw error("FIN-REC-409", "当前状态不允许作废");
|
||||
if (!Set.of("DRAFT", "PENDING_REVIEW", "REJECTED", "ISSUED").contains(status)) throw error("FIN-REC-409", "当前状态不允许作废");
|
||||
yield new Transition(status, "VOIDED");
|
||||
}
|
||||
default -> throw error("FIN-REQ-400", "对账单动作无效");
|
||||
@@ -287,6 +287,10 @@ public class ReconciliationStatementService {
|
||||
return new Transition(expected, target);
|
||||
}
|
||||
|
||||
private static String reconciliationStatus(String billStatus) {
|
||||
return "ISSUED".equals(billStatus) ? "APPROVED" : billStatus;
|
||||
}
|
||||
|
||||
private static Source source(ResultSet rs) throws SQLException {
|
||||
return new Source(rs.getLong("id"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
|
||||
rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"),
|
||||
|
||||
@@ -33,6 +33,16 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
|
||||
"CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code");
|
||||
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
|
||||
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
|
||||
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
|
||||
"VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot");
|
||||
CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
|
||||
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
|
||||
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_included',
|
||||
"TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是' AFTER review_reason");
|
||||
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_reason',
|
||||
"VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明' AFTER settlement_included");
|
||||
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
|
||||
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号' AFTER settlement_reason");
|
||||
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
|
||||
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
|
||||
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash',
|
||||
@@ -51,7 +61,10 @@ UPDATE fin_fee_source
|
||||
fee_item_code = COALESCE(fee_item_code, 'LEGACY'),
|
||||
source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)),
|
||||
source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)),
|
||||
updated_at = COALESCE(updated_at, created_at);
|
||||
updated_at = COALESCE(updated_at, created_at),
|
||||
review_status = COALESCE(review_status, 'PENDING'),
|
||||
settlement_included = COALESCE(settlement_included, 0),
|
||||
operation_version = COALESCE(operation_version, 0);
|
||||
UPDATE fin_inbox_event
|
||||
SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256));
|
||||
|
||||
@@ -74,7 +87,8 @@ BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
|
||||
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
|
||||
'source_hash','source_snapshot','updated_at')) <> 8 THEN
|
||||
'source_hash','source_snapshot','review_status','review_reason',
|
||||
'settlement_included','settlement_reason','operation_version','updated_at')) <> 13 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
|
||||
@@ -20,6 +20,11 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
|
||||
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
|
||||
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
|
||||
review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因',
|
||||
settlement_included TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是',
|
||||
settlement_reason VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明',
|
||||
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
|
||||
|
||||
@@ -18,6 +18,9 @@ import org.springframework.jdbc.core.RowMapper;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -29,6 +32,7 @@ import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.startsWith;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -67,16 +71,46 @@ class FinancePhase1OperationBehaviorTest {
|
||||
|
||||
@Test
|
||||
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), new ObjectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper());
|
||||
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountListReturnsFixedPagedDto() {
|
||||
AccountJdbc jdbc = new AccountJdbc();
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
PageEnvelope<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
|
||||
void rejectedFeeCannotBeIncludedInSettlement() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, new ObjectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
assertThatThrownBy(() -> service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
|
||||
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 2)))
|
||||
@@ -84,6 +118,46 @@ class FinancePhase1OperationBehaviorTest {
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-FEE-409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||
feeSourceJdbc("APPROVED", true, 2), objectMapper());
|
||||
|
||||
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
|
||||
|
||||
assertThat(detail.feeSourceId()).isEqualTo(88L);
|
||||
assertThat(detail.customerId()).isEqualTo(9L);
|
||||
assertThat(detail.currency()).isEqualTo("CNY");
|
||||
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
|
||||
assertThat(detail.settlementIncluded()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedFeeCanBeIncludedWithExpectedVersion() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
|
||||
"update".equals(invocation.getMethod().getName())
|
||||
? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation));
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<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
|
||||
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
@@ -96,12 +170,18 @@ class FinancePhase1OperationBehaviorTest {
|
||||
});
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(700L), eq(3))).thenReturn(false);
|
||||
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), any(), any(), any(), any())).thenReturn(List.of());
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), (Object[]) any())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="), (Object[]) any())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), (Object[]) any())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any())).thenReturn(1);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, new ObjectMapper());
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_REVIEW"), eq("review-key"), anyString())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="),
|
||||
eq("APPROVED"), eq("核对无误"), eq(88L), eq(0))).thenReturn(1);
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"),
|
||||
eq(88L), eq("FEE_SOURCE_REVIEW_APPROVE"), eq("核对无误"), anyString(), anyString(),
|
||||
eq(71L), eq("operator-71"), any(), any())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
|
||||
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
||||
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
||||
@@ -119,7 +199,8 @@ class FinancePhase1OperationBehaviorTest {
|
||||
});
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(700L), eq(3))).thenReturn(false);
|
||||
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(anyString(), any(), any(), any(), any())).thenReturn(List.of());
|
||||
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
|
||||
return jdbc;
|
||||
}
|
||||
|
||||
@@ -146,4 +227,35 @@ class FinancePhase1OperationBehaviorTest {
|
||||
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"settlementIncluded\":" + included + ",\"operationVersion\":" + version + "}");
|
||||
return rs;
|
||||
}
|
||||
|
||||
private static ObjectMapper objectMapper() {
|
||||
return new ObjectMapper().findAndRegisterModules();
|
||||
}
|
||||
|
||||
private static final class AccountJdbc extends JdbcTemplate {
|
||||
private final List<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 org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@@ -38,6 +40,13 @@ public interface WarehousePalletLocationClient {
|
||||
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@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,
|
||||
String inboundCode, String idempotencyKey, long operatorId,
|
||||
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
|
||||
@@ -75,4 +84,7 @@ public interface WarehousePalletLocationClient {
|
||||
String operatorName, String requestId, List<Long> releasedReservationIds,
|
||||
List<SettlementTransferItem> remainingItems) {}
|
||||
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
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
|
||||
import java.util.List;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
@@ -48,6 +49,15 @@ public class PlatformLoadingPdaController {
|
||||
return ApiResponse.success(service.task(containerNo, warehouseId));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/inventory-snapshot")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<InventorySnapshot> inventorySnapshot(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.inventorySnapshot(taskId, warehouseId));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/claim")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Task> claim(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
@@ -60,6 +60,7 @@ public final class LoadingPickDtos {
|
||||
@NotNull @Min(1) Long warehouseId,
|
||||
@NotBlank @Size(max = 64) String containerNo,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 64) String inventorySnapshotToken,
|
||||
@NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items
|
||||
) {
|
||||
}
|
||||
@@ -118,4 +119,18 @@ public final class LoadingPickDtos {
|
||||
String workflowStatus, Integer version, Assignment assignment,
|
||||
Review review, List<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;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
|
||||
@@ -15,6 +17,9 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
|
||||
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
@@ -26,6 +31,8 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -43,10 +50,13 @@ import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 装柜中 PDA 拣货任务及明细同步。 */
|
||||
@Service
|
||||
public class LoadingPickService {
|
||||
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
|
||||
private static final String SOURCE_SERVICE = "firstleg-service";
|
||||
@Resource
|
||||
private LoadingPickRepository repository;
|
||||
@Resource
|
||||
@@ -55,6 +65,10 @@ public class LoadingPickService {
|
||||
private LoadingListService loadingListService;
|
||||
@Resource
|
||||
private PdaPickSettlementService settlementService;
|
||||
@Resource
|
||||
private WarehousePalletLocationClient warehouse;
|
||||
@Value("${baiyue.security.internal-service-token:test-internal-service-token}")
|
||||
private String internalToken;
|
||||
|
||||
@Transactional
|
||||
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
||||
@@ -105,6 +119,12 @@ public class LoadingPickService {
|
||||
return view(task, warehouseId);
|
||||
}
|
||||
|
||||
public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) {
|
||||
LoadingPickTaskEntity task = requiredTask(taskId);
|
||||
List<LoadingPickItemEntity> items = warehouseScopedItems(task, warehouseId);
|
||||
return buildInventorySnapshot(task, warehouseId, items);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) {
|
||||
String operationId = operation(request.operationId());
|
||||
@@ -275,6 +295,7 @@ public class LoadingPickService {
|
||||
}
|
||||
|
||||
List<BatchLine> lines = validateBatchLines(task, request);
|
||||
validateInventorySnapshot(task, request, lines);
|
||||
for (BatchLine line : lines) {
|
||||
applyBatchLine(taskId, line, operationId, operatorId, operatorName);
|
||||
}
|
||||
@@ -554,6 +575,93 @@ public class LoadingPickService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<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,
|
||||
long operatorId, String operatorName) {
|
||||
LoadingPickItemEntity item = line.item();
|
||||
@@ -690,12 +798,13 @@ public class LoadingPickService {
|
||||
|
||||
private static String batchRequestHash(long taskId, BatchRequest request) {
|
||||
if (request == null || request.warehouseId() == null || !StringUtils.hasText(request.containerNo())
|
||||
|| request.expectedVersion() == null || request.items() == null || request.items().isEmpty()) {
|
||||
|| request.expectedVersion() == null || !StringUtils.hasText(request.inventorySnapshotToken())
|
||||
|| request.items() == null || request.items().isEmpty()) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "整批提交参数不完整");
|
||||
}
|
||||
StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|')
|
||||
.append(request.warehouseId()).append('|').append(request.containerNo().trim()).append('|')
|
||||
.append(request.expectedVersion());
|
||||
.append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim());
|
||||
request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId,
|
||||
Comparator.nullsFirst(Long::compareTo)))
|
||||
.forEach(item -> {
|
||||
|
||||
@@ -20,6 +20,16 @@ paths:
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}}
|
||||
responses:
|
||||
'200': {description: Claimed task or A0409 when another operator owns it}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/inventory-snapshot:
|
||||
get:
|
||||
summary: Read the current Warehouse inventory snapshot before atomic submission
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TaskId'}
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
responses:
|
||||
'200':
|
||||
description: Current occupied resources grouped by inbound order with a stable snapshot token
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/InventorySnapshot'}}}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/assign:
|
||||
post:
|
||||
summary: Assign or reassign a task with optimistic locking
|
||||
@@ -91,11 +101,12 @@ components:
|
||||
operationId: {type: string, maxLength: 128}
|
||||
BatchRequest:
|
||||
type: object
|
||||
required: [warehouseId, containerNo, expectedVersion, items]
|
||||
required: [warehouseId, containerNo, expectedVersion, inventorySnapshotToken, items]
|
||||
properties:
|
||||
warehouseId: {type: integer, format: int64, minimum: 1}
|
||||
containerNo: {type: string, maxLength: 64}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
inventorySnapshotToken: {type: string, minLength: 64, maxLength: 64}
|
||||
items:
|
||||
type: array
|
||||
minItems: 1
|
||||
@@ -122,3 +133,36 @@ components:
|
||||
expectedTaskVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
remark: {type: string, maxLength: 500}
|
||||
InventorySnapshot:
|
||||
type: object
|
||||
required: [taskId, taskVersion, containerNo, warehouseId, capturedAt, snapshotToken, inbounds]
|
||||
properties:
|
||||
taskId: {type: integer, format: int64}
|
||||
taskVersion: {type: integer, minimum: 0}
|
||||
containerNo: {type: string}
|
||||
warehouseId: {type: integer, format: int64}
|
||||
capturedAt: {type: string, format: date-time}
|
||||
snapshotToken: {type: string, minLength: 64, maxLength: 64}
|
||||
inbounds:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/InboundInventory'}
|
||||
InboundInventory:
|
||||
type: object
|
||||
required: [inboundId, availablePackageCount, resources]
|
||||
properties:
|
||||
inboundId: {type: integer, format: int64}
|
||||
availablePackageCount: {type: integer, minimum: 0}
|
||||
resources:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/InventoryResource'}
|
||||
InventoryResource:
|
||||
type: object
|
||||
required: [reservationId, occupancyId, palletId, locationId, sharedOverflow, availablePackageCount]
|
||||
properties:
|
||||
reservationId: {type: integer, format: int64}
|
||||
occupancyId: {type: integer, format: int64}
|
||||
palletId: {type: integer, format: int64}
|
||||
locationId: {type: integer, format: int64}
|
||||
locationCode: {type: string, nullable: true}
|
||||
sharedOverflow: {type: boolean}
|
||||
availablePackageCount: {type: integer, minimum: 1}
|
||||
|
||||
+3
@@ -15,6 +15,7 @@ class PdaConvergenceOpenApiContractTest {
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/first-leg/pda-loading/tasks:")
|
||||
.contains("/tasks/{taskId}/claim:")
|
||||
.contains("/tasks/{taskId}/inventory-snapshot:")
|
||||
.contains("/tasks/{taskId}/assign:")
|
||||
.contains("/tasks/{taskId}/items/{itemId}/pick:")
|
||||
.contains("/tasks/{taskId}/items/{itemId}/partial:")
|
||||
@@ -24,6 +25,8 @@ class PdaConvergenceOpenApiContractTest {
|
||||
.contains("/tasks/{taskId}/operations:")
|
||||
.contains("expectedAssignmentVersion")
|
||||
.contains("BatchRequest")
|
||||
.contains("inventorySnapshotToken")
|
||||
.contains("InventorySnapshot")
|
||||
.contains("expectedTaskVersion");
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ class PlatformLoadingPdaControllerContractTest {
|
||||
@Test
|
||||
void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception {
|
||||
authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, long.class);
|
||||
authority("inventorySnapshot", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class);
|
||||
authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class);
|
||||
authority("assign", "platform:firstleg:pda:assign", AuthenticatedUser.class, long.class, AssignRequest.class);
|
||||
authority("pick", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, PickRequest.class);
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
|
||||
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
@@ -44,6 +46,8 @@ class LoadingPickServiceTest {
|
||||
private LoadingListService loadingListService;
|
||||
@MockBean
|
||||
private PdaPickSettlementService settlementService;
|
||||
@MockBean
|
||||
private WarehousePalletLocationClient warehouse;
|
||||
@Resource
|
||||
private LoadingPickService service;
|
||||
|
||||
@@ -219,7 +223,11 @@ class LoadingPickServiceTest {
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
when(repository.touchTask(20L, 0)).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, List.of(
|
||||
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn(
|
||||
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
|
||||
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
|
||||
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
|
||||
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, snapshotToken, List.of(
|
||||
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
|
||||
new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton")));
|
||||
|
||||
@@ -249,7 +257,7 @@ class LoadingPickServiceTest {
|
||||
when(repository.assignment(20L)).thenReturn(assignment());
|
||||
when(repository.items(20L)).thenReturn(items);
|
||||
when(repository.items(20L, 101L)).thenReturn(items);
|
||||
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, List.of(
|
||||
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, "stale-token", List.of(
|
||||
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
|
||||
new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null)));
|
||||
|
||||
@@ -262,12 +270,36 @@ class LoadingPickServiceTest {
|
||||
verify(settlementService, never()).settle(20L, 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsChangedInventorySnapshotBeforeWritingAnyBatchLine() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
LoadingPickItemEntity picked = item();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.assignment(20L)).thenReturn(assignment());
|
||||
when(repository.items(20L)).thenReturn(List.of(picked));
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(picked));
|
||||
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn(
|
||||
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
|
||||
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
|
||||
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, "0".repeat(64), List.of(
|
||||
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null)));
|
||||
|
||||
assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("库存已变化");
|
||||
|
||||
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(),
|
||||
any(Integer.class), any(Long.class));
|
||||
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
|
||||
verify(settlementService, never()).settle(20L, 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBatchTaskVersionConflictBeforeWriting() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
task.setVersion(3);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 2, List.of(
|
||||
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 2, "stale-token", List.of(
|
||||
new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
|
||||
|
||||
assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator"))
|
||||
|
||||
@@ -200,12 +200,22 @@ export async function run(context) {
|
||||
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushed?.version) + 1,
|
||||
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const inventoryResponse = await context.request('GET',
|
||||
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '实时库存快照包含稳定令牌和可用箱数', inventoryResponse,
|
||||
typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64
|
||||
&& inventory?.inbounds?.some?.((item) => Number(item.availablePackageCount) >= 1),
|
||||
`token=${inventory?.snapshotToken ? 'present' : 'missing'}, inboundCount=${inventory?.inbounds?.length ?? 0}`, OPTIONS)
|
||||
if (!inventory?.snapshotToken) return
|
||||
|
||||
const batchPath = `${PDA}/tasks/${taskId}/batch`
|
||||
const batchItems = [{ itemId: id(pickItem.id), action: 'PICK', pickedPackageCount: 1,
|
||||
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
|
||||
version: Number(pickItem.version), reason: null }]
|
||||
const staleBatchResponse = await request(context, 'POST', batchPath, {
|
||||
warehouseId, containerNo, expectedVersion: Number(task.version) + 1, items: batchItems,
|
||||
warehouseId, containerNo, expectedVersion: Number(task.version) + 1,
|
||||
inventorySnapshotToken: inventory.snapshotToken, items: batchItems,
|
||||
}, { 'Idempotency-Key': `${context.marker}-pda-batch-stale`.slice(0, 128) })
|
||||
context.addStep(MODULE, 'PDA batch 旧任务版本返回 A0409', staleBatchResponse,
|
||||
staleBatchResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleBatchResponse)),
|
||||
@@ -222,7 +232,8 @@ export async function run(context) {
|
||||
&& afterFailedItem?.itemStatus === pickItem.itemStatus,
|
||||
`taskVersion=${afterFailedBatch?.version ?? 'missing'}, itemVersion=${afterFailedItem?.version ?? 'missing'}, picked=${afterFailedItem?.pickedPackageCount ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const batchPayload = { warehouseId, containerNo, expectedVersion: Number(task.version), items: batchItems }
|
||||
const batchPayload = { warehouseId, containerNo, expectedVersion: Number(task.version),
|
||||
inventorySnapshotToken: inventory.snapshotToken, items: batchItems }
|
||||
const batchHeaders = { 'Idempotency-Key': `${context.marker}-pda-batch`.slice(0, 128) }
|
||||
const pickResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders)
|
||||
const picked = context.apiSuccess(MODULE, '单次调用 PDA 原子 batch 完成拣货', pickResponse, '', OPTIONS)
|
||||
|
||||
@@ -207,11 +207,22 @@ export async function run(context) {
|
||||
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushedLoadingPlan?.version) + 1,
|
||||
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const inventoryResponse = await context.request('GET',
|
||||
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '实时库存快照包含当前入库单和稳定令牌', inventoryResponse,
|
||||
typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64
|
||||
&& inventory?.inbounds?.some?.((item) => id(item.inboundId) === inboundId
|
||||
&& Number(item.availablePackageCount) >= 1),
|
||||
`token=${inventory?.snapshotToken ? 'present' : 'missing'}, inboundCount=${inventory?.inbounds?.length ?? 0}`, OPTIONS)
|
||||
if (!inventory?.snapshotToken) return
|
||||
|
||||
const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
|
||||
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
|
||||
const batchPayload = {
|
||||
warehouseId, containerNo,
|
||||
expectedVersion: Number(claimed?.version),
|
||||
inventorySnapshotToken: inventory.snapshotToken,
|
||||
items: [{
|
||||
itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1,
|
||||
releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version),
|
||||
|
||||
+11
@@ -13,10 +13,12 @@ import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
@@ -81,6 +83,15 @@ public class InternalPalletLocationController {
|
||||
return ApiResponse.success(service.settle(request));
|
||||
}
|
||||
|
||||
@GetMapping("/occupied-resources")
|
||||
@Operation(summary = "查询业务当前占用资源", description = "PDA提交前使用的只读实时库存事实")
|
||||
public ApiResponse<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")
|
||||
@Operation(summary = "A0临时库位换绑", description = "将一条具体A0托盘占用换绑到同仓空闲普通库位")
|
||||
public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation(
|
||||
|
||||
@@ -458,6 +458,19 @@ public class LocationResourceService {
|
||||
.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 的一条托盘占用换绑到同仓空闲普通库位。 */
|
||||
@Transactional
|
||||
public LocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation(
|
||||
|
||||
@@ -153,9 +153,6 @@ public class OverseasStorageFeeService {
|
||||
.thenComparingInt(OverseasStorageFeeDtos.TierRequest::startDay));
|
||||
for (String feeType : List.of("STORAGE", "RETURN_STORAGE")) {
|
||||
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;
|
||||
for (int i = 0; i < group.size(); i++) {
|
||||
OverseasStorageFeeDtos.TierRequest tier = group.get(i);
|
||||
|
||||
+5
@@ -538,6 +538,11 @@ public class PalletLocationAllocationService {
|
||||
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,
|
||||
PalletVolumeSplitter.SplitResult split) {
|
||||
if (active.size() != split.palletCount()) {
|
||||
|
||||
+13
@@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.PalletAllocationDtos;
|
||||
import com.baiyue.warehouse.dto.LocationDtos;
|
||||
import com.baiyue.warehouse.service.PalletLocationAllocationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -65,6 +66,18 @@ class InternalPalletLocationControllerTest {
|
||||
verify(service).settle(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsOccupiedResourcesOnlyForInternalCaller() {
|
||||
List<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() {
|
||||
return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L,
|
||||
"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();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsOnlyCurrentOccupiedResourcesWithAllocatedPackageCounts() {
|
||||
LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L);
|
||||
active.setTenantId(0L);
|
||||
active.setSourceService("firstleg-service");
|
||||
active.setBusinessId(901L);
|
||||
active.setAllocatedPackageCount(7);
|
||||
LocationOccupancyEntity released = occupancy(202L, 102L, 10L, "A2", 502L);
|
||||
released.setOccupancyStatus("RELEASED");
|
||||
when(repository.findOccupanciesByBusiness(0L, "firstleg-service", 901L))
|
||||
.thenReturn(List.of(released, active));
|
||||
|
||||
var result = service.findOccupiedResources(0L, "firstleg-service", 901L);
|
||||
|
||||
assertThat(result).singleElement().satisfies(item -> {
|
||||
assertThat(item.reservationId()).isEqualTo(101L);
|
||||
assertThat(item.occupancyId()).isEqualTo(201L);
|
||||
assertThat(item.allocatedPackageCount()).isEqualTo(7);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase01() {
|
||||
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
|
||||
|
||||
+25
@@ -53,6 +53,26 @@ class OverseasStorageFeeServiceTest {
|
||||
verify(repository, never()).insertRule(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsVariableTierCountsEvenWhenTotalIsNine() {
|
||||
doAnswer(invocation -> {
|
||||
OverseasStorageFeeRuleEntity rule = invocation.getArgument(0);
|
||||
rule.setId(10L);
|
||||
return null;
|
||||
}).when(repository).insertRule(any());
|
||||
List<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
|
||||
void rejectsDuplicateCustomer() {
|
||||
when(repository.existsCustomer(10001L, null)).thenReturn(true);
|
||||
@@ -124,6 +144,11 @@ class OverseasStorageFeeServiceTest {
|
||||
return new OverseasStorageFeeDtos.TierRequest(start, end, new BigDecimal(price));
|
||||
}
|
||||
|
||||
private static OverseasStorageFeeDtos.TierRequest typedTier(String feeType, String billingUnit,
|
||||
int start, Integer end) {
|
||||
return new OverseasStorageFeeDtos.TierRequest(feeType, billingUnit, start, end, BigDecimal.ONE);
|
||||
}
|
||||
|
||||
private static OverseasStorageFeeRuleEntity rule() {
|
||||
OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity();
|
||||
rule.setId(10L);
|
||||
|
||||
Reference in New Issue
Block a user