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
@@ -43,6 +43,8 @@ public class OverseasFeeSource {
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class) private List<String> paymentVoucherFileIds;
@TableField(typeHandler = JacksonTypeHandler.class)
private com.baiyue.common.event.WalletSettlementReference walletSettlement;
private LocalDate businessDate;
private String currency;
private BigDecimal amount;
@@ -9,5 +9,6 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface OverseasFeeSourceMapper extends BaseMapper<OverseasFeeSource> {
@Select("SELECT * FROM ll_finance_fee_source WHERE source_fee_id=#{sourceFeeId} ORDER BY source_version DESC LIMIT 1 FOR UPDATE")
@org.apache.ibatis.annotations.ResultMap("mybatis-plus_OverseasFeeSource")
OverseasFeeSource lockCurrent(@Param("sourceFeeId") long sourceFeeId);
}
@@ -69,7 +69,7 @@ public class OverseasFeeOutboxService {
value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(),
value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(),
value.getReviewStatus(),
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash);
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash, value.getWalletSettlement());
}
private static String text(Long value) { return value == null ? null : String.valueOf(value); }
@@ -79,14 +79,20 @@ public class OverseasFeeSourceService {
if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送");
}
private static void requireNonProcurement(String sourceType,String businessType) {
if("OVERSEAS_PROCUREMENT_FEE".equals(sourceType) || "OVERSEAS_PROCUREMENT".equals(businessType) || "PROCUREMENT_FEE".equals(businessType))
throw conflict("采购费用必须通过采购履约或财务节点确认,实扣成功后自动关联来源");
}
@Transactional
public OverseasFeeSource create(String operatorId, String key, SaveRequest request) {
requireNonProcurement(request.sourceType(),request.businessType());
OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request);
if (replay != null) return current(replay.getSourceFeeId());
validate(request);
businessGuard.requireOwned(request);
long sourceFeeId = IdWorker.getId();
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request);
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, null);
mapper.insert(value);
command(operatorId, "CREATE", key, request, sourceFeeId, 1);
return value;
@@ -108,11 +114,45 @@ public class OverseasFeeSourceService {
return value;
}
private record SettledRequest(SaveRequest fee, OverseasFeeCurrencyConverter.Conversion conversion,
com.baiyue.common.event.WalletSettlementReference settlement) { }
/** Internal business entry: money has already succeeded and its confirmed FX must not be recalculated. */
@Transactional
public OverseasFeeSource publishSettled(String operatorId, String key, SaveRequest request,
OverseasFeeCurrencyConverter.Conversion conversion,
com.baiyue.common.event.WalletSettlementReference settlement) {
if (!"RECEIVABLE".equals(request.direction()) || conversion == null || settlement == null
|| text(settlement.commandEventId()) == null || text(settlement.holdId()) == null || text(settlement.ledgerId()) == null
|| !request.currency().equals(conversion.originalCurrency())
|| request.amount().compareTo(conversion.originalAmount()) != 0
|| conversion.settlementAmount().signum() <= 0 || conversion.exchangeRate().signum() <= 0
|| !request.businessDate().toString().substring(0, 7).equals(conversion.exchangeRateMonth())) {
throw bad("已扣费用的资金引用或确认汇率快照不完整");
}
var confirmed = new SettledRequest(request, conversion, settlement);
OverseasFeeCommand replay = replay(operatorId, "PUBLISH_SETTLED", key, confirmed);
if (replay != null) return current(replay.getSourceFeeId());
validate(request);
businessGuard.requireOwned(request);
long sourceFeeId = IdWorker.getId();
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, conversion);
value.setWalletSettlement(settlement);
value.setReviewStatus("APPROVED"); value.setPublicationStatus("PUBLISHED");
value.setReviewerId(operatorId); value.setReviewedAt(now());
value.setReviewReason("已核定业务费用扣款成功后发布");
mapper.insert(value);
outbox.publish(value, "PUBLISHED");
command(operatorId, "PUBLISH_SETTLED", key, confirmed, sourceFeeId, 1);
return value;
}
@Transactional
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType());
if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus()))
throw conflict("只有草稿或已驳回费用可以提交");
value.setReviewStatus("PENDING"); value.setReviewReason(null);
@@ -126,6 +166,7 @@ public class OverseasFeeSourceService {
OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType());
if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()))
throw conflict("只有待审核的有效费用可以审核");
boolean approved = "APPROVE".equals(request.decision());
@@ -143,6 +184,8 @@ public class OverseasFeeSourceService {
OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource old = lock(sourceFeeId);
if (old.getWalletSettlement() != null) throw conflict("已扣费用只能通过关联的财务差额或退款调整");
requireNonProcurement(old.getSourceType(),old.getBusinessType());
if (cancellation.requested(old)) throw conflict("该费用已申请作废,请先处理财务结果");
if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus())
|| old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整");
@@ -157,7 +200,7 @@ public class OverseasFeeSourceService {
|| !Objects.equals(old.getContainerNo(), text(request.fee().containerNo())))
throw conflict("调整不得改变费用来源或业务归属");
old.setSourceStatus("SUPERSEDED"); old.setUpdatedBy(operatorId); old.setUpdatedAt(now()); mapper.updateById(old);
OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee());
OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee(), null);
next.setReviewStatus("PENDING");
mapper.insert(next);
command(operatorId, "ADJUST", request.idempotencyKey(), request, sourceFeeId, next.getSourceVersion());
@@ -169,6 +212,8 @@ public class OverseasFeeSourceService {
OverseasFeeCommand replay = replay(operatorId, "VOID", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
requireNonProcurement(value.getSourceType(),value.getBusinessType());
if (value.getWalletSettlement() != null) throw conflict("已扣费用不能作废,请使用关联退款");
if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())
|| value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废");
if (cancellation.requested(value)) return cancellation.describe(List.of(value)).get(0);
@@ -183,7 +228,8 @@ public class OverseasFeeSourceService {
return value;
}
private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request) {
private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request,
OverseasFeeCurrencyConverter.Conversion confirmedConversion) {
OverseasFeeSource value = new OverseasFeeSource();
value.setId(id); value.setSourceFeeId(sourceFeeId); value.setSourceVersion(version);
value.setCustomerId(request.customerId().trim()); value.setCustomerName(text(request.customerName()));
@@ -196,7 +242,8 @@ public class OverseasFeeSourceService {
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate());
OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate());
OverseasFeeCurrencyConverter.Conversion conversion = confirmedConversion != null ? confirmedConversion
: currencyConverter.convert(request.currency(), request.amount(), request.businessDate());
value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount());
value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount());
value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth());