This commit is contained in:
2026-10-21 07:53:31 +08:00
parent af5cf1e3bd
commit f00d0e16cf
147 changed files with 1696 additions and 872 deletions
@@ -33,6 +33,7 @@ public class OverseasProcurementFee {
private Long sourceFeeId;
private Long parentFeeId;
private String requestHash;
private String idempotencyKey;
private String feeItemCode;
private String feeItemName;
private BigDecimal amount;
@@ -3,6 +3,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper<OverseasProcurementFee> {
/** 调用方持有订单锁时,读取当前退款版本上界。 */
@org.apache.ibatis.annotations.Select("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=#{orderId} AND purpose='REFUND'")
int maxRefundVersion(@org.apache.ibatis.annotations.Param("orderId") long orderId);
@org.apache.ibatis.annotations.Select("SELECT * FROM ll_overseas_procurement_fee WHERE id=#{id} AND order_id=#{orderId} AND customer_id=#{customerId} FOR UPDATE")
OverseasProcurementFee lock(@org.apache.ibatis.annotations.Param("id") long id,
@org.apache.ibatis.annotations.Param("orderId") long orderId,
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.procurement.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import org.springframework.stereotype.Repository;
/** 采购退款的请求身份独立于展示单号,跨日重试仍定位原费用。 */
@Repository
public class ProcurementFeeNumberRepository {
private final OverseasProcurementFeeMapper mapper;
/** 使用采购费用所属服务的 Mapper。 */
public ProcurementFeeNumberRepository(OverseasProcurementFeeMapper mapper) { this.mapper = mapper; }
/** 上层已持有订单锁,同客户同订单的退款请求只对应一个费用。 */
public OverseasProcurementFee findRefund(String customerId, long orderId, String key) {
return mapper.selectOne(new LambdaQueryWrapper<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getCustomerId, customerId)
.eq(OverseasProcurementFee::getOrderId, orderId)
.eq(OverseasProcurementFee::getPurpose, "REFUND")
.eq(OverseasProcurementFee::getIdempotencyKey, key));
}
/** 版本沿用同订单退款序列,业务编号不承担费用版本含义。 */
public int nextRefundVersion(long orderId) { return mapper.maxRefundVersion(orderId) + 1; }
}
@@ -43,7 +43,7 @@ import org.springframework.web.multipart.MultipartFile;
@Service
public class OverseasProcurementService {
@Resource private ProcurementOrderNumberGenerator orderNoGenerator;
@Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator;
public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -61,6 +61,7 @@ public class OverseasProcurementService {
@Resource private OverseasProcurementFeeMapper procurementFees;
@Resource private ProcurementFileGateway fileGateway;
/** 创建客户采购订单,首次请求分配 CG 日流水。 */
@Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
OverseasProcurementRequests.Create request) {
@@ -74,7 +75,7 @@ public class OverseasProcurementService {
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setCustomerName(trim(request.customerName()));
order.setOrderNo(orderNoGenerator.next(customerId, request.customerName()));
order.setOrderNo(orderNoGenerator.next("CG"));
order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim());
order.setProductName(request.productName().trim());
order.setProductSpecification(trim(request.productSpecification()));
@@ -16,6 +16,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService
@Service @RequiredArgsConstructor
public class ProcurementAdjustmentService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final ProcurementLifecycleService lifecycle;
private final ProcurementFinanceService finance;
private final JdbcTemplate jdbc;
@@ -23,6 +25,7 @@ public class ProcurementAdjustmentService {
BigDecimal exchangeRate,String exchangeRateMonth,BigDecimal targetAmount,BigDecimal previousNetAmount,
BigDecimal differenceAmount,List<String> feeNos) { }
private record Identity(long rootFeeId,Correct request) { }
/** 更正原采购费用并以 PG 日流水标识本次更正记录。 */
@Transactional
public Result correct(String customer,String orderNo,String operator,long rootFeeId,Correct request) {
var order=lifecycle.lock(customer,orderNo);var identity=new Identity(rootFeeId,request);
@@ -37,7 +40,7 @@ public class ProcurementAdjustmentService {
.subtract(chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add));
BigDecimal target=request.targetOriginalAmount().multiply(root.getExchangeRate()).setScale(2,RoundingMode.HALF_UP),delta=target.subtract(net);
if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围");
long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();
long id=IdWorker.getId();String no=documentNumbers.next("PG");
// The node command carries the exact request hash; this table stores the computed immutable monetary facts.
jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
id,order.getId(),customer,no,rootFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),root.getOriginalCurrency(),root.getExchangeRate(),root.getExchangeRateMonth(),target,net,delta,request.voucherFileId(),request.reason(),operator);
@@ -47,6 +50,7 @@ public class ProcurementAdjustmentService {
lifecycle.complete(order,operator,"CORRECT_FEE_TARGET",request.idempotencyKey(),request.reason(),identity);
return result(order.getId(),request.idempotencyKey());
}
/** 替换失败费用,保留原费用链并分配新的 PG 更正记录号。 */
@Transactional
public Result replaceFailed(String customer,String orderNo,String operator,long originalFeeId,Correct request) {
var order=lifecycle.lock(customer,orderNo);var identity=new Identity(originalFeeId,request);
@@ -57,7 +61,7 @@ public class ProcurementAdjustmentService {
BigDecimal target=request.targetOriginalAmount().multiply(original.getExchangeRate()).setScale(2,RoundingMode.HALF_UP);
if("SAMPLE_PAYMENT".equals(original.getPurpose()))lifecycle.requireReplacementBudget(order,request.targetOriginalAmount(),original.getOriginalCurrency(),original.getBusinessDate(),target);
if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围");
long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();
long id=IdWorker.getId();String no=documentNumbers.next("PG");
jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
id,order.getId(),customer,no,originalFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),original.getOriginalCurrency(),original.getExchangeRate(),original.getExchangeRateMonth(),target,BigDecimal.ZERO,target,request.voucherFileId(),request.reason(),operator);
var fee=finance.replaceFailed(customer,orderNo,operator,originalFeeId,request.targetOriginalAmount(),request.voucherFileId(),request.reason());
@@ -13,6 +13,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService
@Service @RequiredArgsConstructor
public class ProcurementControlService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final ProcurementLifecycleService lifecycle;
private final ProcurementFinanceService finance;
private final ProcurementWarehouseDirectory warehouses;
@@ -141,5 +143,6 @@ public class ProcurementControlService {
return result;
}
private String serialize(Object value){if(value==null)return null;try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();}
/** 说明和关闭记录使用各自独立的日流水。 */
private String number(String prefix){return documentNumbers.next(prefix);}
}
@@ -38,6 +38,9 @@ import java.util.UUID;
@Service
@RequiredArgsConstructor
public class ProcurementFinanceService implements WalletResultHandler {
@jakarta.annotation.Resource private com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository feeNumbers;
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final OverseasProcurementFeeMapper fees;
private final OverseasProcurementFileMapper files;
private final OverseasProcurementOrderMapper orders;
@@ -118,7 +121,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
LocalDateTime now = LocalDateTime.now();
OverseasProcurementFee fee = new OverseasProcurementFee();
fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(order.getCustomerId());
fee.setFeeNo("PF-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase());
fee.setFeeNo(documentNumbers.next("PF"));
fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1);
fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code);
fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : request.purpose().label());
@@ -205,6 +208,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
var order = lockOrder(customerId, orderNo);
return refundLocked(order,operatorId,originalFeeId,amount,voucherFileId,reason,idempotencyKey,true);
}
/** 订单锁内先恢复同幂等键退款,首次请求校验后才分配独立 PF 业务号。 */
private OverseasProcurementFee refundLocked(OverseasProcurementOrder order,String operatorId,long originalFeeId,
BigDecimal amount,long voucherFileId,String reason,String idempotencyKey,boolean checkChain) {
String customerId=order.getCustomerId(),orderNo=order.getOrderNo();
@@ -212,11 +216,8 @@ public class ProcurementFinanceService implements WalletResultHandler {
var request = new Confirmation(ProcurementFeePurpose.REFUND, "REFUND", "退款", "POSITIVE", amount,
"CNY", LocalDate.now(), voucherFileId, reason);
validate(request);
// Identity is independent of wall-clock time, so retrying after midnight returns the same refund.
String number = "RF-" + UUID.nameUUIDFromBytes((customerId + ':' + orderNo + ':' + idempotencyKey)
.getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString().replace("-", "").toUpperCase();
String requestHash = hash(List.of(originalFeeId, amount, voucherFileId, reason));
var existing = fees.selectOne(new LambdaQueryWrapper<OverseasProcurementFee>().eq(OverseasProcurementFee::getFeeNo, number));
var existing = feeNumbers.findRefund(customerId, order.getId(), idempotencyKey);
if (existing != null) {
if (!requestHash.equals(existing.getRequestHash())) throw conflict("退款幂等键对应不同原扣款或金额");
return existing;
@@ -237,10 +238,11 @@ public class ProcurementFinanceService implements WalletResultHandler {
if (already.add(amount).compareTo(original.getAmount()) > 0) throw bad("退款超过原扣款可退余额");
var voucher = voucher(order, request, true);
var fee = new OverseasProcurementFee();
String number = documentNumbers.next("PF");
fee.setIdempotencyKey(idempotencyKey);
fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(customerId); fee.setFeeNo(number);
fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("REFUND"); fee.setFeeItemCode("REFUND"); fee.setFeeItemName("关联费用退款");
Integer last = jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='REFUND'", Integer.class, order.getId());
fee.setFeeVersion(last == null ? 1 : last + 1); fee.setActive(true); fee.setDetermination("POSITIVE");
fee.setFeeVersion(feeNumbers.nextRefundVersion(order.getId())); fee.setActive(true); fee.setDetermination("POSITIVE");
fee.setAmount(amount); fee.setCurrency("CNY"); fee.setOriginalAmount(amount); fee.setOriginalCurrency("CNY");
fee.setExchangeRate(BigDecimal.ONE); fee.setExchangeRateMonth(request.businessDate().toString().substring(0, 7));
fee.setBusinessDate(request.businessDate()); fee.setParentFeeId(originalFeeId); fee.setWalletBusinessId(fee.getId());
@@ -272,7 +274,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
requireSourceCapacity(delta,delta);
var proof=voucher(order,new Confirmation(ProcurementFeePurpose.ADJUSTMENT,"ADJUSTMENT","追加费用","POSITIVE",delta,"CNY",root.getBusinessDate(),voucherId,reason),true);
var fee=new OverseasProcurementFee();fee.setId(IdWorker.getId());fee.setOrderId(order.getId());fee.setCustomerId(customer);
fee.setFeeNo("PF-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase());fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT");
fee.setFeeNo(documentNumbers.next("PF"));fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT");
fee.setFeeItemCode("ADJ_"+root.getId());fee.setFeeItemName("原费用正额调整");
fee.setFeeVersion(jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0)+1 FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='ADJUSTMENT' AND fee_item_code=?",Integer.class,order.getId(),fee.getFeeItemCode()));
fee.setActive(true);fee.setDetermination("POSITIVE");fee.setAmount(delta);fee.setCurrency("CNY");fee.setOriginalCurrency("CNY");fee.setOriginalAmount(delta);
@@ -1,48 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import net.sourceforge.pinyin4j.PinyinHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
@Component
public class ProcurementOrderNumberGenerator {
private final JdbcTemplate jdbc;
public ProcurementOrderNumberGenerator(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@Transactional
public String next(String customerId, String customerName) {
LocalDate date = LocalDate.now();
jdbc.update("INSERT INTO ll_procurement_daily_sequence(customer_id,business_date,sequence_value) VALUES(?,?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE sequence_value=LAST_INSERT_ID(sequence_value+1)",
customerId, date);
Integer value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Integer.class);
if (value == null || value < 1 || value > 9999) throw new IllegalStateException("客户采购订单当日序号已耗尽");
return initials(customerName) + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%04d", value);
}
static String initials(String customerName) {
if (!StringUtils.hasText(customerName)) return "KH";
StringBuilder result = new StringBuilder();
boolean latinWord = false;
for (char value : customerName.trim().toCharArray()) {
if (result.length() >= 8) break;
if (Character.UnicodeScript.of(value) == Character.UnicodeScript.HAN) {
String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(value);
if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) result.append(Character.toUpperCase(pinyin[0].charAt(0)));
latinWord = false;
} else if (Character.isLetterOrDigit(value)) {
if (!latinWord) result.append(Character.toUpperCase(value));
latinWord = true;
} else {
latinWord = false;
}
}
String normalized = result.toString().replaceAll("[^A-Z0-9]", "").toUpperCase(Locale.ROOT);
return normalized.isBlank() ? "KH" : normalized.substring(0, Math.min(normalized.length(), 8));
}
}
@@ -19,6 +19,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService
@Service
@RequiredArgsConstructor
public class ProcurementPhysicalService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final ProcurementLifecycleService lifecycle;
private final ProcurementWarehouseDirectory warehouses;
private final ProcurementFinanceService finance;
@@ -327,5 +329,6 @@ public class ProcurementPhysicalService {
}
private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("箱规须为正数且最多两位小数,毛重须为正数且最多三位小数");}
private static long add(long a,long b){try{return Math.addExact(a,b);}catch(ArithmeticException e){throw bad("数量超过可处理范围");}}
private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();}
/** 实收记录使用独立 PR 日流水。 */
private String number(String prefix){return documentNumbers.next(prefix);}
}
@@ -19,6 +19,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService
/** Every shipment is a single immutable consumption of this order's entire eligible batch. */
@Service @RequiredArgsConstructor
public class ProcurementTransportService {
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
private final ProcurementLifecycleService lifecycle;
private final ProcurementPhysicalService physical;
private final ProcurementFinanceService finance;
@@ -142,6 +144,7 @@ public class ProcurementTransportService {
return snapshot(order);
}
/** 确认采购交寄并记录 PJ 日流水,保留原单关联与重放结果。 */
@Transactional
public Snapshot ship(String customer,String orderNo,String operator,Ship request) {
var order=lifecycle.lock(customer,orderNo);
@@ -155,7 +158,7 @@ public class ProcurementTransportService {
} else {
if(request.shippedAt().isBefore(((LocalDateTime)flow.get("domestic_received_at"))))throw bad("交寄时间不能早于国内收货完成");
}
long id=IdWorker.getId();String number="PS-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();
long id=IdWorker.getId();String number=documentNumbers.next("PJ");
jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?)",
id,order.getId(),customer,number,request.leg().name(),labels.manifestId(),request.shippedAt(),serialize(new Addresses(labels.sender(),labels.receiver())),operator);
for(var carton:cargo) {