feat(procurement): implement sample fulfillment and settled fee workflow

This commit is contained in:
2026-10-10 02:19:38 +08:00
parent 040f3bbd93
commit a973be2103
104 changed files with 9823 additions and 714 deletions
@@ -130,6 +130,14 @@ public class BusinessFeeSourceEventService {
}
positive(value.sourceFeeId(), "sourceFeeId");
positive(value.customerId(), "customerId");
if (value.walletSettlement() != null) {
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
}
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
if (!"LASTLEG".equals(value.sourceSystem()) || !"OVERSEAS_CONTAINER".equals(value.businessType()))
throw error("FIN-EVT-012", "海外柜费来源不一致");
@@ -160,7 +168,7 @@ public class BusinessFeeSourceEventService {
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(),
value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(),
value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null, value.walletSettlement());
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
}
@@ -189,7 +197,40 @@ public class BusinessFeeSourceEventService {
private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) {
if (!"RECEIVABLE".equals(source.direction())) return;
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND h.amount=f.amount",
if (source.walletSettlement() != null) {
var reference = source.walletSettlement();
long customerId = positive(source.customerId(), "customerId");
long holdId = positive(reference.holdId(), "holdId");
long ledgerId = positive(reference.ledgerId(), "ledgerId");
List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT l.amount,c.result_json FROM fin_wallet_hold h JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id JOIN fin_wallet_command_inbox c ON c.event_id=? AND c.status='SUCCEEDED' WHERE h.id=? AND l.id=? AND h.customer_id=? AND h.business_type=? AND h.business_no=? AND h.status='SETTLED' AND l.change_type='CONSUMPTION' FOR UPDATE",
reference.commandEventId(), holdId, ledgerId, customerId, source.businessType(), source.businessNo());
if (rows.size() != 1 || !(rows.get(0).get("amount") instanceof java.math.BigDecimal amount)
|| amount.negate().compareTo(source.totalAmount()) != 0) {
throw error("FIN-EVT-017", "费用来源与成功扣款的客户、业务或金额不匹配");
}
com.fasterxml.jackson.databind.JsonNode result;
try { result = objectMapper.readTree(String.valueOf(rows.get(0).get("result_json"))); }
catch (JsonProcessingException exception) { throw error("FIN-EVT-017", "原资金结果无法核对"); }
if (!"SUCCEEDED".equals(result.path("result").asText())
|| !Set.of("SETTLE", "SETTLE_ACTUAL").contains(result.path("operation").asText())
|| !reference.commandEventId().equals(result.path("commandEventId").asText())
|| !reference.holdId().equals(result.path("walletHoldId").asText())
|| !reference.ledgerId().equals(result.path("walletLedgerId").asText())
|| result.path("customerId").asLong() != customerId
|| !source.businessType().equals(result.path("businessType").asText())
|| !source.businessNo().equals(result.path("businessNo").asText())
|| result.path("amount").decimalValue().compareTo(source.totalAmount()) != 0) {
throw error("FIN-EVT-017", "费用来源引用的资金结果不匹配");
}
List<Long> used = jdbc.queryForList("SELECT id FROM fin_fee_source WHERE wallet_hold_id=? AND id<>? FOR UPDATE",
Long.class, holdId, feeSourceId);
if (!used.isEmpty()) throw error("FIN-EVT-017", "同一成功扣款已关联其他费用来源");
jdbc.update("UPDATE fin_fee_source SET wallet_hold_id=?,status='SETTLED' WHERE id=? AND customer_id=? AND status='AVAILABLE'",
holdId, feeSourceId, customerId);
return;
}
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id AND l.change_type='CONSUMPTION' SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND f.amount=-l.amount",
feeSourceId);
}
@@ -300,6 +300,113 @@ public class FinanceWalletService {
return completeHold(customerId, businessType, businessNo, idempotencyKey, request, false);
}
/** Settles one estimate without releasing its protection before checking any shortfall. */
@Transactional
public WalletHold settleActual(long customerId, String rawBusinessType, String rawBusinessNo,
String idempotencyKey, BigDecimal actualAmount, String remark) {
requirePositive(customerId, "客户");
BigDecimal amount = money(actualAmount);
String businessType = businessType(rawBusinessType);
String businessNo = businessNo(rawBusinessNo);
String operation = "WALLET_HOLD_SETTLE_ACTUAL";
String requestHash = hash(Map.of("customerId", customerId, "businessType", businessType,
"businessNo", businessNo, "amount", amount, "remark", String.valueOf(remark)));
Long replay = reserve(customerId, 0, operation, idempotencyKey, requestHash);
if (replay != null) return requiredHold(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
WalletHold hold = findHold(customerId, businessType, businessNo, true);
if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
WalletAccount after = settleActualAccount(before, hold, amount);
updateAccount(customerId, after, before.version(), 0);
WalletLedgerEntry ledger = insertLedger(customerId, "CONSUMPTION", amount.negate(), after,
businessType, businessNo, LocalDate.now(), null, businessNo, 0, "业务系统", remark);
// The hold retains the original estimate; its action ledger records the actual charge.
int updated = jdbc.update("UPDATE fin_wallet_hold SET status='SETTLED',action_ledger_id=?,completed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='FROZEN'",
ledger.ledgerId(), hold.holdId());
if (updated != 1) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
audit(customerId, "WALLET_HOLD", hold.holdId(), "SETTLE_ACTUAL", remark, before, after, 0, "业务系统");
complete(customerId, 0, operation, idempotencyKey, hold.holdId());
return requiredHold(hold.holdId(), customerId);
}
/** Refunds a successful consumption; recharge allocation rules apply without creating a recharge. */
@Transactional
public WalletLedgerEntry refundConsumption(long customerId, long originalLedgerId, String refundNo,
String idempotencyKey, BigDecimal refundAmount,
long operatorId, String operatorName, String remark) {
requirePositive(customerId, "客户");
requirePositive(originalLedgerId, "原扣款流水");
String reference = businessNo(refundNo);
BigDecimal amount = money(refundAmount);
if (!StringUtils.hasText(remark)) throw bad("退款原因不能为空");
String requestHash = hash(Map.of("customerId", customerId, "originalLedgerId", originalLedgerId,
"refundNo", reference, "amount", amount, "remark", remark));
Long replay = reserve(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, requestHash);
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
List<WalletLedgerEntry> originals = jdbc.query(
"SELECT * FROM fin_wallet_ledger WHERE id=? AND customer_id=? FOR UPDATE",
(rs, n) -> ledgerRow(rs), originalLedgerId, customerId);
if (originals.isEmpty()) throw error("FIN-WALLET-404", "原扣款流水不存在");
WalletLedgerEntry original = originals.get(0);
if (!"CONSUMPTION".equals(original.changeType()) || original.amount().signum() >= 0) {
throw bad("仅可对成功业务扣款发起关联退款");
}
List<WalletLedgerEntry> existing = jdbc.query(
"SELECT * FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND source_no=? FOR UPDATE",
(rs, n) -> ledgerRow(rs), customerId, reference);
if (!existing.isEmpty()) {
WalletLedgerEntry refunded = existing.get(0);
if (refunded.amount().compareTo(amount) != 0 || !original.businessNo().equals(refunded.referenceNo())) {
throw error("FIN-IDEMPOTENCY-CONFLICT", "同一退款单对应的原扣款或金额不一致");
}
complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, refunded.ledgerId());
return refunded;
}
// Locking reads see refunds committed while this transaction waited for the account lock,
// even under MySQL REPEATABLE READ after the idempotency lookup established a snapshot.
BigDecimal refunded = jdbc.queryForList(
"SELECT amount FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND reference_no=? FOR UPDATE",
BigDecimal.class, customerId, original.businessNo()).stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (refunded.add(amount).compareTo(original.amount().negate()) > 0) {
throw error("FIN-WALLET-REFUND-EXCEEDED", "退款金额超过原扣款剩余可退金额");
}
WalletAccount after = rechargeAccount(before, amount);
updateAccount(customerId, after, before.version(), operatorId);
WalletLedgerEntry result = insertLedger(customerId, "REFUND", amount, after,
"BUSINESS_REFUND", reference, LocalDate.now(), null, original.businessNo(),
operatorId, operatorName, remark);
audit(customerId, "WALLET_LEDGER", originalLedgerId, "REFUND", remark, before, after, operatorId, operatorName);
complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, result.ledgerId());
return result;
}
WalletAccount settleActualAccount(WalletAccount before, WalletHold hold, BigDecimal actualAmount) {
BigDecimal amount = money(actualAmount);
BigDecimal extra = amount.subtract(hold.amount()).max(BigDecimal.ZERO);
if (extra.signum() > 0 && before.availableBalance().compareTo(extra) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "实际费用补差所需的可用余额和授信额度不足");
}
BigDecimal actual = before.actualBalance();
BigDecimal creditUsed = before.creditUsed();
if (before.creditActive()) {
BigDecimal reservedCharge = amount.min(hold.amount());
BigDecimal cashCharge = reservedCharge.min(hold.actualFrozenAmount());
BigDecimal freeCash = actual.subtract(before.frozenBalance().subtract(before.creditFrozen()))
.max(BigDecimal.ZERO);
cashCharge = cashCharge.add(extra.min(freeCash));
actual = actual.subtract(cashCharge);
creditUsed = creditUsed.add(amount.subtract(cashCharge));
} else {
// Previously reserved credit still settles after expiry, without granting new credit.
actual = actual.subtract(amount);
}
return accountState(actual, before.frozenBalance().subtract(hold.amount()), before.creditLimit(),
creditUsed, before.creditFrozen().subtract(hold.creditFrozenAmount()),
before.creditExpiresOn(), before.version());
}
private WalletHold completeHold(long customerId, String rawBusinessType, String rawBusinessNo,
String idempotencyKey, WalletHoldActionRequest request, boolean settle) {
requirePositive(customerId, "客户");
@@ -29,9 +29,10 @@ import java.util.Set;
@Service
public class WalletCommandEventService {
private static final Set<String> OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE");
private static final Set<String> OPERATIONS = Set.of("FREEZE", "SETTLE", "SETTLE_ACTUAL", "RELEASE", "REFUND");
private static final Set<String> FINAL_REJECTIONS = Set.of(
"FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400");
"FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400",
"FIN-WALLET-REFUND-EXCEEDED", "FIN-WALLET-404");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceWalletService walletService;
@@ -56,10 +57,9 @@ public class WalletCommandEventService {
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
// Wallet methods join this transaction: money, the Inbox outcome and
// the result Outbox commit together, or all roll back.
WalletHold hold = execute(command);
if (hold == null) throw new IllegalStateException("资金操作未返回处理结果");
ExecutionResult execution = execute(command);
finalizeResult(envelope, completed(envelope.eventId(), command, "SUCCEEDED",
hold.status(), hold.amount(), null, null));
execution.status(), execution.amount(), null, null, execution.holdId(), execution.ledgerId()));
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
});
} catch (FinanceEventException exception) {
@@ -70,7 +70,7 @@ public class WalletCommandEventService {
requireLockedIdentity(envelope);
if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
finalizeResult(envelope, completed(envelope.eventId(), command, "FAILED",
null, command.amount(), exception.code(), exception.getMessage()));
null, command.amount(), exception.code(), exception.getMessage(), null, null));
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
});
}
@@ -86,21 +86,33 @@ public class WalletCommandEventService {
}
}
private WalletHold execute(WalletCommandRequestedV1 command) {
private record ExecutionResult(String status, BigDecimal amount, String holdId, String ledgerId) { }
private ExecutionResult execute(WalletCommandRequestedV1 command) {
if ("REFUND".equals(command.operation())) {
var refund = walletService.refundConsumption(command.customerId(), Long.parseLong(command.originalLedgerId()),
command.businessNo(), command.idempotencyKey(), command.amount(), 0, "业务系统", command.remark());
return new ExecutionResult("REFUNDED", refund.amount(), null, String.valueOf(refund.ledgerId()));
}
WalletHold hold = switch (command.operation()) {
case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(),
new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark()));
case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(),
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
case "SETTLE_ACTUAL" -> walletService.settleActual(command.customerId(), command.businessType(), command.businessNo(),
command.idempotencyKey(), command.amount(), command.remark());
case "RELEASE" -> walletService.release(command.customerId(), command.businessType(), command.businessNo(),
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效");
};
if ("SETTLE".equals(command.operation())) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=h.amount",
if (hold == null) throw new IllegalStateException("资金操作未返回处理结果");
if (Set.of("SETTLE", "SETTLE_ACTUAL").contains(command.operation())) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id AND l.change_type='CONSUMPTION' SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=-l.amount",
hold.holdId());
}
return hold;
Long ledgerId = "FREEZE".equals(command.operation()) ? hold.freezeLedgerId() : hold.actionLedgerId();
return new ExecutionResult(hold.status(), "SETTLE_ACTUAL".equals(command.operation()) ? command.amount() : hold.amount(),
String.valueOf(hold.holdId()), ledgerId == null ? null : String.valueOf(ledgerId));
}
private WalletCommandRequestedV1 validate(EventEnvelope envelope) {
@@ -118,14 +130,22 @@ public class WalletCommandEventService {
String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT);
if (!OPERATIONS.contains(operation) || value.customerId() <= 0 || blank(value.businessType())
|| blank(value.businessNo()) || blank(value.idempotencyKey())
|| ("FREEZE".equals(operation) && (value.amount() == null || value.amount().signum() <= 0))) {
|| (Set.of("FREEZE", "SETTLE_ACTUAL", "REFUND").contains(operation)
&& (value.amount() == null || value.amount().signum() <= 0 || value.amount().scale() > 2))) {
throw new FinanceEventException("FIN-EVT-001", "资金指令字段无效");
}
if ("REFUND".equals(operation)) {
if (blank(value.originalLedgerId()) || Long.parseLong(value.originalLedgerId()) <= 0 || blank(value.remark())) {
throw new FinanceEventException("FIN-EVT-001", "退款必须关联原扣款并填写原因");
}
} else if (value.originalLedgerId() != null) {
throw new FinanceEventException("FIN-EVT-001", "非退款指令不能指定原扣款");
}
if (!String.valueOf(value.customerId()).equals(envelope.tenantId())) {
throw new FinanceEventException("FIN-EVT-007", "资金指令客户归属不一致");
}
return new WalletCommandRequestedV1(operation, value.customerId(), value.businessType(), value.businessNo(),
value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt());
value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt(), value.originalLedgerId());
} catch (FinanceEventException exception) { throw exception; }
catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令载荷无法解析"); }
}
@@ -159,9 +179,10 @@ public class WalletCommandEventService {
return count != null && count > 0;
}
private WalletCommandCompletedV1 completed(String eventId, WalletCommandRequestedV1 command, String result,
String holdStatus, BigDecimal amount, String errorCode, String errorMessage) {
String holdStatus, BigDecimal amount, String errorCode, String errorMessage,
String holdId, String ledgerId) {
return new WalletCommandCompletedV1(eventId, command.operation(), command.customerId(), command.businessType(),
command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now());
command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now(), holdId, ledgerId);
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }