提交
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+19
@@ -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," +
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+4
-2
@@ -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();
|
||||
// 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
|
||||
|
||||
+2
@@ -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); }
|
||||
/** 保存申请所选实物箱快照。 */
|
||||
|
||||
+30
-15
@@ -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,
|
||||
|
||||
+14
-2
@@ -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)); }
|
||||
|
||||
+1
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
-7
@@ -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("车次时间超出允许范围"); }
|
||||
|
||||
+21
-3
@@ -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) { }
|
||||
}
|
||||
|
||||
+2
@@ -46,6 +46,8 @@ public class StorageRentLot {
|
||||
private BigDecimal remainingQuantity;
|
||||
/** 原批次计费体积或托数。 */
|
||||
private BigDecimal originalUnits;
|
||||
/** 退货原托已生成仓租的截止库龄天数,部分出库后余货仍按整托续计。 */
|
||||
private Long chargedDays = 0L;
|
||||
/** 实际入库时间。 */
|
||||
private LocalDateTime inboundAt;
|
||||
/** 创建时间。 */
|
||||
|
||||
+21
-1
@@ -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(
|
||||
|
||||
+30
-1
@@ -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){
|
||||
|
||||
+19
-8
@@ -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;
|
||||
}
|
||||
/** 返回前端可执行标记;只有已确认冻结或原已消费请求可继续原扫码幂等恢复。 */
|
||||
|
||||
+27
-5
@@ -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);
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
|
||||
+3
-2
@@ -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}
|
||||
|
||||
+4
-4
@@ -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", "验收:库存不足应失败")));
|
||||
|
||||
+36
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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);
|
||||
|
||||
+16
-58
@@ -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());
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+12
@@ -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("未发车");
|
||||
|
||||
+19
-5
@@ -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() {
|
||||
|
||||
+30
-6
@@ -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();
|
||||
}
|
||||
|
||||
/** 缺失体积、逆序时间及超出原批次的数量不能被当成零费用放行。 */
|
||||
|
||||
+70
@@ -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);}
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
|
||||
+46
@@ -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) {
|
||||
|
||||
+21
-3
@@ -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("原出库单不一致");
|
||||
}
|
||||
|
||||
+2
-2
@@ -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());
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
+20
@@ -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");
|
||||
|
||||
+9
-3
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user