This commit is contained in:
2026-11-03 11:41:58 +08:00
parent 21a9ce7d27
commit 7b5ef9ba64
56 changed files with 679 additions and 173 deletions
@@ -34,10 +34,11 @@ public class CargoDiscardController {
return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode));
}
/** 从可信登录身份保存申请人姓名快照。 */
@PostMapping
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
return ApiResponse.success(service.create(scope(user, customerId), operator(user), org.springframework.util.StringUtils.hasText(user.displayName()) ? user.displayName() : user.username(), request));
}
@GetMapping
@@ -24,10 +24,11 @@ public class MerchantCargoDiscardController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); }
/** 从可信登录身份保存申请人姓名快照。 */
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CREATE + "')")
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), org.springframework.util.StringUtils.hasText(user.displayName()) ? user.displayName() : user.username(), request)); }
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@@ -38,7 +38,7 @@ public final class CargoDiscardResponses {
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt,
List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages) {}
List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages, String createdByName) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
@@ -26,6 +26,7 @@ public class CargoDiscardApplication {
private String idempotencyKey;
private String requestHash;
private String createdBy;
private String createdByName;
private String reviewedBy;
private String reviewRemark;
private LocalDateTime customerConfirmedAt;
@@ -84,6 +84,25 @@ public interface CargoDiscardInventoryMapper {
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 现场箱唛须能溯源到该客户该仓位的实收流水或原入仓单,不能凭数量放行。 */
@Select("""
SELECT l.carton_mark FROM ll_inventory_ledger l
JOIN ll_inventory_position p ON p.customer_id=l.customer_id AND p.id=l.position_id AND p.balance_id=l.balance_id
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id AND b.inbound_order_id=l.inbound_order_id
WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId}
AND l.carton_mark IS NOT NULL AND l.carton_mark<>''
GROUP BY l.carton_mark HAVING SUM(l.delta_packages)>0
UNION
SELECT DISTINCT c.carton_mark FROM ll_inventory_position p
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id
JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.received_at IS NOT NULL
AND c.status IN ('AVAILABLE','DISCARD_PENDING') AND c.carton_mark IS NOT NULL AND c.carton_mark<>''
AND (c.warehouse_pallet_id=p.pallet_id OR c.warehouse_pallet_id IS NULL)
AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'')
""")
List<String> traceableCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 按当前可用仓位筛选入库单,不能只依赖箱唛的入库状态。 */
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
@@ -30,7 +30,7 @@ public final class PdaCargoDiscardDtos {
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status, String quantityUnit,
boolean actualQuantityEditable) { }
boolean actualQuantityEditable, List<String> allowedCartonMarks) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks, String remark,
@@ -94,6 +94,7 @@ public class PdaCargoDiscardService {
if (scanned.size() != input.actualPackages() || scanned.stream().anyMatch(mark -> mark == null || mark.isBlank()
|| mark.length() > 128 || !mark.equals(mark.trim()) || !observed.add(mark) || otherMarks.contains(mark)))
throw conflict("实际废弃箱数必须与逐箱扫码一致,箱唛不能重复或跨托盘重复废弃");
if (!"COMPLETED".equals(item.getPdaStatus())) stockService.validateActualCartonMarks(application.getCustomerId(), item, scanned);
if ("COMPLETED".equals(item.getPdaStatus()) && !new HashSet<>(stockService.actualCartonMarks(item)).equals(new HashSet<>(scanned)))
throw conflict("已完成托盘的实际废弃箱唛不能修改");
}
@@ -145,7 +146,7 @@ public class PdaCargoDiscardService {
var actualMarks = task.skus().stream().filter(s -> s.itemId().equals(item.getId())).findFirst().orElseThrow().actualQuantityEditable()
? request.lines().stream().filter(i -> i.itemId().equals(item.getId())).findFirst().orElseThrow().scannedCartonMarks()
: cartonMarks(application, item);
if ("INVENTORY".equals(item.getSourceType())) stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
}
@@ -218,7 +219,8 @@ public class PdaCargoDiscardService {
CargoDiscardItem first = entry.getValue().get(0);
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i),
"INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i) ? stockService.allowedCartonMarks(application.getCustomerId(), i) : cartonMarks(application, i))).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
// 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
@@ -32,6 +32,8 @@ public class CargoDiscardStockRepository {
public boolean dropshipPosition(String customer, Long position) { return stock.isDropshipPosition(customer, position) > 0; }
/** 读取散箱实收流水中的箱码快照。 */
public List<String> looseMarks(String customer, Long position) { return stock.looseCartonMarks(customer, position); }
/** 读取本仓位原入库单与实收流水允许扫描的箱唛。 */
public List<String> traceableMarks(String customer, Long position) { return stock.traceableCartonMarks(customer, position); }
/** 保存废弃申请的库存来源明细。 */
public void saveItem(CargoDiscardItem item) { items.insert(item); }
/** 保存申请所选实物箱快照。 */
@@ -84,16 +84,10 @@ public class CargoDiscardService {
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
private BigDecimal feePerPackage = new BigDecimal("5.00");
/** 仅提供允许废弃的普通库存和退货箱候选。 */
public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) {
List<CargoDiscardCandidateRow> rows = inventory.candidates(customerId, trim(inboundCode));
Map<Long, CandidateAccumulator> grouped = new LinkedHashMap<>();
for (CargoDiscardCandidateRow row : rows) {
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
value.add(row);
}
List<CargoDiscardResponses.Candidate> result = grouped.values().stream()
.map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId())))
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
// 货物废弃仅开放普通库存和退货库存,不提供一件代发订单候选。
List<CargoDiscardResponses.Candidate> result = new java.util.ArrayList<>();
result.addAll(stockService.candidates(customerId, trim(inboundCode)));
String keyword = trim(inboundCode);
if (returnCartons == null || returnCartonItems == null) return result;
@@ -141,10 +135,12 @@ public class CargoDiscardService {
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
}
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
/** 申请人姓名由认证身份提供快照,不采信请求字段。 */
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
public CargoDiscardResponses.Detail create(String customerId, String operatorId, String operatorName,
CargoDiscardRequests.Create request) {
if (request.items().stream().anyMatch(item -> item.orderId() != null))
throw badRequest("货物废弃不支持一件代发订单");
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (request.requestedPackages() != null && !StringUtils.hasText(request.remark()))
@@ -243,7 +239,7 @@ public class CargoDiscardService {
application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total)));
application.setCurrency("USD"); application.setStatus(PENDING_REVIEW);
application.setRemark(trim(request.remark())); application.setIdempotencyKey(key);
application.setRequestHash(requestHash); application.setCreatedBy(operatorId);
application.setRequestHash(requestHash); application.setCreatedBy(operatorId); application.setCreatedByName(operatorName);
application.setCreatedAt(now); application.setUpdatedAt(now);
applications.insert(application);
@@ -292,6 +288,7 @@ public class CargoDiscardService {
return statisticsService.statistics(customerId, query);
}
/** 详情返回各来源实际完成箱唛及托盘,退货沿用实收箱事实。 */
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
@@ -299,8 +296,7 @@ public class CargoDiscardService {
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value),
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus(),
stockService.actualCartonMarks(value), value.getInventoryPositionId() == null ? null
: stockService.position(customerId, value.getInventoryPositionId()).getPalletNo()))
actualMarks(customerId, application, value), detailPallet(customerId, value)))
.toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -309,6 +305,25 @@ public class CargoDiscardService {
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews, photoService.list(application, null, true));
}
/** 实际箱唛优先取作业快照,退货已完成明细可沿已核验的所选箱恢复。 */
private List<String> actualMarks(String customer, CargoDiscardApplication application, CargoDiscardItem item) {
var marks = stockService.actualCartonMarks(item);
if (!marks.isEmpty() || !"COMPLETED".equals(item.getPdaStatus()) || item.getActualDiscardPackages() == null || item.getActualDiscardPackages() <= 0) return marks;
return selectedMarks(customer, application, item);
}
/** 托盘只读对应来源的真实实收记录,散箱保留明确标记。 */
private String detailPallet(String customer, CargoDiscardItem item) {
if (item.getInventoryPositionId() != null) return stockService.position(customer, item.getInventoryPositionId()).getPalletNo();
if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !customer.equals(carton.getCustomerId())) return null;
return StringUtils.hasText(carton.getPalletCode()) ? carton.getPalletCode() : "散箱";
}
var profile = profiles.selectById(item.getProfileId());
return profile != null && customer.equals(profile.getCustomerId()) ? profile.getPalletNo() : null;
}
public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String discardNo, long fileId) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
@@ -539,7 +554,7 @@ public class CargoDiscardService {
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual);
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual, value.getCreatedByName());
}
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
@@ -27,7 +27,7 @@ public class CargoDiscardStockService {
var positions = new LinkedHashMap<Long, InventoryPosition>();
repository.candidates(customer, keyword).forEach(p -> positions.put(p.getId(), p));
relabelStock.positions(customer, keyword).forEach(p -> positions.put(p.getId(), p));
return positions.values().stream().map(position -> {
return positions.values().stream().filter(position -> !repository.dropshipPosition(customer, position.getId())).map(position -> {
var balance = repository.balance(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
long available = available(customer, position, balance);
@@ -74,7 +74,7 @@ public class CargoDiscardStockService {
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
if (request.discardPackages() != available) throw conflict("无箱唛的候选托盘须完整预留,废弃目标箱数请填写在申请总数中");
}
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
if (repository.dropshipPosition(customer, position.getId())) throw conflict("货物废弃不支持一件代发库存");
return balance;
}
@@ -270,6 +270,18 @@ public class CargoDiscardStockService {
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
}
/** 可扫描范围只来自该申请明细仓位的原入仓及当前净实收箱唛。 */
public List<String> allowedCartonMarks(String customer, CargoDiscardItem line) {
position(customer, line.getInventoryPositionId());
return repository.traceableMarks(customer, line.getInventoryPositionId());
}
/** 无逐箱快照时仍需验证原入库箱唛归属,无法溯源的箱子拒绝出库。 */
public void validateActualCartonMarks(String customer, CargoDiscardItem line, List<String> marks) {
position(customer, line.getInventoryPositionId());
var allowed = new HashSet<>(repository.traceableMarks(customer, line.getInventoryPositionId()));
if (!allowed.containsAll(marks)) throw conflict("箱唛不属于本废弃单的原入仓库存,请核对后重新扫描");
}
/** 现场扫码单独保存,不改写客户选箱快照或把无关联库存伪装成已锁定实箱。 */
public void saveActualCartonMarks(CargoDiscardItem line, List<String> marks) {
try { line.setActualCartonMarksJson(objectMapper.writeValueAsString(marks)); }
@@ -24,9 +24,8 @@ public final class DispatchCommands {
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
/** 计划发车时间是车次表必填字段;前端未填写时给验收/草稿单一个可编辑的默认时间。 */
/** 草稿未知计划时间保持为空,避免重试时动态补时间破坏原请求幂等。 */
public Create {
plannedDepartureAt = plannedDepartureAt == null ? Instant.now().plusSeconds(3600) : plannedDepartureAt;
publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand);
}
}
@@ -20,7 +20,14 @@ public class OverseasFeeSource {
public record RentLine(long lotId, java.time.OffsetDateTime inboundAt, java.math.BigDecimal quantity, long days,
java.math.BigDecimal calculatedAmount, String sourceBusinessType, String sourceBusinessNo, String batchNo) { }
public record RentDetail(String outboundNo, java.time.OffsetDateTime outboundAt,
java.math.BigDecimal calculatedAmount, java.math.BigDecimal chargedAmount, java.util.List<RentLine> lines) { }
java.math.BigDecimal calculatedAmount, java.math.BigDecimal chargedAmount, java.util.List<RentLine> lines,
java.util.List<RentCalculation> calculations) { }
/** 每个原批次的实际计费基数、已覆盖天数及阶梯,金额沿用原计费快照。 */
public record RentCalculation(String sourceBusinessNo,String batchNo,String ruleCode,
java.math.BigDecimal billingQuantity,String billingUnit,long storageDays,long previouslyChargedDays,
java.util.List<RentTier> tiers,java.math.BigDecimal calculatedAmount) { }
/** 阶梯金额保留计算精度,由费用合计统一按分取整。 */
public record RentTier(long startDay,long endDay,long days,java.math.BigDecimal unitPrice,java.math.BigDecimal amount) { }
@TableId(type = IdType.INPUT) private Long id;
private Long sourceFeeId;
private Integer sourceVersion;
@@ -143,7 +143,6 @@ public class OverseasManualFeeService implements WalletResultHandler {
public OverseasFeeSource create(String operator, String key, SaveRequest request) {
if (request.businessId() == null) throw bad("请选择本次服务对应的业务订单");
request = resolveServiceBusiness(request);
requireManualItem(request.feeItemCode());
catalog.requireActive(request.feeItemCode(), request.feeItemName(), request.direction());
OverseasFeeSource fee = sources.create(operator, key, request);
fee = required(repository.lockFee(fee.getSourceFeeId()));
@@ -363,7 +362,6 @@ public class OverseasManualFeeService implements WalletResultHandler {
if (replay)
return create(operator, createKey, request.fee());
requireBeforeReview(fee, state, request.expectedVersion());
requireManualItem(request.fee().feeItemCode());
catalog.requireActive(request.fee().feeItemCode(), request.fee().feeItemName(), request.fee().direction());
if (!Objects.equals(fee.getCustomerId(), request.fee().customerId()) || !Objects.equals(fee.getBusinessType(), request.fee().businessType())
|| !Objects.equals(fee.getBusinessId(), request.fee().businessId()) || !Objects.equals(fee.getDirection(), request.fee().direction())
@@ -504,11 +502,6 @@ public class OverseasManualFeeService implements WalletResultHandler {
return describe(sources.listForBusiness(customerId, "OVERSEAS_PROCUREMENT", orderId));
}
/** 仓租只由实际出库自动生成,人工收费和调价不得重复录入。 */
private void requireManualItem(String code) {
if ("FEE_TYPE_077".equals(code)) throw bad("仓租自动计算,请勿手动添加");
}
/** 钱包只收人民币;核对创建时保存的原币和月度汇率快照,不重新取价。 */
private void requireCnySnapshot(OverseasFeeSource fee) {
if (!"RECEIVABLE".equals(fee.getDirection()) || !"CNY".equals(fee.getCurrency()) || fee.getAmount() == null
@@ -335,7 +335,7 @@ public class DriverService {
case REJECTED -> "司机证照已被打回";
case EXPIRED -> "驾驶证已过期";
case MISSING_TRIP_END -> "请填写司机预计任务结束时间";
case INVALID_TRIP_END -> "司机预计任务结束时间必须晚于当前时间";
case INVALID_TRIP_END -> "预计结束时间已过,请调整运输计划";
case EXPIRES_DURING_TRIP -> "驾驶证将在预计任务结束前到期,请更换司机或调整计划";
case ELIGIBLE -> "司机可派车";
};
@@ -641,7 +641,9 @@ public class FleetTripService {
}
/** 校验发车和任务结束时间以及路线站点完整性。 */
private void validatePlan(Instant departure, Instant end, List<FleetTripModels.Stop> stops) {
if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间");
if (departure == null || end == null) throw bad("请填写发车和预计结束时间");
if (!end.isAfter(departure)) throw bad("预计结束时间须晚于发车时间");
if (!end.isAfter(clock.instant())) throw bad("预计结束时间已过,请调整运输计划");
try {
if (utc(departure).getYear() < 1900 || utc(end).getYear() > 9998) throw bad("车次时间超出允许范围");
} catch (DateTimeException invalid) { throw bad("车次时间超出允许范围"); }
@@ -34,14 +34,32 @@ public final class StorageRentDtos {
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) { }
LocalDateTime inboundAt, long days, Long previouslyChargedDays) {
/** 中转按数量分摊及既有快照不携带退货整托已计费天数。 */
public Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount,
LocalDateTime inboundAt, long days) {
this(lotId, shareId, quantity, amount, inboundAt, days, null);
}
}
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 record CalculationTier(long startDay,long endDay,long days,BigDecimal unitPrice,BigDecimal amount) { }
/** 按原批次汇总的不可变计算过程,计租数量与出库商品数量分开。 */
public record Calculation(long lotId,String ruleCode,BigDecimal billingQuantity,String billingUnit,
long storageDays,long previouslyChargedDays,List<CalculationTier> tiers,BigDecimal amount) { }
public record Plan(Owner owner, List<Allocation> allocations, LocalDateTime calculatedAt, BigDecimal amount,Pricing pricing,
List<Calculation> calculations) {
/** 旧快照没有计算过程时保留为空,查询时只按原报价与原批次依据解释。 */
public Plan { calculations=calculations==null?List.of():List.copyOf(calculations); }
/** 保留明确提供报价的调用,计算过程由正式定价流程一次性生成。 */
public Plan(Owner owner,List<Allocation> allocations,LocalDateTime calculatedAt,BigDecimal amount,Pricing pricing) {
this(owner,allocations,calculatedAt,amount,pricing,List.of());
}
/** 本轮旧测试构造继续明确使用报价表默认值,实际预占必须读取配置服务。 */
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())));
@@ -55,6 +73,6 @@ public final class StorageRentDtos {
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) { }
OffsetDateTime createdAt,OffsetDateTime outboundAt,BigDecimal billingQuantity,String billingUnit) { }
public record LotDetail(LotView lot,List<ChargeView> charges) { }
}
@@ -46,6 +46,8 @@ public class StorageRentLot {
private BigDecimal remainingQuantity;
/** 原批次计费体积或托数。 */
private BigDecimal originalUnits;
/** 退货原托已生成仓租的截止库龄天数,部分出库后余货仍按整托续计。 */
private Long chargedDays = 0L;
/** 实际入库时间。 */
private LocalDateTime inboundAt;
/** 创建时间。 */
@@ -30,7 +30,7 @@ public final class StorageRentCalculator {
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);
@@ -41,9 +41,29 @@ public final class StorageRentCalculator {
if (originalUnits == null || originalUnits.signum() <= 0 || originalQuantity == null || originalQuantity.signum() <= 0
|| shippedQuantity == null || shippedQuantity.signum() <= 0 || shippedQuantity.compareTo(originalQuantity) > 0)
throw new IllegalArgumentException("计租体积或原批次数量不完整");
if ("RETURN".equals(rule)) return returnPalletAmount(pricing, inbound, outbound, originalUnits, 0);
return unitAmount(pricing,rule, days(inbound, outbound)).multiply(originalUnits).multiply(shippedQuantity)
.divide(originalQuantity, 12, RoundingMode.HALF_UP);
}
/** 原托仍有一箱即按整托续租,只累计上次已生成费用之后的阶梯天数。 */
public static BigDecimal returnPalletAmount(Pricing pricing, LocalDateTime inbound, LocalDateTime outbound,
BigDecimal pallets, long chargedDays) {
long currentDays = days(inbound, outbound);
if (pallets == null || pallets.signum() <= 0 || chargedDays < 0 || chargedDays > currentDays)
throw new IllegalArgumentException("退货原托计租数量或已计费天数无效");
return unitAmount(pricing, "RETURN", currentDays).subtract(unitAmount(pricing, "RETURN", chargedDays))
.multiply(pallets.setScale(0, RoundingMode.CEILING));
}
/** 展开真实收费区间的阶梯,免费档也保留,展示金额不采用前端当前报价。 */
public static List<CalculationTier> breakdown(Pricing pricing,String rule,long previousDays,long currentDays,BigDecimal units) {
String type="RETURN".equals(rule)?"RETURN_STORAGE":"STORAGE";
return pricing.tiers().stream().filter(t->type.equals(t.feeType()))
.sorted(java.util.Comparator.comparingInt(PricingTier::startDay))
.map(t->{long start=Math.max(previousDays+1,t.startDay());long end=Math.min(currentDays,t.endDay()==null?Long.MAX_VALUE:t.endDay());
long count=Math.max(0,end-start+1);
return new CalculationTier(start,end,count,t.unitPrice(),t.unitPrice().multiply(BigDecimal.valueOf(count)).multiply(units));})
.filter(t->t.days()>0).toList();
}
/** 两张报价表仅作为明确默认报价及本轮测试构造,服务故障不调用此默认值。 */
public static Pricing defaults(Long customer) {
return new Pricing(null,customer,null,"USD",List.of(
@@ -77,10 +77,39 @@ public class StorageRentReviewService implements StorageRentFeeWorkflow {
&&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()));
}).toList(),calculationDetails(plan,rent,fee,lots)));
}
}
/** 按原快照展示计算过程;旧费用仅在同客户同仓原批次基数完整时解释,绝不使用当前报价。 */
private List<OverseasFeeSource.RentCalculation> calculationDetails(Plan plan,StorageRentPreparation rent,OverseasFeeSource fee,
Map<Long,com.baiyue.lastleg.storagerent.entity.StorageRentLot> lots) {
var calculations=new ArrayList<>(plan.calculations());
if(calculations.isEmpty()&&plan.pricing()!=null) {
for(long id:plan.allocations().stream().map(Allocation::lotId).distinct().toList()) {
var lot=lots.get(id);
if(lot==null||!Objects.equals(lot.getCustomerId(),fee.getCustomerId())||!Objects.equals(lot.getWarehouseId(),rent.getWarehouseId())
||lot.getOriginalUnits()==null||lot.getOriginalQuantity()==null||lot.getOriginalQuantity().signum()<=0
||!Set.of("RETURN","TRANSIT").contains(lot.getRuleCode()))continue;
var parts=plan.allocations().stream().filter(a->a.lotId()==id).toList();
boolean whole=parts.get(0).previouslyChargedDays()!=null;
var quantity=parts.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add);
var units=whole?lot.getOriginalUnits().setScale(0,RoundingMode.CEILING)
:lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP);
long previous=whole?parts.get(0).previouslyChargedDays():0;long days=parts.get(0).days();
calculations.add(new Calculation(id,whole?"RETURN_PALLET":"RETURN".equals(lot.getRuleCode())?"RETURN_SHARE":"TRANSIT",
units,"RETURN".equals(lot.getRuleCode())?"PALLET":"CBM",days,previous,
StorageRentCalculator.breakdown(plan.pricing(),lot.getRuleCode(),previous,days,units),
parts.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add)));
}
}
return calculations.stream().filter(c->{var lot=lots.get(c.lotId());return lot!=null
&&Objects.equals(lot.getCustomerId(),fee.getCustomerId())&&Objects.equals(lot.getWarehouseId(),rent.getWarehouseId());})
.map(c->{var lot=lots.get(c.lotId());return new OverseasFeeSource.RentCalculation(lot.getBusinessNo(),lot.getBatchNo(),c.ruleCode(),
c.billingQuantity(),c.billingUnit(),c.storageDays(),c.previouslyChargedDays(),
c.tiers().stream().map(t->new OverseasFeeSource.RentTier(t.startDay(),t.endDay(),t.days(),t.unitPrice(),t.amount())).toList(),c.amount());}).toList();
}
/** 审核与幂等命令同事务保存;审批通过后才申请按确认金额结算。 */
@Transactional
public OverseasFeeSource review(long id,String operator,String name,ReviewRequest request){
@@ -282,7 +282,7 @@ public class StorageRentService implements WalletResultHandler {
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());
@@ -301,7 +301,10 @@ public class StorageRentService implements WalletResultHandler {
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);
lot.setRemainingQuantity(lot.getRemainingQuantity().subtract(allocation.quantity()));
// 原托行锁与实际出库同事务推进计费截止日,后续箱或其他单据不再收同一天仓租。
if(allocation.previouslyChargedDays()!=null)lot.setChargedDays(allocation.days());
lot.setUpdatedAt(plan.calculatedAt());repository.save(lot);
}
value.setConsumedAt(plan.calculatedAt());value.setUpdatedAt(plan.calculatedAt());
// 出库只生成待审核费用并沿用原冻结;审核通过后才发送真实扣款指令。
@@ -388,10 +391,11 @@ public class StorageRentService implements WalletResultHandler {
}
/** 混合批次同一扫码费用按原冻结原币份额呈现,扣款总额仍归原应收。 */
private ChargeView charge(StorageRentPreparation value,long lotId) {
var allocations=read(value).allocations().stream().filter(a->a.lotId()==lotId).toList();
var snapshot=read(value);var allocations=snapshot.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()));
var calculation=snapshot.calculations().stream().filter(c->c.lotId()==lotId).findFirst().orElse(null);
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,snapshot.pricing().currency(),settlement,value.getExchangeRate(),value.getStatus(),utc(value.getCreatedAt()),utc(value.getConsumedAt()),calculation==null?null:calculation.billingQuantity(),calculation==null?null:calculation.billingUnit());
}
/** 原资金总额按批次稳定分配到分,末批承接尾差,确保多批显示合计等于实际扣款。 */
private Map<Long,BigDecimal> lotAmounts(StorageRentPreparation value,BigDecimal amount){
@@ -404,11 +408,17 @@ public class StorageRentService implements WalletResultHandler {
}
/** 新仓租表统一记录 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";};
var returnPeriods=plan.allocations().stream().filter(a->a.previouslyChargedDays()!=null)
.map(a->a.days()>a.previouslyChargedDays()
?"第"+(a.previouslyChargedDays()+1)+"–"+a.days()+"计租日":"本计租日已计费,不重复收费")
.distinct().toList();
String remark=returnPeriods.isEmpty()?null:"退货按原托整托计费,余一箱仍计一托;"+String.join(";",returnPeriods);
if(remark!=null&&remark.length()>500)remark=remark.substring(0,499)+"…";
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,
"FEE_TYPE_077","仓储费", remark,"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());
}
@@ -437,11 +447,12 @@ public class StorageRentService implements WalletResultHandler {
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;}
if(x.lotId()!=y.lotId()||!Objects.equals(x.shareId(),y.shareId())||x.quantity().compareTo(y.quantity())!=0||x.days()!=y.days()
||!Objects.equals(x.previouslyChargedDays(),y.previouslyChargedDays()))return false;}
return true;
}
/** 返回前端可执行标记;只有已确认冻结或原已消费请求可继续原扫码幂等恢复。 */
@@ -31,11 +31,33 @@ public class StorageRentSourceResolver {
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);
var allocations=new ArrayList<Allocation>();
var chargedLots=new HashSet<Long>();
for(var a:plan.allocations()) {
var lot=repository.lockLot(a.lotId());
if("RETURN".equals(lot.getRuleCode())) {
long previous=lot.getChargedDays();
var amount=chargedLots.add(a.lotId())
?StorageRentCalculator.returnPalletAmount(pricing,lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),previous)
:BigDecimal.ZERO;
allocations.add(new Allocation(a.lotId(),a.shareId(),a.quantity(),amount,a.inboundAt(),a.days(),previous));
} else allocations.add(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()));
}
var calculations=new ArrayList<Calculation>();
for(long id:allocations.stream().map(Allocation::lotId).distinct().toList()) {
var lot=repository.lockLot(id);var parts=allocations.stream().filter(a->a.lotId()==id).toList();
var quantity=parts.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add);
boolean returns="RETURN".equals(lot.getRuleCode());long previous=returns?lot.getChargedDays():0;
var units=returns?lot.getOriginalUnits().setScale(0,RoundingMode.CEILING)
:lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP);
long days=parts.get(0).days();
calculations.add(new Calculation(id,returns?"RETURN_PALLET":"TRANSIT",units,returns?"PALLET":"CBM",days,previous,
StorageRentCalculator.breakdown(pricing,lot.getRuleCode(),previous,days,units),
parts.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add)));
}
return new Plan(plan.owner(),allocations,plan.calculatedAt(),allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP),pricing,calculations);
}
/** 先解析真实来源;此阶段默认金额只用于内部数量校验,不作为最终收费依据。 */
private Plan resolveSources(String warehouse,PrepareRequest request,LocalDateTime at) {
@@ -155,7 +177,7 @@ public class StorageRentSourceResolver {
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);
@@ -57,6 +57,14 @@ public class MerchantUsTruckOrderController {
()->service.update(customer(user),orderNo,operator(user),request)));
}
/** 客户只可取消本企业待审核卡车单,并按版本和幂等键处理。 */
@PatchMapping("/{orderNo}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')")
public ApiResponse<UsTruckOrderResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(customer(user), orderNo, operator(user), request));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
@@ -65,6 +65,7 @@ public class PlatformUsTruckOrderController {
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
}
/** 平台不得代客户取消本土卡车订单,保留旧入口的明确拒绝。 */
@PatchMapping("/{orderNo}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
@Operation(summary = "Change local truck order status", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
@@ -72,7 +73,7 @@ public class PlatformUsTruckOrderController {
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "本土卡车订单仅允许客户取消");
}
@PostMapping("/{orderNo}/review")
@@ -24,9 +24,9 @@ public final class UsTruckOrderRequests {
public record Create(
@NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank(message = "请填写取货地址") @Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@NotBlank(message = "请填写送货地址") @Size(max = 500) String destinationAddress,
@NotNull @Positive Integer palletCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@@ -64,9 +64,9 @@ public final class UsTruckOrderRequests {
@NotNull @Min(0) Long version,
@NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType,
@Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank(message = "请填写取货地址") @Size(max = 500) String originAddress,
@Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@NotBlank(message = "请填写送货地址") @Size(max = 500) String destinationAddress,
@NotNull @Positive Integer palletCount,
@DecimalMin("0.00") BigDecimal totalWeightKg,
@DecimalMin("0.00") BigDecimal totalVolumeCbm,
@@ -10,7 +10,8 @@ import java.util.List;
@Mapper
public interface UsTruckOrderStatusHistoryMapper extends BaseMapper<UsTruckOrderStatusHistory> {
@Select("SELECT * FROM ll_us_truck_order_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id")
/** 按操作时间及同刻流水倒序展示卡车订单记录。 */
@Select("SELECT * FROM ll_us_truck_order_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at DESC,id DESC")
List<UsTruckOrderStatusHistory> listOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
}
@@ -54,8 +54,7 @@ public class UsTruckOrderService {
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, FUNDING_PENDING, FUNDING_FAILED, WAITING_INBOUND, RECEIVING,
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH);
private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW);
private static final Set<String> VEHICLE_TYPES = Set.of("PALLET_15", "PALLET_30");
@Resource private com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository repository;
@@ -169,6 +168,8 @@ public class UsTruckOrderService {
if (!CANCELLED.equals(norm(request.status())) || !CANCELLABLE.contains(order.getStatus())) {
throw conflict("Only cancellable truck orders can use the generic status endpoint");
}
// 取消待审核订单时同步申请释放预算预占,避免客户资金继续冻结。
if (orderBudget != null) orderBudget.releaseAfterReview(customerId, "trucking", orderNo);
return transition(order, operatorId, "CANCEL", request.idempotencyKey(), request,
order.getStatus(), CANCELLED, request.remark());
}
@@ -2879,7 +2879,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
created_by VARCHAR(128) NULL COMMENT '创建人ID', created_by_name VARCHAR(128) NULL COMMENT '申请人姓名快照', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
@@ -4493,6 +4493,7 @@ CREATE TABLE IF NOT EXISTS ll_storage_rent_lot (
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
charged_days bigint NOT NULL DEFAULT 0 COMMENT '退货原托已生成仓租的截止库龄天数,同一天不重复计费',
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
created_at datetime(3) NOT NULL COMMENT '创建时间',
updated_at datetime(3) NOT NULL COMMENT '更新时间',
@@ -15,14 +15,16 @@ info:
Cancellation must use the discard application workflow. PDA resolve optionally accepts
discardNo together with palletNo; ambiguous scans require selection of a discard application.
Candidates include INVENTORY positions from every document type with available warehouse stock,
plus DROPSHIP and RETURN_CARTON sources. Create items identify exactly one of orderId,
returnCartonId or inventoryPositionId and carry selected cartonIds for partial whole-carton disposal; quantities must match those IDs.
plus RETURN_CARTON sources; one-click dropship orders and positions are excluded.
Create items identify exactly one of returnCartonId or inventoryPositionId and carry selected cartonIds for partial whole-carton disposal; quantities must match those IDs.
RETURN_CARTON selections consume every SKU in the selected physical carton.
Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason.
Stock without complete carton/pallet ownership remains available for full disposal only.
PDA tasks with real cartonMarks use CARTON scanMode, including full bound-pallet tasks.
Every distinct selected carton mark must be scanned; confirmAllCartons cannot bypass this check.
Ordinary stock without carton marks uses PALLET mode and actual carton quantities.
Ordinary stock without selected carton marks uses PALLET mode and actual carton quantities.
Each SKU returns allowedCartonMarks from the source inbound cartons or net stock-in ledger;
PDA scans must match this allowlist immediately and confirmation revalidates ownership.
The loose group uses 散箱 as its API path key, never as a scan code.
The application may select a subset of available cartons. PDA must finish every selected carton;
short actual quantities are rejected. At least one photo remains required.
@@ -35,6 +37,18 @@ info:
security:
- bearerAuth: []
paths:
/api/pda/v1/last-leg/cargo-discards:
get:
summary: 查询当前绑定仓已审核废弃任务
description: 返回 PalletTask 数组;skus.allowedCartonMarks 为当前申请明细原入仓库存允许扫描的箱唛,无归属的箱唛不得计数或确认;未知箱唛不降级为自由扫码。
responses:
'200': { description: 当前绑定仓废弃任务及明细箱唛白名单 }
/api/pda/v1/last-leg/cargo-discards/resolve:
post:
summary: 按废弃单及托盘定位任务
description: 返回 PalletTask,包括各明细的 allowedCartonMarks 供即时扫码归属校验;提交确认时服务端再次核验。
responses:
'200': { description: 当前废弃单托盘任务及明细箱唛白名单 }
/api/merchant/v1/last-leg/cargo-discards/{discardNo}/confirm-completion:
parameters:
- $ref: '#/components/parameters/DiscardNo'
@@ -105,7 +119,7 @@ paths:
required: [itemId, actualPackages]
properties:
itemId: { type: string, pattern: '^[1-9][0-9]*$' }
scannedCartonMarks: { type: array, uniqueItems: true, items: { type: string, minLength: 1, maxLength: 128 }, description: 无关联库存现场箱唛清单,数量须与actualPackages一致,整单跨托盘不重复,详情以actualCartonMarks回显 }
scannedCartonMarks: { type: array, uniqueItems: true, items: { type: string, minLength: 1, maxLength: 128 }, description: 无关联库存现场箱唛清单,数量须与actualPackages一致,整单跨托盘不重复;每个箱唛必须能溯源到本单明细原入仓库存,未知或外单箱唛拒绝;详情以actualCartonMarks回显 }
actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages }
scannedCartonMarks:
type: array
@@ -124,7 +138,7 @@ paths:
uniqueItems: true
items: { type: string, pattern: '^[1-9][0-9]*$' }
responses:
'200': { description: ApiResponse containing the completed PalletTask; conflicts use business body code }
'200': { description: ApiResponse containing the completed PalletTask; skus.allowedCartonMarks provides the source-scoped scan allowlist for immediate PDA validation; conflicts use business body code }
/api/platform/v1/last-leg/cargo-discards/{discardNo}/photos/files/{fileId}/download:
parameters:
- $ref: '#/components/parameters/DiscardNo'
@@ -2665,13 +2665,13 @@ paths:
responses: { '200': { description: 当前客户货物废弃申请分页 }, '403': { description: 缺少货物废弃查看权限 } }
post:
summary: 客户创建货物废弃申请
description: 权限 merchant:overseas-warehouse:discard:create;客户范围只取认证 Token。
description: 权限 merchant:overseas-warehouse:discard:create;客户范围只取认证 Token;仅支持普通库存与退货库存,拒绝一件代发订单,申请人 createdByName 从认证账号姓名保存快照。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的货物废弃申请 }, '403': { description: 缺少货物废弃创建权限 }, '409': { description: 库存或状态冲突 } }
/api/merchant/v1/last-leg/cargo-discards/inventory-candidates:
get:
summary: 查询当前客户可废弃库存候选
description: 权限 merchant:overseas-warehouse:discard:list;候选按认证客户隔离。
description: 权限 merchant:overseas-warehouse:discard:list;候选按认证客户隔离,过滤一件代发订单与库存仓位。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 可废弃库存候选 }, '403': { description: 缺少货物废弃查看权限 } }
/api/merchant/v1/last-leg/cargo-discards/{discardNo}:
@@ -2837,16 +2837,25 @@ paths:
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
/api/merchant/v1/last-leg/us-truck-orders/{orderNo}/status:
patch:
summary: 客户取消待审核本土卡车订单
description: 权限 merchant:cross-border:domestic-trucking:edit;客户范围取当前身份,仅 PENDING_REVIEW 可取消,携带版本、幂等键和取消原因,同时释放预算预占。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCancel' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 当前订单不可取消或版本已变化 } }
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/status:
patch:
summary: 取消未发车的本土卡车订单
description: 权限 platform:overseas-business:truck:status;该通用入口只接受 CANCELLED,正向状态必须使用具名命令,不能绕过实收、超托与分配事实。
summary: 平台取消入口已禁止
description: 平台不得取消本土卡车订单,旧入口始终拒绝;由客户待审核阶段自行取消。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCancel' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } }
responses: { '403': { description: 平台不能取消本土卡车订单 } }
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/review:
parameters: &truckOrderTarget
- { name: orderNo, in: path, required: true, schema: { type: string } }
@@ -5240,13 +5249,13 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
TruckCreate:
type: object
required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount, idempotencyKey]
required: [vehicleType, origin, originAddress, destination, destinationAddress, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount, idempotencyKey]
properties:
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
originAddress: { type: string, minLength: 1, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
destinationAddress: { type: string, minLength: 1, maxLength: 500 }
palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
@@ -5262,14 +5271,14 @@ components:
idempotencyKey: { type: string, maxLength: 255 }
TruckUpdate:
type: object
required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount]
required: [version, vehicleType, originAddress, destinationAddress, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount]
properties:
version: { type: integer, format: int64, minimum: 0 }
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
originAddress: { type: string, minLength: 1, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
destinationAddress: { type: string, minLength: 1, maxLength: 500 }
palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
@@ -1,5 +1,5 @@
openapi: 3.0.3
info: { title: Overseas Business Fee Source API, version: 1.1.0, description: '首版除仓租外均人工登记审核;换标报价、外接运费及拆柜成本不自动收费;旧资金链同项目已锁款或收取时拒绝重复登记。' }
info: { title: Overseas Business Fee Source API, version: 1.1.0, description: '支持人工登记费用(含仓租)并审核;自动仓租继续按出库计租流程生成;换标报价、外接运费及拆柜成本不自动收费;旧资金链同项目已锁款或收取时拒绝重复登记。' }
paths:
/api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/retries:
get:
@@ -184,6 +184,30 @@ components:
outboundAt: {type: string, format: date-time}
calculatedAmount: {type: number, description: 原快照计算金额,币种为originalCurrency}
chargedAmount: {type: number, nullable: true, description: 实际扣费CNY,未成功时为null}
calculations:
type: array
description: 逐原批次的实际计算过程;新费用取冻结快照,历史费用仅在原报价和计租基数完整时解释,不使用当前报价。
items:
type: object
properties:
sourceBusinessNo: {type: string, nullable: true}
batchNo: {type: string, nullable: true}
ruleCode: {type: string, enum: [TRANSIT, RETURN_PALLET, RETURN_SHARE], description: 中转体积、新退货整托、历史退货份额计价}
billingQuantity: {type: number, description: 实际用于乘单价的CBM或托数,区别于出库商品件数}
billingUnit: {type: string, enum: [CBM, PALLET]}
storageDays: {type: integer, format: int64, description: 原入库时刻起累计库龄}
previouslyChargedDays: {type: integer, format: int64, description: 本次计算前该原托已生成费用的截止库龄日,不重复收费}
calculatedAmount: {type: number, description: 原批次未按分取整的计算金额,费用合计统一按分取整}
tiers:
type: array
items:
type: object
properties:
startDay: {type: integer, format: int64}
endDay: {type: integer, format: int64}
days: {type: integer, format: int64}
unitPrice: {type: number, description: 原报价快照中每托或每CBM每天单价}
amount: {type: number, description: days乘billingQuantity乘unitPrice,保留计算精度}
lines:
type: array
items:
@@ -248,7 +272,7 @@ components:
inboundCode: { type: string, maxLength: 64, nullable: true }
feeItemCode:
type: string
description: 必须选择共享费用目录的有效编码,人工录费禁止自动仓租项目FEE_TYPE_077。OVERSEAS_CONTAINER时同时符合OverseasContainerFeeItem枚举。
description: 必须选择共享费用目录的有效编码,支持人工录入仓租项目FEE_TYPE_077并沿普通人工费用审核及收付流程处理。OVERSEAS_CONTAINER时同时符合OverseasContainerFeeItem枚举。
feeItemName: { type: string }
feeRemark: { type: string, maxLength: 500, nullable: true, description: 费用说明;同单同方向已有相同项目时必填,允许实际作业分次收费。已驳回原费用终结,只能重新新增。 }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
@@ -3,10 +3,12 @@ info:
title: 海外仓租与出库资金预校验
version: 1.0.0
description: |
仅仓租自动计费。中转按CBM/天、退货按原托份额/天累进,首次或重建预校验读取客户当前仓租配置。
仅仓租自动计费。中转按CBM/天、退货按原托整托/天累进;原托只要剩余一箱仍计一托,不按件数分摊仓租。
退货部分出库生成该原托截至本计租日的未计费期间仓租,同一原托同一天不重复收费,清空后停止计租。
首次或重建预校验读取客户当前仓租配置,退货默认阶梯为第1–30天1.5、第31–60天2、第61–90天3、第91天起4 USD/托/天。
未配置客户使用仓库服务明确返回的两张默认报价表;网络、无效配置或不支持币种均阻断出库,不降级免费。
PlanJson保存配置ID、版本、币种和完整阶梯。有效预占重放与consume使用原快照及冻结金额,不受期间改价影响。
后续部分出库采用客户最新配置,原入库时间与原托份额保持不变。原币支持USD/CNY,结算币为CNY。
后续部分出库采用客户最新配置,原入库时间及阶梯库龄保持不变;退货只计尚未生成仓租的新增库龄。原币支持USD/CNY,结算币为CNY。
实际PDA入库开始计租,实际出库按本次数量生成待审核费用,审核通过后按确认金额扣款;所有海外仓租从余额及有效授信扣除。
头程同单同仓多次入库合并计租,统一沿用首次实际入库时间;其他业务按各批实际入库时间分别计龄。
每满24小时一天,不足24小时向上取整。公开时间均为ISO8601 UTC时间,携带Z,客户端按本地时区显示。
@@ -183,6 +185,8 @@ components:
currency: {type: string, enum: [USD, CNY]}
settlementAmount: {type: number, description: CNY结算金额}
currencyRate: {type: number, nullable: true}
billingQuantity: {type: number, nullable: true, description: 本次真实计租基数,来自原费用快照,整托退货不再按件数摊薄;历史未记录时为空}
billingUnit: {type: string, nullable: true, enum: [CBM, PALLET]}
status: {type: string, enum: [PENDING_REVIEW, REVIEW_FREEZE_PENDING, REVIEW_FREEZE_FAILED, REJECTED, CONSUMED, SETTLE_FAILED, SETTLED]}
createdAt: {type: string, format: date-time}
outboundAt: {type: string, format: date-time}
@@ -67,14 +67,14 @@ class CargoDiscardMysqlAcceptanceTest {
new CargoDiscardRequests.Item(990001001L, null, "SKU-BOX-RED", 3),
new CargoDiscardRequests.Item(990001001L, null, "SKU-RACK-KITCHEN", 2)),
"ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃");
var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest);
var approved = service.create(CUSTOMER, "acceptance-creator", "验收申请人", approveRequest);
assertEquals(20L, skuPackages(990001001L));
approved = service.review(CUSTOMER, approved.summary().discardNo(), "acceptance-reviewer",
new CargoDiscardRequests.Review("APPROVE", "仓库复核破损属实"));
assertEquals(CargoDiscardService.APPROVED, approved.summary().status());
assertEquals(15L, skuPackages(990001001L));
var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
var rejected = service.create(CUSTOMER, "acceptance-creator", "验收申请人", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001002L, null, "SKU-LAMP-WHITE", 4)),
"ACCEPT-DISCARD-REJECT", "验收:外箱可修复"));
assertEquals(10L, skuPackages(990001002L));
@@ -83,13 +83,13 @@ class CargoDiscardMysqlAcceptanceTest {
assertEquals(CargoDiscardService.REJECTED, rejected.summary().status());
assertEquals(10L, skuPackages(990001002L));
var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
var pending = service.create(CUSTOMER, "acceptance-creator", "验收申请人", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 2)),
"ACCEPT-DISCARD-PENDING", "验收:等待仓库审核"));
assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status());
assertEquals(7L, skuPackages(990001003L));
assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator",
assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator", "验收申请人",
new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 99)),
"ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败")));
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** 用真实 Mapper 查询验证废弃扫码的来源隔离和已移出箱拦截。 */
class CargoDiscardTraceableCartonMapperTest {
/** 原入仓箱和净实收流水可溯源,其他订单、客户、托盘及已移出箱均不得混入。 */
@Test void onlyCurrentSourceCartonsAreTraceable() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source);
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,destination_warehouse_code VARCHAR(20))");
db.execute("CREATE TABLE ll_inventory_ledger(customer_id VARCHAR(20),position_id BIGINT,balance_id BIGINT,inbound_order_id BIGINT,carton_mark VARCHAR(80),delta_packages BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_carton(customer_id VARCHAR(20),order_id BIGINT,warehouse_pallet_id BIGINT,received_at TIMESTAMP,status VARCHAR(30),carton_mark VARCHAR(80),destination_warehouse_code VARCHAR(20))");
db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100)");
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA')");
db.update("INSERT INTO ll_inventory_ledger VALUES('C',41,1,10,'LEDGER',1),('C',41,1,10,'MOVED',1),('C',41,1,10,'MOVED',-1),('C',41,1,20,'FOREIGN-ORDER',1),('X',41,1,10,'FOREIGN-CUSTOMER',1)");
db.update("INSERT INTO ll_inbound_plan_carton VALUES('C',10,100,CURRENT_TIMESTAMP,'AVAILABLE','INBOUND','FBA'),('C',10,200,CURRENT_TIMESTAMP,'AVAILABLE','OTHER-PALLET','FBA'),('C',20,100,CURRENT_TIMESTAMP,'AVAILABLE','OTHER-ORDER','FBA'),('C',10,100,CURRENT_TIMESTAMP,'DISPATCHED','SHIPPED','FBA')");
var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
config.addMapper(CargoDiscardInventoryMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper = session.getMapper(CargoDiscardInventoryMapper.class);
assertEquals(java.util.Set.of("LEDGER", "INBOUND"), new java.util.HashSet<>(mapper.traceableCartonMarks("C", 41L)));
assertTrue(mapper.traceableCartonMarks("X", 41L).isEmpty());
}
}
}
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
ReflectionTestUtils.setField(service, "stockService", mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository();
ReflectionTestUtils.setField(service, "repository", repository);
@@ -127,6 +127,7 @@ class CargoDiscardServiceTest {
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
ReflectionTestUtils.setField(service, "outboundService", outboundService);
ReflectionTestUtils.setField(service, "profiles", org.mockito.Mockito.mock(com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper.class));
ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class));
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
ReflectionTestUtils.setField(service, "applications", applications);
@@ -152,28 +153,11 @@ class CargoDiscardServiceTest {
lenient().when(histories.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedHistories));
}
@Test
void createReservesExactSkuCartonsWithoutDeductingInventory() {
LastlegOrder order = order();
order.setOverseasWarehouseId("99"); order.setOverseasWarehouseName(null);
DropshipSkuInventory sku = skuInventory();
when(orders.selectById(101L)).thenReturn(order);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L);
when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(2L);
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged"));
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
assertEquals("洛杉矶仓", storedApplication.get().getOverseasWarehouseName());
assertEquals(2, detail.summary().totalPackages());
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
assertEquals(2, storedLinks.size());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
/** 新建废弃不能通过旧订单参数绕过一件代发过滤。 */
@Test void dropshipCreationIsRejectedBeforeReservation() {
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () ->
service.create("100", "7", null, new CargoDiscardRequests.Create(List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "key", null)));
verify(applications, never()).insert(any(CargoDiscardApplication.class));
}
@Test void historicalDiscardDetailResolvesMissingWarehouseNameByOperatingWarehouseId() {
@@ -185,41 +169,10 @@ class CargoDiscardServiceTest {
}
@Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() {
when(orders.selectById(101L)).thenReturn(order());
when(inventory.lockSelected("100", 101L, "SKU-1", List.of(2L))).thenReturn(List.of(carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(skuInventory());
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.availableOrderCartons("100", 101L)).thenReturn(List.of(carton(1L), carton(2L), carton(3L)));
when(inventory.changeCartonStatus("100", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 1, null, List.of(2L))), "partial-create", "damage"));
assertEquals(1, detail.summary().totalPackages()); assertEquals(new BigDecimal("5.00"), detail.summary().totalFee());
assertEquals(List.of("C-2"), detail.items().get(0).cartonMarks());
assertEquals(1, storedLinks.size()); assertEquals(2L, storedLinks.get(0).getCartonId());
org.junit.jupiter.api.Assertions.assertTrue(storedItems.get(0).getCartonScanRequired());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(1L), any(), any());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(3L), any(), any());
}
@Test void duplicatePhysicalBoxAcrossSkuLinesIsRejectedBeforeCreation() {
when(orders.selectById(101L)).thenReturn(order());
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-A", 1, null, List.of(2L)),
new CargoDiscardRequests.Item(101L, null, "SKU-B", 1, null, List.of(2L))), "duplicate-box", null)));
verify(applications, never()).insert(any(CargoDiscardApplication.class));
}
@Test
void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() {
CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L);
CargoDiscardCandidateRow skuB = candidate("SKU-B", 20L);
when(inventory.candidates("100", "RK-1")).thenReturn(List.of(skuA, skuB));
when(inventory.countAvailableOrder("100", 101L)).thenReturn(20L);
var result = service.candidates("100", "RK-1");
assertEquals(1, result.size());
assertEquals(20, result.get(0).availablePackages());
assertEquals(2, result.get(0).skus().size());
/** 即使存在一件代发可用箱,也不向货物废弃候选暴露。 */
@Test void candidatesExcludeDropshipOrders() {
assertEquals(List.of(), service.candidates("100", "RK-1"));
verify(inventory, never()).candidates(any(), any());
}
@Test
@@ -264,15 +217,20 @@ class CargoDiscardServiceTest {
when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1);
var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of(
var detail = service.create("100", "OP", "申请人姓名", new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(null, 901L, "A", 2),
new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null));
assertEquals("申请人姓名", detail.summary().createdByName());
assertEquals(1, storedApplication.get().getTotalPackages());
assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee());
assertEquals(1, detail.summary().totalPackages());
assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum());
storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00"));
carton.setPalletCode("RETURN-PALLET");
storedItems.forEach(line -> { line.setPdaStatus("COMPLETED"); line.setActualDiscardPackages(line.getDiscardPackages()); });
var existing = service.detail("100", storedApplication.get().getDiscardNo());
assertEquals("RETURN-PALLET", existing.items().get(0).palletNo());
assertEquals(List.of("BOX-1"), existing.items().get(0).actualCartonMarks());
assertEquals(1, existing.summary().totalPackages());
assertEquals(new BigDecimal("5.00"), existing.summary().totalFee());
}
@@ -54,6 +54,15 @@ class CargoDiscardStockServiceTest {
when(stock.lockGenericPosition("C1", 42L)).thenReturn(position);
when(balances.selectById(51L)).thenReturn(balance);
}
/** 非本单原入仓箱唛必须在任何扣库存前拒绝。 */
@Test void unknownAndForeignCartonsCannotPassQuantityScanning() {
var line = new CargoDiscardItem(); line.setInventoryPositionId(42L);
when(stock.traceableCartonMarks("C1",42L)).thenReturn(List.of("OWN-BOX"));
service.validateActualCartonMarks("C1", line, List.of("OWN-BOX"));
assertThrows(ResponseStatusException.class, () -> service.validateActualCartonMarks("C1", line, List.of("RANDOM")));
assertThrows(ResponseStatusException.class, () -> service.validateActualCartonMarks("OTHER", line, List.of("OWN-BOX")));
verifyNoInteractions(commands);
}
@Test void warehouseTransferStockIsEligibleAndKeepsTrueSourcePosition() {
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
var candidate = service.candidates("C1", "WT").get(0);
@@ -28,6 +28,8 @@ class DispatchDeparturePersistenceTest {
var b=executor.submit(()->fixture.tryDepart(start,new Actor("picker",ActorMode.PDA,"9"),pdaRequest));
start.countDown();assertThat(List.of(a.get(20,TimeUnit.SECONDS),b.get(20,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {executor.shutdownNow();}
// 发车属于作业阶段变化,单据仍有效;不得重新写入旧版DEPARTED状态。
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_dispatch_order",String.class)).isEqualTo("ACTIVE");
assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("DELIVERING");
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(fixture.f.db.queryForObject("SELECT shipped_package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(8);
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.BusinessSource;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchOutboundPort;
import com.baiyue.lastleg.fleet.trip.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -42,6 +42,18 @@ class TransportTaskServiceTest {
assertThatThrownBy(()->service.cancel("PC1","OP",new FleetTripModels.Cancel(3L,"取消"))).hasMessage("已进入运输取消");
verify(trips).cancel(eq("TR1"),eq("OP"),any());
}
/** 过期计划不阻止审核前取消;取消不重新核验司机或运输时间。 */
@Test void expiredLocalTruckPlanCanStillCancel() {
var root=roots.find("PC1");root.setStage("BEFORE_LOADING");root.setReviewState("DRAFT");
when(roots.lock("PC1")).thenReturn(root);
trip.setStatus("PLANNED");
trip.setPlannedDepartureAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(2));
trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(1));
doAnswer(call->{trip.setStatus("CANCELLED");return null;}).when(trips).cancel(eq("TR1"),eq("OP"),any());
service.cancel("PC1","OP",new FleetTripModels.Cancel(3L,"取消过期计划"));
assertThat(trip.getStatus()).isEqualTo("CANCELLED");
verify(trips).cancel(eq("TR1"),eq("OP"),any());
}
/** 本土卡车审核仍拦截执行中任务、无效时间和未维护车辆,不因分流而放松门禁。 */
@Test void localTruckReviewRejectsInvalidExecutionTimeAndMissingResources() {
assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("未发车");
@@ -390,16 +390,30 @@ class OverseasManualFeeServiceTest {
verifyNoInteractions(outbox);
}
/** 自动仓租项目不能通过人工入口重复建应收或锁款。 */
@Test void automaticRentItemCannotBeCreatedManually() throws Exception {
/** 人工仓租沿人工费用资金身份冻结和审核,不进入自动仓租链。 */
@Test void storageRentItemCanBeCreatedManually() throws Exception {
var mapper = new ObjectMapper().findAndRegisterModules();
com.fasterxml.jackson.databind.node.ObjectNode data = mapper.valueToTree(
OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"));
data.put("feeItemCode", "FEE_TYPE_077"); data.put("feeItemName", "仓储费");
var request = mapper.treeToValue(data, com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
assertThatThrownBy(() -> service.create("operator", "manual-rent", request))
.hasMessageContaining("仓租自动计算");
verifyNoInteractions(sources, repository, catalog, wallet, outbox);
fee.setFeeItemCode("FEE_TYPE_077"); fee.setFeeItemName("仓储费");
when(sources.create("operator", "manual-rent", request)).thenReturn(fee);
when(repository.lockFunding(701L)).thenReturn(null);
var created = service.create("operator", "manual-rent", request);
assertThat(created.getReviewStatus()).isEqualTo("PENDING");
assertThat(StorageRentFeeWorkflow.isRent(created)).isFalse();
verify(catalog).requireActive("FEE_TYPE_077", "仓储费", request.direction());
var captured = ArgumentCaptor.forClass(OverseasFeeFunding.class);
verify(repository).insertFunding(captured.capture());
verify(wallet).publish(eq("FREEZE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"),
eq(new BigDecimal("88.00")), anyString(), eq(1L));
captured.getValue().setWalletStatus("FROZEN");
when(repository.lockFunding(701L)).thenReturn(captured.getValue());
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "manual-rent-approve"));
verify(wallet).publish(eq("SETTLE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"),
isNull(), anyString(), eq(2L));
verifyNoInteractions(outbox);
}
@Test void createReplayDoesNotSendSecondFreezeCommand() {
@@ -46,13 +46,37 @@ class StorageRentCalculatorTest {
assertThat(amount("TRANSIT", 31, "2", "10", "6")).isEqualByComparingTo("23.28");
}
/** 已进位的一托按原商品比例分摊,剩余六成不能再次进位成一托。 */
/** 退货不足一托也按整托,部分商品出库不能按件数摊薄原托仓租。 */
@Test
void apportionsTheOriginalRoundedPalletAcrossShipments() {
assertThat(amount("RETURN", 10, "1", "100", "40")).isEqualByComparingTo("6");
assertThat(amount("RETURN", 40, "1", "100", "60")).isEqualByComparingTo("39");
var thirds = amount("RETURN", 10, "1", "3", "1");
assertThat(thirds.multiply(new BigDecimal("3"))).isEqualByComparingTo("15");
void chargesWholePalletEvenWhenOnlyOnePieceLeaves() {
assertThat(amount("RETURN", 10, "1", "100", "40")).isEqualByComparingTo("15");
assertThat(amount("RETURN", 40, "1", "100", "1")).isEqualByComparingTo("65");
assertThat(amount("RETURN", 1, "0.2", "16", "9")).isEqualByComparingTo("1.5");
}
/** 部分出库后仅对新增库龄收整托阶梯价,同一计租日不重复收取。 */
@Test
void chargesOnlyUnbilledDaysAcrossAllReturnTiers() {
var pricing = StorageRentCalculator.defaults(null);
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(10), BigDecimal.ONE, 0)).isEqualByComparingTo("15");
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(10), BigDecimal.ONE, 10)).isZero();
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(40), BigDecimal.ONE, 10)).isEqualByComparingTo("50");
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(61), BigDecimal.ONE, 40)).isEqualByComparingTo("43");
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(91), BigDecimal.ONE, 61)).isEqualByComparingTo("91");
assertThatThrownBy(() -> StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(1), BigDecimal.ONE, 2))
.isInstanceOf(IllegalArgumentException.class);
}
/** 解释明细必须截取实际未计费天数,跨阶梯分行,免费区间也能解释为零。 */
@Test void explainsTierDaysUnitsAndUnroundedAmounts() {
var pricing=StorageRentCalculator.defaults(null);
var tiers=StorageRentCalculator.breakdown(pricing,"RETURN",10,40,BigDecimal.ONE);
assertThat(tiers).extracting(com.baiyue.lastleg.storagerent.dto.StorageRentDtos.CalculationTier::days).containsExactly(20L,10L);
assertThat(tiers).extracting(com.baiyue.lastleg.storagerent.dto.StorageRentDtos.CalculationTier::amount)
.usingComparatorForType(BigDecimal::compareTo,BigDecimal.class).containsExactly(new BigDecimal("30"),new BigDecimal("20"));
assertThat(StorageRentCalculator.breakdown(pricing,"TRANSIT",0,7,new BigDecimal("0.8"))).hasSize(1)
.allSatisfy(t->assertThat(t.amount()).isZero());
assertThat(StorageRentCalculator.breakdown(pricing,"RETURN",1,1,BigDecimal.ONE)).isEmpty();
}
/** 缺失体积、逆序时间及超出原批次的数量不能被当成零费用放行。 */
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.storagerent.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.storagerent.entity.StorageRentLot;
import com.baiyue.lastleg.storagerent.mapper.StorageRentLotMapper;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
/** 真实 Mapper 和行锁验证整托计费截止日与出库数量的原子性。 */
class StorageRentPalletPeriodPersistenceTest {
/** 两个同时出库事务只生成一份当日整托租金,失败事务不能推进计费日或扣余量。 */
@Test void serializesWholePalletDaysAndRollsBackFailedOutbound() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
String baseline;
try(var input=getClass().getResourceAsStream("/db/lastleg.sql")) {baseline=new String(input.readAllBytes(),StandardCharsets.UTF_8);}
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_storage_rent_lot (");
String ddl=baseline.substring(start,baseline.indexOf(';',start));
ddl=ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?","");
ddl=ddl.substring(0,ddl.indexOf(") ENGINE="))+")";ddl=ddl.replaceAll(",\\s*\\)$",")");
new JdbcTemplate(ds).execute(ddl);
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(StorageRentLotMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(StorageRentLotMapper.class);
var repository=new StorageRentRepository();ReflectionTestUtils.setField(repository,"lots",mapper);
var tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
var inbound=LocalDateTime.of(2026,11,2,10,0);
var lot=new StorageRentLot();lot.setId(1L);lot.setSourceKey("RETURN:1");lot.setCustomerId("10");lot.setWarehouseId("20");
lot.setBusinessType("RETURN_ORDER");lot.setBusinessId(1L);lot.setBusinessNo("TH1");lot.setRuleCode("RETURN");
lot.setOriginalQuantity(new BigDecimal("16"));lot.setRemainingQuantity(new BigDecimal("16"));lot.setOriginalUnits(BigDecimal.ONE);
lot.setInboundAt(inbound);lot.setCreatedAt(inbound);lot.setUpdatedAt(inbound);repository.insert(lot);
assertThatThrownBy(()->tx.execute(status->{var locked=repository.lockLot(1L);locked.setChargedDays(1L);
locked.setRemainingQuantity(new BigDecimal("15"));repository.save(locked);throw new IllegalStateException("出库失败");}))
.hasMessageContaining("出库失败");
assertThat(repository.lot(1L).getChargedDays()).isZero();assertThat(repository.lot(1L).getRemainingQuantity()).isEqualByComparingTo("16");
var firstLocked=new CountDownLatch(1);var secondStarted=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var first=workers.submit(()->tx.execute(status->{var locked=repository.lockLot(1L);firstLocked.countDown();
await(secondStarted);return consumeDay(repository,locked,inbound.plusHours(1));}));
assertThat(firstLocked.await(5,TimeUnit.SECONDS)).isTrue();
var second=workers.submit(()->tx.execute(status->{secondStarted.countDown();
return consumeDay(repository,repository.lockLot(1L),inbound.plusHours(2));}));
assertThat(first.get(5,TimeUnit.SECONDS).add(second.get(5,TimeUnit.SECONDS))).isEqualByComparingTo("1.5");
assertThat(repository.lot(1L).getChargedDays()).isEqualTo(1);assertThat(repository.lot(1L).getRemainingQuantity()).isEqualByComparingTo("14");
} finally {workers.shutdownNow();}
}
/** 锁内计算未收费期间并同时保存实际出库数量,模拟业务事务的持久化边界。 */
private BigDecimal consumeDay(StorageRentRepository repository,StorageRentLot lot,LocalDateTime at) {
var amount=StorageRentCalculator.returnPalletAmount(StorageRentCalculator.defaults(null),lot.getInboundAt(),at,lot.getOriginalUnits(),lot.getChargedDays());
lot.setChargedDays(StorageRentCalculator.days(lot.getInboundAt(),at));lot.setRemainingQuantity(lot.getRemainingQuantity().subtract(BigDecimal.ONE));
repository.save(lot);return amount;
}
/** 有界同步并发测试,不依赖休眠制造时序。 */
private void await(CountDownLatch latch) {
try {if(!latch.await(5,TimeUnit.SECONDS))throw new IllegalStateException("并发测试等待超时");}
catch(InterruptedException interrupted) {Thread.currentThread().interrupt();throw new IllegalStateException(interrupted);}
}
}
@@ -45,6 +45,37 @@ class StorageRentReviewSourceTest {
verify(rents).lots(List.of(1L,2L,3L,4L,5L));
}
/** 测试单的旧比例费用只按原报价解释,当前整托口径和新价格不能改写既有金额。 */
@Test void explainsHistoricalReturnFractionUsingSavedPricing() throws Exception {
var rents=mock(StorageRentRepository.class);var json=new ObjectMapper().findAndRegisterModules();
var service=new StorageRentReviewService(rents,mock(OverseasManualFeeRepository.class),mock(OverseasFeeSourceService.class),
mock(OverseasFeeCurrencyConverter.class),mock(WalletCommandOutboxService.class),json);
var at=LocalDateTime.of(2026,11,2,10,0);
var allocation=new Allocation(1L,2L,new BigDecimal("9"),new BigDecimal("0.84375"),at,1);
var plan=new Plan(new Owner("10",null,"DISCARD_ORDER",3L,"CD3",null,null,"20"),List.of(allocation),at.plusHours(2),new BigDecimal("0.84"));
var rent=new StorageRentPreparation();rent.setId(90L);rent.setSourceFeeId(100L);rent.setWarehouseId("20");
rent.setExecutionNo("CD3");rent.setConsumedAt(at.plusHours(2));rent.setPlanJson(json.writeValueAsString(plan));
var fee=new OverseasFeeSource();fee.setSourceFeeId(100L);fee.setCreatedBy("STORAGE_RENT");fee.setFeeItemCode("FEE_TYPE_077");fee.setCustomerId("10");
var original=lot(1L,"10","20","RETURN_ORDER","TH1");original.setRuleCode("RETURN");original.setOriginalQuantity(new BigDecimal("16"));original.setOriginalUnits(BigDecimal.ONE);
when(rents.forFees(List.of(100L))).thenReturn(List.of(rent));when(rents.lots(List.of(1L))).thenReturn(List.of(original));
service.describe(List.of(fee));
var calculation=fee.getStorageRent().calculations().get(0);
assertThat(calculation.ruleCode()).isEqualTo("RETURN_SHARE");assertThat(calculation.billingQuantity()).isEqualByComparingTo("0.5625");
assertThat(calculation.tiers().get(0).unitPrice()).isEqualByComparingTo("1.5");
assertThat(calculation.tiers().get(0).amount()).isEqualByComparingTo("0.84375");
assertThat(fee.getStorageRent().calculatedAmount()).isEqualByComparingTo("0.84");
// 新费用解释直接采用原快照的基数及阶梯,即使后来原批次信息改变也不重算。
var snapshot=new Calculation(1L,"RETURN_PALLET",BigDecimal.ONE,"PALLET",40,10,
StorageRentCalculator.breakdown(plan.pricing(),"RETURN",10,40,BigDecimal.ONE),new BigDecimal("50"));
rent.setPlanJson(json.writeValueAsString(new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),new BigDecimal("50"),plan.pricing(),List.of(snapshot))));
original.setOriginalUnits(new BigDecimal("99"));original.setOriginalQuantity(new BigDecimal("999"));
service.describe(List.of(fee));calculation=fee.getStorageRent().calculations().get(0);
assertThat(calculation.ruleCode()).isEqualTo("RETURN_PALLET");assertThat(calculation.billingQuantity()).isEqualByComparingTo("1");
assertThat(calculation.previouslyChargedDays()).isEqualTo(10);assertThat(calculation.tiers()).hasSize(2);
assertThat(calculation.tiers().get(0).amount()).isEqualByComparingTo("30");assertThat(calculation.tiers().get(1).amount()).isEqualByComparingTo("20");
}
/** 构造原入库业务批次,不从现有出库订单推断来源。 */
private StorageRentLot lot(long id,String customer,String warehouse,String type,String number) {
var lot=new StorageRentLot();lot.setId(id);lot.setCustomerId(customer);lot.setWarehouseId(warehouse);
@@ -103,6 +103,52 @@ class StorageRentServiceTest {
verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any());verify(fees,never()).publishSettled(anyString(),anyString(),any(),any(),any());
}
/** 同托一次扫码多条商品只收一次,出库推进整托计费日,余货同一天出库为零租金。 */
@Test void partialReturnConsumesWholePalletPeriodAndRemainingBoxDoesNotPaySameDayAgain() {
var at=plan.calculatedAt();lot.setRuleCode("RETURN");
plan=new Plan(plan.owner(),List.of(
new Allocation(51L,61L,new BigDecimal("4"),new BigDecimal("15"),at.minusDays(10),10,0L),
new Allocation(51L,62L,new BigDecimal("5"),BigDecimal.ZERO,at.minusDays(10),10,0L)),at,new BigDecimal("15"));
share.setQuantity(new BigDecimal("4"));
var second=new StorageRentShare();second.setId(62L);second.setLotId(51L);second.setQuantity(new BigDecimal("6"));
when(repository.lockShare(62L)).thenReturn(second);
ready();assertThat(lot.getChargedDays()).isZero();service.consume("9",request);
assertThat(lot.getChargedDays()).isEqualTo(10);assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("1");
var captured=org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
verify(fees).createPendingStorageRent(anyString(),captured.capture(),any());
assertThat(captured.getValue().feeRemark()).contains("余一箱仍计一托", "第1–10计租日");
current=null;clearInvocations(wallet,converter);
plan=new Plan(plan.owner(),List.of(new Allocation(51L,62L,BigDecimal.ONE,BigDecimal.ZERO,at.minusDays(10),10,10L)),at,BigDecimal.ZERO);
assertThat(service.prepare("9",request).ready()).isTrue();service.consume("9",request);
assertThat(lot.getRemainingQuantity()).isZero();assertThat(lot.getChargedDays()).isEqualTo(10);
verifyNoInteractions(wallet,converter);
}
/** 原托另一箱先完成计费后,旧冻结必须重建,不能继续按旧整托金额再收费。 */
@Test void anotherReturnScanAdvancingCoveredDaysInvalidatesOriginalFreeze() {
var at=plan.calculatedAt();lot.setRuleCode("RETURN");
plan=new Plan(plan.owner(),List.of(new Allocation(51L,61L,new BigDecimal("4"),new BigDecimal("15"),at.minusDays(10),10,0L)),at,new BigDecimal("15"));
ready();var first=current;
lot.setChargedDays(10L);
plan=new Plan(plan.owner(),List.of(new Allocation(51L,61L,new BigDecimal("4"),BigDecimal.ZERO,at.minusDays(10),10,10L)),at,BigDecimal.ZERO);
assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("计费时间或货物已变化");
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");verifyNoInteractions(fees);
var rebuilt=service.prepare("9",request);
assertThat(rebuilt.ready()).isTrue();assertThat(rebuilt.originalAmount()).isZero();
assertThat(first.getStatus()).isEqualTo("EXPIRED");assertThat(first.getReleaseEventId()).isNotBlank();
service.consume("9",request);assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
assertThat(current.getOriginalAmount()).isZero();assertThat(lot.getChargedDays()).isEqualTo(10);
}
/** 冻结未实际出库不代表整托已计费,取消预占后下次仍须计算原期间。 */
@Test void cancelledReturnPreparationDoesNotAdvanceChargedDays() {
var a=plan.allocations().get(0);lot.setRuleCode("RETURN");
plan=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),new BigDecimal("15"),a.inboundAt(),10,0L)),plan.calculatedAt(),new BigDecimal("15"));
ready();service.cancel("9",current.getId());
assertThat(lot.getChargedDays()).isZero();assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");
verify(repository,never()).save(any(StorageRentLot.class));
}
/** 使用真实费用校验验证扣款回执只登记本地明细,重放不重复扣款或推送结算。 */
@ParameterizedTest @ValueSource(strings={"LASTLEG_ORDER","RETURN_ORDER","WAREHOUSE_TRANSFER","RELABEL_ORDER"})
void eachBusinessCreatesPendingRentBeforeApproval(String businessType) {
@@ -116,8 +116,8 @@ class StorageRentSourceResolverTest {
assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("业务单据关联的入库订单");
}
/** 退货只计原托商品比例,四件从十件原托出库不再向上取一个整托。 */
@Test void returnSkuOutboundUsesOriginalPalletFractionAndCurrentReturnOrder(){
/** 退货四件从十件原托出库仍收整托,费用保留当前退货单及原托库龄。 */
@Test void returnSkuOutboundChargesWholePalletAndCurrentReturnOrder(){
var task=new OutboundPdaTask();task.setOutboundOrderId(41L);task.setCustomerId("101");task.setStatus("PENDING");
var outbound=new OutboundOrder();outbound.setOverseasWarehouseId("9");outbound.setOutboundType("RETURN_OUTBOUND");
var line=new OutboundPalletItem();line.setOutboundOrderId(41L);line.setCustomerId("101");line.setCartonMarks("BOX-1");line.setSkuCode("SKU");line.setReturnSkuQuantity(4L);line.setInboundCode("RET-1");
@@ -127,8 +127,26 @@ class StorageRentSourceResolverTest {
when(repository.task("9","TASK-1")).thenReturn(task);when(repository.outboundItem(12L)).thenReturn(line);when(repository.outbound(41L)).thenReturn(outbound);
when(repository.returnOrder("101","RET-1")).thenReturn(ret);when(repository.carton("BOX-1")).thenReturn(carton);when(repository.shares("101","9","BOX-1","SKU")).thenReturn(List.of(share));when(repository.lockLot(52L)).thenReturn(lot(52L,"RETURN",2,BigDecimal.TEN));
var r=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"SKU",4L);
var plan=service.resolve("9",r,at);assertThat(plan.owner().businessNo()).isEqualTo("RET-1");assertThat(plan.amount()).isEqualByComparingTo("1.20");
var plan=service.resolve("9",r,at);assertThat(plan.owner().businessNo()).isEqualTo("RET-1");assertThat(plan.amount()).isEqualByComparingTo("3.00");
assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("4");
assertThat(plan.allocations().get(0).previouslyChargedDays()).isZero();
assertThat(plan.calculations()).hasSize(1);assertThat(plan.calculations().get(0).billingQuantity()).isEqualByComparingTo("1");
assertThat(plan.calculations().get(0).tiers()).containsExactly(new CalculationTier(1,2,2,new BigDecimal("1.5"),new BigDecimal("3.0")));
// 同一天已生成整托租金后另扫余货只扣数量,跨日再按原阶梯补新增整托租金。
var original=repository.lockLot(52L);original.setChargedDays(2L);
assertThat(service.resolve("9",r,at).amount()).isZero();
var continued=service.resolve("9",r,at.plusDays(1));assertThat(continued.amount()).isEqualByComparingTo("1.50");
assertThat(continued.calculations().get(0).tiers()).containsExactly(new CalculationTier(3,3,1,new BigDecimal("1.5"),new BigDecimal("1.5")));
// 同一原托混多个 SKU 只收一次;若来自两个原托则分别各收一托。
original.setChargedDays(0L);line.setReturnSkuQuantity(null);line.setSkuCode(null);
var other=new StorageRentShare();other.setId(63L);other.setLotId(52L);other.setQuantity(BigDecimal.ONE);
share.setQuantity(new BigDecimal("9"));when(repository.shares("101","9","BOX-1",null)).thenReturn(List.of(share,other));
var mixed=service.resolve("9",r,at);assertThat(mixed.amount()).isEqualByComparingTo("3.00");
assertThat(mixed.calculations()).hasSize(1);
assertThat(mixed.allocations()).extracting(Allocation::amount).containsExactly(new BigDecimal("3.0"),BigDecimal.ZERO);
other.setLotId(53L);when(repository.lockLot(53L)).thenReturn(lot(53L,"RETURN",2,BigDecimal.ONE));
assertThat(service.resolve("9",r,at).amount()).isEqualByComparingTo("6.00");
line.setReturnSkuQuantity(4L);line.setSkuCode("SKU");
var wrong=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"OTHER",4L);
assertThatThrownBy(()->service.resolve("9",wrong,at)).hasMessageContaining("原出库单不一致");
}
@@ -16,7 +16,7 @@ class UsTruckOrderRequestsTest {
var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required");
var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet());
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount"), fields);
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount", "originAddress", "destinationAddress"), fields);
}
}
@@ -25,7 +25,7 @@ class UsTruckOrderRequestsTest {
void onlyCurrentVehicleTypesAreAccepted() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) {
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 1,
var request = new UsTruckOrderRequests.Create(vehicle, "LA", "123 Main St", "ONT", "456 Oak St", 1,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ONE, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle");
assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty());
@@ -44,10 +44,20 @@ class UsTruckOrderControllerTest {
}
@Test
void platformStatusChangeUsesExplicitTargetTenant() {
void platformCannotCancelEvenWithStatusPermission() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", "cancelled");
platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request);
var error = assertThrows(ResponseStatusException.class,
() -> platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request));
assertEquals(403, error.getStatusCode().value());
}
@Test
void merchantCancellationUsesTrustedCustomer() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-customer", "客户取消需求");
merchant.changeStatus(user(100L, "MERCHANT", Set.of()), "TK1", request);
verify(service).changeStatus("100", "TK1", "7", request);
assertThrows(ResponseStatusException.class,
() -> merchant.changeStatus(user(0L, "PLATFORM", Set.of()), "TK1", request));
}
@Test
@@ -81,6 +81,26 @@ class UsTruckOrderServiceTest {
created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created);
}
/** 客户取消待审订单须释放预算,同一请求重试不得重复释放。 */
@Test
void pendingCustomerCancellationReleasesBudgetOnceAndReplays() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-pending", "客户取消需求");
var result = service.changeStatus("100", "TK1", "operator-7", request);
assertEquals("CANCELLED", result.summary().status());
service.changeStatus("100", "TK1", "operator-7", request);
verify(orderBudget).releaseAfterReview("100", "trucking", "TK1");
assertEquals(1L, order.getVersion());
}
/** 审核后已接入排车,不能通过取消原单绕过运输任务约束。 */
@Test
void customerCannotCancelApprovedTransportOrder() {
order.setStatus(UsTruckOrderService.PENDING_DISPATCH);
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "TK1", "operator-7",
new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-approved", "客户取消需求")));
org.mockito.Mockito.verifyNoInteractions(orderBudget);
}
@Test
void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() {
when(orderNoGenerator.next(anyString())).thenReturn("TK1");
@@ -68,14 +68,20 @@ class OrderBudgetServiceTest {
assertThat(service.read("9","procurement","key").usable()).isFalse();
assertThat(service.read("9","procurement","key").message()).contains("过期");
}
@Test void reviewReleaseImmediatelyReleasesFrozenBudget() {
@ParameterizedTest @ValueSource(strings={"procurement","trucking"})
void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) {
row.setKind(kind);
row.setOrderNo("CG-1"); row.setStatus("FROZEN");
when(repository.lockOrder("9","procurement","CG-1")).thenReturn(row);
when(repository.lockOrder("9",kind,"CG-1")).thenReturn(row);
when(wallet.publish(eq("RELEASE"),eq(9L),eq(OrderBudgetService.BUSINESS_TYPE),eq("11"),eq(new BigDecimal("800")),anyString(),eq(2L))).thenReturn("release");
service.releaseAfterReview("9","procurement","CG-1");
service.releaseAfterReview("9",kind,"CG-1");
assertThat(row.getReleaseRequested()).isTrue();
assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
verify(repository).save(row);
service.releaseAfterReview("9",kind,"CG-1");
verify(wallet,times(1)).publish(eq("RELEASE"),eq(9L),eq(OrderBudgetService.BUSINESS_TYPE),eq("11"),eq(new BigDecimal("800")),anyString(),eq(2L));
service.handle(result("release","RELEASE","SUCCEEDED","RELEASED",null));
assertThat(row.getStatus()).isEqualTo("RELEASED");
}
/** 已绑定订单的预算释放后仍允许原键恢复原单,但客户、金额与币种不能借重放改写。 */
@ParameterizedTest @ValueSource(strings={"RELEASE_PENDING","RELEASED","RELEASE_FAILED"})