费用提交

This commit is contained in:
2026-10-26 13:41:43 +08:00
parent 0fe1d10566
commit 761fe3686e
171 changed files with 8750 additions and 386 deletions
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.storagerent.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.Pricing;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
/** 仓租价格权威仍为国内仓库服务,不在海外维护第二份客户报价。 */
@FeignClient(name="warehouse-service",contextId="customerStoragePricingClient",url="${baiyue.clients.domestic-warehouse.url:}")
public interface CustomerStoragePricingClient {
/** 使用内部服务令牌读取指定真实库存客户的当前仓租价格。 */
@GetMapping("/internal/warehouse/v1/customer-overseas-storage-fees/{customerId}/pricing")
ApiResponse<Pricing> pricing(@PathVariable("customerId") long customerId,
@RequestHeader("X-Internal-Service-Token") String token);
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.storagerent.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.service.*;
import com.baiyue.lastleg.storagerent.service.preparation.StorageRentPreparationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** PDA 仓租只允许操作本人绑定仓。 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/pda/v1/last-leg/storage-rent")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaStorageRentController {
private final StorageRentService rents;
private final StorageRentPreparationService preparations;
/** 扫码前确认仓租与余额授信冻结结果。 */
@PostMapping("/preparations")
public ApiResponse<PreparationResult> prepare(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody PrepareRequest request) {
return ApiResponse.success(preparations.prepare(PdaWarehouseIdentity.require(user),request));
}
/** 查询已提交预占的真实财务回执状态。 */
@GetMapping("/preparations/{id}")
public ApiResponse<PreparationResult> status(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
return ApiResponse.success(rents.preparation(String.valueOf(PdaWarehouseIdentity.require(user).overseasWarehouseId()),id));
}
/** 放弃扫码释放尚未使用的仓租占用。 */
@DeleteMapping("/preparations/{id}")
public ApiResponse<PreparationResult> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
return ApiResponse.success(rents.cancel(String.valueOf(PdaWarehouseIdentity.require(user).overseasWarehouseId()),id));
}
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.storagerent.controller;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.service.StorageRentService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/** 海外仓租原批次及逐次出库费用查询。 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/storage-rent")
@PreAuthorize("hasAuthority('platform:overseas-fee:list')")
public class PlatformStorageRentController {
private final StorageRentService service;
private final com.baiyue.lastleg.storagerent.service.StorageRentReviewService reviews;
/** 审核前修改仓租金额并强制保存原因,入口权限与其他费用编辑一致。 */
@PostMapping("/fees/{sourceFeeId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<com.baiyue.lastleg.financefee.entity.OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId,@jakarta.validation.Valid @RequestBody AdjustRentRequest request){
platform(user);return ApiResponse.success(reviews.adjust(sourceFeeId,String.valueOf(user.userId()),request));
}
/** 按客户、业务及库存状态分页查询。 */
@GetMapping
public ApiResponse<PageResult<LotView>> list(@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute Query query) {
platform(user);return ApiResponse.success(service.list(query));
}
/** 一个原批次下只列本批实际产生的费用。 */
@GetMapping("/{id}")
public ApiResponse<LotDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
platform(user);return ApiResponse.success(service.detail(id));
}
/** 查询已有计租批次对应仓库。 */
@GetMapping("/warehouse-options")
public ApiResponse<List<WarehouseOption>> warehouses(@AuthenticationPrincipal AuthenticatedUser user) {
platform(user);return ApiResponse.success(service.warehouseOptions());
}
/** 资金与客户信息只允许平台作用域查询。 */
private static void platform(AuthenticatedUser user) {
if(user==null||user.tenantId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode())||user.tenantId()!=0)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可查询仓租");
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.storagerent.dto;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.List;
/** 仓租列表、来源快照与 PDA 预校验协议。 */
public final class StorageRentDtos {
/** 仅承载协议定义。 */
private StorageRentDtos() { }
public record PrepareRequest(@NotBlank @Pattern(regexp="DISPATCH|OUTBOUND|OUTBOUND_PALLET|DISCARD") String mode,
@NotBlank @Size(max=128) String businessNo, Long itemId, Long positionId, @Positive Long packages,
String cartonMark, String palletNo, @NotBlank @Size(max=255) String idempotencyKey,
List<String> cartonMarks, String skuCode, @PositiveOrZero Long productQuantity,
List<com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos.ActualLine> lines,
List<Long> photoFileIds,List<String> scannedCartonMarks,Boolean confirmAllCartons) {
/** 普通扫码不承载废弃作业的申请明细。 */
public PrepareRequest(String mode,String businessNo,Long itemId,Long positionId,Long packages,
String cartonMark,String palletNo,String idempotencyKey,List<String> cartonMarks,String skuCode,Long productQuantity) {
this(mode,businessNo,itemId,positionId,packages,cartonMark,palletNo,idempotencyKey,cartonMarks,skuCode,productQuantity,null,null,null,null);
}
}
public record PreparationResult(String preparationId, String status, boolean ready, BigDecimal amount,
String currency, BigDecimal originalAmount, String originalCurrency, BigDecimal exchangeRate,
String exchangeRateMonth, String message) { }
public record Query(String customerId, String businessNo, String warehouseId, String businessType,
String status, Long page, Long size) { }
public record WarehouseOption(String id, String warehouseName) { }
public record AdjustRentRequest(@NotNull @Min(1) Integer expectedVersion,
@NotNull @DecimalMin("0.00") @Digits(integer=12,fraction=2) BigDecimal amount,
@NotBlank @Size(max=500) String reason, @NotBlank @Size(max=128) String idempotencyKey) { }
public record Owner(String customerId, String customerName, String businessType, Long businessId,
String businessNo, Long orderId, String inboundCode, String warehouseId) { }
public record Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount,
LocalDateTime inboundAt, long days) { }
public record PricingTier(String id,Integer startDay,Integer endDay,BigDecimal unitPrice,Integer sortNo,
String feeType,String billingUnit) { }
public record Pricing(String configId,Long customerId,Integer version,String currency,List<PricingTier> tiers,String source) {
/** 固化不可变阶梯快照,防止调用方修改已确认的计费依据。 */
public Pricing { if(tiers!=null)tiers=List.copyOf(tiers); }
}
public record Plan(Owner owner, List<Allocation> allocations, LocalDateTime calculatedAt, BigDecimal amount,Pricing pricing) {
/** 本轮旧测试构造继续明确使用报价表默认值,实际预占必须读取配置服务。 */
public Plan(Owner owner,List<Allocation> allocations,LocalDateTime calculatedAt,BigDecimal amount) {
this(owner,allocations,calculatedAt,amount,com.baiyue.lastleg.storagerent.service.StorageRentCalculator.defaults(owner==null?null:Long.valueOf(owner.customerId())));
}
}
public record CurrencyAmount(String currency,BigDecimal amount) { }
public record LotView(Long id,String customerId,String customerName,String businessType,String businessNo,
String warehouseId,String warehouseName,String batchNo,OffsetDateTime inboundAt,long storageDays,
BigDecimal originalQuantity,BigDecimal remainingQuantity,String quantityUnit,BigDecimal billingQuantity,
String billingUnit,String ruleCode,String status,BigDecimal generatedAmount,String generatedCurrency,List<CurrencyAmount> generatedAmounts,
BigDecimal settledAmount,long chargeCount,long settledChargeCount) { }
public record ChargeView(Long id,String outboundNo,String businessNo,BigDecimal quantity,long storageDays,
BigDecimal originalAmount,String currency,BigDecimal settlementAmount,BigDecimal currencyRate,String status,
OffsetDateTime createdAt,OffsetDateTime outboundAt) { }
public record LotDetail(LotView lot,List<ChargeView> charges) { }
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.storagerent.entity;
import lombok.Data;
/** 当前计费服务单只读投影,仓租不操作其他业务聚合。 */
@Data
public class StorageRentBusinessSource {
private Long id;
private String businessNo;
private Long lastlegOrderId;
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.storagerent.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 实收原批次,库龄及计费基数不可被换标或移库重置。 */
@Data
@TableName("ll_storage_rent_lot")
public class StorageRentLot {
/** 原批次主键。 */
@TableId(type=IdType.INPUT)
private Long id;
/** 实收来源幂等键。 */
private String sourceKey;
/** 客户标识。 */
private String customerId;
/** 客户名称。 */
private String customerName;
/** 海外仓标识。 */
private String warehouseId;
/** 海外仓名称。 */
private String warehouseName;
/** 原入库业务类型。 */
private String businessType;
/** 原入库业务主键。 */
private Long businessId;
/** 原入库业务单号。 */
private String businessNo;
/** 尾程订单主键。 */
private Long orderId;
/** 入仓码。 */
private String inboundCode;
/** 普通库存余额主键。 */
private Long balanceId;
/** 原实收批次。 */
private String batchNo;
/** 商品编码。 */
private String skuCode;
/** 仓租规则。 */
private String ruleCode;
/** 实收原始数量。 */
private BigDecimal originalQuantity;
/** 未出库原始数量。 */
private BigDecimal remainingQuantity;
/** 原批次计费体积或托数。 */
private BigDecimal originalUnits;
/** 实际入库时间。 */
private LocalDateTime inboundAt;
/** 创建时间。 */
private LocalDateTime createdAt;
/** 更新时间。 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.storagerent.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 一次扫码的资金冻结与不可变仓租快照。 */
@Data
@TableName("ll_storage_rent_preparation")
public class StorageRentPreparation {
/** 预校验主键。 */
@TableId(type=IdType.INPUT)
private Long id;
/** 作业海外仓。 */
private String warehouseId;
/** 费用客户。 */
private String customerId;
/** 扫码类型。 */
private String mode;
/** 实际作业单号。 */
private String executionNo;
/** 原扫码幂等键。 */
private String requestKey;
/** 原扫码内容哈希。 */
private String requestHash;
/** 冻结尝试次数。 */
private Integer attempt;
/** 本次应收业务类型。 */
private String businessType;
/** 本次应收业务主键。 */
private Long businessId;
/** 本次应收业务单号。 */
private String businessNo;
/** 资金与扫码状态。 */
private String status;
/** 原批次计费不可变快照。 */
private String planJson;
/** 美元仓租金额。 */
private BigDecimal originalAmount;
/** 人民币结算金额。 */
private BigDecimal amount;
/** 冻结确认汇率。 */
private BigDecimal exchangeRate;
/** 汇率月份。 */
private String exchangeRateMonth;
/** 冻结指令事件。 */
private String freezeEventId;
/** 扣款指令事件。 */
private String settleEventId;
/** 解冻指令事件。 */
private String releaseEventId;
/** 解冻回执状态,失败后由恢复任务重发。 */
private String releaseStatus;
/** 同一预占资金命令版本,重试使用新版本保留审计。 */
private Long walletVersion;
/** 财务冻结主键。 */
private String holdId;
/** 财务扣款流水。 */
private String ledgerId;
/** 资金处理结果。 */
@TableField(updateStrategy=FieldStrategy.ALWAYS)
private String errorMessage;
/** 财务费用来源主键。 */
private Long sourceFeeId;
/** 预校验时间。 */
private LocalDateTime createdAt;
/** 实际扫描出库时间。 */
private LocalDateTime consumedAt;
/** 更新时间。 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.storagerent.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 退货箱内商品与原计租批次的数量归属。 */
@Data
@TableName("ll_storage_rent_share")
public class StorageRentShare {
/** 普通库存当前余额,退货箱份额为空。 */
private Long balanceId;
/** 普通库存当前批次,换标搬移保持原计租批次引用。 */
private String batchNo;
/** 份额主键。 */
@TableId(type=IdType.INPUT)
private Long id;
/** 原计租批次主键。 */
private Long lotId;
/** 客户标识。 */
private String customerId;
/** 海外仓标识。 */
private String warehouseId;
/** 当前箱唛。 */
private String cartonMark;
/** 商品编码。 */
private String skuCode;
/** 剩余商品数量。 */
private BigDecimal quantity;
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.storagerent.gateway;
import com.baiyue.lastleg.storagerent.client.CustomerStoragePricingClient;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/** 无配置时只接受权威服务明确返回的默认报价,网络或非法配置不能降级免租。 */
@Component
public class StorageRentPricingGateway {
@Resource private CustomerStoragePricingClient client;
@Value("${baiyue.security.internal-service-token}") private String token;
/** 不缓存客户报价;每次新预占读取当前版本,消费只用已保存的快照。 */
public Pricing current(String customer) {
try {
long id=Long.parseLong(customer);var response=client.pricing(id,token);
if(response==null||!response.successful()||response.data()==null)throw invalid();
var pricing=response.data();validate(pricing,id);return pricing;
} catch(ResponseStatusException failure){throw failure;}
catch(Exception failure){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"客户仓租配置暂不可用,不能出库",failure);}
}
/** 保存和重放都校验完整阶梯,避免缺档、错单位或混币产生低收费。 */
public static void validate(Pricing pricing,long customer) {
if(pricing==null||!Objects.equals(pricing.customerId(),customer)||!("USD".equals(pricing.currency())||"CNY".equals(pricing.currency()))
||!("CUSTOMER".equals(pricing.source())||"DEFAULT".equals(pricing.source()))||pricing.tiers()==null||pricing.tiers().isEmpty()
||pricing.version()!=null&&pricing.version()<0||"CUSTOMER".equals(pricing.source())&&(pricing.version()==null||pricing.configId()==null||pricing.configId().isBlank()))throw invalid();
for(var tier:pricing.tiers())if(tier==null||tier.startDay()==null||tier.startDay()<1||tier.unitPrice()==null||tier.unitPrice().signum()<0
||!("STORAGE".equals(tier.feeType())&&"CBM".equals(tier.billingUnit())||"RETURN_STORAGE".equals(tier.feeType())&&"PALLET".equals(tier.billingUnit())))throw invalid();
for(String type:List.of("STORAGE","RETURN_STORAGE")) {
var tiers=pricing.tiers().stream().filter(t->type.equals(t.feeType())).sorted(Comparator.comparing(PricingTier::startDay)).toList();
if(tiers.isEmpty())throw invalid();long next=1;
for(int i=0;i<tiers.size();i++){var t=tiers.get(i);if(t.startDay()!=next||t.endDay()!=null&&t.endDay()<t.startDay())throw invalid();
if(t.endDay()==null){if(i!=tiers.size()-1)throw invalid();next=-1;}else next=(long)t.endDay()+1;}
if(next!=-1)throw invalid();
}
}
/** 配置异常是出库门禁,绝不返回空阶梯或零金额替代。 */
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.CONFLICT,"客户仓租配置不完整,不能出库");}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.storagerent.mapper;
import com.baiyue.lastleg.storagerent.entity.StorageRentBusinessSource;
import org.apache.ibatis.annotations.*;
/** 同服务本地只读业务归属投影,不借用其他聚合的写 Mapper。 */
@Mapper
public interface StorageRentBusinessSourceMapper {
/** 换标出库号解析该次独立服务单。 */
@Select("SELECT id,relabel_no AS business_no,lastleg_order_id FROM ll_relabel_application WHERE customer_id=#{customer} AND outbound_order_no=#{outbound} LIMIT 1")
StorageRentBusinessSource relabel(@Param("customer") String customer,@Param("outbound") String outbound);
/** 排车来源换标单号精确匹配当前客户。 */
@Select("SELECT id,relabel_no AS business_no,lastleg_order_id FROM ll_relabel_application WHERE customer_id=#{customer} AND relabel_no=#{number} LIMIT 1")
StorageRentBusinessSource relabelNo(@Param("customer") String customer,@Param("number") String number);
/** 仓储转运独立业务单归属。 */
@Select("SELECT id,transfer_no AS business_no,lastleg_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} LIMIT 1")
StorageRentBusinessSource transfer(@Param("customer") String customer,@Param("number") String number);
/** 原尾程投影映射唯一仓储转运业务。 */
@Select("SELECT id,transfer_no AS business_no,lastleg_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND lastleg_order_id=#{id} LIMIT 1")
StorageRentBusinessSource transferByTail(@Param("customer") String customer,@Param("id") Long id);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.storagerent.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.storagerent.entity.StorageRentLot;
/** 实收原批次,库龄及计费基数不可被换标或移库重置持久化。 */
@Mapper
public interface StorageRentLotMapper extends BaseMapper<StorageRentLot> {
/** 当前读实收计租登记,补入库及出库后重放均不重复累计。 */
@org.apache.ibatis.annotations.Select("SELECT lot_id FROM ll_storage_rent_inbound WHERE receipt_id=#{receiptId} FOR UPDATE")
Long inboundRentLot(@org.apache.ibatis.annotations.Param("receiptId") long receiptId);
/** 同一实收凭证仅登记一次,异常回滚同时撤销批次和份额增量。 */
@org.apache.ibatis.annotations.Insert("INSERT INTO ll_storage_rent_inbound(receipt_id,lot_id,quantity,created_at) VALUES(#{receiptId},#{lotId},#{quantity},UTC_TIMESTAMP(3))")
int recordInboundRent(@org.apache.ibatis.annotations.Param("receiptId") long receiptId,@org.apache.ibatis.annotations.Param("lotId") long lotId,@org.apache.ibatis.annotations.Param("quantity") java.math.BigDecimal quantity);
/** 仓库名称是原业务引用快照,选项不构建独立仓库主档。 */
@org.apache.ibatis.annotations.Select("SELECT warehouse_id AS id,MAX(warehouse_name) AS warehouse_name FROM ll_storage_rent_lot GROUP BY warehouse_id ORDER BY warehouse_name,warehouse_id")
java.util.List<com.baiyue.lastleg.storagerent.dto.StorageRentDtos.WarehouseOption> warehouseOptions();
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.storagerent.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.storagerent.entity.StorageRentPreparation;
/** 一次扫码的资金冻结与不可变仓租快照持久化。 */
@Mapper
public interface StorageRentPreparationMapper extends BaseMapper<StorageRentPreparation> {
/** 关联原批次快照保留所有实际出库费用;直接展开避免关联子查询优化导致漏行。 */
@org.apache.ibatis.annotations.Select("SELECT DISTINCT p.* FROM ll_storage_rent_preparation p JOIN JSON_TABLE(p.plan_json,'$.allocations[*]' COLUMNS(lot_id BIGINT PATH '$.lotId')) a ON a.lot_id=#{lotId} WHERE p.consumed_at IS NOT NULL ORDER BY p.consumed_at DESC,p.id DESC")
java.util.List<StorageRentPreparation> charges(@org.apache.ibatis.annotations.Param("lotId") long lotId);
/** 分页批量展开快照,多个份额命中同笔费用时去重,防止重复累计扣款。 */
@org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT p.* FROM ll_storage_rent_preparation p JOIN JSON_TABLE(p.plan_json,'$.allocations[*]' COLUMNS(lot_id BIGINT PATH '$.lotId')) a ON a.lot_id IN <foreach collection='lotIds' item='id' open='(' close=')' separator=','>#{id}</foreach> WHERE p.consumed_at IS NOT NULL ORDER BY p.consumed_at DESC,p.id DESC</script>")
java.util.List<StorageRentPreparation> chargesForLots(@org.apache.ibatis.annotations.Param("lotIds") java.util.List<Long> lotIds);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.storagerent.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.storagerent.entity.StorageRentShare;
/** 退货箱内商品与原计租批次的数量归属持久化。 */
@Mapper
public interface StorageRentShareMapper extends BaseMapper<StorageRentShare> { }
@@ -0,0 +1,170 @@
package com.baiyue.lastleg.storagerent.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.storagerent.entity.*;
import com.baiyue.lastleg.storagerent.mapper.*;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.*;
/** 仓租全部持久化及同服务来源解析,服务不直接访问 Mapper。 */
@Repository
public class StorageRentRepository {
@Resource private StorageRentLotMapper lots;
@Resource private StorageRentShareMapper shares;
@Resource private StorageRentPreparationMapper preparations;
@Resource private LastlegOrderMapper orders;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private DispatchAggregateMapper dispatches;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundPdaTaskMapper tasks;
@Resource private OutboundOrderMapper outbounds;
@Resource private OutboundPalletItemMapper outboundItems;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private ReturnOrderMapper returns;
@Resource private ReturnReceiptMapper returnReceipts;
@Resource private ReturnReceiptItemMapper returnReceiptItems;
@Resource private ReturnReceiptBatchMapper returnBatches;
@Resource private StorageRentBusinessSourceMapper businessSources;
/** 按唯一实收来源读取,防止重复入库增加仓租基数。 */
public StorageRentLot source(String key) { return lots.selectOne(new LambdaQueryWrapper<StorageRentLot>().eq(StorageRentLot::getSourceKey,key)); }
/** 当前读锁定合并批次,避免事务快照漏掉其他入库事务刚建的批次。 */
public StorageRentLot lockSource(String key) {return lots.selectOne(new LambdaQueryWrapper<StorageRentLot>().eq(StorageRentLot::getSourceKey,key).last("FOR UPDATE"));}
/** 头程同单补入库串行化,不使用库存剩余量判定凭证是否已处理。 */
public LastlegOrder lockOrder(long id) {return orders.selectOne(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getId,id).last("FOR UPDATE"));}
/** 读取已登记的实收凭证,出库清空份额后仍保持幂等。 */
public Long inboundRentLot(long receiptId) {return lots.inboundRentLot(receiptId);}
/** 登记本次实收对原计租批次的增量,同入库事务提交。 */
public void recordInboundRent(long receiptId,long lotId,java.math.BigDecimal quantity) {lots.recordInboundRent(receiptId,lotId,quantity);}
/** 补入库查找含零数量的原库存份额,避免违反同批次唯一约束。 */
public StorageRentShare lockStockShare(long lotId,long balanceId,String batchNo) {return shares.selectOne(new LambdaQueryWrapper<StorageRentShare>()
.eq(StorageRentShare::getLotId,lotId).eq(StorageRentShare::getBalanceId,balanceId).eq(StorageRentShare::getBatchNo,batchNo).last("FOR UPDATE"));}
/** 保存新的原始计租批次。 */
public void insert(StorageRentLot lot) { lots.insert(lot); }
/** 锁定原批次,出库扣减与实际库存处于同一事务。 */
public StorageRentLot lockLot(long id) { return lots.selectOne(new LambdaQueryWrapper<StorageRentLot>().eq(StorageRentLot::getId,id).last("FOR UPDATE")); }
/** 普通库存只解析同一原余额和原批次,不能跨入仓码猜来源。 */
public List<StorageRentLot> stockLots(long balanceId,String batchNo) { return lots.selectList(new LambdaQueryWrapper<StorageRentLot>().eq(StorageRentLot::getBalanceId,balanceId).eq(StorageRentLot::getBatchNo,batchNo).gt(StorageRentLot::getRemainingQuantity,0).orderByAsc(StorageRentLot::getId).last("FOR UPDATE")); }
/** 当前库存的原实收份额跨 SKU 和批次搬移后仍引用原计租批次。 */
public List<StorageRentShare> stockShares(String customer,String warehouse,long balanceId,String batchNo) {
return shares.selectList(new LambdaQueryWrapper<StorageRentShare>().eq(StorageRentShare::getCustomerId,customer)
.eq(StorageRentShare::getWarehouseId,warehouse).eq(StorageRentShare::getBalanceId,balanceId).eq(StorageRentShare::getBatchNo,batchNo)
.gt(StorageRentShare::getQuantity,0).orderByAsc(StorageRentShare::getLotId,StorageRentShare::getId).last("FOR UPDATE"));
}
/** 更新已实际出库后的原批次剩余数量。 */
public void save(StorageRentLot lot) { lots.updateById(lot); }
/** 保存原托商品归属,合箱不复制计租基数。 */
public void insert(StorageRentShare share) { shares.insert(share); }
/** 锁定某箱某商品的原批次归属,顺序固定避免并发合箱与出库互相覆盖。 */
public List<StorageRentShare> shares(String customer,String warehouse,String mark,String sku) {
return shares.selectList(new LambdaQueryWrapper<StorageRentShare>().eq(StorageRentShare::getCustomerId,customer)
.eq(StorageRentShare::getWarehouseId,warehouse).eq(StorageRentShare::getCartonMark,mark)
.eq(sku!=null,StorageRentShare::getSkuCode,sku).gt(StorageRentShare::getQuantity,0)
.orderByAsc(StorageRentShare::getLotId,StorageRentShare::getId).last("FOR UPDATE"));
}
/** 按主键锁定冻结时记录的商品归属。 */
public StorageRentShare lockShare(long id) { return shares.selectOne(new LambdaQueryWrapper<StorageRentShare>().eq(StorageRentShare::getId,id).last("FOR UPDATE")); }
/** 更新或清空原托在当前箱的剩余份额。 */
public void save(StorageRentShare share) { shares.updateById(share); }
/** 签收后首次清点可重建该批次分箱,业务层确保尚未出库。 */
public void deleteShares(long lotId) { shares.delete(new LambdaQueryWrapper<StorageRentShare>().eq(StorageRentShare::getLotId,lotId)); }
/** 锁定同一次扫码的最新资金尝试,保留旧尝试供解冻回执关联。 */
public StorageRentPreparation latest(String warehouse,String mode,String key) {
return preparations.selectOne(new LambdaQueryWrapper<StorageRentPreparation>().eq(StorageRentPreparation::getWarehouseId,warehouse)
.eq(StorageRentPreparation::getMode,mode).eq(StorageRentPreparation::getRequestKey,key)
.orderByDesc(StorageRentPreparation::getAttempt).last("LIMIT 1 FOR UPDATE"));
}
/** 回执及取消按主键锁定原冻结记录。 */
public StorageRentPreparation lockPreparation(long id) { return preparations.selectOne(new LambdaQueryWrapper<StorageRentPreparation>().eq(StorageRentPreparation::getId,id).last("FOR UPDATE")); }
/** 费用列表批量读取本次出库的精确计费依据。 */
public List<StorageRentPreparation> forFees(Collection<Long> ids) { return ids.isEmpty()?List.of():preparations.selectList(new LambdaQueryWrapper<StorageRentPreparation>().in(StorageRentPreparation::getSourceFeeId,ids)); }
/** 创建资金尝试,唯一键保证扫码重试不会重复冻结。 */
public void insert(StorageRentPreparation value) { preparations.insert(value); }
/** 保存冻结、扣款、解冻状态及原事件身份。 */
public void save(StorageRentPreparation value) { preparations.updateById(value); }
/** 取消作业释放未使用的冻结。 */
public List<StorageRentPreparation> pending(String mode,String execution) { return preparations.selectList(new LambdaQueryWrapper<StorageRentPreparation>().eq(StorageRentPreparation::getMode,mode).eq(StorageRentPreparation::getExecutionNo,execution).in(StorageRentPreparation::getStatus,"FREEZE_PENDING","READY","FAILED").orderByAsc(StorageRentPreparation::getId).last("FOR UPDATE")); }
/** 列表只查询指定客户、仓库与业务类型,避免全量内存分页。 */
public Page<StorageRentLot> page(Query q,long page,long size) {
var filter=new LambdaQueryWrapper<StorageRentLot>().eq(text(q.customerId()),StorageRentLot::getCustomerId,q.customerId())
.like(text(q.businessNo()),StorageRentLot::getBusinessNo,q.businessNo()).eq(text(q.warehouseId()),StorageRentLot::getWarehouseId,q.warehouseId())
.eq(text(q.businessType()),StorageRentLot::getBusinessType,q.businessType());
if("IN_STOCK".equals(q.status()))filter.gt(StorageRentLot::getRemainingQuantity,0);
if("CLEARED".equals(q.status())||"OUTBOUNDED".equals(q.status()))filter.eq(StorageRentLot::getRemainingQuantity,0);
return lots.selectPage(new Page<>(page,size),filter.orderByDesc(StorageRentLot::getInboundAt,StorageRentLot::getId));
}
/** 详情读取原始批次。 */
public StorageRentLot lot(long id) { return lots.selectById(id); }
/** 批量读取费用快照引用的原始计租来源,避免逐条明细查询。 */
public List<StorageRentLot> lots(Collection<Long> ids) {return ids.isEmpty()?List.of():lots.selectBatchIds(ids);}
/** 根据快照中原批次编号查出库费用,JSON_SEARCH 不依赖模糊数字匹配。 */
public List<StorageRentPreparation> charges(long lotId) { return preparations.charges(lotId); }
/** 当前页一次读取所有已出库快照,避免每行执行 JSON_TABLE。 */
public List<StorageRentPreparation> charges(List<Long> lotIds) {return lotIds.isEmpty()?List.of():preparations.chargesForLots(lotIds);}
/** 限量查出过期或失败记录,业务恢复逐条在独立事务锁定复核。 */
public List<Long> recoveryCandidates(java.time.LocalDateTime expiry,java.time.LocalDateTime retryBefore) {
return preparations.selectList(new LambdaQueryWrapper<StorageRentPreparation>()
.and(w->w.and(x->x.in(StorageRentPreparation::getStatus,"FREEZE_PENDING","READY").lt(StorageRentPreparation::getCreatedAt,expiry))
.or(x->x.and(y->y.in(StorageRentPreparation::getStatus,"SETTLE_FAILED","REVIEW_FREEZE_FAILED").or().eq(StorageRentPreparation::getReleaseStatus,"FAILED")).lt(StorageRentPreparation::getUpdatedAt,retryBefore)))
.orderByAsc(StorageRentPreparation::getUpdatedAt).last("LIMIT 100")).stream().map(StorageRentPreparation::getId).toList();
}
/** 仓库选项来自当前计租业务引用,不建立第二套仓库主档。 */
public List<WarehouseOption> warehouseOptions() { return lots.warehouseOptions(); }
/** 业务来源订单。 */
public LastlegOrder order(Long id) { return id==null?null:orders.selectById(id); }
/** 原扫码选择的真实库存位置。 */
public InventoryPosition position(Long id) { return id==null?null:positions.selectById(id); }
/** 原库存余额所属订单。 */
public InventoryBalance balance(Long id) { return id==null?null:balances.selectById(id); }
/** 排车仓库及状态锁。 */
public DispatchAggregate dispatch(String number) { return dispatches.lock(number); }
/** 排车单明细,用于解析真实费用客户和业务单。 */
public DispatchItem dispatchItem(Long id) { return id==null?null:dispatchItems.selectById(id); }
/** PDA 出库工单与绑定仓校验。 */
public OutboundPdaTask task(String warehouse,String number) { return tasks.lockByNoAndWarehouse(number,warehouse); }
/** 实际出库单。 */
public OutboundOrder outbound(Long id) { return outbounds.selectById(id); }
/** 出库明细的原商品与原申请快照。 */
public OutboundPalletItem outboundItem(Long id) { return id==null?null:outboundItems.selectById(id); }
/** 原退货箱,商品数量和份额单独锁定。 */
public ReturnInventoryCarton carton(String mark) { return cartons.lockMark(mark); }
/** 原签收分箱列表。 */
public List<ReturnInventoryCarton> receiptCartons(long id) { return cartons.lockReceipt(id); }
/** 箱内每商品的当前实物数量。 */
public List<ReturnInventoryCartonItem> items(long id) { return cartonItems.lockCarton(id); }
/** 历史清点修复也锁定原始签收凭证,不根据箱码推断签收时间或原托数。 */
public ReturnReceipt lockReturnReceipt(long id) {return returnReceipts.selectOne(new LambdaQueryWrapper<ReturnReceipt>().eq(ReturnReceipt::getId,id).last("FOR UPDATE"));}
/** 原退货业务归属必须与签收、库存箱和计租批次完全相同。 */
public ReturnOrder returnOrder(long id) {return returns.selectById(id);}
/** 原签收商品明细是件数权威,不能从清点输入补造原数量。 */
public List<ReturnReceiptItem> returnReceiptItems(long id) {return returnReceiptItems.selectList(new LambdaQueryWrapper<ReturnReceiptItem>().eq(ReturnReceiptItem::getReceiptId,id).last("FOR UPDATE"));}
/** 已保存的签收计费起点用于交叉核实历史时间和仓库。 */
public List<ReturnReceiptBatch> returnReceiptBatches(long id) {return returnBatches.selectList(new LambdaQueryWrapper<ReturnReceiptBatch>().eq(ReturnReceiptBatch::getReceiptId,id).last("FOR UPDATE"));}
/** 本次退货应收归属。 */
public ReturnOrder returnOrder(String customer,String no) { return returns.selectOne(new LambdaQueryWrapper<ReturnOrder>().eq(ReturnOrder::getCustomerId,customer).eq(ReturnOrder::getReturnNo,no)); }
/** 从换标出库单解析当前业务申请。 */
public StorageRentBusinessSource relabel(String customer,String outbound) { return businessSources.relabel(customer,outbound); }
/** 排车明细使用明确换标业务单号匹配当前独立服务申请。 */
public StorageRentBusinessSource relabelNo(String customer,String no) { return businessSources.relabelNo(customer,no); }
/** 按明确的转运单号解析归属。 */
public StorageRentBusinessSource transfer(String customer,String no) { return businessSources.transfer(customer,no); }
/** 原订单映射其独立仓储转运工单。 */
public StorageRentBusinessSource transferByTail(String customer,Long id) { return businessSources.transferByTail(customer,id); }
/** 不把空字符串用于查询过滤。 */
private static boolean text(String value) { return value!=null&&!value.isBlank(); }
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.storagerent.scheduler;
import com.baiyue.lastleg.storagerent.service.StorageRentService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** 未使用预占自动释放,资金失败保留原账龄并恢复处理。 */
@Slf4j
@Component
public class StorageRentRecoveryScheduler {
@Resource private StorageRentService service;
/** 单条失败不阻断其余客户恢复,事务锁保证多实例不会重复发布。 */
@Scheduled(fixedDelayString="${lastleg.storage-rent.recovery-delay-ms:60000}")
public void recover() {
for(Long id:service.recoveryCandidates())try{service.recover(id);}catch(Exception failure){log.warn("仓租资金恢复失败 preparationId={}",id,failure);}
}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.storagerent.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
/** 仓租按实收时刻起每满或不足二十四小时进一天,分段累加。 */
public final class StorageRentCalculator {
/** 纯计费工具不允许实例化。 */
private StorageRentCalculator() { }
/** 零时长不收费,任何正时长向上取整,禁止以自然日期代替库龄。 */
public static long days(LocalDateTime inbound, LocalDateTime outbound) {
if (inbound == null || outbound == null || outbound.isBefore(inbound)) throw new IllegalArgumentException("计租时间无效");
Duration elapsed = Duration.between(inbound, outbound);
long seconds = elapsed.getSeconds();
return seconds / 86400 + (seconds % 86400 > 0 || elapsed.getNano() > 0 ? 1 : 0);
}
/** 返回每单位的累计租金,区间端点与报价表保持一致。 */
public static BigDecimal unitAmount(String rule, long days) {
return unitAmount(defaults(null),rule,days);
}
/** 已确认的客户阶梯按业务类型分组累加,不能混用退货和中转单位。 */
public static BigDecimal unitAmount(Pricing pricing,String rule,long days) {
if(days<0)throw new IllegalArgumentException("库龄不能为负数");
String type=switch(rule){case "TRANSIT"->"STORAGE";case "RETURN"->"RETURN_STORAGE";default->throw new IllegalArgumentException("仓租规则无效");};
var tiers=pricing.tiers().stream().filter(t->type.equals(t.feeType())).toList();
if(tiers.isEmpty())throw new IllegalArgumentException("仓租报价缺少业务阶梯");
return tiers.stream().map(t->BigDecimal.valueOf(Math.max(0,Math.min(days,t.endDay()==null?Long.MAX_VALUE:t.endDay())-t.startDay()+1)).multiply(t.unitPrice())).reduce(BigDecimal.ZERO,BigDecimal::add);
}
/** 每次出库只承担原批次对应份额,退货原托的拆分不重新向上取托数。 */
public static BigDecimal amount(String rule, LocalDateTime inbound, LocalDateTime outbound,
BigDecimal originalUnits, BigDecimal originalQuantity, BigDecimal shippedQuantity) {
return amount(defaults(null),rule,inbound,outbound,originalUnits,originalQuantity,shippedQuantity);
}
/** 实际出库使用预占所保存的价格快照,不读取客户当前报价。 */
public static BigDecimal amount(Pricing pricing,String rule,LocalDateTime inbound,LocalDateTime outbound,
BigDecimal originalUnits,BigDecimal originalQuantity,BigDecimal shippedQuantity) {
if (originalUnits == null || originalUnits.signum() <= 0 || originalQuantity == null || originalQuantity.signum() <= 0
|| shippedQuantity == null || shippedQuantity.signum() <= 0 || shippedQuantity.compareTo(originalQuantity) > 0)
throw new IllegalArgumentException("计租体积或原批次数量不完整");
return unitAmount(pricing,rule, days(inbound, outbound)).multiply(originalUnits).multiply(shippedQuantity)
.divide(originalQuantity, 12, RoundingMode.HALF_UP);
}
/** 两张报价表仅作为明确默认报价及本轮测试构造,服务故障不调用此默认值。 */
public static Pricing defaults(Long customer) {
return new Pricing(null,customer,null,"USD",List.of(
tier("STORAGE","CBM",1,7,"0"),tier("STORAGE","CBM",8,30,"0.8"),tier("STORAGE","CBM",31,60,"1"),tier("STORAGE","CBM",61,90,"1.5"),tier("STORAGE","CBM",91,null,"2"),
tier("RETURN_STORAGE","PALLET",1,30,"1.5"),tier("RETURN_STORAGE","PALLET",31,60,"2"),tier("RETURN_STORAGE","PALLET",61,90,"3"),tier("RETURN_STORAGE","PALLET",91,null,"4")),"DEFAULT");
}
/** 默认阶梯也保留业务和单位,使快照协议完全一致。 */
private static PricingTier tier(String type,String unit,int start,Integer end,String rate){return new PricingTier(null,start,end,new BigDecimal(rate),start,type,unit);}
}
@@ -0,0 +1,213 @@
package com.baiyue.lastleg.storagerent.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.common.event.WalletSettlementReference;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.*;
import com.baiyue.lastleg.financefee.entity.*;
import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository;
import com.baiyue.lastleg.financefee.service.*;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.entity.StorageRentPreparation;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.*;
import java.time.*;
import java.util.*;
import java.security.MessageDigest;
/** 出库仓租沿原冻结审核和调整;资金成功回执才确认实扣,不生成第二笔应收。 */
@Service
@RequiredArgsConstructor
public class StorageRentReviewService implements StorageRentFeeWorkflow {
private final StorageRentRepository rents;
private final OverseasManualFeeRepository fees;
private final OverseasFeeSourceService sources;
private final OverseasFeeCurrencyConverter converter;
private final WalletCommandOutboxService wallet;
private final ObjectMapper json;
/** 来源标记只用于路由,实际写入还要核对仓租和费用持久化关联。 */
public static boolean isRent(OverseasFeeSource fee) {
return "STORAGE_RENT".equals(fee.getCreatedBy()) && "FEE_TYPE_077".equals(fee.getFeeItemCode());
}
/** 旧版已扣仓租没有审核资金编排,仍由原回执状态机收尾。 */
public boolean managed(StorageRentPreparation rent){return rent.getSourceFeeId()!=null&&fees.funding(rent.getSourceFeeId())!=null;}
/** 出库事务内登记应收和原资金占用,尚未审核时不发结算指令。 */
public void create(StorageRentPreparation rent, SaveRequest request, Plan plan) {
var conversion = new OverseasFeeCurrencyConverter.Conversion(plan.pricing().currency(),rent.getOriginalAmount(),
rent.getExchangeRate(),rent.getExchangeRateMonth(),rent.getAmount());
var fee = sources.createPendingStorageRent(rent.getId().toString(),request,conversion);
rent.setSourceFeeId(fee.getSourceFeeId()); rent.setStatus("PENDING_REVIEW");
var funding = new OverseasFeeFunding(); funding.setSourceFeeId(fee.getSourceFeeId());
funding.setCustomerId(rent.getCustomerId()); funding.setCollectionMode("WALLET");
funding.setWalletStatus("FROZEN"); funding.setWalletBusinessNo(rent.getId().toString());
funding.setWalletHoldId(rent.getHoldId()); funding.setCommandVersion(rent.getWalletVersion());
funding.setCommandHistory(new LinkedHashMap<>()); funding.setCreatedAt(now()); funding.setUpdatedAt(now());
funding.getCommandHistory().put("holdAmount",rent.getAmount().toPlainString());
if(rent.getFreezeEventId()!=null)funding.getCommandHistory().put("initialFreeze",rent.getFreezeEventId());
fees.insertFunding(funding);
}
/** 列表及订单明细共用精确入库、出库及实扣依据,零元不伪造扣款流水。 */
public void describe(List<OverseasFeeSource> rows) {
var ids=rows.stream().filter(StorageRentReviewService::isRent).map(OverseasFeeSource::getSourceFeeId).toList();
var preparations=new HashMap<Long,StorageRentPreparation>();
for(var rent:rents.forFees(ids))preparations.put(rent.getSourceFeeId(),rent);
var plans=new HashMap<Long,Plan>();
for(var rent:preparations.values())plans.put(rent.getId(),plan(rent));
var lotIds=plans.values().stream().flatMap(p->p.allocations().stream()).map(Allocation::lotId).distinct().toList();
var lots=new HashMap<Long,com.baiyue.lastleg.storagerent.entity.StorageRentLot>();
for(var lot:rents.lots(lotIds))lots.put(lot.getId(),lot);
for(var fee:rows){var rent=preparations.get(fee.getSourceFeeId());if(rent==null)continue;
var plan=plans.get(rent.getId());
fee.setStorageRent(new OverseasFeeSource.RentDetail(rent.getExecutionNo(),rent.getConsumedAt().atOffset(ZoneOffset.UTC),
plan.amount(),"SETTLED".equals(rent.getStatus())?fee.getAmount():null,
plan.allocations().stream().map(a->{
var lot=lots.get(a.lotId());
// 只展示同客户、同仓库的原批次来源,缺失关联不猜测为当前换标单。
boolean owned=lot!=null&&Objects.equals(lot.getCustomerId(),fee.getCustomerId())
&&Objects.equals(lot.getWarehouseId(),rent.getWarehouseId());
return new OverseasFeeSource.RentLine(a.lotId(),a.inboundAt().atOffset(ZoneOffset.UTC),a.quantity(),a.days(),a.amount(),
owned?lot.getBusinessType():null,owned?lot.getBusinessNo():null,owned?lot.getBatchNo():null);
}).toList()));
}
}
/** 审核与幂等命令同事务保存;审批通过后才申请按确认金额结算。 */
@Transactional
public OverseasFeeSource review(long id,String operator,String name,ReviewRequest request){
var rent=requiredRent(id);var fee=requiredFee(rent);var funding=fees.lockFunding(id);
if(replayed(operator,"RENT_REVIEW",request.idempotencyKey(),id,request))return fee;
if(!Objects.equals(fee.getSourceVersion(),request.expectedVersion()))throw conflict("仓租金额版本已变化,请刷新后重新审核");
if(!"PENDING".equals(fee.getReviewStatus())||!"PENDING_REVIEW".equals(rent.getStatus())
||funding==null||!"FROZEN".equals(funding.getWalletStatus()))throw conflict("仓租尚未锁定或已审核,请刷新");
if(!Set.of("APPROVE","REJECT").contains(request.decision()))throw conflict("审核操作无效");
boolean approved="APPROVE".equals(request.decision());
if(!approved&&(request.reason()==null||request.reason().isBlank()))throw conflict("请填写驳回原因");
fee.setReviewStatus(approved?"APPROVED":"REJECTED");fee.setReviewerId(operator);fee.setReviewerName(name);
fee.setReviewedAt(now());fee.setReviewReason(request.reason());fee.setUpdatedBy(operator);fee.setUpdatedAt(now());
if(!approved){rent.setStatus("REJECTED");if(rent.getHoldId()!=null)send(rent,funding,"RELEASE");else funding.setWalletStatus("RELEASED");}
else if(fee.getAmount().signum()==0){
if(rent.getHoldId()!=null){rent.setStatus("CONSUMED");send(rent,funding,"RELEASE");}
else{rent.setStatus("SETTLED");funding.setWalletStatus("SETTLED");fee.setPublicationStatus("PUBLISHED");}
}else{rent.setStatus("CONSUMED");send(rent,funding,"SETTLE_ACTUAL");}
save(rent,fee,funding);record(operator,"RENT_REVIEW",request.idempotencyKey(),id,request,fee.getSourceVersion());return fee;
}
/** 审核前改费生成不可覆盖的新版本,固定原币和原汇率,并强制保存说明。 */
@Transactional
public OverseasFeeSource adjust(long id,String operator,AdjustRentRequest request){
var rent=requiredRent(id);var old=requiredFee(rent);var funding=fees.lockFunding(id);
if(replayed(operator,"RENT_ADJUST",request.idempotencyKey(),id,request))return old;
if(funding==null||!"FROZEN".equals(funding.getWalletStatus())||!"PENDING_REVIEW".equals(rent.getStatus())||!"PENDING".equals(old.getReviewStatus())
||!Objects.equals(old.getSourceVersion(),request.expectedVersion()))throw conflict("仅当前待审核仓租可以调整,请刷新");
if(request.reason()==null||request.reason().isBlank()||request.reason().trim().length()>500
||request.amount()==null||request.amount().signum()<0||request.amount().scale()>2)throw conflict("请填写有效金额和调整说明");
var next=json.convertValue(old,OverseasFeeSource.class);next.setId(IdWorker.getId());next.setSourceVersion(old.getSourceVersion()+1);
next.setOriginalAmount(request.amount());next.setFeeRemark(request.reason().trim());next.setUpdatedBy(operator);next.setUpdatedAt(now());next.setStorageRent(null);
if(next.getExchangeRate()==null&&request.amount().signum()>0){
var converted=converter.convert(next.getOriginalCurrency(),request.amount(),next.getBusinessDate());
next.setExchangeRate(converted.exchangeRate());next.setExchangeRateMonth(converted.exchangeRateMonth());
rent.setExchangeRate(converted.exchangeRate());rent.setExchangeRateMonth(converted.exchangeRateMonth());
}
next.setAmount(request.amount().signum()==0?BigDecimal.ZERO.setScale(2):request.amount().multiply(next.getExchangeRate()).setScale(2,RoundingMode.HALF_UP));
rent.setOriginalAmount(next.getOriginalAmount());rent.setAmount(next.getAmount());
// 原非零冻结不提前释放;Finance 按实额结算时原子校验差额,余额不足保留原冻结。
if(rent.getHoldId()==null&&next.getAmount().signum()>0){rent.setStatus("REVIEW_FREEZE_PENDING");send(rent,funding,"FREEZE");}
old.setSourceStatus("SUPERSEDED");fees.updateFee(old);fees.insertFee(next);
rent.setUpdatedAt(now());rents.save(rent);funding.setUpdatedAt(now());fees.updateFunding(funding);
record(operator,"RENT_ADJUST",request.idempotencyKey(),id,request,next.getSourceVersion());return next;
}
/** 原冻结身份接收资金结果,只有批准且匹配原指令的回执可登记已扣金额。 */
public void handle(StorageRentPreparation rent,WalletCommandCompletedV1 event){
var fee=requiredFee(rent);var funding=fees.lockFunding(fee.getSourceFeeId());
if("FREEZE".equals(event.operation())&&Objects.equals(funding.getCommandHistory().get("initialFreeze"),event.commandEventId())){
if(!Objects.equals(event.businessNo(),rent.getId().toString())||!"STORAGE_RENT".equals(event.businessType())
||!fee.getCustomerId().equals(String.valueOf(event.customerId()))||!"SUCCEEDED".equals(event.result())
||!"FROZEN".equals(event.holdStatus())||!Objects.equals(rent.getHoldId(),event.walletHoldId())
||event.amount()==null||event.amount().compareTo(new BigDecimal(funding.getCommandHistory().get("holdAmount")))!=0)
throw conflict("原仓租冻结回执不一致");
return;
}
String expected=funding.getCommandHistory().get(event.commandEventId());String fingerprint=event.operation()+":"+hash(event);
if(fingerprint.equals(expected))return;
if(!Objects.equals(funding.getCommandEventId(),event.commandEventId())||!Objects.equals(expected,event.operation())
||!Objects.equals(event.businessNo(),funding.getWalletBusinessNo())||!"STORAGE_RENT".equals(event.businessType())
||!fee.getCustomerId().equals(String.valueOf(event.customerId()))||event.completedAt()==null
||!Set.of("SUCCEEDED","FAILED").contains(Objects.toString(event.result(),"")))throw conflict("仓租资金回执与原指令不一致");
boolean success="SUCCEEDED".equals(event.result());
var expectedAmount="RELEASE".equals(event.operation())?new BigDecimal(funding.getCommandHistory().get("holdAmount")):rent.getAmount();
if(success&&(event.amount()==null||event.amount().compareTo(expectedAmount)!=0))throw conflict("仓租资金金额不一致");
if(success&&!"FREEZE".equals(event.operation())&&!Objects.equals(rent.getHoldId(),event.walletHoldId()))throw conflict("仓租冻结凭据不一致");
if(success)switch(event.operation()){
case "FREEZE"->{if(!"FROZEN".equals(event.holdStatus())||event.walletHoldId()==null)throw conflict("仓租冻结凭据不完整");rent.setHoldId(event.walletHoldId());funding.setWalletHoldId(event.walletHoldId());funding.getCommandHistory().put("holdAmount",event.amount().toPlainString());rent.setStatus("PENDING_REVIEW");funding.setWalletStatus("FROZEN");}
case "SETTLE_ACTUAL"->{
if(!"APPROVED".equals(fee.getReviewStatus())||!"SETTLED".equals(event.holdStatus())||event.walletLedgerId()==null)throw conflict("仓租尚未审核或实扣凭据不完整");
fee.setWalletSettlement(new WalletSettlementReference(event.commandEventId(),event.walletHoldId(),event.walletLedgerId(),"STORAGE_RENT",rent.getId().toString()));
fee.setPublicationStatus("PUBLISHED");rent.setStatus("SETTLED");rent.setLedgerId(event.walletLedgerId());funding.setWalletLedgerId(event.walletLedgerId());funding.setWalletStatus("SETTLED");
}
case "RELEASE"->{if(!"RELEASED".equals(event.holdStatus()))throw conflict("仓租解冻凭据不完整");rent.setReleaseStatus("SUCCEEDED");funding.setWalletStatus("RELEASED");
if("APPROVED".equals(fee.getReviewStatus())&&fee.getAmount().signum()==0){rent.setStatus("SETTLED");fee.setPublicationStatus("PUBLISHED");funding.setWalletStatus("SETTLED");}}
default->throw conflict("仓租资金操作无效");
}else{funding.setWalletStatus(("SETTLE_ACTUAL".equals(event.operation())?"SETTLE":event.operation())+"_FAILED");
if("SETTLE_ACTUAL".equals(event.operation()))rent.setStatus("SETTLE_FAILED");
if("FREEZE".equals(event.operation()))rent.setStatus("REVIEW_FREEZE_FAILED");
if("RELEASE".equals(event.operation()))rent.setReleaseStatus("FAILED");}
funding.getCommandHistory().put(event.commandEventId(),fingerprint);funding.setErrorMessage(success?null:event.errorMessage());rent.setErrorMessage(success?null:event.errorMessage());save(rent,fee,funding);
}
/** 已知失败才产生新资金版本,不重放库存或重建费用;待审核冻结不因超时释放。 */
public void recover(StorageRentPreparation rent){
var fee=requiredFee(rent);var funding=fees.lockFunding(fee.getSourceFeeId());
if("SETTLE_FAILED".equals(funding.getWalletStatus())&&"APPROVED".equals(fee.getReviewStatus())){rent.setStatus("CONSUMED");send(rent,funding,"SETTLE_ACTUAL");}
else if("RELEASE_FAILED".equals(funding.getWalletStatus()))send(rent,funding,"RELEASE");
else if("FREEZE_FAILED".equals(funding.getWalletStatus())&&"PENDING".equals(fee.getReviewStatus())){rent.setStatus("REVIEW_FREEZE_PENDING");send(rent,funding,"FREEZE");}
else return;
save(rent,fee,funding);
}
/** 同一仓租复用原冻结;释放必须提交原冻结额,实扣提交审核后的实际金额。 */
private void send(StorageRentPreparation rent,OverseasFeeFunding funding,String operation){
long version=Math.addExact(rent.getWalletVersion(),1);rent.setWalletVersion(version);
var amount="RELEASE".equals(operation)?null:rent.getAmount();
String event=wallet.publish(operation,Long.parseLong(rent.getCustomerId()),"STORAGE_RENT",rent.getId().toString(),amount,"海外仓租审核结算",version);
funding.setCommandEventId(event);funding.setCommandVersion(version);funding.getCommandHistory().put(event,operation);
funding.setWalletStatus(("SETTLE_ACTUAL".equals(operation)?"SETTLE":operation)+"_PENDING");funding.setErrorMessage(null);
if("RELEASE".equals(operation)){rent.setReleaseEventId(event);rent.setReleaseStatus("PENDING");}
else if("FREEZE".equals(operation))rent.setFreezeEventId(event);else rent.setSettleEventId(event);
}
/** 写入口统一加锁顺序并核对费用来源,拒绝旧已扣费用进入新审核流程。 */
private StorageRentPreparation requiredRent(long id){
// 资金主键定位原预占主键,避免按无索引的费用引用扫描并锁住其他客户仓租。
var state=fees.funding(id);
if(state==null||state.getWalletBusinessNo()==null||!state.getWalletBusinessNo().matches("[1-9][0-9]{0,18}"))throw conflict("没有对应的出库仓租");
var rent=rents.lockPreparation(Long.parseLong(state.getWalletBusinessNo()));
if(rent==null||rent.getConsumedAt()==null||!Objects.equals(rent.getSourceFeeId(),id))throw conflict("没有对应的出库仓租");
return rent;
}
/** 仓租不能借用其他客户或项目的费用来源。 */
private OverseasFeeSource requiredFee(StorageRentPreparation rent){var fee=fees.lockFee(rent.getSourceFeeId());if(fee==null||!isRent(fee)||!Objects.equals(fee.getCustomerId(),rent.getCustomerId()))throw conflict("仓租费用来源不一致");return fee;}
/** 状态、费用和资金占用在调用方同一事务内提交。 */
private void save(StorageRentPreparation rent,OverseasFeeSource fee,OverseasFeeFunding funding){rent.setUpdatedAt(now());rents.save(rent);fee.setUpdatedAt(now());fees.updateFee(fee);funding.setUpdatedAt(now());fees.updateFunding(funding);}
/** 幂等签名包含费用身份,同键不能修改审核决定、金额或原因。 */
private boolean replayed(String operator,String action,String key,long id,Object request){var prior=fees.command(operator,action,key);if(prior==null)return false;if(!Objects.equals(prior.getSourceFeeId(),id)||!Objects.equals(prior.getRequestHash(),hash(request)))throw conflict("操作标识已用于不同仓租请求");return true;}
/** 保存每次审核和调整的独立操作标识,版本记录保留调整前后的金额和说明。 */
private void record(String operator,String action,String key,long id,Object request,int version){var command=new OverseasFeeCommand();command.setId(IdWorker.getId());command.setOperatorId(operator);command.setAction(action);command.setIdempotencyKey(key);command.setSourceFeeId(id);command.setSourceVersion(version);command.setRequestHash(hash(request));command.setCreatedAt(now());fees.insertCommand(command);}
/** 已保存的原批次计费依据不可重新定价。 */
private Plan plan(StorageRentPreparation rent){try{return json.readValue(rent.getPlanJson(),Plan.class);}catch(Exception failure){throw new IllegalStateException("仓租快照读取失败",failure);}}
/** 对持久化业务请求计算稳定签名,不接受内容变化的重试。 */
private String hash(Object value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}catch(Exception failure){throw new IllegalStateException(failure);}}
/** 仓租新记录统一保存 UTC。 */
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
/** 状态冲突交由统一接口协议返回。 */
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,480 @@
package com.baiyue.lastleg.storagerent.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.common.event.WalletSettlementReference;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.entity.*;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.wallet.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.*;
import java.time.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
/** 实收计租、资金预占、扫码扣减与财务已扣来源发布的同源事务编排。 */
@Service
public class StorageRentService implements WalletResultHandler {
@Resource private StorageRentRepository repository;
@Resource private StorageRentSourceResolver resolver;
@Resource private WalletCommandOutboxService wallet;
@Resource private OverseasFeeCurrencyConverter converter;
@Resource private OverseasFeeSourceService feeSources;
@Resource private ObjectMapper json;
@Resource private StorageRentReviewService rentReview;
@Value("${baiyue.storage-rent.legacy-return-count-receipt-nos:}") private String legacyReturnCountReceiptNos="";
@Value("${baiyue.storage-rent.legacy-return-time-zone:}") private String legacyReturnTimeZone="";
private static final String WALLET_TYPE="STORAGE_RENT";
/** 仅自有仓目的地头程和仓储转运入库计租,普通直送货物不产生仓租。 */
public static boolean eligible(LastlegOrder order) {
return order!=null && ("WAREHOUSE_TRANSFER".equals(order.getOrderType())
|| "FIRST_LEG".equals(order.getOrderType())&&"OVERSEAS".equals(order.getDestinationType()));
}
/** 旧管理端直出路径遇到计租来源必须改走 PDA,不能跳过资金冻结门禁。 */
public void requirePdaOutbound(String customer,String warehouse,Long positionId){
var position=repository.position(positionId);if(position==null||!customer.equals(position.getCustomerId())||!warehouse.equals(position.getOverseasWarehouseId()))throw conflict("出库库存不属于当前客户和仓库");
var balance=repository.balance(position.getBalanceId());
if(!repository.stockShares(customer,warehouse,position.getBalanceId(),position.getBatchNo()).isEmpty()||balance!=null&&eligible(repository.order(balance.getInboundOrderId())))throw conflict("计租库存请关联出库单并通过 PDA 扫码出库");
}
/** 头程同单同仓合并计租,其余业务按实际实收凭证分别起算库龄。 */
@Transactional
public void registerInbound(InboundStockReceipt receipt, LastlegOrder order) {
if(!eligible(order))return;
if("FIRST_LEG".equals(order.getOrderType())){registerFirstLegInbound(receipt,order);return;}
String key="INBOUND:"+receipt.getId(); if(repository.source(key)!=null)return;
var lot=base(key,order.getCustomerId(),order.getCustomerName(),order.getOverseasWarehouseId(),order.getOverseasWarehouseName(),
"LASTLEG_ORDER",order.getId(),order.getOrderNo(),order.getId(),receipt.getInboundCode(),receipt.getBatchNo(),receipt.getSkuCode(),LocalDateTime.now(ZoneOffset.UTC));
var transfer=repository.transferByTail(order.getCustomerId(),order.getId());
if(transfer!=null){lot.setBusinessType("WAREHOUSE_TRANSFER");lot.setBusinessId(transfer.getId());lot.setBusinessNo(transfer.getBusinessNo());}
lot.setBalanceId(receipt.getInventoryBalanceId());lot.setRuleCode("TRANSIT");
lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedPackages()));lot.setRemainingQuantity(lot.getOriginalQuantity());
// 一个订单的体积按原箱数分到每张实收凭证,不把整单 CBM 重复计入多个 SKU。
lot.setOriginalUnits(order.getVolumeCbm()!=null&&order.getVolumeCbm().signum()>0&&order.getPackageCount()!=null&&order.getPackageCount()>0
? order.getVolumeCbm().multiply(lot.getOriginalQuantity()).divide(BigDecimal.valueOf(order.getPackageCount()),12,RoundingMode.HALF_UP):null);
repository.insert(lot);insertStockShare(lot,receipt.getInventoryBalanceId(),receipt.getBatchNo(),lot.getOriginalQuantity());
}
/** 头程补入库只增加原批次数量,首次起点、既有出库及计费单位比例保持不变。 */
private void registerFirstLegInbound(InboundStockReceipt receipt,LastlegOrder order){
// 订单行先串行化首次建批及补入库;登记表与库存份额同事务,重放不重复累计。
var locked=repository.lockOrder(order.getId());
if(locked==null||!eligible(locked)||!"FIRST_LEG".equals(locked.getOrderType())
||!Objects.equals(locked.getCustomerId(),order.getCustomerId())
||!Objects.equals(locked.getOverseasWarehouseId(),order.getOverseasWarehouseId()))throw conflict("头程计租订单归属已变化,请刷新后重试");
if(repository.inboundRentLot(receipt.getId())!=null)return;
if(receipt.getReceivedPackages()==null||receipt.getReceivedPackages()<=0)throw conflict("计租实收箱数必须大于零");
String key="FIRST_LEG:"+locked.getId()+":"+locked.getOverseasWarehouseId();
var lot=repository.lockSource(key);var quantity=BigDecimal.valueOf(receipt.getReceivedPackages());
if(lot==null){
lot=base(key,locked.getCustomerId(),locked.getCustomerName(),locked.getOverseasWarehouseId(),locked.getOverseasWarehouseName(),
"LASTLEG_ORDER",locked.getId(),locked.getOrderNo(),locked.getId(),receipt.getInboundCode(),locked.getOrderNo(),null,LocalDateTime.now(ZoneOffset.UTC));
lot.setBalanceId(receipt.getInventoryBalanceId());lot.setRuleCode("TRANSIT");
lot.setOriginalQuantity(quantity);lot.setRemainingQuantity(quantity);
lot.setOriginalUnits(locked.getVolumeCbm()!=null&&locked.getVolumeCbm().signum()>0&&locked.getPackageCount()!=null&&locked.getPackageCount()>0
?locked.getVolumeCbm().multiply(quantity).divide(BigDecimal.valueOf(locked.getPackageCount()),12,RoundingMode.HALF_UP):null);
repository.insert(lot);
}else{
// 不能重取改单后的整单体积,也不能将累计实收直接覆盖剩余箱数。
if(lot.getOriginalUnits()!=null)lot.setOriginalUnits(lot.getOriginalUnits().add(
lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP)));
lot.setOriginalQuantity(lot.getOriginalQuantity().add(quantity));
lot.setRemainingQuantity(lot.getRemainingQuantity().add(quantity));
lot.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));repository.save(lot);
}
// 份额可能已被全部出库为零,补货时仍复用原行,不能受剩余量过滤影响。
var share=repository.lockStockShare(lot.getId(),receipt.getInventoryBalanceId(),receipt.getBatchNo());
if(share==null)insertStockShare(lot,receipt.getInventoryBalanceId(),receipt.getBatchNo(),quantity);
else{share.setQuantity(share.getQuantity().add(quantity));repository.save(share);}
repository.recordInboundRent(receipt.getId(),lot.getId(),quantity);
}
/** 外来转运实收采用测量后的该来源体积,避免等待客户确认时重置库龄。 */
@Transactional
public void registerTransfer(InboundStockReceipt receipt, com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder transfer,
BigDecimal volume, String warehouseName) {
String key="INBOUND:"+receipt.getId();if(repository.source(key)!=null)return;
var order=repository.order(transfer.getLastlegOrderId());
var lot=base(key,transfer.getCustomerId(),order==null?null:order.getCustomerName(),transfer.getOperatingWarehouseId(),warehouseName,
"WAREHOUSE_TRANSFER",transfer.getId(),transfer.getTransferNo(),transfer.getLastlegOrderId(),receipt.getInboundCode(),receipt.getBatchNo(),receipt.getSkuCode(),LocalDateTime.now(ZoneOffset.UTC));
lot.setBalanceId(receipt.getInventoryBalanceId());lot.setRuleCode("TRANSIT");lot.setOriginalUnits(volume);
lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedPackages()));lot.setRemainingQuantity(lot.getOriginalQuantity());repository.insert(lot);
insertStockShare(lot,receipt.getInventoryBalanceId(),receipt.getBatchNo(),lot.getOriginalQuantity());
}
/** 每张原始退货签收托为一个计费原托,箱内份额随实际商品数量分配。 */
@Transactional
public void registerReturn(ReturnOrder order, ReturnReceipt receipt) {
String key="RETURN:"+receipt.getId();if(repository.source(key)!=null)return;
var tail=repository.order(order.getLastlegOrderId());
var lot=base(key,order.getCustomerId(),tail==null?null:tail.getCustomerName(),order.getOverseasWarehouseId(),tail==null?null:tail.getOverseasWarehouseName(),
"RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,receipt.getReceiptBatchNo(),null,LocalDateTime.now(ZoneOffset.UTC));
lot.setRuleCode("RETURN");lot.setOriginalUnits(BigDecimal.ONE);
lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedQuantity()));lot.setRemainingQuantity(lot.getOriginalQuantity());repository.insert(lot);
rebuildReturnShares(receipt.getId());
}
/** 首次清点只重分箱内商品归属,不能改变原托时间、托数或商品总量。 */
@Transactional
public void rebuildReturnShares(long receiptId) {
var found=repository.source("RETURN:"+receiptId);if(found==null)throw conflict("退货签收缺少计租原批次");
var lot=repository.lockLot(found.getId());
if(lot.getOriginalQuantity().compareTo(lot.getRemainingQuantity())!=0)throw conflict("原批次已出库,不能重新分配计租份额");
var cartons=repository.receiptCartons(receiptId);var newShares=new ArrayList<StorageRentShare>();BigDecimal total=BigDecimal.ZERO;
for(var carton:cartons)for(var item:repository.items(carton.getId())){
var share=newShare(lot,carton.getCartonMark(),item.getSkuCode(),BigDecimal.valueOf(item.getQuantity()));
newShares.add(share);total=total.add(share.getQuantity());
}
if(total.compareTo(lot.getOriginalQuantity())!=0)throw conflict("清点商品数量与原计租批次不一致");
repository.deleteShares(lot.getId());newShares.forEach(repository::insert);
}
/** 仅显式指定的历史签收可在正常清点事务内恢复缺失计租批次,新单缺失继续阻断。 */
@Transactional
public void ensureReturnCountLot(long receiptId) {
if(repository.source("RETURN:"+receiptId)!=null)return;
var receipt=repository.lockReturnReceipt(receiptId);
var allowed=Arrays.stream(legacyReturnCountReceiptNos.split(",")).map(String::trim).filter(v->!v.isBlank()).toList();
if(receipt==null||!allowed.contains(receipt.getReceiptNo()))throw conflict("退货签收缺少计租原批次");
ZoneId zone;try{if(legacyReturnTimeZone.isBlank())throw new IllegalArgumentException();zone=ZoneId.of(legacyReturnTimeZone);}catch(Exception invalid){throw conflict("历史签收时区未核实,不能恢复计租批次");}
if(!"SIGNED".equals(receipt.getStatus())||!"PALLET".equals(receipt.getUnitType())||receipt.getSignedAt()==null||receipt.getReturnOrderId()==null
||receipt.getReceivedQuantity()==null||receipt.getReceivedQuantity()<=0||receipt.getTotalBoxCount()==null||receipt.getTotalBoxCount()<=0
||receipt.getPalletCode()==null||receipt.getPalletCode().isBlank()||!Objects.equals(receipt.getPalletCode(),receipt.getUnitCode()))throw conflict("历史签收原托和数量凭证不完整");
var offsets=zone.getRules().getValidOffsets(receipt.getSignedAt());if(offsets.size()!=1)throw conflict("历史签收时间存在时区歧义");
var signedUtc=receipt.getSignedAt().atOffset(offsets.get(0)).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
if(signedUtc.isAfter(LocalDateTime.now(ZoneOffset.UTC)))throw conflict("历史签收时间不能在未来");
var order=repository.returnOrder(receipt.getReturnOrderId());
if(order==null||!Objects.equals(order.getCustomerId(),receipt.getCustomerId())||order.getOverseasWarehouseId()==null||order.getRelabelApplicationId()!=null)throw conflict("历史签收业务归属或换标状态不满足恢复条件");
var batches=repository.returnReceiptBatches(receiptId);
if(batches.size()!=1)throw conflict("历史签收计费起点凭证不完整");var batch=batches.get(0);
if(!Objects.equals(batch.getCustomerId(),receipt.getCustomerId())||!Objects.equals(batch.getReturnOrderId(),order.getId())
||!Objects.equals(batch.getOverseasWarehouseId(),order.getOverseasWarehouseId())||!Objects.equals(batch.getBatchNo(),receipt.getReceiptBatchNo())
||!Objects.equals(batch.getStorageStartedAt(),receipt.getSignedAt())||!"PENDING_PRICING".equals(batch.getStorageBillingStatus()))throw conflict("历史签收计费起点凭证与原签收不一致");
var expected=new TreeMap<String,Long>();for(var line:repository.returnReceiptItems(receiptId)){
if(!Objects.equals(line.getCustomerId(),receipt.getCustomerId())||line.getSkuCode()==null||line.getReceivedQuantity()==null||line.getReceivedQuantity()<=0)throw conflict("历史签收原商品数量凭证不完整");
expected.merge(line.getSkuCode(),line.getReceivedQuantity(),Math::addExact);}
if(!Objects.equals(expected.values().stream().reduce(0L,Math::addExact),receipt.getReceivedQuantity()))throw conflict("历史签收原商品总量不一致");
var boxes=repository.receiptCartons(receiptId);if(boxes.size()!=receipt.getTotalBoxCount())throw conflict("历史签收原箱数量不完整");
var actual=new TreeMap<String,Long>();
for(var box:boxes){
if(!Objects.equals(box.getCustomerId(),receipt.getCustomerId())||!Objects.equals(box.getOverseasWarehouseId(),order.getOverseasWarehouseId())
||!Objects.equals(box.getSourceReceiptId(),receiptId)||!Objects.equals(box.getPalletCode(),receipt.getPalletCode())||!"PALLET".equals(box.getSourceUnitType())
||!Objects.equals(box.getSourceBatchNo(),receipt.getReceiptBatchNo())||!Objects.equals(box.getSourceReturnNo(),order.getReturnNo())||!Objects.equals(box.getSourceUnitCode(),receipt.getUnitCode())
||!"PENDING".equals(box.getCountStatus())||!"AVAILABLE".equals(box.getStatus())||box.getRelabelApplicationId()!=null||box.getRelabelNo()!=null
||box.getCountedAt()!=null||box.getCountedBy()!=null||box.getRelabelCompletedAt()!=null
||box.getMergedAt()!=null||box.getNewCartonMark()!=null||!repository.shares(receipt.getCustomerId(),order.getOverseasWarehouseId(),box.getCartonMark(),null).isEmpty())throw conflict("历史签收箱已清点、换标、合箱或发生其他作业,不能恢复原托");
long total=0;for(var item:repository.items(box.getId())){
if(!Objects.equals(item.getCustomerId(),receipt.getCustomerId())||item.getSkuCode()==null||item.getQuantity()==null||item.getQuantity()<=0
||item.getPackingReservedQuantity()!=null&&item.getPackingReservedQuantity()>0)throw conflict("历史签收箱商品或预留状态不满足恢复条件");
actual.merge(item.getSkuCode(),item.getQuantity(),Math::addExact);total=Math.addExact(total,item.getQuantity());}
if(!Objects.equals(box.getTotalQuantity(),total))throw conflict("历史签收箱商品总量不一致");
}
if(!expected.equals(actual))throw conflict("历史签收商品已有变化,不能恢复原托计租");
var tail=repository.order(order.getLastlegOrderId());
var lot=base("RETURN:"+receiptId,receipt.getCustomerId(),tail==null?null:tail.getCustomerName(),order.getOverseasWarehouseId(),tail==null?null:tail.getOverseasWarehouseName(),
"RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,receipt.getReceiptBatchNo(),null,signedUtc);
lot.setRuleCode("RETURN");lot.setOriginalUnits(BigDecimal.ONE);lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedQuantity()));lot.setRemainingQuantity(lot.getOriginalQuantity());
// 恢复创建时间记录本次正式事务,计租起点始终保留原签收时刻,不生成免租或伪造资金流水。
lot.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));lot.setUpdatedAt(lot.getCreatedAt());repository.insert(lot);
}
/** 合箱按明确原商品份额搬移,不创建新托、不重新开始免费期。 */
@Transactional
public void moveReturnShares(String customer,String warehouse,String source,String target,String sku,long quantity) {
if(Objects.equals(source,target))return;
var available=repository.shares(customer,warehouse,source,sku);
var total=available.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
var requested=BigDecimal.valueOf(quantity);if(total.compareTo(requested)<0)throw conflict("合箱来源缺少原计租份额");
// 同箱同 SKU 混有多个原托时按数量比例保留各原托归属,不猜 FIFO 来源。
BigDecimal allocated=BigDecimal.ZERO;
for(int i=0;i<available.size();i++){
var share=available.get(i);var moved=i==available.size()-1?requested.subtract(allocated)
:requested.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);
allocated=allocated.add(moved);share.setQuantity(share.getQuantity().subtract(moved));repository.save(share);
if(moved.signum()==0)continue;
var targetShare=repository.shares(customer,warehouse,target,sku).stream().filter(s->s.getLotId().equals(share.getLotId())).findFirst().orElse(null);
if(targetShare==null)repository.insert(newShare(repository.lockLot(share.getLotId()),target,sku,moved));
else {targetShare.setQuantity(targetShare.getQuantity().add(moved));repository.save(targetShare);}
}
}
/** SKU 转换保留原租批次;精确迁移快照供审核退回恢复,避免混合目标 SKU 改变原托归属。 */
@Transactional
public String convertReturnSkuShares(String customer,String warehouse,String mark,String oldSku,String newSku,long quantity) {
var source=repository.shares(customer,warehouse,mark,oldSku);var total=source.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal requested=BigDecimal.valueOf(quantity),used=BigDecimal.ZERO;if(quantity<=0||total.compareTo(requested)<0)throw conflict("换标来源缺少原计租份额");
var movements=new ArrayList<SkuMovement>();
for(int i=0;i<source.size();i++){var share=source.get(i);var moved=i==source.size()-1?requested.subtract(used):requested.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(moved);
if(moved.signum()==0)continue;share.setQuantity(share.getQuantity().subtract(moved));repository.save(share);
addReturnSkuShare(customer,warehouse,mark,newSku,share.getLotId(),moved);movements.add(new SkuMovement(share.getLotId(),moved));}
return write(movements);
}
/** 仅按原转换快照逆向恢复,不从已混合的新 SKU 按比例重新分摊原托。 */
@Transactional
public void reverseReturnSkuShares(String customer,String warehouse,String mark,String oldSku,String newSku,String snapshot) {
List<SkuMovement> movements;try{movements=json.readValue(snapshot,new com.fasterxml.jackson.core.type.TypeReference<List<SkuMovement>>(){});}catch(Exception failure){throw conflict("换标原计租份额快照不完整");}
for(var movement:movements){var sources=repository.shares(customer,warehouse,mark,newSku).stream().filter(v->v.getLotId().equals(movement.lotId())).toList();
var total=sources.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);if(total.compareTo(movement.quantity())<0)throw conflict("换标原计租份额已出库,不能退回");
var remaining=movement.quantity();for(var share:sources){var amount=share.getQuantity().min(remaining);share.setQuantity(share.getQuantity().subtract(amount));repository.save(share);remaining=remaining.subtract(amount);if(remaining.signum()==0)break;}
addReturnSkuShare(customer,warehouse,mark,oldSku,movement.lotId(),movement.quantity());}
}
/** 同箱同 SKU 的原批次合并份额,原托时间与计费基数始终只在 lot 保存。 */
private void addReturnSkuShare(String customer,String warehouse,String mark,String sku,long lotId,BigDecimal quantity){
var target=repository.shares(customer,warehouse,mark,sku).stream().filter(v->v.getLotId().equals(lotId)).findFirst().orElse(null);
if(target==null)repository.insert(newShare(repository.lockLot(lotId),mark,sku,quantity));else{target.setQuantity(target.getQuantity().add(quantity));repository.save(target);}
}
/** 一次 SKU 转换实际迁移的原托数量份额。 */
private record SkuMovement(Long lotId,BigDecimal quantity) { }
/** 换标变更 SKU 或批次只搬移原库存份额,不启动新库龄、不重复创建原托。 */
@Transactional
public void moveStockShares(String customer,String warehouse,long oldBalance,String oldBatch,
long newBalance,String newBatch,long quantity) {
if(oldBalance==newBalance&&Objects.equals(oldBatch,newBatch))return;
var source=repository.stockShares(customer,warehouse,oldBalance,oldBatch);
if(source.isEmpty()){
var old=repository.balance(oldBalance);if(old!=null&&eligible(repository.order(old.getInboundOrderId())))throw conflict("换标来源缺少原计租批次");return;
}
var total=source.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal requested=BigDecimal.valueOf(quantity),used=BigDecimal.ZERO;if(total.compareTo(requested)<0)throw conflict("换标来源计租数量不足");
for(int i=0;i<source.size();i++){var share=source.get(i);var part=i==source.size()-1?requested.subtract(used):requested.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(part);
share.setQuantity(share.getQuantity().subtract(part));repository.save(share);if(part.signum()==0)continue;
var target=repository.stockShares(customer,warehouse,newBalance,newBatch).stream().filter(s->s.getLotId().equals(share.getLotId())).findFirst().orElse(null);
if(target==null)insertStockShare(repository.lockLot(share.getLotId()),newBalance,newBatch,part);
else{target.setQuantity(target.getQuantity().add(part));repository.save(target);}}
}
/** 提交预校验与冻结 Outbox 后正常返回,待回执阶段不能以抛错回滚冻结命令。 */
@Transactional
public PreparationResult prepare(String warehouse,PrepareRequest request) {
validateRequest(request);String hash=hash(request);var old=repository.latest(warehouse,request.mode(),request.idempotencyKey());
if(old!=null&&!old.getRequestHash().equals(hash))throw conflict("扫码幂等键已用于不同的货物或数量");
if(old!=null&&old.getConsumedAt()!=null)return result(old);
var now=LocalDateTime.now(ZoneOffset.UTC);
boolean reuse=old!=null&&Set.of("FREEZE_PENDING","READY").contains(old.getStatus())&&old.getCreatedAt().isAfter(now.minusMinutes(15));
var plan=reuse?resolver.resolve(warehouse,request,now,read(old).pricing()):resolver.resolve(warehouse,request,now);
if(plan.allocations().isEmpty())return new PreparationResult(null,"NOT_REQUIRED",true,BigDecimal.ZERO,"CNY",BigDecimal.ZERO,plan.pricing()==null?null:plan.pricing().currency(),null,null,null);
if(old!=null&&Set.of("FREEZE_PENDING","READY").contains(old.getStatus())){
if(old.getCreatedAt().isAfter(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(15))&&sameBasis(read(old),plan)&&plan.amount().compareTo(old.getOriginalAmount())<=0)return result(old);
release(old,"EXPIRED");
// 重建尝试才读取客户新配置;有效的重复请求和原扫码消费都使用旧快照。
if(reuse)plan=resolver.resolve(warehouse,request,now);
}
if(plan.owner()==null||plan.owner().businessId()==null||plan.owner().businessNo()==null)throw conflict("计租货物缺少对应业务单,不能出库");
// 免费批次仍保存出库计租记录;没有应收金额时不要求汇率,不发布资金指令。
var conversion=plan.amount().signum()==0
?new OverseasFeeCurrencyConverter.Conversion(plan.pricing().currency(),BigDecimal.ZERO.setScale(2),null,plan.calculatedAt().toLocalDate().toString().substring(0,7),BigDecimal.ZERO.setScale(2))
:converter.convert(plan.pricing().currency(),plan.amount(),plan.calculatedAt().toLocalDate());
var value=new StorageRentPreparation();value.setId(IdWorker.getId());value.setWarehouseId(warehouse);value.setCustomerId(plan.owner().customerId());
value.setMode(request.mode());value.setExecutionNo(request.businessNo());value.setRequestKey(request.idempotencyKey());value.setRequestHash(hash);
value.setAttempt(old==null?1:Math.addExact(old.getAttempt(),1));value.setBusinessType(plan.owner().businessType());value.setBusinessId(plan.owner().businessId());value.setBusinessNo(plan.owner().businessNo());
value.setPlanJson(write(plan));value.setOriginalAmount(conversion.originalAmount());value.setAmount(conversion.settlementAmount());
value.setExchangeRate(conversion.exchangeRate());value.setExchangeRateMonth(conversion.exchangeRateMonth());value.setCreatedAt(plan.calculatedAt());value.setUpdatedAt(plan.calculatedAt());
value.setWalletVersion(3L);value.setStatus(value.getAmount().signum()==0?"READY":"FREEZE_PENDING");
try{repository.insert(value);}catch(DuplicateKeyException concurrent){var winner=repository.latest(warehouse,request.mode(),request.idempotencyKey());if(winner==null||!winner.getRequestHash().equals(hash))throw concurrent;return result(winner);}
if(value.getAmount().signum()>0){value.setFreezeEventId(wallet.publish("FREEZE",customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租预占",1));repository.save(value);}
return result(value);
}
/** 原始扫码事务内校验成功冻结和同一来源份额,任何异常使库存与租金一并回滚。 */
@Transactional
public void consume(String warehouse,PrepareRequest request) {
var value=repository.latest(warehouse,request.mode(),request.idempotencyKey());
var plan=value==null?resolver.resolve(warehouse,request,LocalDateTime.now(ZoneOffset.UTC))
:resolver.resolve(warehouse,request,LocalDateTime.now(ZoneOffset.UTC),read(value).pricing());
if(plan.allocations().isEmpty())return;
if(value==null||!value.getRequestHash().equals(hash(request)))throw conflict("请先完成本次扫码的仓租预校验");
if(value.getConsumedAt()!=null)throw conflict("本仓租扫码已扣减,请恢复原扫码结果");
if(!"READY".equals(value.getStatus())||value.getCreatedAt().isBefore(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(15))||value.getAmount().signum()>0&&value.getHoldId()==null)throw conflict("仓租资金未冻结成功或预占已过期,请重新预校验");
var saved=read(value);
if(!sameBasis(saved,plan)||plan.amount().compareTo(value.getOriginalAmount())>0)throw conflict("仓租计费时间或货物已变化,请重新预校验");
// 先按批次排序锁定,再逐份扣减,资金与实际库存使用调用者同一事务。
for(var allocation:saved.allocations().stream().sorted(Comparator.comparingLong(Allocation::lotId)).toList()){
var lot=repository.lockLot(allocation.lotId());
if(lot==null||!value.getCustomerId().equals(lot.getCustomerId())||lot.getRemainingQuantity().compareTo(allocation.quantity())<0)throw conflict("原计租批次剩余数量已变化");
if(allocation.shareId()!=null){var share=repository.lockShare(allocation.shareId());
if(share==null||share.getQuantity().compareTo(allocation.quantity())<0)throw conflict("退货计租商品份额已变化");
share.setQuantity(share.getQuantity().subtract(allocation.quantity()));repository.save(share);}
lot.setRemainingQuantity(lot.getRemainingQuantity().subtract(allocation.quantity()));lot.setUpdatedAt(plan.calculatedAt());repository.save(lot);
}
value.setConsumedAt(plan.calculatedAt());value.setUpdatedAt(plan.calculatedAt());
// 出库只生成待审核费用并沿用原冻结;审核通过后才发送真实扣款指令。
rentReview.create(value,feeRequest(value,saved),saved);
repository.save(value);
}
/** 取消扫描仅解冻未使用记录,已扣减的库存必须沿原业务撤销流程处理。 */
@Transactional
public PreparationResult cancel(String warehouse,long id) {var value=owned(warehouse,id);if(value.getConsumedAt()!=null)throw conflict("已出库仓租不能取消预占");release(value,"CANCELLED");return result(value);}
/** 原工单取消后释放其所有未使用冻结,回执迟到也按取消态解冻。 */
@Transactional
public void cancelExecution(String mode,String execution) {for(var value:repository.pending(mode,execution))release(value,"CANCELLED");}
/** 查询资金状态仍限制在 PDA 实际绑定仓。 */
@Transactional
public PreparationResult preparation(String warehouse,long id) {return result(owned(warehouse,id));}
/** 资金消费者仅路由仓租专用业务身份。 */
@Override public boolean supports(String type) {return WALLET_TYPE.equals(type);}
/** 每次回执校验原指令、客户、金额和冻结身份,幂等更新不重复生成费用。 */
@Override @Transactional
public void handle(WalletCommandCompletedV1 event) {
if(!supports(event.businessType()))return;
var value=repository.lockPreparation(Long.parseLong(event.businessNo()));if(value==null)throw conflict("仓租资金来源不存在");
if(value.getConsumedAt()!=null&&rentReview.managed(value)){rentReview.handle(value,event);return;}
if(customer(value)!=event.customerId()||event.amount()==null||event.amount().compareTo(value.getAmount())!=0)throw conflict("仓租资金回执客户或金额不一致");
if(!("SUCCEEDED".equals(event.result())||"FAILED".equals(event.result()))||event.completedAt()==null)throw conflict("仓租资金回执结果不完整");
boolean success="SUCCEEDED".equals(event.result());
switch(event.operation()){
case "FREEZE" -> {
if(!Objects.equals(value.getFreezeEventId(),event.commandEventId()))throw conflict("仓租冻结回执指令不一致");
if(!Set.of("FREEZE_PENDING","EXPIRED","CANCELLED").contains(value.getStatus()))return;
if(success&&!"FROZEN".equals(event.holdStatus()))throw conflict("仓租冻结结果缺少有效冻结状态");
if(success){if(event.walletHoldId()==null)throw conflict("仓租冻结结果缺少资金占用凭据");value.setHoldId(event.walletHoldId());
if(Set.of("EXPIRED","CANCELLED").contains(value.getStatus()))release(value,value.getStatus());else value.setStatus("READY");}
else if("FREEZE_PENDING".equals(value.getStatus()))value.setStatus("FAILED");
}
case "SETTLE" -> {
if(!Objects.equals(value.getSettleEventId(),event.commandEventId())||success&&!Objects.equals(value.getHoldId(),event.walletHoldId()))throw conflict("仓租扣款回执来源不一致");
if("SETTLED".equals(value.getStatus()))return;
if(value.getConsumedAt()==null)throw conflict("未出库仓租不能扣款");
if(success){if(!"SETTLED".equals(event.holdStatus())||event.walletLedgerId()==null)throw conflict("仓租扣款凭据不完整");
value.setLedgerId(event.walletLedgerId());var plan=read(value);var request=feeRequest(value,plan);
var converted=new OverseasFeeCurrencyConverter.Conversion(plan.pricing().currency(),value.getOriginalAmount(),value.getExchangeRate(),value.getExchangeRateMonth(),value.getAmount());
var fee=feeSources.publishSettled("STORAGE_RENT",value.getId().toString(),request,converted,new WalletSettlementReference(event.commandEventId(),event.walletHoldId(),event.walletLedgerId(),"STORAGE_RENT",value.getId().toString()));
value.setSourceFeeId(fee.getSourceFeeId());value.setStatus("SETTLED");
}else value.setStatus("SETTLE_FAILED");
}
case "RELEASE" -> {if(!Objects.equals(value.getReleaseEventId(),event.commandEventId())||success&&!Objects.equals(value.getHoldId(),event.walletHoldId()))throw conflict("仓租解冻回执指令不一致");
if(success&&!"RELEASED".equals(event.holdStatus()))throw conflict("仓租解冻结果状态不完整");value.setReleaseStatus(success?"SUCCEEDED":"FAILED");}
default -> throw conflict("仓租资金操作无效");
}
value.setErrorMessage(success?null:event.errorMessage());value.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));repository.save(value);
}
/** 列表只展示一个原始批次一行,不重复展示订单详情字段。 */
public PageResult<LotView> list(Query query) {
long page=query.page()==null?1:query.page(),size=query.size()==null?20:query.size();
if(page<1||size<1||size>200)throw conflict("分页参数无效");var result=repository.page(query,page,size);
var charges=repository.charges(result.getRecords().stream().map(StorageRentLot::getId).toList());
var settled=new HashMap<Long,BigDecimal>();var counts=new HashMap<Long,Long>();var settledCounts=new HashMap<Long,Long>();
var generated=new HashMap<Long,Map<String,BigDecimal>>();var lastOutbound=new HashMap<Long,LocalDateTime>();
for(var value:charges){var amounts=lotAmounts(value,value.getOriginalAmount());for(var entry:amounts.entrySet()){
counts.merge(entry.getKey(),1L,Long::sum);
// 仅成功扣款回执计入已扣款,处理中和失败费用仍保留原币应收。
if("SETTLED".equals(value.getStatus())){
settled.merge(entry.getKey(),lotAmounts(value,value.getAmount()).get(entry.getKey()),BigDecimal::add);
settledCounts.merge(entry.getKey(),1L,Long::sum);
}
generated.computeIfAbsent(entry.getKey(),key->new TreeMap<>()).merge(read(value).pricing().currency(),entry.getValue(),BigDecimal::add);
if(value.getConsumedAt()!=null)lastOutbound.merge(entry.getKey(),value.getConsumedAt(),(a,b)->a.isAfter(b)?a:b);
}
}
return new PageResult<>(result.getRecords().stream().map(lot->view(lot,generated.getOrDefault(lot.getId(),Map.of()),lastOutbound.get(lot.getId()),settled.getOrDefault(lot.getId(),BigDecimal.ZERO.setScale(2)),counts.getOrDefault(lot.getId(),0L),settledCounts.getOrDefault(lot.getId(),0L))).toList(),page,size,result.getTotal(),result.getPages());
}
/** 批次详情包含出库应收与资金状态,可追溯到原始扫码。 */
public LotDetail detail(long id) {var lot=repository.lot(id);if(lot==null)throw missing("仓租批次不存在");var charges=repository.charges(id).stream().map(v->charge(v,id)).toList();var last=charges.stream().map(ChargeView::outboundAt).filter(Objects::nonNull).max(Comparator.naturalOrder()).map(OffsetDateTime::toLocalDateTime).orElse(null);var generated=new TreeMap<String,BigDecimal>();for(var charge:charges)generated.merge(charge.currency(),charge.originalAmount(),BigDecimal::add);var settled=charges.stream().filter(c->"SETTLED".equals(c.status())).toList();return new LotDetail(view(lot,generated,last,settled.stream().map(ChargeView::settlementAmount).reduce(BigDecimal.ZERO.setScale(2),BigDecimal::add),charges.size(),settled.size()),charges);}
/** 只读仓库选项由仓库引用快照组成。 */
public List<WarehouseOption> warehouseOptions() {return repository.warehouseOptions();}
/** 将内部规则和字段转成前端稳定展示协议。 */
private LotView view(StorageRentLot lot,Map<String,BigDecimal> generated,LocalDateTime lastOutbound,BigDecimal settledAmount,long chargeCount,long settledChargeCount) {
// 清仓后库龄固定在最后一次实际出库,查询时间不再增加已结清批次库龄。
var end=lot.getRemainingQuantity().signum()==0&&lastOutbound!=null?lastOutbound:LocalDateTime.now(ZoneOffset.UTC);
long days=StorageRentCalculator.days(lot.getInboundAt(),end.isBefore(lot.getInboundAt())?lot.getInboundAt():end);
return new LotView(lot.getId(),lot.getCustomerId(),lot.getCustomerName(),lot.getBusinessType(),lot.getBusinessNo(),lot.getWarehouseId(),lot.getWarehouseName(),lot.getBatchNo(),utc(lot.getInboundAt()),days,
lot.getOriginalQuantity(),lot.getRemainingQuantity(),"RETURN".equals(lot.getRuleCode())?"PIECE":"PACKAGE",lot.getOriginalUnits(),"RETURN".equals(lot.getRuleCode())?"PALLET":"CBM",lot.getRuleCode()+"_V1",lot.getRemainingQuantity().signum()==0?"CLEARED":"IN_STOCK",generated.size()>1?null:generated.values().stream().findFirst().orElse(BigDecimal.ZERO.setScale(2)),generated.size()==1?generated.keySet().iterator().next():null,generated.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e->new CurrencyAmount(e.getKey(),e.getValue())).toList(),settledAmount,chargeCount,settledChargeCount);
}
/** 混合批次同一扫码费用按原冻结原币份额呈现,扣款总额仍归原应收。 */
private ChargeView charge(StorageRentPreparation value,long lotId) {
var allocations=read(value).allocations().stream().filter(a->a.lotId()==lotId).toList();
BigDecimal amount=lotAmounts(value,value.getOriginalAmount()).getOrDefault(lotId,BigDecimal.ZERO.setScale(2));
BigDecimal settlement=lotAmounts(value,value.getAmount()).getOrDefault(lotId,BigDecimal.ZERO.setScale(2));
return new ChargeView(value.getId(),value.getExecutionNo(),value.getBusinessNo(),allocations.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add),allocations.stream().mapToLong(Allocation::days).max().orElse(0),amount,read(value).pricing().currency(),settlement,value.getExchangeRate(),value.getStatus(),utc(value.getCreatedAt()),utc(value.getConsumedAt()));
}
/** 原资金总额按批次稳定分配到分,末批承接尾差,确保多批显示合计等于实际扣款。 */
private Map<Long,BigDecimal> lotAmounts(StorageRentPreparation value,BigDecimal amount){
var basis=new TreeMap<Long,BigDecimal>();for(var a:read(value).allocations())basis.merge(a.lotId(),a.amount(),BigDecimal::add);
var total=basis.values().stream().reduce(BigDecimal.ZERO,BigDecimal::add);
// 原免租批次人工补收时按实际数量分摊,不能把非零费用汇总成零。
if(total.signum()==0){basis.clear();for(var a:read(value).allocations())basis.merge(a.lotId(),a.quantity(),BigDecimal::add);total=basis.values().stream().reduce(BigDecimal.ZERO,BigDecimal::add);}
var result=new LinkedHashMap<Long,BigDecimal>();BigDecimal used=BigDecimal.ZERO;
for(var entry:basis.entrySet()){var part=total.signum()==0?BigDecimal.ZERO.setScale(2):entry.getKey().equals(basis.lastKey())?amount.subtract(used):amount.multiply(entry.getValue()).divide(total,2,RoundingMode.DOWN);result.put(entry.getKey(),part);used=used.add(part);}return result;
}
/** 新仓租表统一记录 UTC,公开协议携带偏移,避免浏览器当成本地墙钟时间。 */
private static OffsetDateTime utc(LocalDateTime value){return value==null?null:value.atOffset(ZoneOffset.UTC);}
/** 财务费用记本次服务单;余额授信应收不携带仅应付使用的供应商结算方式。 */
private SaveRequest feeRequest(StorageRentPreparation value,Plan plan) {
var owner=plan.owner();String source=switch(owner.businessType()){case "LASTLEG_ORDER"->"OVERSEAS_ORDER_FEE";case "RELABEL_ORDER"->"OVERSEAS_RELABEL_FEE";default->"OVERSEAS_WAREHOUSE_FEE";};
return new SaveRequest(owner.customerId(),owner.customerName(),source,owner.businessType(),owner.businessId(),owner.businessNo(),owner.orderId(),null,owner.inboundCode(),null,null,null,Long.valueOf(owner.warehouseId()),
"FEE_TYPE_077","仓储费", null,"RECEIVABLE",null,null,null,null,null,null,null,null,
plan.calculatedAt().toLocalDate(),plan.pricing().currency(),value.getOriginalAmount(),null,null,null,
plan.allocations().stream().map(Allocation::inboundAt).min(Comparator.naturalOrder()).orElseThrow().toLocalDate(),value.getConsumedAt().toLocalDate());
}
/** 解冻命令只发布一次;冻结处理中取消时等待原冻结回执后补发解冻。 */
private void release(StorageRentPreparation value,String status) {
if(value.getConsumedAt()!=null)return;value.setStatus(status);
if(value.getHoldId()!=null&&value.getReleaseEventId()==null&&value.getAmount().signum()>0){
value.setReleaseStatus("PENDING");value.setReleaseEventId(wallet.publish("RELEASE",customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租取消预占",3));}
value.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));repository.save(value);
}
/** 恢复任务只读取需要处理的主键,避免持有大批资金行锁。 */
public List<Long> recoveryCandidates(){var now=LocalDateTime.now(ZoneOffset.UTC);return repository.recoveryCandidates(now.minusMinutes(15),now.minusMinutes(1));}
/** 原已消费仓租只重试结算,未使用且过期的预占只释放资金。 */
@Transactional
public void recover(long id){
var value=repository.lockPreparation(id);if(value==null)return;var now=LocalDateTime.now(ZoneOffset.UTC);
if(value.getConsumedAt()!=null&&rentReview.managed(value)&&value.getUpdatedAt().isBefore(now.minusMinutes(1))){rentReview.recover(value);return;}
if(value.getConsumedAt()==null&&Set.of("FREEZE_PENDING","READY").contains(value.getStatus())&&value.getCreatedAt().isBefore(now.minusMinutes(15))){release(value,"EXPIRED");return;}
if(!value.getUpdatedAt().isBefore(now.minusMinutes(1)))return;
String operation;
if("SETTLE_FAILED".equals(value.getStatus())&&value.getConsumedAt()!=null){operation="SETTLE";value.setStatus("CONSUMED");}
else if("FAILED".equals(value.getReleaseStatus())&&value.getConsumedAt()==null){operation="RELEASE";value.setReleaseStatus("PENDING");}
else return;
long version=Math.addExact(value.getWalletVersion()==null?3:value.getWalletVersion(),1);value.setWalletVersion(version);
String event=wallet.publish(operation,customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租资金恢复",version);
if("SETTLE".equals(operation))value.setSettleEventId(event);else value.setReleaseEventId(event);
value.setErrorMessage(null);value.setUpdatedAt(now);repository.save(value);
}
/** 报价重新计算可降低金额,但不能增加已冻结金额或改变原商品份额。 */
private static boolean sameBasis(Plan a,Plan b) {
if(!Objects.equals(a.owner(),b.owner())||a.allocations().size()!=b.allocations().size())return false;
for(int i=0;i<a.allocations().size();i++){var x=a.allocations().get(i);var y=b.allocations().get(i);
if(x.lotId()!=y.lotId()||!Objects.equals(x.shareId(),y.shareId())||x.quantity().compareTo(y.quantity())!=0||x.days()!=y.days())return false;}
return true;
}
/** 返回前端可执行标记;只有已确认冻结或原已消费请求可继续原扫码幂等恢复。 */
private PreparationResult result(StorageRentPreparation v) {String status=v.getStatus();boolean consumed=v.getConsumedAt()!=null;
return new PreparationResult(v.getId().toString(),consumed?"CONSUMED":status,"READY".equals(status)||consumed,v.getAmount(),"CNY",v.getOriginalAmount(),read(v).pricing().currency(),v.getExchangeRate(),v.getExchangeRateMonth(),v.getErrorMessage());}
/** PDA 查询不得穿透绑定仓。 */
private StorageRentPreparation owned(String warehouse,long id) {var v=repository.lockPreparation(id);if(v==null||!warehouse.equals(v.getWarehouseId()))throw missing("当前仓没有该仓租预校验");return v;}
/** 资金协议只接收持久化来源客户,不能接受客户端传入客户。 */
private static long customer(StorageRentPreparation v) {return Long.parseLong(v.getCustomerId());}
/** 缺少扫码关键身份不允许创建孤立预占。 */
private static void validateRequest(PrepareRequest r) {if(r==null||r.mode()==null||r.businessNo()==null||r.idempotencyKey()==null||r.idempotencyKey().isBlank())throw conflict("扫码预校验参数不完整");}
/** 标准化请求用于跨接口原扫码核对,忽略调用方多传的空集合。 */
private String hash(PrepareRequest r) {var marks=r.cartonMarks()==null?List.<String>of():r.cartonMarks().stream().filter(Objects::nonNull).sorted().toList();
var lines=r.lines()==null?List.of():r.lines().stream().sorted(Comparator.comparing(com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos.ActualLine::itemId)).toList();
var photos=r.photoFileIds()==null?List.of():r.photoFileIds().stream().sorted().toList();
var scanned=r.scannedCartonMarks()==null?List.of():r.scannedCartonMarks().stream().sorted().toList();
var parts=Arrays.asList(r.mode(),r.businessNo(),r.itemId(),r.positionId(),r.packages(),blank(r.cartonMark()),blank(r.palletNo()),r.idempotencyKey(),marks,blank(r.skuCode()),r.productQuantity(),lines,photos,scanned,Boolean.TRUE.equals(r.confirmAllCartons()));
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(parts).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException("仓租请求签名失败",e);}}
/** 空文本统一表达为未提交。 */
private static String blank(String value){return value==null||value.isBlank()?null:value.trim();}
/** 原快照序列化失败必须回滚,不能静默丢失资金依据。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("仓租快照写入失败",e);}}
/** 回执只使用原确认的快照,不重新读取汇率或报价。 */
private Plan read(StorageRentPreparation v){try{return json.readValue(v.getPlanJson(),Plan.class);}catch(Exception e){throw new IllegalStateException("仓租快照读取失败",e);}}
/** 原始批次通用身份快照。 */
private static StorageRentLot base(String key,String customer,String name,String warehouse,String warehouseName,String type,Long businessId,String businessNo,Long orderId,String inboundCode,String batch,String sku,LocalDateTime at){
var v=new StorageRentLot();v.setId(IdWorker.getId());v.setSourceKey(key);v.setCustomerId(customer);v.setCustomerName(name);v.setWarehouseId(warehouse);v.setWarehouseName(warehouseName);v.setBusinessType(type);v.setBusinessId(businessId);v.setBusinessNo(businessNo);v.setOrderId(orderId);v.setInboundCode(inboundCode);v.setBatchNo(batch);v.setSkuCode(sku);v.setInboundAt(at);v.setCreatedAt(at);v.setUpdatedAt(at);return v;}
/** 新分箱份额只引用原批次,不复制原托计费基数。 */
private static StorageRentShare newShare(StorageRentLot lot,String mark,String sku,BigDecimal quantity){var s=new StorageRentShare();s.setId(IdWorker.getId());s.setLotId(lot.getId());s.setCustomerId(lot.getCustomerId());s.setWarehouseId(lot.getWarehouseId());s.setCartonMark(mark);s.setSkuCode(sku);s.setQuantity(quantity);return s;}
/** 普通实收数量与原批次建立可搬移的库存份额。 */
private void insertStockShare(StorageRentLot lot,long balance,String batch,BigDecimal quantity){var s=newShare(lot,null,lot.getSkuCode(),quantity);s.setBalanceId(balance);s.setBatchNo(batch);repository.insert(s);}
/** 业务状态冲突沿现有 API 错误协议返回。 */
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
/** 资源不存在。 */
private static ResponseStatusException missing(String message){return new ResponseStatusException(HttpStatus.NOT_FOUND,message);}
}
@@ -0,0 +1,170 @@
package com.baiyue.lastleg.storagerent.service;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.storagerent.entity.*;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import com.baiyue.lastleg.entity.LastlegOrder;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.math.*;
import java.time.LocalDateTime;
import java.util.*;
/** 与真实 PDA 原库存位置、原退货箱及当前服务业务单同源解析。 */
@Service
public class StorageRentSourceResolver {
@Resource private StorageRentRepository repository;
@Resource private com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway pricingGateway;
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository discards;
/** 客户、仓库及费用单号均从持久化来源取值,绝不信任前端客户字段。 */
public Plan resolve(String warehouse,PrepareRequest request,LocalDateTime at) {
var plan=resolveSources(warehouse,request,at);
if(plan.allocations().isEmpty())return plan;
return reprice(plan,pricingGateway.current(plan.owner().customerId()));
}
/** 原预占消费只使用已保存的客户价格;仍重新校验实际库存和业务归属。 */
public Plan resolve(String warehouse,PrepareRequest request,LocalDateTime at,Pricing snapshot) {
var plan=resolveSources(warehouse,request,at);
if(plan.allocations().isEmpty())return plan;
com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway.validate(snapshot,Long.parseLong(plan.owner().customerId()));
return reprice(plan,snapshot);
}
/** 定价与来源解析分离,原批次体积、原托份额及实收时间始终保持不变。 */
private Plan reprice(Plan plan,Pricing pricing) {
var allocations=plan.allocations().stream().map(a->{var lot=repository.lockLot(a.lotId());
return new Allocation(a.lotId(),a.shareId(),a.quantity(),StorageRentCalculator.amount(pricing,lot.getRuleCode(),lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),lot.getOriginalQuantity(),a.quantity()),a.inboundAt(),a.days());}).toList();
return new Plan(plan.owner(),allocations,plan.calculatedAt(),allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP),pricing);
}
/** 先解析真实来源;此阶段默认金额只用于内部数量校验,不作为最终收费依据。 */
private Plan resolveSources(String warehouse,PrepareRequest request,LocalDateTime at) {
if("DISCARD".equals(request.mode()))return discard(warehouse,request,at);
if(request.itemId()==null||request.packages()==null||request.packages()<=0)throw conflict("扫码来源明细与数量不能为空");
Owner owner;String customer;String marks=null;String sku=null;Long pieceQuantity=null;Long palletId=null;
Long expectedOrderId=null;String expectedInboundCode=null;Long expectedPositionId=null;
if("DISPATCH".equals(request.mode())){
var dispatch=repository.dispatch(request.businessNo());var item=repository.dispatchItem(request.itemId());
if(dispatch==null||item==null||!Objects.equals(dispatch.getId(),item.getDispatchOrderId())
||!warehouse.equals(dispatch.getOverseasWarehouseId())||!warehouse.equals(item.getOverseasWarehouseId()))throw conflict("扫码来源不属于当前仓排车单");
if(!"ACTIVE".equals(dispatch.getStatus())||!"LOADING".equals(dispatch.getStage()))throw conflict("当前排车阶段不允许预占仓租");
customer=item.getCustomerId();palletId=item.getWarehousePalletId();
expectedOrderId=item.getOrderId();expectedInboundCode=item.getInboundCode();
if("RETURN_RELABEL".equals(item.getSourceStage())){marks=request.cartonMark();if(marks==null)throw conflict("退货换标必须扫描原箱唛");}
owner=business(customer,item.getCustomerName(),item.getSourceBusinessType(),item.getSourceBusinessNo(),item.getOrderId(),warehouse,item.getInboundCode());
}else if("OUTBOUND".equals(request.mode())){
var task=repository.task(warehouse,request.businessNo());var item=repository.outboundItem(request.itemId());
if(task==null||item==null||!Objects.equals(task.getOutboundOrderId(),item.getOutboundOrderId())||!task.getCustomerId().equals(item.getCustomerId()))throw conflict("扫码来源不属于当前仓出库工单");
var outbound=repository.outbound(task.getOutboundOrderId());
if(outbound==null||!warehouse.equals(outbound.getOverseasWarehouseId())||!Set.of("PENDING","IN_PROGRESS").contains(task.getStatus()))throw conflict("当前出库工单不能预占仓租");
customer=task.getCustomerId();palletId=item.getWarehousePalletId();
expectedOrderId=item.getSourceOrderId();expectedInboundCode=item.getInboundCode();expectedPositionId=item.getSourceInventoryPositionId();
if("RETURN_OUTBOUND".equals(outbound.getOutboundType())||"RELABEL_OUTBOUND".equals(outbound.getOutboundType())&&palletId==null){
marks=item.getCartonMarks();sku=item.getSkuCode();pieceQuantity=item.getReturnSkuQuantity();
// SKU 扫码的商品数量必须与出库单不可变快照一致,不能用商品条码代替任意箱。
if(pieceQuantity!=null&&(!Objects.equals(sku,request.skuCode())||!Objects.equals(pieceQuantity,request.productQuantity())))throw conflict("扫码商品及数量与原出库单不一致");
if(pieceQuantity==null&&(request.cartonMarks()==null||!request.cartonMarks().contains(marks)))throw conflict("扫码箱唛与原出库单不一致");
}
if("RELABEL_OUTBOUND".equals(outbound.getOutboundType())){
var relabel=repository.relabel(customer,outbound.getOutboundNo());if(relabel==null)throw conflict("出库单缺少对应换标申请");
owner=new Owner(customer,null,"RELABEL_ORDER",relabel.getId(),relabel.getBusinessNo(),relabel.getLastlegOrderId(),item.getInboundCode(),warehouse);
}else if("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())){
var transfer=repository.transfer(customer,item.getOrderRefs());
if(transfer==null&&item.getSourceOrderId()!=null)transfer=repository.transferByTail(customer,item.getSourceOrderId());
if(transfer==null)throw conflict("出库单缺少对应仓储转运业务单");
owner=new Owner(customer,null,"WAREHOUSE_TRANSFER",transfer.getId(),transfer.getBusinessNo(),transfer.getLastlegOrderId(),item.getInboundCode(),warehouse);
}else if("RETURN_OUTBOUND".equals(outbound.getOutboundType())){
var order=repository.returnOrder(customer,item.getInboundCode());if(order==null)throw conflict("出库单缺少对应退货业务单");
owner=new Owner(customer,null,"RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,warehouse);
}else owner=business(customer,null,"LASTLEG_ORDER",null,item.getSourceOrderId(),warehouse,item.getInboundCode());
}else throw conflict("整托预校验应先展开为真实扫码来源");
List<Allocation> allocations;
if(marks!=null){if(request.positionId()!=null)throw conflict("退货扫码不能指定普通库存位置");allocations=returnAllocations(customer,warehouse,marks,sku,pieceQuantity,at);}
else {
var position=repository.position(request.positionId());
if(position==null||!customer.equals(position.getCustomerId())||!warehouse.equals(position.getOverseasWarehouseId())
||palletId!=null&&!palletId.equals(position.getPalletId()))throw conflict("仓租预校验库存位置不属于原客户和原托盘");
var balance=repository.balance(position.getBalanceId());if(balance==null||!customer.equals(balance.getCustomerId()))throw conflict("仓租来源库存余额不存在");
if(expectedOrderId!=null&&!Objects.equals(expectedOrderId,balance.getInboundOrderId()))throw conflict("扫码库存不属于业务单据关联的入库订单");
if(expectedInboundCode!=null&&!expectedInboundCode.isBlank()&&!Objects.equals(expectedInboundCode,balance.getInboundCode()))throw conflict("扫码库存不属于业务单据关联的入仓码");
if(expectedPositionId!=null&&!Objects.equals(expectedPositionId,position.getId()))throw conflict("扫码库存位置与出库单来源位置不一致");
var original=repository.order(balance.getInboundOrderId());
var shares=repository.stockShares(customer,warehouse,position.getBalanceId(),position.getBatchNo());
if(shares.isEmpty()){
if(original==null)throw conflict("原库存缺少有效业务订单,不能判定计租范围");
if(!StorageRentService.eligible(original))return new Plan(owner,List.of(),at,BigDecimal.ZERO.setScale(2));
throw conflict("原实收批次计租数量不完整,不能出库");
}
var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
if(total.compareTo(BigDecimal.valueOf(request.packages()))<0)throw conflict("原实收批次计租数量不足,不能出库");
allocations=proportionShares(shares,BigDecimal.valueOf(request.packages()),total,at);
if(owner.businessId()==null||owner.businessNo()==null)owner=business(customer,balance.getCustomerName(),"LASTLEG_ORDER",null,balance.getInboundOrderId(),warehouse,balance.getInboundCode());
}
return new Plan(owner,allocations,at,allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP));
}
/** 废弃同样属于实物出仓,按原申请客户、仓位和退货原托份额结算。 */
private Plan discard(String warehouse,PrepareRequest request,LocalDateTime at) {
var application=discards.lockApplication(request.businessNo(),warehouse);
if(application==null||!"APPROVED".equals(application.getStatus()))throw conflict("废弃申请未审核或不属于当前仓库");
if(request.lines()==null||request.lines().isEmpty()||request.palletNo()==null)throw conflict("废弃托盘与实际明细不能为空");
String customer=application.getCustomerId();var result=new ArrayList<Allocation>();var seen=new HashSet<Long>();
for(var line:request.lines()){
if(!seen.add(line.itemId()))throw conflict("废弃明细重复");
var item=discards.item(line.itemId());
if(item==null||!customer.equals(item.getCustomerId())||!application.getId().equals(item.getApplicationId())||line.actualPackages()<0||line.actualPackages()>item.getDiscardPackages())throw conflict("废弃数量或来源与申请不一致");
String pallet;Long positionId=item.getInventoryPositionId();
if(item.getReturnCartonId()!=null){var carton=discards.returnCarton(item.getReturnCartonId());
if(carton==null||!customer.equals(carton.getCustomerId())||!warehouse.equals(carton.getOverseasWarehouseId()))throw conflict("退货废弃来源不属于当前客户和仓库");
pallet=carton.getPalletCode();
if(line.actualPackages()>0)result.addAll(returnAllocations(customer,warehouse,carton.getCartonMark(),item.getSkuCode(),(long)line.actualPackages(),at));
}else{
if(positionId==null){var profile=discards.profile(item.getProfileId());if(profile==null||!customer.equals(profile.getCustomerId()))throw conflict("废弃原托盘不存在");positionId=profile.getInventoryPositionId();}
var position=repository.position(positionId);
if(position==null||!customer.equals(position.getCustomerId())||!warehouse.equals(position.getOverseasWarehouseId()))throw conflict("废弃原库存不属于当前仓库");
pallet=position.getPalletNo();var shares=repository.stockShares(customer,warehouse,position.getBalanceId(),position.getBatchNo());
if(shares.isEmpty()){
var balance=repository.balance(position.getBalanceId());var order=balance==null?null:repository.order(balance.getInboundOrderId());
if(order==null||StorageRentService.eligible(order))throw conflict("废弃原实收批次缺少计租记录");
}else if(line.actualPackages()>0){var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);if(total.compareTo(BigDecimal.valueOf(line.actualPackages()))<0)throw conflict("废弃原计租批次数量不足");result.addAll(proportionShares(shares,BigDecimal.valueOf(line.actualPackages()),total,at));}
}
if(!Objects.equals(pallet==null||pallet.isBlank()?"散箱":pallet.trim(),request.palletNo().trim()))throw conflict("废弃明细不属于本次托盘");
}
var owner=new Owner(customer,null,"DISCARD_ORDER",application.getId(),application.getDiscardNo(),null,null,warehouse);
return new Plan(owner,result,at,result.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP));
}
/** 当前独立服务工单优先,不能把新换标或转运应收记回原头程运输单。 */
private Owner business(String customer,String name,String type,String no,Long orderId,String warehouse,String inbound) {
if("RELABEL_ORDER".equals(type)){var value=repository.relabelNo(customer,no);if(value==null)throw conflict("排车来源换标业务单不存在");return new Owner(customer,name,type,value.getId(),value.getBusinessNo(),value.getLastlegOrderId(),inbound,warehouse);}
if("WAREHOUSE_TRANSFER".equals(type)){var value=repository.transfer(customer,no);if(value==null)throw conflict("排车来源转运业务单不存在");return new Owner(customer,name,type,value.getId(),value.getBusinessNo(),value.getLastlegOrderId(),inbound,warehouse);}
if("RETURN_ORDER".equals(type)){var value=repository.returnOrder(customer,no);if(value==null)throw conflict("排车来源退货业务单不存在");return new Owner(customer,name,type,value.getId(),value.getReturnNo(),value.getLastlegOrderId(),inbound,warehouse);}
LastlegOrder order=repository.order(orderId);
if(order==null)return new Owner(customer,name,"LASTLEG_ORDER",null,null,orderId,inbound,warehouse);
if(!customer.equals(order.getCustomerId()))throw conflict("原订单不属于扫码客户");
if("WAREHOUSE_TRANSFER".equals(order.getOrderType())){var value=repository.transferByTail(customer,order.getId());if(value==null)throw conflict("原转运订单缺少独立业务工单");return new Owner(customer,name,"WAREHOUSE_TRANSFER",value.getId(),value.getBusinessNo(),order.getId(),inbound,warehouse);}
return new Owner(customer,name==null?order.getCustomerName():name,"LASTLEG_ORDER",order.getId(),order.getOrderNo(),order.getId(),inbound,warehouse);
}
/** 原批次内部混入多次实收时按剩余来源份额分配,不能跨来源用 FIFO 猜箱。 */
private List<Allocation> proportionShares(List<StorageRentShare> shares,BigDecimal quantity,BigDecimal total,LocalDateTime at) {
var result=new ArrayList<Allocation>();BigDecimal used=BigDecimal.ZERO;
for(int i=0;i<shares.size();i++){var share=shares.get(i);var part=i==shares.size()-1?quantity.subtract(used):quantity.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(part);if(part.signum()>0)result.add(allocation(repository.lockLot(share.getLotId()),share.getId(),part,at));}
return result;
}
/** 退货按当前箱内 SKU 的原托份额计费,部分 SKU 出库不会新增一个计费整托。 */
private List<Allocation> returnAllocations(String customer,String warehouse,String mark,String sku,Long quantity,LocalDateTime at) {
var carton=repository.carton(mark);if(carton==null||!customer.equals(carton.getCustomerId())||!warehouse.equals(carton.getOverseasWarehouseId()))throw conflict("原退货箱不属于当前客户和仓库");
var shares=repository.shares(customer,warehouse,mark,sku);var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal requested=quantity==null?total:BigDecimal.valueOf(quantity);
if(shares.isEmpty()||requested.signum()<=0||total.compareTo(requested)<0)throw conflict("退货原托计租份额不完整,不能出库");
var result=new ArrayList<Allocation>();BigDecimal used=BigDecimal.ZERO;
for(int i=0;i<shares.size();i++){var share=shares.get(i);var part=i==shares.size()-1?requested.subtract(used):requested.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(part);if(part.signum()>0)result.add(allocation(repository.lockLot(share.getLotId()),share.getId(),part,at));}
return result;
}
/** 单份金额保留十二位,仅在完整扫码汇总时按分取整。 */
private static Allocation allocation(StorageRentLot lot,Long share,BigDecimal quantity,LocalDateTime at) {
if(lot==null)throw conflict("原仓租批次不存在");
try{return new Allocation(lot.getId(),share,quantity,StorageRentCalculator.amount(lot.getRuleCode(),lot.getInboundAt(),at,lot.getOriginalUnits(),lot.getOriginalQuantity(),quantity),lot.getInboundAt(),StorageRentCalculator.days(lot.getInboundAt(),at));}
catch(IllegalArgumentException e){throw conflict(e.getMessage());}
}
/** 来源不一致不得降级为免租。 */
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.storagerent.service.preparation;
import com.baiyue.lastleg.storagerent.service.StorageRentService;
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.*;
/** 整托和普通扫码共用实际出库规划,避免资金预占与库存扣减来源不同。 */
@Service
public class StorageRentPreparationService {
@Resource private StorageRentService rents;
@Resource private DeliveryTransferService delivery;
/** 整托每条原扫码分别冻结,只有全部成功才允许调用原整托接口。 */
@Transactional
public PreparationResult prepare(PdaWarehouseIdentity identity,PrepareRequest request) {
String warehouse=String.valueOf(identity.overseasWarehouseId());
if(!"OUTBOUND_PALLET".equals(request.mode()))return rents.prepare(warehouse,request);
var scans=delivery.storageRentPalletRequests(warehouse,identity.operatorKey(),request.businessNo(),request.palletNo(),new DeliveryRequests.PdaPalletPick(request.idempotencyKey()));
var results=new ArrayList<PreparationResult>();
for(var scan:scans)results.add(rents.prepare(warehouse,new PrepareRequest("OUTBOUND",request.businessNo(),scan.outboundPalletItemId(),scan.inventoryPositionId(),scan.packages(),null,null,scan.idempotencyKey(),scan.cartonMarks(),scan.skuCode(),scan.productQuantity())));
var blocker=results.stream().filter(r->!r.ready()).findFirst().orElse(null);
var currencies=results.stream().filter(r->!"NOT_REQUIRED".equals(r.status())).map(PreparationResult::originalCurrency).distinct().toList();
boolean single=currencies.size()==1;
var priced=single?results.stream().filter(r->r.exchangeRate()!=null).findFirst().orElse(null):null;
boolean sameRate=priced!=null&&results.stream().filter(r->r.exchangeRate()!=null).allMatch(r->r.exchangeRate().compareTo(priced.exchangeRate())==0&&Objects.equals(r.exchangeRateMonth(),priced.exchangeRateMonth()));
return new PreparationResult(null,blocker==null?"READY":blocker.status(),blocker==null,
results.stream().map(PreparationResult::amount).reduce(BigDecimal.ZERO,BigDecimal::add),"CNY",
single?results.stream().map(PreparationResult::originalAmount).reduce(BigDecimal.ZERO,BigDecimal::add):currencies.isEmpty()?BigDecimal.ZERO:null,single?currencies.get(0):null,
sameRate?priced.exchangeRate():null,sameRate?priced.exchangeRateMonth():null,blocker==null?null:blocker.message());
}
}