merge: preserve TEST finance and rent changes with fleet photo updates
This commit is contained in:
+5
@@ -19,6 +19,11 @@ public interface DomesticWarehouseDirectoryClient {
|
||||
ApiResponse<ReceivingWarehouse> receivingWarehouse(@PathVariable("warehouseId") long warehouseId,
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken);
|
||||
|
||||
/** 启用的国内公共仓库,供国内采购收货选择。 */
|
||||
@GetMapping("/internal/warehouses/domestic")
|
||||
ApiResponse<List<DomesticWarehouse>> domesticWarehouses(@RequestHeader("X-Internal-Service-Token") String internalToken);
|
||||
record DomesticWarehouse(Long id, String warehouseCode, String warehouseName) { }
|
||||
|
||||
record ReceivingWarehouse(Long warehouseId, String warehouseCode, String warehouseName, String countryCode, String status) { }
|
||||
|
||||
@GetMapping("/internal/warehouses/0/{warehouseId}/active")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 费用目录只读契约,不共享头程领域实体。 */
|
||||
@FeignClient(name = "firstleg-service", contextId = "firstlegFeeCatalogClient", url = "${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}")
|
||||
public interface FirstlegFeeCatalogClient {
|
||||
/** 获取海外仓可选项目。 */
|
||||
@GetMapping("/internal/firstleg/v1/fee-items")
|
||||
ApiResponse<List<Item>> list(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam("direction") String direction);
|
||||
|
||||
/** 验证实际录费使用的项目。 */
|
||||
@GetMapping("/internal/firstleg/v1/fee-items/{code}")
|
||||
ApiResponse<Item> detail(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable("code") String code, @RequestParam("direction") String direction);
|
||||
|
||||
record Item(String code, String name, BigDecimal defaultPrice) { }
|
||||
}
|
||||
+27
-18
@@ -91,6 +91,8 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class DeliveryTransferService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
|
||||
@Resource private DispatchAggregateRepository dispatches;
|
||||
@Resource private DispatchItemMapper dispatchItems;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@@ -526,6 +528,7 @@ public class DeliveryTransferService {
|
||||
return pickResolved(warehouseId, operatorId, taskNo, request, false);
|
||||
}
|
||||
|
||||
/** PDA 真实出库在扣库存前校验同一扫码的仓租冻结,原幂等结果优先恢复。 */
|
||||
private DeliveryResponses.PdaTask pickResolved(String warehouseId, String operatorId, String taskNo,
|
||||
DeliveryRequests.PdaPick request, boolean palletScan) {
|
||||
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"),
|
||||
@@ -560,6 +563,9 @@ public class DeliveryTransferService {
|
||||
if (item == null || !customerId.equals(item.getCustomerId())
|
||||
|| !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found");
|
||||
if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity");
|
||||
storageRent.consume(warehouseId, new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest(
|
||||
"OUTBOUND", taskNo, request.outboundPalletItemId(), request.inventoryPositionId(), request.packages(), null, null,
|
||||
request.idempotencyKey(), request.cartonMarks(), request.skuCode(), request.productQuantity()));
|
||||
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|
||||
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) {
|
||||
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) && StringUtils.hasText(item.getPalletNo()) && !palletScan)
|
||||
@@ -729,9 +735,22 @@ public class DeliveryTransferService {
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
/** 扫描整托前核平全部指定来源,任一不足时不执行部分拣货。 */
|
||||
/** 整托扫描使用与资金预校验完全一致的实际子来源,任一失败则整托回滚。 */
|
||||
@Transactional
|
||||
public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo,
|
||||
String palletNo, DeliveryRequests.PdaPalletPick request) {
|
||||
var children = storageRentPalletRequests(warehouseId, operatorId, taskNo, palletNo, request);
|
||||
DeliveryResponses.PdaTask result = null;
|
||||
for (var child : children) result = pickResolved(warehouseId, operatorId, taskNo, child, true);
|
||||
if (result != null) return result;
|
||||
var task = pdaTasks.lockByNoAndWarehouse(taskNo, warehouseId);
|
||||
if (task == null) throw notFound("当前仓没有该出库工单");
|
||||
return taskResult(requireOutbound(task), task);
|
||||
}
|
||||
|
||||
/** 资金预校验和实际整托扫码共享同一来源展开及子扫描幂等键算法。 */
|
||||
@Transactional
|
||||
public java.util.List<DeliveryRequests.PdaPick> storageRentPalletRequests(String warehouseId, String operatorId, String taskNo,
|
||||
String palletNo, DeliveryRequests.PdaPalletPick request) {
|
||||
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
|
||||
String code = required(taskNo, "PDA task number is required");
|
||||
@@ -752,7 +771,7 @@ public class DeliveryTransferService {
|
||||
.stream().filter(item -> palletCode.equals(item.getPalletNo())).toList();
|
||||
if (palletItems.isEmpty()) throw notFound("当前出库任务没有该托盘");
|
||||
if (palletItems.stream().allMatch(item -> value(item.getPickedPackageCount()) >= value(item.getPackageCount()))) {
|
||||
return taskResult(outbound, task);
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
@@ -763,14 +782,9 @@ public class DeliveryTransferService {
|
||||
!task.getCustomerId().equals(carton.getCustomerId()) || !expectedMarks.contains(carton.getCartonMark())
|
||||
|| !"OUTBOUND_PENDING".equals(carton.getStatus())))
|
||||
throw conflict("托盘内容或出库预留已变化,请刷新核对");
|
||||
DeliveryResponses.PdaTask result = taskResult(outbound, task);
|
||||
for (OutboundPalletItem item : palletItems.stream().sorted(Comparator.comparing(OutboundPalletItem::getId)).toList()) {
|
||||
if (value(item.getPickedPackageCount()) >= value(item.getPackageCount())) continue;
|
||||
result = pickResolved(boundWarehouseId, operatorId, code,
|
||||
new DeliveryRequests.PdaPick(item.getId(), null, 1L,
|
||||
childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks())), true);
|
||||
}
|
||||
return result;
|
||||
return palletItems.stream().filter(item -> value(item.getPickedPackageCount()) < value(item.getPackageCount()))
|
||||
.sorted(Comparator.comparing(OutboundPalletItem::getId)).map(item -> new DeliveryRequests.PdaPick(item.getId(), null, 1L,
|
||||
childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks()))) .toList();
|
||||
}
|
||||
|
||||
List<PalletPickPlan> plans = new ArrayList<>();
|
||||
@@ -797,13 +811,8 @@ public class DeliveryTransferService {
|
||||
if (remaining > 0) throw conflict("托盘可用库存不足,整托拣货未执行:" + palletCode);
|
||||
}
|
||||
|
||||
DeliveryResponses.PdaTask result = taskResult(outbound, task);
|
||||
for (PalletPickPlan plan : plans) {
|
||||
String operationId = palletPickOperationId(request.idempotencyKey(), code, palletCode, plan);
|
||||
result = pdaPick(boundWarehouseId, operatorId, code, new DeliveryRequests.PdaPick(
|
||||
plan.itemId(), plan.positionId(), plan.packages(), operationId, plan.cartonMarks()));
|
||||
}
|
||||
return result;
|
||||
return plans.stream().map(plan -> new DeliveryRequests.PdaPick(plan.itemId(), plan.positionId(), plan.packages(),
|
||||
palletPickOperationId(request.idempotencyKey(), code, palletCode, plan), plan.cartonMarks())).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -1011,7 +1020,7 @@ public class DeliveryTransferService {
|
||||
delivery.setUpdatedAt(now); cancellations.save(delivery);
|
||||
}
|
||||
OutboundPdaTask task = cancellations.task(outbound);
|
||||
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); }
|
||||
if (task != null) { storageRent.cancelExecution("OUTBOUND",task.getTaskNo()); task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); }
|
||||
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); cancellations.save(outbound);
|
||||
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); cancellations.save(item); }
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
|
||||
+7
-13
@@ -116,17 +116,13 @@ public class DispatchDeliveryFees {
|
||||
if (fee==null || blank(fee.feeType()) || !SERVICES.contains(fee.feeType()) || blank(fee.direction()) || !Set.of("RECEIVABLE","PAYABLE").contains(fee.direction())
|
||||
|| blank(fee.currency()) || !Set.of("CNY","USD").contains(fee.currency()) || blank(fee.feeName()) || fee.amount()==null || fee.amount().signum()<=0)
|
||||
throw conflict("派送费用类型、方向、币种或金额不合法");
|
||||
if ("RECEIVABLE".equals(fee.direction())) throw conflict("请到海外费用登记并审核应收费用");
|
||||
if (!seen.add(fee.direction()+":"+fee.feeType())) throw conflict("同组同方向同费用不得重复登记");
|
||||
if ("RECEIVABLE".equals(fee.direction()) && group.cargo().stream().allMatch(c->covered(c,fee.feeType(),evidence,freight)))
|
||||
throw conflict("本组该服务已全部覆盖,不得重复登记应收");
|
||||
}
|
||||
var required=new HashSet<String>(); required.add("DELIVERY_FEE");
|
||||
if (Set.of("FLOOR","MIXED").contains(input.loadingType())) required.add("LOADING_FEE");
|
||||
if ("BUY_APPOINTMENT".equals(input.appointmentAccount())) {
|
||||
if (blank(input.sellerName())) throw conflict("买约须填写卖家名称");required.add("APPOINTMENT_FEE");
|
||||
}
|
||||
for (var code:required) if (group.cargo().stream().anyMatch(c->!covered(c,code,evidence,freight)) && !seen.contains("RECEIVABLE:"+code))
|
||||
throw conflict("本组尚有未覆盖的服务费用,请核对" );
|
||||
if ("BUY_APPOINTMENT".equals(input.appointmentAccount()) && blank(input.sellerName()))
|
||||
throw conflict("买约须填写卖家名称");
|
||||
}
|
||||
|
||||
/** 子单及来源锁已经收齐后才核对原费用撤销状态,不能重复收费。 */
|
||||
@@ -134,7 +130,7 @@ public class DispatchDeliveryFees {
|
||||
fees.requireNoActiveDeliveryReceivables(customerId,freight.priorInboundCodes());
|
||||
}
|
||||
|
||||
/** 费用与子单同事务保存,只向真实客户目的地发布一次正数来源。 */
|
||||
/** 外接报价保留记录;只发布供应商应付,客户应收走人工审核。 */
|
||||
public void publish(Context context,Group group,ArrangeGroup input,DeliveryOrder delivery,
|
||||
DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight,String operatorId,String arrangementKey) {
|
||||
var now=LocalDateTime.now();
|
||||
@@ -144,12 +140,10 @@ public class DispatchDeliveryFees {
|
||||
operation.setOperatorId(operatorId);operation.setOperationNode("CONFIRM_EXTERNAL_FREIGHT");
|
||||
operation.setResult("外接预订 "+quote.bookingNo()+";实收确认版本 "+quote.recordVersion()+";约定运费 "+quote.currency()+" "+quote.amount().toPlainString());
|
||||
operation.setCreatedAt(now);repository.operation(operation);
|
||||
if (quote.amount().signum()==0) continue;
|
||||
fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),quote.bookingNo(),"FREIGHT")),
|
||||
save(group,delivery,null,"DELIVERY_FEE","外接约定运费", "RECEIVABLE",quote.currency(),quote.amount(),
|
||||
quote.quantity(),quote.quantityUnit(),quote.unitPrice(),null,quote.inboundCode()));
|
||||
// 约定运费只保留商业记录,应收由海外费用人工登记审核。
|
||||
}
|
||||
for (var fee:input.fees()==null?List.<FeeItem>of():input.fees()) {
|
||||
if (!"PAYABLE".equals(fee.direction())) throw conflict("请到海外费用登记并审核应收费用");
|
||||
var charged=group.cargo().stream().filter(c->!"RECEIVABLE".equals(fee.direction())||!covered(c,fee.feeType(),evidence,freight)).toList();
|
||||
var orders=charged.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList();
|
||||
var inbound=charged.stream().map(CargoLine::inboundCode).filter(Objects::nonNull).distinct().toList();
|
||||
@@ -164,7 +158,7 @@ public class DispatchDeliveryFees {
|
||||
String direction,String currency,BigDecimal amount,BigDecimal quantity,String unit,BigDecimal unitPrice,FeeItem input,String inboundCode) {
|
||||
return new OverseasFeeSourceDtos.SaveRequest(group.customerId(),group.customerName(),"OVERSEAS_DELIVERY_FEE","DELIVERY_ORDER",
|
||||
delivery.getId(),delivery.getDeliveryNo(),orderId,null,inboundCode,null,null,null,Long.valueOf(delivery.getOverseasWarehouseId()),
|
||||
code,name,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(),
|
||||
code,name, null,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(),
|
||||
input==null?null:input.settlementMode(),input==null||input.paidAt()==null?null:LocalDateTime.ofInstant(input.paidAt(),ZoneId.systemDefault()),
|
||||
input==null?null:input.paymentMethod(),input==null?null:input.paymentReferenceNo(),input==null?null:input.paymentVoucherFileIds(),
|
||||
LocalDate.now(),currency,amount,quantity,unit,unitPrice,input==null?null:input.periodStart(),input==null?null:input.periodEnd());
|
||||
|
||||
+6
-2
@@ -16,8 +16,12 @@ public final class PdaCargoDiscardDtos {
|
||||
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
|
||||
public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
|
||||
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
|
||||
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
|
||||
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
|
||||
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
|
||||
@NotBlank @Size(max=255) String idempotencyKey) {
|
||||
/** 兼容已有调用,正式 PDA 必须提交扫码幂等键。 */
|
||||
public Confirm(List<ActualLine> lines,List<Long> photos,List<String> marks,Boolean all){this(lines,photos,marks,all,null);}
|
||||
/** 兼容已有内部照片确认调用。 */
|
||||
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false,null); }
|
||||
}
|
||||
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
|
||||
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
|
||||
|
||||
+4
@@ -23,6 +23,7 @@ public class PdaCargoDiscardService {
|
||||
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
|
||||
@Resource private CargoDiscardService discardService;
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService;
|
||||
@Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
|
||||
/** 只读取绑定仓已审核申请的实际托盘任务。 */
|
||||
public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) {
|
||||
@@ -95,6 +96,9 @@ public class PdaCargoDiscardService {
|
||||
// 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。
|
||||
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
|
||||
if (terminal) throw conflict("废弃终态不能执行未完成托盘");
|
||||
storageRent.consume(String.valueOf(identity.overseasWarehouseId()),new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest(
|
||||
"DISCARD",discardNo,null,null,null,null,palletNo,request.idempotencyKey(),null,null,null,
|
||||
request.lines(),request.photoFileIds(),request.scannedCartonMarks(),request.confirmAllCartons()));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (CargoDiscardItem item : palletItems) {
|
||||
if ("COMPLETED".equals(item.getPdaStatus())) {
|
||||
|
||||
+2
@@ -50,6 +50,7 @@ import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class CargoDiscardService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
@Resource private CargoDiscardStatisticsService statisticsService;
|
||||
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
|
||||
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
|
||||
@@ -389,6 +390,7 @@ public class CargoDiscardService {
|
||||
release(customerId, application, links, items.listApplication(customerId, application.getId()));
|
||||
LocalDateTime at = now();
|
||||
String from = application.getStatus();
|
||||
storageRent.cancelExecution("DISCARD",discardNo);
|
||||
application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy());
|
||||
application.setReviewRemark(platform ? "平台取消" : application.getReviewRemark());
|
||||
application.setUpdatedAt(at); applications.updateById(application);
|
||||
|
||||
+2
@@ -32,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
|
||||
@Service
|
||||
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
|
||||
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos;
|
||||
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
|
||||
@@ -377,6 +378,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
// 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。
|
||||
var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
|
||||
releaseSources(row,actor,items,now());
|
||||
storageRent.cancelExecution("DISPATCH",number);
|
||||
fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消");
|
||||
return saveCommand(row,actor,identity,request.reason());
|
||||
}
|
||||
|
||||
+5
@@ -23,6 +23,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
|
||||
@Service
|
||||
public class DispatchPickingService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
|
||||
@@ -66,6 +67,10 @@ public class DispatchPickingService {
|
||||
if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位");
|
||||
long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType());
|
||||
List<DispatchSourceAllocation> relabelAllocations=List.of();
|
||||
// 原扫描已经通过业务门禁后才消费已确认资金冻结,之后任一库存失败整体回滚。
|
||||
storageRent.consume(actor.boundWarehouseId(), new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest(
|
||||
"DISPATCH", number, itemId, request.positionId(), request.packages(), request.cartonMark(), null,
|
||||
request.idempotencyKey(), java.util.List.of(), null, request.productQuantity()));
|
||||
if(nativeReturn) {
|
||||
attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId());
|
||||
returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId();
|
||||
|
||||
@@ -46,6 +46,7 @@ public final class LastlegOrderRequests {
|
||||
if (page == null || page == 0) page = 1L;
|
||||
if (size == null || size == 0) size = 20L;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public record ContainerProgressQuery(
|
||||
@@ -100,6 +101,7 @@ public final class LastlegOrderRequests {
|
||||
public record CreateFee(
|
||||
@NotBlank @Size(max = 64) String feeItemCode,
|
||||
@NotBlank @Size(max = 200) String feeItemName,
|
||||
@Size(max = 500) String feeRemark,
|
||||
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
|
||||
@NotBlank @Pattern(regexp = "CNY|USD") String currency,
|
||||
@NotNull @DecimalMin(value = "0.01") BigDecimal amount,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.financefee.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
/** 海外费用只通过 IAM 内部接口读取客户主档,不跨库查询。 */
|
||||
@FeignClient(name = "iam-service", contextId = "overseasFeeCustomerClient", url = "${baiyue.clients.iam.url:}")
|
||||
public interface OverseasFeeCustomerClient {
|
||||
/** 按客户精确查名,不限制启用状态,以保留停用客户的历史费用展示。 */
|
||||
@GetMapping("/internal/iam/finance-customers")
|
||||
ApiResponse<PageResult<Customer>> customers(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam("page") long page, @RequestParam("size") long size,
|
||||
@RequestParam("customerId") long customerId);
|
||||
|
||||
record Customer(Long id, String tenantName) { }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.lastleg.financefee.config;
|
||||
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService.RetryRecord;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/** 费用系统时间按 UTC 存储;仅响应序列化声明时区,不修改业务录入时间和实体类型。 */
|
||||
@Configuration
|
||||
public class OverseasFeeResponseJsonConfiguration {
|
||||
/** 费用接口目前直接返回实体,通过专属混入避免改变其他业务 LocalDateTime 的含义。 */
|
||||
@Bean
|
||||
public SimpleModule overseasFeeResponseTimeModule() {
|
||||
var module = new SimpleModule("overseas-fee-response-time");
|
||||
module.setMixInAnnotation(OverseasFeeSource.class, FeeTimes.class);
|
||||
module.setMixInAnnotation(RetryRecord.class, RetryTimes.class);
|
||||
return module;
|
||||
}
|
||||
|
||||
abstract static class FeeTimes {
|
||||
@JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime getCreatedAt();
|
||||
@JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime getReviewedAt();
|
||||
@JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime getUpdatedAt();
|
||||
}
|
||||
|
||||
abstract static class RetryTimes {
|
||||
@JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime requestedAt();
|
||||
}
|
||||
|
||||
/** 补充 UTC 标记而非偏移数值,浏览器可以按用户时区显示同一时刻。 */
|
||||
public static class UtcTimeSerializer extends JsonSerializer<LocalDateTime> {
|
||||
@Override public void serialize(LocalDateTime value, JsonGenerator output, SerializerProvider provider) throws IOException {
|
||||
output.writeString(java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value.atOffset(ZoneOffset.UTC)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.lastleg.financefee.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.financefee.service.FeeCatalogGateway;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 海外费用录入的只读项目候选,复用费用列表权限。 */
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/platform/v1/last-leg/finance-fee-sources/fee-items")
|
||||
public class PlatformOverseasFeeCatalogController {
|
||||
private final FeeCatalogGateway catalog;
|
||||
|
||||
/** 返回当前平台可用于海外收费的公共费用项目。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
|
||||
public ApiResponse<List<FeeCatalogGateway.FeeItem>> list(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(defaultValue = "RECEIVABLE") String direction) {
|
||||
if (user == null || !Long.valueOf(0).equals(user.tenantId()) || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅平台人员可查询费用项目");
|
||||
}
|
||||
return ApiResponse.success(catalog.listActive(direction));
|
||||
}
|
||||
}
|
||||
+112
-9
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.financefee.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest;
|
||||
@@ -10,6 +11,10 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasManualFeeService;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.RetryDeliveryRequest;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -32,20 +37,26 @@ import java.util.List;
|
||||
@RequestMapping("/api/platform/v1/last-leg/finance-fee-sources")
|
||||
public class PlatformOverseasFeeSourceController {
|
||||
private final OverseasFeeSourceService service;
|
||||
private final OverseasManualFeeService manual;
|
||||
private final OverseasFeeDeliveryService delivery;
|
||||
|
||||
/** 查询海外费用当前版本。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
|
||||
public ApiResponse<List<OverseasFeeSource>> list(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@RequestParam(required = false) String businessType,
|
||||
@RequestParam(required = false) String businessNo,
|
||||
@RequestParam(required = false) Long containerBookingId) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.list(customerId, businessType, businessNo, containerBookingId));
|
||||
var result = manual.describe(service.list(customerId, businessType, businessNo, containerBookingId));
|
||||
delivery.describe(result);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/** 查询真实柜下可计费客户。 */
|
||||
@GetMapping("/container-customers")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
|
||||
public ApiResponse<List<ContainerCustomerOption>> listContainerCustomers(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long containerBookingId) {
|
||||
@@ -53,47 +64,139 @@ public class PlatformOverseasFeeSourceController {
|
||||
return ApiResponse.success(service.listContainerCustomers(containerBookingId));
|
||||
}
|
||||
|
||||
/** 创建人工费用并开始锁定余额及授信。 */
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||
public ApiResponse<OverseasFeeSource> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody SaveRequest request) {
|
||||
requirePlatform(user); return ApiResponse.success(service.create(operator(user), key, request));
|
||||
requirePlatform(user); return ApiResponse.success(manual.create(operator(user), key, request));
|
||||
}
|
||||
|
||||
/** 重新提交费用审核。 */
|
||||
@PostMapping("/{sourceFeeId}/submit")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||
public ApiResponse<OverseasFeeSource> submit(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) {
|
||||
requirePlatform(user); return ApiResponse.success(service.submit(sourceFeeId, operator(user), request.idempotencyKey()));
|
||||
requirePlatform(user); return ApiResponse.success(manual.submit(sourceFeeId, operator(user), request.idempotencyKey()));
|
||||
}
|
||||
|
||||
/** 审核费用并推进对应结算。 */
|
||||
@PostMapping("/{sourceFeeId}/review")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
|
||||
public ApiResponse<OverseasFeeSource> review(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long sourceFeeId, @Valid @RequestBody ReviewRequest request) {
|
||||
requirePlatform(user); return ApiResponse.success(service.review(sourceFeeId, operator(user), request));
|
||||
requirePlatform(user); return ApiResponse.success(manual.review(sourceFeeId, operator(user), operatorName(user), request));
|
||||
}
|
||||
|
||||
/** 提交账单费用的调整版本。 */
|
||||
@PostMapping("/{sourceFeeId}/adjust")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||
public ApiResponse<OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long sourceFeeId, @Valid @RequestBody AdjustRequest request) {
|
||||
requirePlatform(user); return ApiResponse.success(service.adjust(sourceFeeId, operator(user), request));
|
||||
requirePlatform(user); return ApiResponse.success(manual.adjust(sourceFeeId, operator(user), request));
|
||||
}
|
||||
|
||||
/** 申请作废已推送账单费用。 */
|
||||
@PostMapping("/{sourceFeeId}/void")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
|
||||
public ApiResponse<OverseasFeeSource> voidSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long sourceFeeId, @Valid @RequestBody VoidRequest request) {
|
||||
requirePlatform(user); return ApiResponse.success(service.voidSource(sourceFeeId, operator(user), request));
|
||||
requirePlatform(user); return ApiResponse.success(manual.voidSource(sourceFeeId, operator(user), request));
|
||||
}
|
||||
|
||||
/** 分页查询海外费用及审核、扣款进度。 */
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
|
||||
public ApiResponse<PageResult<OverseasFeeSource>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String customerId, @RequestParam(required = false) String businessType,
|
||||
@RequestParam(required = false) String businessNo, @RequestParam(required = false) String feeItemCode,
|
||||
@RequestParam(required = false) String reviewStatus, @RequestParam(required = false) String walletStatus,
|
||||
@RequestParam(defaultValue = "RECEIVABLE") String direction,
|
||||
@RequestParam(required = false) String processingStatus,
|
||||
@RequestParam(defaultValue = "false") boolean storageRentOnly) {
|
||||
requirePlatform(user);
|
||||
var result = manual.page(page, size, customerId, businessType, businessNo, feeItemCode, reviewStatus, walletStatus, direction, processingStatus, storageRentOnly);
|
||||
delivery.describe(result.records());
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/** 读取单条费用详情与当前真实可操作性。 */
|
||||
@GetMapping("/{sourceFeeId}")
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
|
||||
public ApiResponse<OverseasFeeSource> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId) {
|
||||
requirePlatform(user);
|
||||
var result = manual.detail(sourceFeeId); delivery.describe(List.of(result));
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/** 查询费用客户筛选选项。 */
|
||||
@GetMapping("/customer-options")
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
|
||||
public ApiResponse<List<ContainerCustomerOption>> customers(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
requirePlatform(user); return ApiResponse.success(manual.customers());
|
||||
}
|
||||
|
||||
/** 查询本次服务业务单候选。 */
|
||||
@GetMapping("/business-options")
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
|
||||
public ApiResponse<List<BusinessOption>> businessOptions(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String businessType, @RequestParam(required = false) String customerId,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
requirePlatform(user); return ApiResponse.success(manual.businessOptions(businessType, customerId, keyword));
|
||||
}
|
||||
|
||||
/** 取消未收取人工费用并释放已冻结资金。 */
|
||||
@PostMapping("/{sourceFeeId}/cancel")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||
public ApiResponse<OverseasFeeSource> cancel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) {
|
||||
requirePlatform(user); return ApiResponse.success(manual.cancel(sourceFeeId, operator(user), request.idempotencyKey()));
|
||||
}
|
||||
|
||||
/** 显式重试已经失败的冻结、扣款或释放指令。 */
|
||||
@PostMapping("/{sourceFeeId}/retry-funding")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||
public ApiResponse<OverseasFeeSource> retryFunding(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) {
|
||||
requirePlatform(user); return ApiResponse.success(manual.retry(sourceFeeId, operator(user), auditOperator(user), request.idempotencyKey()));
|
||||
}
|
||||
|
||||
/** 重试当前费用失败的原投递事件,不新建费用或重复扣款。 */
|
||||
@PostMapping("/{sourceFeeId}/retry-delivery")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||
public ApiResponse<OverseasFeeSource> retryDelivery(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long sourceFeeId, @Valid @RequestBody RetryDeliveryRequest request) {
|
||||
requirePlatform(user); delivery.retry(sourceFeeId, auditOperator(user), request);
|
||||
var result = manual.detail(sourceFeeId); delivery.describe(List.of(result));
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/** 查询单条费用历次重试及实际结果,权限与费用详情保持一致。 */
|
||||
@GetMapping("/{sourceFeeId}/retries")
|
||||
@PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')")
|
||||
public ApiResponse<List<OverseasFeeDeliveryService.RetryRecord>> retries(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId) {
|
||||
requirePlatform(user); return ApiResponse.success(delivery.history(sourceFeeId));
|
||||
}
|
||||
|
||||
/** 操作人从认证身份取得,保留名称与ID,不能由浏览器指定。 */
|
||||
private static String auditOperator(AuthenticatedUser user) {
|
||||
String value = operatorName(user) + " [" + operator(user) + "]";
|
||||
return value.length() <= 128 ? value : value.substring(0, 128);
|
||||
}
|
||||
|
||||
/** 海外费用平台入口不接受客户身份。 */
|
||||
private static void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L)
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可操作海外费用");
|
||||
}
|
||||
/** 记录当前审核或创建操作人。 */
|
||||
private static String operator(AuthenticatedUser user) {
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
/** 为审核留存平台操作人的名称快照,方便费用明细直接追溯。 */
|
||||
private static String operatorName(AuthenticatedUser user) {
|
||||
return user.displayName() == null || user.displayName().isBlank() ? user.username() : user.displayName().trim();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -17,11 +17,14 @@ public final class OverseasFeeSourceDtos {
|
||||
|
||||
public record ContainerCustomerOption(String customerId, String customerName) { }
|
||||
|
||||
public record BusinessOption(Long businessId, String businessType, String businessNo, String customerId,
|
||||
String customerName, Long warehouseId, Long orderId, String orderType) { }
|
||||
|
||||
public record SaveRequest(
|
||||
@NotBlank @Size(max = 128) String customerId,
|
||||
@Size(max = 200) String customerName,
|
||||
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE|OVERSEAS_ORDER_FEE|OVERSEAS_DELIVERY_FEE|OVERSEAS_RELABEL_FEE") String sourceType,
|
||||
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER") String businessType,
|
||||
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER|WAREHOUSE_TRANSFER|RETURN_ORDER|DISCARD_ORDER") String businessType,
|
||||
@Min(1) Long businessId,
|
||||
@NotBlank @Size(max = 128) String businessNo,
|
||||
@Min(1) Long orderId,
|
||||
@@ -33,11 +36,14 @@ public final class OverseasFeeSourceDtos {
|
||||
@Min(1) Long warehouseId,
|
||||
@NotBlank @Size(max = 64) String feeItemCode,
|
||||
@NotBlank @Size(max = 200) String feeItemName,
|
||||
/** 业务人员补收或重复作业时填写的简短说明,和审核驳回原因分开保存。 */
|
||||
@Size(max = 500) String feeRemark,
|
||||
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
|
||||
@Min(1) Long supplierId,
|
||||
@Size(max = 200) String supplierName,
|
||||
@Size(max = 32) String supplierCategory,
|
||||
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
|
||||
/** 应收可选 BILL(推送结算)或 WALLET(余额扣费);应付仍使用账期/已付。 */
|
||||
@Pattern(regexp = "BILL|WALLET|ON_ACCOUNT|DIRECT_PAID") String settlementMode,
|
||||
LocalDateTime paidAt,
|
||||
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
|
||||
@Size(max = 128) String paymentReferenceNo,
|
||||
@@ -53,9 +59,16 @@ public final class OverseasFeeSourceDtos {
|
||||
) { }
|
||||
|
||||
public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { }
|
||||
public record RetryDeliveryRequest(@NotNull @Min(1) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 64) String eventId,
|
||||
@NotBlank @Pattern(regexp = "[0-9a-fA-F-]{36}") String idempotencyKey) { }
|
||||
public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
|
||||
@Size(max = 500) String reason,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey) { }
|
||||
@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@Min(1) @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) Integer expectedVersion) {
|
||||
/** 兼容既有人工费用调用,仓租审核另要求明确当前版本。 */
|
||||
public ReviewRequest(String decision,String reason,String idempotencyKey){this(decision,reason,idempotencyKey,null);}
|
||||
}
|
||||
public record AdjustRequest(@NotNull @Min(1) Integer expectedVersion,
|
||||
@NotNull SaveRequest fee,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey) { }
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.lastleg.financefee.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/** 人工费用的资金指令状态;实际余额和账本始终由 Finance 维护。 */
|
||||
@Data
|
||||
@TableName(value = "ll_finance_fee_funding", autoResultMap = true)
|
||||
public class OverseasFeeFunding {
|
||||
@TableId private Long sourceFeeId;
|
||||
private String customerId;
|
||||
private String collectionMode;
|
||||
private String walletStatus;
|
||||
private String walletBusinessNo;
|
||||
private String commandEventId;
|
||||
private String walletHoldId;
|
||||
private String walletLedgerId;
|
||||
private Long commandVersion;
|
||||
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String errorMessage;
|
||||
@TableField(typeHandler = JacksonTypeHandler.class) private Map<String, String> commandHistory;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.financefee.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 复用本库人工重试审计;查询字段保留每次操作对应的原事件与资金回执。 */
|
||||
@Data
|
||||
public class OverseasFeeRetryAudit {
|
||||
private String requestId;
|
||||
private String producerTable;
|
||||
private String entryId;
|
||||
private String eventId;
|
||||
private String eventType;
|
||||
private String payloadHash;
|
||||
private String operatorName;
|
||||
private String reason;
|
||||
private Integer previousFailures;
|
||||
private String previousError;
|
||||
private LocalDateTime requestedAt;
|
||||
private String currentStatus;
|
||||
private String currentError;
|
||||
private String walletResult;
|
||||
private LocalDateTime walletCompletedAt;
|
||||
private String attemptOutcome;
|
||||
}
|
||||
+24
@@ -15,6 +15,12 @@ import java.util.List;
|
||||
@Data
|
||||
@TableName(value = "ll_finance_fee_source", autoResultMap = true)
|
||||
public class OverseasFeeSource {
|
||||
/** 自动仓租本次出库依据;查询时装配,不复制库存和资金账本。 */
|
||||
@TableField(exist = false) private RentDetail storageRent;
|
||||
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) { }
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long sourceFeeId;
|
||||
private Integer sourceVersion;
|
||||
@@ -23,6 +29,7 @@ public class OverseasFeeSource {
|
||||
private String sourceType;
|
||||
private String sourceNo;
|
||||
private String businessType;
|
||||
@TableField(exist = false) private String displayBusinessType;
|
||||
private Long businessId;
|
||||
private String businessNo;
|
||||
private Long orderId;
|
||||
@@ -34,6 +41,8 @@ public class OverseasFeeSource {
|
||||
private Long warehouseId;
|
||||
private String feeItemCode;
|
||||
private String feeItemName;
|
||||
/** 费用业务说明,不用于记录审核结论。 */
|
||||
private String feeRemark;
|
||||
private String direction;
|
||||
private Long supplierId;
|
||||
private String supplierName;
|
||||
@@ -50,6 +59,8 @@ public class OverseasFeeSource {
|
||||
private BigDecimal amount;
|
||||
private String originalCurrency;
|
||||
private BigDecimal originalAmount;
|
||||
/** 免费仓租显式写入空汇率,避免存量库默认1被误当美元调价汇率。 */
|
||||
@TableField(insertStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private BigDecimal exchangeRate;
|
||||
private String exchangeRateMonth;
|
||||
private BigDecimal chargeableQuantity;
|
||||
@@ -61,8 +72,21 @@ public class OverseasFeeSource {
|
||||
private String publicationStatus;
|
||||
private String sourceStatus;
|
||||
@TableField(exist = false) private String voidStatus;
|
||||
@TableField(exist = false) private String collectionMode;
|
||||
@TableField(exist = false) private String walletStatus;
|
||||
@TableField(exist = false) private String walletErrorMessage;
|
||||
@TableField(exist = false) private String deliveryStatus;
|
||||
@TableField(exist = false) private String deliveryKind;
|
||||
@TableField(exist = false) private String deliveryEventId;
|
||||
@TableField(exist = false) private String deliveryErrorMessage;
|
||||
@TableField(exist = false) private List<String> allowedActions;
|
||||
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String reviewReason;
|
||||
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String reviewerId;
|
||||
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String reviewerName;
|
||||
@TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private LocalDateTime reviewedAt;
|
||||
private String createdBy;
|
||||
private String updatedBy;
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.baiyue.lastleg.financefee.mapper;
|
||||
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.SelectProvider;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 只读取 Lastleg 自有业务事实,客户条件不可省略。 */
|
||||
@Mapper
|
||||
public interface OverseasFeeBusinessMapper {
|
||||
/** 查询业务记录是否属于当前客户和单号。 */
|
||||
@SelectProvider(type = Sql.class, method = "owned")
|
||||
int countOwned(@Param("type") String type, @Param("id") long id,
|
||||
@Param("customerId") String customerId, @Param("businessNo") String businessNo);
|
||||
/** 补充关联也必须属于相同客户,不能夹带其他客户的订单或入仓信息。 */
|
||||
@SelectProvider(type = Sql.class, method = "customerOwned")
|
||||
int countCustomerOwned(@Param("type") String type, @Param("id") long id, @Param("customerId") String customerId);
|
||||
/** 校验入仓码属于对应客户计划。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_inbound_plan_line WHERE plan_id=#{id} AND customer_id=#{customerId} AND inbound_code=#{code}")
|
||||
int countInbound(@Param("id") long id, @Param("customerId") String customerId, @Param("code") String code);
|
||||
/** 校验真实柜存在。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=#{bookingId}")
|
||||
int countContainer(@Param("bookingId") String bookingId);
|
||||
/** 拒绝同一来源柜中不一致的真实柜号。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=#{bookingId} AND (container_no IS NULL OR TRIM(container_no)='' OR BINARY TRIM(container_no) <> BINARY #{containerNo})")
|
||||
int countInconsistentContainer(@Param("bookingId") String bookingId, @Param("containerNo") String containerNo);
|
||||
/** 校验费用客户确实属于此柜。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=#{bookingId} AND customer_id=#{customerId}")
|
||||
int countContainerCustomer(@Param("bookingId") String bookingId, @Param("customerId") String customerId);
|
||||
/** 按来源柜一次聚合客户。 */
|
||||
@Select("SELECT customer_id,MAX(NULLIF(TRIM(customer_name),'')) customer_name FROM ll_order WHERE source_container_booking_id=#{bookingId} GROUP BY customer_id ORDER BY customer_id")
|
||||
List<ContainerCustomerOption> containerCustomers(@Param("bookingId") String bookingId);
|
||||
/** 客户筛选只取本服务已有真实业务和费用客户。 */
|
||||
@Select("SELECT customer_id,MAX(customer_name) customer_name FROM (SELECT customer_id,customer_name FROM ll_order UNION ALL SELECT customer_id,customer_name FROM ll_finance_fee_source"
|
||||
+ " UNION ALL SELECT customer_id,NULL FROM ll_warehouse_transfer_order UNION ALL SELECT customer_id,NULL FROM ll_return_order"
|
||||
+ " UNION ALL SELECT customer_id,NULL FROM ll_us_truck_order UNION ALL SELECT customer_id,NULL FROM ll_local_looseboard_order"
|
||||
+ " UNION ALL SELECT customer_id,NULL FROM ll_relabel_application UNION ALL SELECT customer_id,NULL FROM ll_cargo_discard_application"
|
||||
+ " UNION ALL SELECT customer_id,NULL FROM ll_inbound_plan UNION ALL SELECT customer_id,NULL FROM ll_delivery_order) c"
|
||||
+ " WHERE customer_id IS NOT NULL AND TRIM(customer_id)!='' GROUP BY customer_id ORDER BY customer_id LIMIT 1000")
|
||||
List<ContainerCustomerOption> customers();
|
||||
/** 头程附加费只认本次服务订单的类型,不能沿货物最初来源推断。 */
|
||||
@Select("SELECT order_type FROM ll_order WHERE id=#{id} AND customer_id=#{customerId}")
|
||||
String orderType(@Param("id") long id, @Param("customerId") String customerId);
|
||||
/** 换标履约订单登记费用时,定位同客户的真实换标申请。 */
|
||||
@Select("SELECT a.id business_id,'RELABEL_ORDER' business_type,a.relabel_no business_no,a.customer_id,"
|
||||
+ "o.customer_name,o.overseas_warehouse_id warehouse_id,o.id order_id,o.order_type"
|
||||
+ " FROM ll_relabel_application a JOIN ll_order o ON o.id=a.lastleg_order_id AND o.customer_id=a.customer_id"
|
||||
+ " WHERE o.id=#{id} AND o.customer_id=#{customerId} AND o.order_type='RELABEL_APPLICATION'")
|
||||
BusinessOption relabelForOrder(@Param("id") long id, @Param("customerId") String customerId);
|
||||
/** 批量按真实客户和订单类型识别换标费用,不根据单号前缀猜测。 */
|
||||
@Select("<script>SELECT DISTINCT f.source_fee_id FROM ll_finance_fee_source f"
|
||||
+ " JOIN ll_order o ON f.business_type='LASTLEG_ORDER' AND o.id=f.business_id AND o.customer_id=f.customer_id"
|
||||
+ " WHERE o.order_type='RELABEL_APPLICATION' AND f.source_fee_id IN"
|
||||
+ " <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
List<Long> relabelFeeIds(@Param("ids") List<Long> ids);
|
||||
/** 按客户与关键字返回真实业务候选。 */
|
||||
@SelectProvider(type = Sql.class, method = "options")
|
||||
List<BusinessOption> options(@Param("type") String type, @Param("customerId") String customerId,
|
||||
@Param("keyword") String keyword);
|
||||
|
||||
final class Sql {
|
||||
/** 业务表和单号字段只由服务端白名单解析,禁止请求传入 SQL 标识符。 */
|
||||
private static String[] table(String type) {
|
||||
return switch (type) {
|
||||
case "OVERSEAS_INBOUND" -> new String[]{"ll_inbound_plan", "plan_no", "NULL", "NULL"};
|
||||
case "OVERSEAS_PROCUREMENT" -> new String[]{"ll_overseas_procurement_order", "order_no", "NULL", "NULL"};
|
||||
case "PROCUREMENT_FEE" -> new String[]{"ll_overseas_procurement_fee", "fee_no", "NULL", "NULL"};
|
||||
case "US_TRUCK" -> new String[]{"ll_us_truck_order", "order_no", "NULL", "NULL"};
|
||||
case "LOCAL_LOOSEBOARD" -> new String[]{"ll_local_looseboard_order", "order_no", "NULL", "NULL"};
|
||||
case "LASTLEG_ORDER" -> new String[]{"ll_order", "order_no", "t.overseas_warehouse_id", "t.order_type"};
|
||||
case "DELIVERY_ORDER" -> new String[]{"ll_delivery_order", "delivery_no", "NULL", "NULL"};
|
||||
case "RELABEL_ORDER" -> new String[]{"ll_relabel_application", "relabel_no", "NULL", "NULL"};
|
||||
case "WAREHOUSE_TRANSFER" -> new String[]{"ll_warehouse_transfer_order", "transfer_no", "t.operating_warehouse_id", "NULL"};
|
||||
case "RETURN_ORDER" -> new String[]{"ll_return_order", "return_no", "t.overseas_warehouse_id", "NULL"};
|
||||
case "DISCARD_ORDER" -> new String[]{"ll_cargo_discard_application", "discard_no", "NULL", "NULL"};
|
||||
default -> throw new IllegalArgumentException("不支持的费用业务类型");
|
||||
};
|
||||
}
|
||||
/** 生成精确业务归属查询。 */
|
||||
public static String owned(Map<String, Object> args) {
|
||||
String[] table = table((String) args.get("type"));
|
||||
return "SELECT COUNT(*) FROM " + table[0] + " WHERE id=#{id} AND customer_id=#{customerId} AND " + table[1] + "=#{businessNo}";
|
||||
}
|
||||
/** 只核对补充引用的主键及客户,业务主关联另行核对单号。 */
|
||||
public static String customerOwned(Map<String, Object> args) {
|
||||
String[] table = table((String) args.get("type"));
|
||||
return "SELECT COUNT(*) FROM " + table[0] + " WHERE id=#{id} AND customer_id=#{customerId}";
|
||||
}
|
||||
/** 候选限制最多五十条;输入只作为绑定参数。 */
|
||||
public static String options(Map<String, Object> args) {
|
||||
String type = (String) args.get("type");
|
||||
String[] table = table(type);
|
||||
return "<script>SELECT t.id business_id,'" + type + "' business_type,t." + table[1] + " business_no,t.customer_id,"
|
||||
+ "(SELECT MAX(o.customer_name) FROM ll_order o WHERE o.customer_id=t.customer_id) customer_name,"
|
||||
+ table[2] + " warehouse_id," + ("LASTLEG_ORDER".equals(type) ? "t.id" : "NULL") + " order_id," + table[3] + " order_type"
|
||||
+ " FROM " + table[0] + " t WHERE 1=1 <if test='customerId != null'>AND t.customer_id=#{customerId}</if>"
|
||||
+ " <if test='keyword != null'>AND t." + table[1] + " LIKE CONCAT('%',#{keyword},'%')</if> ORDER BY t.id DESC LIMIT 50</script>";
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.financefee.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasFeeFundingMapper extends BaseMapper<OverseasFeeFunding> {
|
||||
/** 锁定费用资金编排,避免审核、取消和异步回执相互覆盖。 */
|
||||
@Select("SELECT * FROM ll_finance_fee_funding WHERE source_fee_id=#{sourceFeeId} FOR UPDATE")
|
||||
@org.apache.ibatis.annotations.ResultMap("mybatis-plus_OverseasFeeFunding")
|
||||
OverseasFeeFunding lock(@Param("sourceFeeId") long sourceFeeId);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.financefee.mapper;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeRetryAudit;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 重试只重置原事件状态;费用、金额及事件载荷不可修改。 */
|
||||
@Mapper
|
||||
public interface OverseasFeeRetryMapper {
|
||||
@Select("SELECT * FROM ll_event_outbox WHERE event_key=#{key} FOR UPDATE")
|
||||
LastlegEventOutbox lockEvent(@Param("key") String key);
|
||||
|
||||
@Select("SELECT * FROM event_delivery_retry_audit WHERE request_id=#{requestId}")
|
||||
OverseasFeeRetryAudit request(@Param("requestId") String requestId);
|
||||
|
||||
/** 仅重新排队失败事件,不抢占发送租约;清除旧失败以禁止连续重复点击。 */
|
||||
@Update("UPDATE ll_event_outbox SET status='PENDING',retry_count=0,next_retry_at=#{now},locked_by=NULL,locked_at=NULL,last_error=NULL WHERE id=#{id} AND (status='DEAD' OR (status='PENDING' AND NULLIF(TRIM(last_error),'') IS NOT NULL))")
|
||||
int requeue(@Param("id") long id, @Param("now") LocalDateTime now);
|
||||
|
||||
@Insert("INSERT INTO event_delivery_retry_audit(request_id,producer_table,entry_id,event_id,event_type,payload_hash,operator_name,reason,previous_failures,previous_error,requested_at) VALUES(#{requestId},#{producerTable},#{entryId},#{eventId},#{eventType},#{payloadHash},#{operatorName},#{reason},#{previousFailures},#{previousError},#{requestedAt})")
|
||||
int insert(OverseasFeeRetryAudit audit);
|
||||
|
||||
/** 跨版本历史按稳定费用身份归集;事件和记录标识按字节精确关联,避免表间排序规则冲突或串记录。 */
|
||||
@Select("SELECT a.*,e.status current_status,COALESCE((SELECT newer.previous_error FROM event_delivery_retry_audit newer WHERE newer.event_id=a.event_id AND newer.requested_at > a.requested_at ORDER BY newer.requested_at LIMIT 1),e.last_error) current_error,i.payload_json wallet_result,i.processed_at wallet_completed_at, "
|
||||
+ "(SELECT d.outcome FROM event_delivery_attempt d WHERE CAST(d.event_id AS BINARY)=CAST(a.event_id AS BINARY) AND d.started_at >= a.requested_at "
|
||||
+ "AND d.started_at < COALESCE((SELECT MIN(newer.requested_at) FROM event_delivery_retry_audit newer WHERE newer.event_id=a.event_id AND newer.requested_at > a.requested_at),'9999-12-31 23:59:59') ORDER BY d.started_at DESC,d.attempt_id DESC LIMIT 1) attempt_outcome "
|
||||
+ "FROM event_delivery_retry_audit a JOIN ll_event_outbox e ON a.producer_table='ll_event_outbox' AND CAST(a.entry_id AS BINARY)=CAST(CONCAT('',e.id) AS BINARY) "
|
||||
+ "LEFT JOIN ll_wallet_event_inbox i ON CAST(i.command_event_id AS BINARY)=CAST(a.event_id AS BINARY) AND i.status='PROCESSED' "
|
||||
+ "WHERE e.customer_id=#{customer} AND ((e.event_type='BusinessFeeSourceChanged' AND e.aggregate_type=#{sourceType} AND e.aggregate_id=#{sourceId}) "
|
||||
+ "OR (e.event_type='WalletCommandRequested' AND e.aggregate_type='STORAGE_RENT' AND CAST(e.aggregate_id AS BINARY) IN (SELECT CAST(CONCAT('',r.id) AS BINARY) FROM ll_storage_rent_preparation r WHERE r.source_fee_id=#{sourceId} AND r.customer_id=#{customer})) OR (e.event_type='WalletCommandRequested' AND e.aggregate_type='OVERSEAS_MANUAL_FEE' AND e.aggregate_id LIKE CONCAT(#{sourceId},'-%'))) "
|
||||
+ "ORDER BY a.requested_at DESC,a.request_id DESC")
|
||||
List<OverseasFeeRetryAudit> history(@Param("customer") String customer, @Param("sourceType") String sourceType, @Param("sourceId") String sourceId);
|
||||
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.baiyue.lastleg.financefee.mapper;
|
||||
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.SelectProvider;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.ResultMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface OverseasManualFeeQueryMapper {
|
||||
/** 查询最新费用版本的数量,所有筛选与分页查询一致。 */
|
||||
@SelectProvider(type = Sql.class, method = "count")
|
||||
long count(@Param("query") Map<String, Object> query);
|
||||
/** 返回稳定排序的当前版本,避免财务费用列表无限加载。 */
|
||||
@SelectProvider(type = Sql.class, method = "page")
|
||||
@ResultMap("com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper.mybatis-plus_OverseasFeeSource")
|
||||
List<OverseasFeeSource> page(@Param("query") Map<String, Object> query);
|
||||
|
||||
/** 同客户、业务单及方向核对现有项目;已驳回记录也保留,允许说明后重新登记。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_finance_fee_source f WHERE f.customer_id=#{customer}"
|
||||
+ " AND f.business_type=#{type} AND f.business_id=#{businessId} AND f.direction=#{direction}"
|
||||
+ " AND f.fee_item_code=#{code} AND f.source_status='ACTIVE' AND f.source_fee_id != #{exclude}"
|
||||
+ " AND NOT EXISTS(SELECT 1 FROM ll_finance_fee_source n WHERE n.source_fee_id=f.source_fee_id AND n.source_version>f.source_version)")
|
||||
int sameProject(@Param("customer") String customer, @Param("type") String type, @Param("businessId") long businessId,
|
||||
@Param("direction") String direction, @Param("code") String code, @Param("exclude") long exclude);
|
||||
|
||||
/** 只拦截旧自动链的同项目,新人工费用允许按实际服务分次登记。 */
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_finance_fee_source f WHERE f.customer_id=#{customer} AND f.direction='RECEIVABLE'"
|
||||
+ " AND f.fee_item_code IN ('RELABEL_SERVICE','DELIVERY_FEE','LOADING_FEE','APPOINTMENT_FEE','DESTUFF_PALLET') AND f.source_status='ACTIVE' AND f.review_status IN ('PENDING','APPROVED') AND f.source_fee_id != #{exclude}"
|
||||
+ " AND NOT EXISTS(SELECT 1 FROM ll_finance_fee_source n WHERE n.source_fee_id=f.source_fee_id AND n.source_version>f.source_version)"
|
||||
+ " AND NOT EXISTS(SELECT 1 FROM ll_finance_fee_funding w WHERE w.source_fee_id=f.source_fee_id)"
|
||||
+ " AND ((f.business_type=#{type} AND f.business_id=#{id})"
|
||||
+ "<if test='type == "LASTLEG_ORDER"'> OR (f.order_id=#{id} AND f.fee_item_code='RELABEL_SERVICE')</if>)"
|
||||
+ " AND (f.fee_item_code=#{code} OR f.fee_item_name=#{name}"
|
||||
+ "<if test='code == "FEE_TYPE_071"'> OR f.fee_item_code='RELABEL_SERVICE'</if>"
|
||||
+ "<if test='code == "FEE_TYPE_075"'> OR f.fee_item_code='DELIVERY_FEE'</if>)</script>")
|
||||
int legacyProject(@Param("customer") String customer,@Param("type") String type,@Param("id") long id,
|
||||
@Param("code") String code,@Param("name") String name,@Param("exclude") long exclude);
|
||||
|
||||
/** 尚未生成费用来源的旧换标冻结及已扣快照也必须防重。 */
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_relabel_application WHERE customer_id=#{customer} AND charge_amount>0"
|
||||
+ " AND wallet_status NOT IN ('NOT_REQUIRED','RELEASED')"
|
||||
+ "<choose><when test='type == "RELABEL_ORDER"'> AND id=#{id}</when>"
|
||||
+ "<otherwise> AND lastleg_order_id=#{id}</otherwise></choose></script>")
|
||||
int legacyRelabel(@Param("customer") String customer,@Param("type") String type,@Param("id") long id);
|
||||
|
||||
final class Sql {
|
||||
/** 绑定动态筛选,不拼接任何请求值。 */
|
||||
private static String where() {
|
||||
return " FROM ll_finance_fee_source f LEFT JOIN ll_finance_fee_funding w ON w.source_fee_id=f.source_fee_id"
|
||||
+ " WHERE NOT EXISTS (SELECT 1 FROM ll_finance_fee_source n WHERE n.source_fee_id=f.source_fee_id AND n.source_version>f.source_version)"
|
||||
+ "<if test='query.customerId != null'> AND f.customer_id=#{query.customerId}</if>"
|
||||
+ "<if test='query.businessType != null'> AND (CASE WHEN f.business_type='LASTLEG_ORDER'"
|
||||
+ " AND EXISTS(SELECT 1 FROM ll_order o WHERE o.id=f.business_id AND o.customer_id=f.customer_id"
|
||||
+ " AND o.order_type='RELABEL_APPLICATION') THEN 'RELABEL_ORDER' ELSE f.business_type END)=#{query.businessType}</if>"
|
||||
+ "<if test='query.businessNo != null'> AND f.business_no LIKE CONCAT('%',#{query.businessNo},'%')</if>"
|
||||
+ "<if test='query.feeItemCode != null'> AND f.fee_item_code=#{query.feeItemCode}</if>"
|
||||
// 分页和总数共用来源过滤,人工同名仓储费不混入出库仓租记录。
|
||||
+ "<if test='query.storageRentOnly == true'> AND EXISTS(SELECT 1 FROM ll_storage_rent_preparation r WHERE r.source_fee_id=f.source_fee_id AND r.customer_id=f.customer_id AND r.consumed_at IS NOT NULL)</if>"
|
||||
+ "<if test='query.reviewStatus != null'> AND f.review_status=#{query.reviewStatus}</if>"
|
||||
+ "<if test='query.walletStatus != null'> AND (w.wallet_status=#{query.walletStatus}"
|
||||
+ "<if test='query.walletStatus == "SETTLED"'> OR (w.source_fee_id IS NULL AND f.wallet_settlement IS NOT NULL)</if>)</if>"
|
||||
+ "<if test='query.direction != null'> AND f.direction=#{query.direction}</if>"
|
||||
+ "<if test='query.processingStatus != null'> AND (" + processingStatus() + ")=#{query.processingStatus}</if>";
|
||||
}
|
||||
/** 按当前版本唯一事件归并费用状态,列表总数与分页共用同一表达式。 */
|
||||
private static String processingStatus() {
|
||||
String eventKey = "CASE WHEN f.review_status='APPROVED' AND f.publication_status='PUBLISHED' THEN "
|
||||
+ "CONCAT('BusinessFeeSourceChanged:LASTLEG:',f.source_type,':',f.source_fee_id,':',f.source_version,"
|
||||
+ "CASE WHEN f.source_version=1 THEN ':PUBLISHED' ELSE ':ADJUSTED' END)"
|
||||
+ " WHEN w.collection_mode='WALLET' AND w.wallet_status IN ('FREEZE_PENDING','SETTLE_PENDING','RELEASE_PENDING') THEN "
|
||||
+ "CONCAT('WALLET_COMMAND:',f.customer_id,':',CASE WHEN f.created_by='STORAGE_RENT' AND f.fee_item_code='FEE_TYPE_077' THEN 'STORAGE_RENT' ELSE 'OVERSEAS_MANUAL_FEE' END,':',w.wallet_business_no,':',"
|
||||
+ "CASE WHEN f.created_by='STORAGE_RENT' AND f.fee_item_code='FEE_TYPE_077' AND w.wallet_status='SETTLE_PENDING' THEN 'SETTLE_ACTUAL' ELSE REPLACE(w.wallet_status,'_PENDING','') END,':',w.command_version) ELSE NULL END";
|
||||
String delivery = "(SELECT e.status FROM ll_event_outbox e WHERE e.event_key=(" + eventKey + ") AND e.customer_id=f.customer_id)";
|
||||
String deliveryFailed = "EXISTS(SELECT 1 FROM ll_event_outbox e WHERE e.event_key=(" + eventKey + ") AND e.customer_id=f.customer_id "
|
||||
+ "AND e.status IN ('PENDING','PROCESSING') AND NULLIF(TRIM(e.last_error),'') IS NOT NULL)";
|
||||
// 费用已终结不代表资金已释放;补偿失败须能从失败筛选中找到。
|
||||
return "CASE WHEN w.wallet_status='RELEASE_FAILED' THEN 'FAILED'"
|
||||
+ " WHEN w.wallet_status IN ('FREEZE_PENDING','RELEASE_PENDING') AND (" + delivery + "='DEAD' OR " + deliveryFailed + ") THEN 'FAILED'"
|
||||
+ " WHEN w.wallet_status IN ('FREEZE_PENDING','RELEASE_PENDING') THEN 'PROCESSING'"
|
||||
+ " WHEN f.source_status IN ('VOIDED','SUPERSEDED','CANCELLED') OR f.review_status='REJECTED' THEN 'CLOSED'"
|
||||
+ " WHEN f.direction='RECEIVABLE' AND (w.wallet_status='SETTLED' OR f.wallet_settlement IS NOT NULL) THEN 'COMPLETED'"
|
||||
+ " WHEN w.wallet_status IN ('FREEZE_FAILED','SETTLE_FAILED','RELEASE_FAILED') OR " + delivery + "='DEAD' OR " + deliveryFailed + " THEN 'FAILED'"
|
||||
+ " WHEN w.wallet_status IN ('FREEZE_PENDING','SETTLE_PENDING','RELEASE_PENDING') OR " + delivery + " IN ('PENDING','PROCESSING') THEN 'PROCESSING'"
|
||||
+ " WHEN w.wallet_status='SETTLED' OR f.wallet_settlement IS NOT NULL OR f.publication_status='PUBLISHED' THEN 'COMPLETED' ELSE 'WAITING' END";
|
||||
}
|
||||
/** 生成同口径总数查询。 */
|
||||
public static String count() { return "<script>SELECT COUNT(*)" + where() + "</script>"; }
|
||||
/** 生成稳定分页查询。 */
|
||||
public static String page() {
|
||||
return "<script>SELECT f.*" + where() + " ORDER BY f.created_at DESC,f.id DESC LIMIT #{query.size} OFFSET #{query.offset}</script>";
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.lastleg.financefee.repository;
|
||||
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeBusinessMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasFeeBusinessRepository {
|
||||
private final OverseasFeeBusinessMapper mapper;
|
||||
/** 核对真实业务客户与单号。 */
|
||||
public int owned(String type, long id, String customer, String no) { return mapper.countOwned(type, id, customer, no); }
|
||||
/** 补充业务引用也需核对客户,不能只校验主关联。 */
|
||||
public int customerOwned(String type, long id, String customer) { return mapper.countCustomerOwned(type, id, customer); }
|
||||
/** 核对入仓码。 */
|
||||
public int inbound(long id, String customer, String code) { return mapper.countInbound(id, customer, code); }
|
||||
/** 读取真实柜数量。 */
|
||||
public int container(String id) { return mapper.countContainer(id); }
|
||||
/** 核对柜号一致性。 */
|
||||
public int inconsistentContainer(String id, String no) { return mapper.countInconsistentContainer(id, no); }
|
||||
/** 核对柜内客户。 */
|
||||
public int containerCustomer(String id, String customer) { return mapper.countContainerCustomer(id, customer); }
|
||||
/** 一次读取柜内客户。 */
|
||||
public List<ContainerCustomerOption> containerCustomers(String id) { return mapper.containerCustomers(id); }
|
||||
/** 读取费用筛选客户。 */
|
||||
public List<ContainerCustomerOption> customers() { return mapper.customers(); }
|
||||
/** 读取本次业务订单类型。 */
|
||||
public String orderType(long id, String customer) { return mapper.orderType(id, customer); }
|
||||
/** 从履约主键解析实际换标单,保留客户隔离。 */
|
||||
public BusinessOption relabelForOrder(long id, String customer) { return mapper.relabelForOrder(id, customer); }
|
||||
/** 空列表不查询,费用展示采用批量业务事实。 */
|
||||
public List<Long> relabelFeeIds(List<Long> ids) { return ids.isEmpty() ? List.of() : mapper.relabelFeeIds(ids); }
|
||||
/** 返回真实业务候选。 */
|
||||
public List<BusinessOption> options(String type, String customer, String keyword) { return mapper.options(type, customer, keyword); }
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.financefee.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeRetryMapper;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeRetryAudit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/** 原事件及人工重试审计的数据访问,不接触客户余额或其他服务数据库。 */
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasFeeDeliveryRepository {
|
||||
private final LastlegEventOutboxMapper events;
|
||||
private final OverseasFeeRetryMapper retries;
|
||||
public List<LastlegEventOutbox> events(Collection<String> keys) { return keys.isEmpty() ? List.of() : events.selectList(new LambdaQueryWrapper<LastlegEventOutbox>().in(LastlegEventOutbox::getEventKey, keys)); }
|
||||
public LastlegEventOutbox event(String key) { return events.findByEventKey(key); }
|
||||
public LastlegEventOutbox lock(String key) { return retries.lockEvent(key); }
|
||||
public OverseasFeeRetryAudit request(String key) { return retries.request(key); }
|
||||
public int requeue(long id, LocalDateTime now) { return retries.requeue(id, now); }
|
||||
public void insert(OverseasFeeRetryAudit audit) { retries.insert(audit); }
|
||||
public List<OverseasFeeRetryAudit> history(String customer, String sourceType, long sourceId) { return retries.history(customer, sourceType, String.valueOf(sourceId)); }
|
||||
}
|
||||
+4
@@ -19,9 +19,13 @@ public class OverseasFeeOutboxRepository {
|
||||
|
||||
public boolean prerequisitePublished(LastlegEventOutbox row) { return cancellation.prerequisitePublished(row); }
|
||||
|
||||
/** 仅投递需结算的费用,已确认余额扣款的应收不再发送,避免无效重试占满队列。 */
|
||||
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime stale) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
|
||||
.apply("NOT EXISTS (SELECT 1 FROM ll_finance_fee_source collected WHERE collected.source_fee_id=ll_event_outbox.aggregate_id "
|
||||
+ "AND collected.source_type=ll_event_outbox.aggregate_type AND collected.customer_id=ll_event_outbox.customer_id "
|
||||
+ "AND collected.direction='RECEIVABLE' AND collected.wallet_settlement IS NOT NULL)")
|
||||
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
|
||||
.or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.baiyue.lastleg.financefee.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeFundingMapper;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasManualFeeQueryMapper;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 人工费用读写适配,不持有客户余额。 */
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasManualFeeRepository {
|
||||
private final OverseasFeeSourceMapper fees;
|
||||
private final OverseasFeeFundingMapper funding;
|
||||
private final OverseasManualFeeQueryMapper queries;
|
||||
private final OverseasFeeCommandMapper commands;
|
||||
|
||||
/** 读取操作幂等记录,避免重试审核重复发资金指令。 */
|
||||
public OverseasFeeCommand command(String operator, String action, String key) { return commands.find(operator, action, key); }
|
||||
/** 保存业务操作幂等结果。 */
|
||||
public void insertCommand(OverseasFeeCommand value) { commands.insert(value); }
|
||||
|
||||
/** 统计同筛选条件下的费用。 */
|
||||
public long count(Map<String, Object> query) { return queries.count(query); }
|
||||
/** 查询费用分页。 */
|
||||
public List<OverseasFeeSource> page(Map<String, Object> query) { return queries.page(query); }
|
||||
|
||||
/** 重复收费仅要求解释实际服务,不对同单项目设置唯一收费限制。 */
|
||||
public boolean hasSameProject(OverseasFeeSource fee) {
|
||||
return queries.sameProject(fee.getCustomerId(), fee.getBusinessType(), fee.getBusinessId(),
|
||||
fee.getDirection(), fee.getFeeItemCode(), fee.getSourceFeeId()) > 0;
|
||||
}
|
||||
|
||||
/** 核对相同服务项目是否已经被旧自动应收占用。 */
|
||||
public int legacyProject(String customer,String type,long id,String code,String name,long exclude) {
|
||||
return queries.legacyProject(customer,type,id,code,name,exclude);
|
||||
}
|
||||
/** 读取旧换标资金占用,避免新旧两套资金重复扣款。 */
|
||||
public int legacyRelabel(String customer,String type,long id) { return queries.legacyRelabel(customer,type,id); }
|
||||
|
||||
/** 获取费用最新版本。 */
|
||||
public OverseasFeeSource current(long id) {
|
||||
return fees.selectOne(new LambdaQueryWrapper<OverseasFeeSource>()
|
||||
.eq(OverseasFeeSource::getSourceFeeId, id).orderByDesc(OverseasFeeSource::getSourceVersion).last("LIMIT 1"));
|
||||
}
|
||||
/** 锁定费用后才能读取或变更资金状态,所有写入口保持相同锁顺序。 */
|
||||
public OverseasFeeSource lockFee(long id) { return fees.lockCurrent(id); }
|
||||
/** 保存本地费用状态,不代表完成钱包扣款。 */
|
||||
public void updateFee(OverseasFeeSource fee) { fees.updateById(fee); }
|
||||
/** 仓租调整保留每个金额版本及其说明,旧版本不覆盖。 */
|
||||
public void insertFee(OverseasFeeSource fee) { fees.insert(fee); }
|
||||
/** 查询已有资金编排。 */
|
||||
public OverseasFeeFunding funding(long id) { return funding.selectById(id); }
|
||||
/** 锁定资金编排。 */
|
||||
public OverseasFeeFunding lockFunding(long id) { return funding.lock(id); }
|
||||
/** 批量加载列表资金状态,避免逐条查询。 */
|
||||
public List<OverseasFeeFunding> funding(Collection<Long> ids) {
|
||||
return ids.isEmpty() ? List.of() : funding.selectBatchIds(ids);
|
||||
}
|
||||
/** 首次创建资金编排。 */
|
||||
public void insertFunding(OverseasFeeFunding value) { funding.insert(value); }
|
||||
/** 保存资金指令状态及已核验回执。 */
|
||||
public void updateFunding(OverseasFeeFunding value) { funding.updateById(value); }
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.lastleg.financefee.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.client.FirstlegFeeCatalogClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 海外收费复用公共项目;目录不可用时拒绝新录费,不回退自由输入。 */
|
||||
@Service
|
||||
public class FeeCatalogGateway {
|
||||
private final FirstlegFeeCatalogClient client;
|
||||
private final String token;
|
||||
|
||||
/** 注入只读目录客户端及服务身份。 */
|
||||
public FeeCatalogGateway(FirstlegFeeCatalogClient client,
|
||||
@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||
this.client = client;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** 返回真实启用的海外仓应收项目,默认价币种固定为人民币。 */
|
||||
public List<FeeItem> listActiveReceivable() {
|
||||
return listActive("RECEIVABLE");
|
||||
}
|
||||
|
||||
/** 按收付方向返回当前启用项目,拒绝未知方向。 */
|
||||
public List<FeeItem> listActive(String direction) {
|
||||
if (!List.of("RECEIVABLE", "PAYABLE").contains(direction)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "收付方向无效");
|
||||
}
|
||||
requireConfigured();
|
||||
List<FirstlegFeeCatalogClient.Item> items = requireData(client.list(token, direction));
|
||||
return items.stream().map(item -> new FeeItem(item.code(), item.name(), item.defaultPrice())).toList();
|
||||
}
|
||||
|
||||
/** 校验项目编码、名称快照和收费方向,防止伪造项目名写入财务。 */
|
||||
public void requireActive(String code, String name, String direction) {
|
||||
requireConfigured();
|
||||
FirstlegFeeCatalogClient.Item item = requireData(client.detail(token, code, direction));
|
||||
if (!Objects.equals(code, item.code()) || !Objects.equals(name, item.name())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "费用项目已变更,请重新选择");
|
||||
}
|
||||
}
|
||||
|
||||
/** 未配置内部服务身份时禁止发送匿名目录请求。 */
|
||||
private void requireConfigured() {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "费用项目服务未就绪");
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查业务响应,不能把远端拒绝误认为空目录或有效项目。 */
|
||||
private static <T> T requireData(ApiResponse<T> response) {
|
||||
if (response == null || !"200".equals(response.code()) || response.data() == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "费用项目不可用,请刷新后重试");
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
|
||||
public record FeeItem(String code, String name, BigDecimal defaultPrice) { }
|
||||
}
|
||||
+32
-45
@@ -8,7 +8,7 @@ import jakarta.annotation.Resource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -17,22 +17,24 @@ import java.util.List;
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasFeeBusinessGuard {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final OverseasFeeBusinessRepository repository;
|
||||
@Resource private DomesticWarehouseDirectoryClient warehouses;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
/** 按真实来源柜一次读取客户候选。 */
|
||||
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
|
||||
List<ContainerCustomerOption> customers = jdbc.query(
|
||||
"SELECT customer_id,CASE WHEN COUNT(DISTINCT NULLIF(TRIM(customer_name),''))=1 "
|
||||
+ "THEN MAX(NULLIF(TRIM(customer_name),'')) ELSE NULL END AS customer_name "
|
||||
+ "FROM ll_order WHERE source_container_booking_id=? GROUP BY customer_id ORDER BY customer_id",
|
||||
(result, rowNum) -> new ContainerCustomerOption(
|
||||
result.getString("customer_id"), result.getString("customer_name")),
|
||||
String.valueOf(containerBookingId));
|
||||
return customers;
|
||||
return repository.containerCustomers(String.valueOf(containerBookingId));
|
||||
}
|
||||
|
||||
/** 校验费用与当前服务业务单的客户、单号一致。 */
|
||||
public void requireOwned(SaveRequest fee) {
|
||||
if (fee.orderId() != null && repository.customerOwned("LASTLEG_ORDER", fee.orderId(), fee.customerId().trim()) != 1)
|
||||
throw bad("关联订单不属于该客户");
|
||||
if (fee.inboundId() != null && repository.customerOwned("OVERSEAS_INBOUND", fee.inboundId(), fee.customerId().trim()) != 1)
|
||||
throw bad("关联入仓计划不属于该客户");
|
||||
if (fee.inboundId() != null && !blank(fee.inboundCode())
|
||||
&& repository.inbound(fee.inboundId(), fee.customerId().trim(), fee.inboundCode().trim()) < 1)
|
||||
throw bad("入仓码不属于关联入仓计划和客户");
|
||||
if ("OVERSEAS_CONTAINER".equals(fee.businessType())) {
|
||||
requireOwnedContainer(fee);
|
||||
return;
|
||||
@@ -45,28 +47,21 @@ public class OverseasFeeBusinessGuard {
|
||||
requireActiveOverseasWarehouse(fee.warehouseId());
|
||||
return;
|
||||
}
|
||||
Table table = switch (fee.businessType()) {
|
||||
case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no");
|
||||
case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no");
|
||||
case "PROCUREMENT_FEE" -> new Table("ll_overseas_procurement_fee", "fee_no");
|
||||
case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no");
|
||||
case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no");
|
||||
case "LASTLEG_ORDER" -> new Table("ll_order", "order_no");
|
||||
case "DELIVERY_ORDER" -> new Table("ll_delivery_order", "delivery_no");
|
||||
case "RELABEL_ORDER" -> new Table("ll_relabel_application", "relabel_no");
|
||||
default -> throw bad("业务类型与业务记录ID不匹配");
|
||||
};
|
||||
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name
|
||||
+ " WHERE id=? AND customer_id=? AND " + table.number + "=?",
|
||||
Integer.class, fee.businessId(), fee.customerId().trim(), fee.businessNo().trim());
|
||||
if (count == null || count != 1) throw bad("费用关联业务不存在或不属于该客户");
|
||||
if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode())) {
|
||||
Integer inbound = jdbc.queryForObject("SELECT COUNT(*) FROM ll_inbound_plan_line WHERE plan_id=? AND customer_id=? AND inbound_code=?",
|
||||
Integer.class, fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim());
|
||||
if (inbound == null || inbound < 1) throw bad("入仓码不属于该客户和海外入仓计划");
|
||||
int count;
|
||||
try {
|
||||
count = repository.owned(fee.businessType(), fee.businessId(), fee.customerId().trim(), fee.businessNo().trim());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw bad("业务类型与业务记录ID不匹配");
|
||||
}
|
||||
if (count != 1) throw bad("费用关联业务不存在或不属于该客户");
|
||||
if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode())
|
||||
&& repository.inbound(fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim()) < 1) {
|
||||
throw bad("入仓码不属于该客户和海外入仓计划");
|
||||
}
|
||||
}
|
||||
|
||||
/** 真实柜费用既核对柜号,也核对柜内客户,不能跨客户挂账。 */
|
||||
|
||||
private void requireOwnedContainer(SaveRequest fee) {
|
||||
if (fee.businessId() == null || fee.containerBookingId() == null
|
||||
|| !fee.businessId().equals(fee.containerBookingId()))
|
||||
@@ -75,24 +70,15 @@ public class OverseasFeeBusinessGuard {
|
||||
throw bad("海外货柜费用的业务编号必须与真实集装箱号一致");
|
||||
|
||||
String bookingId = String.valueOf(fee.containerBookingId());
|
||||
Integer total = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=?",
|
||||
Integer.class, bookingId);
|
||||
if (total == null || total < 1) throw bad("费用关联的真实货柜不存在");
|
||||
|
||||
Integer inconsistent = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? "
|
||||
+ "AND (container_no IS NULL OR TRIM(container_no)='' OR BINARY TRIM(container_no)<>BINARY ?)",
|
||||
Integer.class, bookingId, fee.containerNo().trim());
|
||||
if (inconsistent == null || inconsistent > 0)
|
||||
if (repository.container(bookingId) < 1) throw bad("费用关联的真实货柜不存在");
|
||||
if (repository.inconsistentContainer(bookingId, fee.containerNo().trim()) > 0)
|
||||
throw bad("真实货柜的集装箱号缺失或不一致");
|
||||
|
||||
Integer owned = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? AND customer_id=?",
|
||||
Integer.class, bookingId, fee.customerId().trim());
|
||||
if (owned == null || owned < 1) throw bad("该客户不属于费用关联的真实货柜");
|
||||
if (repository.containerCustomer(bookingId, fee.customerId().trim()) < 1)
|
||||
throw bad("该客户不属于费用关联的真实货柜");
|
||||
}
|
||||
|
||||
/** 无业务实体的历史仓服务只允许有效共享海外仓。 */
|
||||
|
||||
private void requireActiveOverseasWarehouse(long warehouseId) {
|
||||
try {
|
||||
var response = warehouses.activeWarehouse(warehouseId, internalToken);
|
||||
@@ -108,9 +94,10 @@ public class OverseasFeeBusinessGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断业务可选字段是否为空。 */
|
||||
private static boolean blank(String value) { return value == null || value.isBlank(); }
|
||||
/** 返回业务参数错误。 */
|
||||
private static ResponseStatusException bad(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
private record Table(String name, String number) { }
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.lastleg.financefee.service;
|
||||
|
||||
import com.baiyue.lastleg.financefee.client.OverseasFeeCustomerClient;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
|
||||
/** 费用查询按客户 ID 展示当前主档名称,历史快照只在查名不可用时兜底,不回写财务历史。 */
|
||||
@Component
|
||||
public class OverseasFeeCustomerNames {
|
||||
private static final Logger log = LoggerFactory.getLogger(OverseasFeeCustomerNames.class);
|
||||
private final OverseasFeeCustomerClient client;
|
||||
private final String token;
|
||||
|
||||
/** 使用现有 IAM 地址和内部服务身份查询客户。 */
|
||||
public OverseasFeeCustomerNames(OverseasFeeCustomerClient client,
|
||||
@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||
this.client = client;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** 同一客户只查询一次主档,防止列表混用旧名称而与详情、财务不一致。 */
|
||||
public Map<String, String> resolve(Collection<ContainerCustomerOption> customers) {
|
||||
Map<String, String> names = new LinkedHashMap<>();
|
||||
var ids = new LinkedHashSet<String>();
|
||||
for (var customer : customers) {
|
||||
if (customer.customerId() == null || customer.customerId().isBlank()) continue;
|
||||
ids.add(customer.customerId());
|
||||
if (customer.customerName() != null && !customer.customerName().isBlank())
|
||||
names.putIfAbsent(customer.customerId(), customer.customerName());
|
||||
}
|
||||
if (token == null || token.isBlank()) return names;
|
||||
for (String id : ids) {
|
||||
long customerId;
|
||||
try { customerId = Long.parseLong(id); }
|
||||
catch (NumberFormatException invalid) { continue; }
|
||||
if (customerId <= 0) continue;
|
||||
try {
|
||||
var response = client.customers(token, 1, 1, customerId);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
log.warn("海外费用客户名称查询暂不可用");
|
||||
break;
|
||||
}
|
||||
response.data().records().stream()
|
||||
.filter(customer -> Long.valueOf(customerId).equals(customer.id()))
|
||||
.map(OverseasFeeCustomerClient.Customer::tenantName)
|
||||
.filter(name -> name != null && !name.isBlank()).findFirst()
|
||||
.ifPresent(name -> names.put(id, name.trim()));
|
||||
} catch (RuntimeException unavailable) {
|
||||
// 查名失败不能隐藏费用或影响资金状态;停止本轮远端调用,后续查询可重试。
|
||||
log.warn("海外费用客户名称查询失败,保留费用原快照", unavailable);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package com.baiyue.lastleg.financefee.service;
|
||||
|
||||
import com.baiyue.common.event.*;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.RetryDeliveryRequest;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasFeeDeliveryRepository;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeRetryAudit;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** 费用明细只重投原事件;沿用事件身份、摘要和运维审计,不能重新生成收费。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasFeeDeliveryService {
|
||||
private final OverseasFeeDeliveryRepository repository;
|
||||
private final OverseasManualFeeRepository fees;
|
||||
private final ObjectMapper json;
|
||||
|
||||
/** 批量读取当前版本的实际投递结果,不把已写入队列显示为财务已接收。 */
|
||||
public void describe(List<OverseasFeeSource> rows) {
|
||||
if (rows.isEmpty()) return;
|
||||
var states = fees.funding(rows.stream().map(OverseasFeeSource::getSourceFeeId).toList()).stream()
|
||||
.collect(Collectors.toMap(OverseasFeeFunding::getSourceFeeId, Function.identity()));
|
||||
Map<String, OverseasFeeSource> keys = new HashMap<>();
|
||||
for (var fee : rows) {
|
||||
if (fee.getAllowedActions() != null) fee.setAllowedActions(fee.getAllowedActions().stream()
|
||||
.filter(action -> !Set.of("RETRY_PUBLISH", "RETRY_FUNDING_DELIVERY").contains(action)).toList());
|
||||
fee.setDeliveryStatus(null); fee.setDeliveryKind(null); fee.setDeliveryEventId(null); fee.setDeliveryErrorMessage(null);
|
||||
if (eligible(fee, states.get(fee.getSourceFeeId()))) {
|
||||
String key = eventKey(fee, states.get(fee.getSourceFeeId()));
|
||||
if (key != null) keys.put(key, fee);
|
||||
}
|
||||
}
|
||||
if (keys.isEmpty()) return;
|
||||
for (var row : repository.events(keys.keySet())) {
|
||||
var fee = keys.get(row.getEventKey());
|
||||
if (fee == null || !Objects.equals(fee.getCustomerId(), row.getCustomerId())) continue;
|
||||
String kind = EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(row.getEventType()) ? "FEE" : "FUNDING";
|
||||
fee.setDeliveryKind(kind); fee.setDeliveryStatus(row.getStatus()); fee.setDeliveryErrorMessage(row.getLastError());
|
||||
try { fee.setDeliveryEventId(envelope(row, fee, states.get(fee.getSourceFeeId())).eventId()); }
|
||||
catch (RuntimeException invalid) { continue; } // 损坏或串单事件不开放重试。
|
||||
if (retryable(row)) {
|
||||
var actions = new ArrayList<>(fee.getAllowedActions() == null ? List.<String>of() : fee.getAllowedActions());
|
||||
actions.add("FEE".equals(kind) ? "RETRY_PUBLISH" : "RETRY_FUNDING_DELIVERY");
|
||||
fee.setAllowedActions(List.copyOf(actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 锁定当前费用后校验版本及原事件;并发请求由事件行锁和唯一审计请求号防重。 */
|
||||
@Transactional
|
||||
public void retry(long id, String operator, RetryDeliveryRequest request) {
|
||||
var fee = fees.lockFee(id);
|
||||
if (fee == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "费用不存在");
|
||||
var funding = fees.lockFunding(id);
|
||||
if (!eligible(fee, funding) || !Objects.equals(fee.getSourceVersion(), request.expectedVersion())) throw conflict("费用版本或状态已变化,请刷新");
|
||||
String key = eventKey(fee, funding);
|
||||
var row = key == null ? null : repository.lock(key);
|
||||
if (row == null) throw conflict("当前费用没有可重试的投递记录");
|
||||
var event = envelope(row, fee, funding);
|
||||
if (!Objects.equals(event.eventId(), request.eventId())) throw conflict("投递记录已变化,请刷新");
|
||||
try { if (!UUID.fromString(request.idempotencyKey()).toString().equalsIgnoreCase(request.idempotencyKey())) throw new IllegalArgumentException(); }
|
||||
catch (RuntimeException invalid) { throw conflict("重试请求编号无效"); }
|
||||
var prior = repository.request(request.idempotencyKey());
|
||||
if (prior != null) {
|
||||
if (!Objects.equals(prior.getEntryId(), String.valueOf(row.getId())) || !Objects.equals(prior.getEventId(), event.eventId())
|
||||
|| !Objects.equals(prior.getPayloadHash(), event.payloadHash()) || !Objects.equals(prior.getOperatorName(), operator)) throw conflict("操作请求编号已用于不同重试参数");
|
||||
return;
|
||||
}
|
||||
if (!retryable(row)) throw conflict("仅允许重试失败事件,正在发送或已成功的不可重试");
|
||||
var now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
if (repository.requeue(row.getId(), now) != 1) throw conflict("投递状态已变化,请刷新");
|
||||
repository.insert(audit(row, event, request.idempotencyKey(), operator, "重试推送", now));
|
||||
}
|
||||
|
||||
/** 人工资金失败重试与新指令在同一业务事务记录,不因回执延迟漏记操作。 */
|
||||
public void recordFundingRetry(OverseasFeeSource fee, OverseasFeeFunding funding, String operator, String key) {
|
||||
String operation = operation(fee, funding);
|
||||
String eventKey = "WALLET_COMMAND:" + fee.getCustomerId() + ":" + walletType(fee) + ":" + funding.getWalletBusinessNo() + ':' + operation + ':' + funding.getCommandVersion();
|
||||
var row = repository.event(eventKey);
|
||||
if (row == null) throw conflict("资金重试指令缺少原始记录");
|
||||
var event = envelope(row, fee, funding);
|
||||
String requestId = UUID.nameUUIDFromBytes((fee.getSourceFeeId() + ":" + key + ":" + operator).getBytes(StandardCharsets.UTF_8)).toString();
|
||||
String action = switch (operation) { case "FREEZE" -> "重试锁定"; case "RELEASE" -> "重试释放"; default -> "重试扣费"; };
|
||||
repository.insert(audit(row, event, requestId, operator, action, LocalDateTime.now(ZoneOffset.UTC)));
|
||||
}
|
||||
|
||||
public record RetryRecord(String requestId, LocalDateTime requestedAt, String operatorName, String action, String result, String errorMessage) { }
|
||||
|
||||
/** 每次重试独立回看当次投递周期和资金结果,不把提交请求当成成功。 */
|
||||
public List<RetryRecord> history(long id) {
|
||||
var fee = fees.current(id);
|
||||
if (fee == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "费用不存在");
|
||||
var rows = repository.history(fee.getCustomerId(), fee.getSourceType(), id);
|
||||
Map<String, LocalDateTime> until = new HashMap<>();
|
||||
List<RetryRecord> result = new ArrayList<>();
|
||||
for (var row : rows) {
|
||||
// 余额费用不再要求费用推送,旧推送审计保留但不混入当前扣费重试记录。
|
||||
if ("RECEIVABLE".equals(fee.getDirection()) && fee.getWalletSettlement() != null
|
||||
&& EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(row.getEventType())) continue;
|
||||
var upper = until.put(row.getEventId(), row.getRequestedAt());
|
||||
String outcome = row.getAttemptOutcome();
|
||||
String status = "SUBMITTED", error = null;
|
||||
if (Set.of("APPLIED", "DUPLICATE", "STALE").contains(Objects.toString(outcome, ""))) status = "SUCCESS";
|
||||
// RETRY表示本次投递已失败,只是系统还会自动重试,不能一直显示已提交。
|
||||
else if (Set.of("RETRY", "DEAD", "LEASE_EXPIRED").contains(Objects.toString(outcome, ""))
|
||||
|| upper == null && ("DEAD".equals(row.getCurrentStatus())
|
||||
|| Set.of("PENDING", "PROCESSING").contains(Objects.toString(row.getCurrentStatus(), ""))
|
||||
&& row.getCurrentError() != null && !row.getCurrentError().isBlank())) { status = "FAILED"; error = row.getCurrentError(); }
|
||||
if (EventTypes.WALLET_COMMAND_REQUESTED.equals(row.getEventType())) {
|
||||
status = "FAILED".equals(status) ? status : "SUBMITTED";
|
||||
if (row.getWalletResult() != null) try {
|
||||
var envelope = json.readValue(row.getWalletResult(), EventEnvelope.class);
|
||||
var completed = json.treeToValue(envelope.payload(), WalletCommandCompletedV1.class);
|
||||
if (Objects.equals(completed.commandEventId(), row.getEventId()) && Objects.equals(String.valueOf(completed.customerId()), fee.getCustomerId())) {
|
||||
status = "SUCCEEDED".equals(completed.result()) ? "SUCCESS" : "FAILED"; error = completed.errorMessage();
|
||||
}
|
||||
} catch (Exception invalid) { status = "FAILED"; error = "重试结果读取失败"; }
|
||||
}
|
||||
result.add(new RetryRecord(row.getRequestId(), row.getRequestedAt(), row.getOperatorName(), row.getReason(), status, error));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 审计沿用原事件身份和摘要,保存实际操作人及失败快照。 */
|
||||
private OverseasFeeRetryAudit audit(LastlegEventOutbox row, EventEnvelope event, String key, String operator, String reason, LocalDateTime time) {
|
||||
var value = new OverseasFeeRetryAudit(); value.setRequestId(key); value.setProducerTable("ll_event_outbox");
|
||||
value.setEntryId(String.valueOf(row.getId())); value.setEventId(event.eventId()); value.setEventType(event.eventType());
|
||||
value.setPayloadHash(event.payloadHash()); value.setOperatorName(operator); value.setReason(reason);
|
||||
value.setPreviousFailures(row.getRetryCount() == null ? 0 : row.getRetryCount()); value.setPreviousError(row.getLastError()); value.setRequestedAt(time);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 终结收费仍须完成原冻结/释放指令,重投不得重新打开扣费或费用发布。 */
|
||||
private boolean eligible(OverseasFeeSource fee, OverseasFeeFunding funding) {
|
||||
if (funding != null && "WALLET".equals(funding.getCollectionMode())
|
||||
&& ("VOIDED".equals(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus()))
|
||||
&& Set.of("FREEZE_PENDING", "RELEASE_PENDING").contains(Objects.toString(funding.getWalletStatus(), ""))) return true;
|
||||
return "ACTIVE".equals(fee.getSourceStatus()) && !"REJECTED".equals(fee.getReviewStatus())
|
||||
&& !Set.of("PENDING_FINANCE", "FINANCE_ATTENTION").contains(Objects.toString(fee.getVoidStatus(), ""));
|
||||
}
|
||||
|
||||
/** 已失败并等待重试时可立即重推;正在发送或已成功的事件不能抢占。 */
|
||||
private boolean retryable(LastlegEventOutbox row) {
|
||||
return "DEAD".equals(row.getStatus()) || "PENDING".equals(row.getStatus())
|
||||
&& row.getLastError() != null && !row.getLastError().isBlank();
|
||||
}
|
||||
|
||||
/** 来源、版本、动作共同确定唯一事件,不按费用项目名称去重。 */
|
||||
private String eventKey(OverseasFeeSource fee, OverseasFeeFunding funding) {
|
||||
// 实扣成功即完成,不再关联费用推送及其失败状态。
|
||||
if ("RECEIVABLE".equals(fee.getDirection()) && (fee.getWalletSettlement() != null
|
||||
|| funding != null && "WALLET".equals(funding.getCollectionMode()) && "SETTLED".equals(funding.getWalletStatus()))) return null;
|
||||
if ("APPROVED".equals(fee.getReviewStatus()) && "PUBLISHED".equals(fee.getPublicationStatus()))
|
||||
return EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:" + fee.getSourceType() + ':' + fee.getSourceFeeId()
|
||||
+ ':' + fee.getSourceVersion() + (fee.getSourceVersion() == 1 ? ":PUBLISHED" : ":ADJUSTED");
|
||||
if (funding == null || !"WALLET".equals(funding.getCollectionMode()) || funding.getWalletStatus() == null
|
||||
|| !funding.getWalletStatus().endsWith("_PENDING")) return null;
|
||||
String operation = operation(fee, funding);
|
||||
return "WALLET_COMMAND:" + fee.getCustomerId() + ":" + walletType(fee) + ":" + funding.getWalletBusinessNo()
|
||||
+ ':' + operation + ':' + funding.getCommandVersion();
|
||||
}
|
||||
|
||||
/** 重试前核对原载荷哈希及业务归属,保留消费端已处理事件的唯一性。 */
|
||||
private EventEnvelope envelope(LastlegEventOutbox row, OverseasFeeSource fee, OverseasFeeFunding funding) {
|
||||
try {
|
||||
var event = json.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
|
||||
if (!Objects.equals(row.getCustomerId(), fee.getCustomerId()) || !Objects.equals(event.tenantId(), fee.getCustomerId())
|
||||
|| !Objects.equals(event.correlationId(), row.getEventKey()) || !Objects.equals(event.eventType(), row.getEventType())
|
||||
|| !EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash()))
|
||||
throw conflict("原事件摘要或客户不一致");
|
||||
if (EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(event.eventType())) {
|
||||
var value = json.treeToValue(event.payload(), BusinessFeeSourceChangedV1.class);
|
||||
if (!Objects.equals(value.sourceFeeId(), String.valueOf(fee.getSourceFeeId()))
|
||||
|| !Objects.equals(value.sourceVersion(), fee.getSourceVersion()) || !Objects.equals(value.customerId(), fee.getCustomerId())
|
||||
|| !Objects.equals(value.sourceType(), fee.getSourceType()) || !"LASTLEG".equals(value.sourceSystem())
|
||||
|| !Objects.equals(event.aggregateId(), value.sourceFeeId()) || !Objects.equals(event.aggregateType(), value.sourceType())
|
||||
|| event.aggregateVersion() != value.sourceVersion())
|
||||
throw conflict("推送记录与费用不一致");
|
||||
} else if (EventTypes.WALLET_COMMAND_REQUESTED.equals(event.eventType())) {
|
||||
var value = json.treeToValue(event.payload(), WalletCommandRequestedV1.class);
|
||||
if (funding == null || !Objects.equals(event.eventId(), funding.getCommandEventId())
|
||||
|| !Objects.equals(value.businessNo(), funding.getWalletBusinessNo()) || !walletType(fee).equals(value.businessType())
|
||||
|| !Objects.equals(String.valueOf(value.customerId()), fee.getCustomerId())
|
||||
|| !Objects.equals(event.aggregateId(), funding.getWalletBusinessNo()) || !walletType(fee).equals(event.aggregateType())
|
||||
|| event.aggregateVersion() != funding.getCommandVersion()
|
||||
|| !Objects.equals(value.operation(), operation(fee, funding))) throw conflict("资金记录与费用不一致");
|
||||
} else throw conflict("投递事件类型不属于费用");
|
||||
return event;
|
||||
} catch (ResponseStatusException invalid) { throw invalid; }
|
||||
catch (Exception invalid) { throw conflict("原投递记录无效,不能重试"); }
|
||||
}
|
||||
|
||||
/** 仓租沿原出库冻结身份,人工费用沿各自资金身份。 */
|
||||
private String walletType(OverseasFeeSource fee) { return StorageRentFeeWorkflow.isRent(fee) ? "STORAGE_RENT" : "OVERSEAS_MANUAL_FEE"; }
|
||||
/** 仓租扣费使用审核后的实额结算指令。 */
|
||||
private String operation(OverseasFeeSource fee, OverseasFeeFunding funding) {
|
||||
String operation = funding.getWalletStatus().replace("_PENDING", "");
|
||||
return StorageRentFeeWorkflow.isRent(fee) && "SETTLE".equals(operation) ? "SETTLE_ACTUAL" : operation;
|
||||
}
|
||||
/** 统一返回状态冲突,禁止通过重试重建费用。 */
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+2
@@ -26,7 +26,9 @@ public class OverseasFeeOutboxService {
|
||||
@Resource private LastlegEventOutboxMapper mapper;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
/** 仅推送账单费用;已实扣的海外应收保留本地明细,不再重复入财务结算池。 */
|
||||
public void publish(OverseasFeeSource source, String action) {
|
||||
if ("RECEIVABLE".equals(source.getDirection()) && source.getWalletSettlement() != null) return;
|
||||
BusinessFeeSourceChangedV1 unsigned = payload(source, null, null);
|
||||
String sourceHash = EventPayloadHash.sha256(
|
||||
EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
|
||||
|
||||
+72
-12
@@ -98,10 +98,11 @@ public class OverseasFeeSourceService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 自动确认仅推进新草稿,已驳回的原费用不得重新审核发布。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) {
|
||||
OverseasFeeSource value = create(operatorId, key, request);
|
||||
if ("DRAFT".equals(value.getReviewStatus()) || "REJECTED".equals(value.getReviewStatus())) {
|
||||
if ("DRAFT".equals(value.getReviewStatus())) {
|
||||
value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit"));
|
||||
}
|
||||
if ("PENDING".equals(value.getReviewStatus())) {
|
||||
@@ -114,10 +115,48 @@ public class OverseasFeeSourceService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 旧换标资金收尾复用既有来源,已发布账单不再生成第二笔应收。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource completeLegacyRelabelSettlement(String operator, String key, SaveRequest request,
|
||||
com.baiyue.common.event.WalletSettlementReference settlement) {
|
||||
if (!"RELABEL_ORDER".equals(request.businessType()) || !"RELABEL_SERVICE".equals(request.feeItemCode())
|
||||
|| !"RECEIVABLE".equals(request.direction()) || !"CNY".equals(request.currency())
|
||||
|| settlement == null || text(settlement.commandEventId()) == null || text(settlement.holdId()) == null
|
||||
|| text(settlement.ledgerId()) == null) throw bad("历史换标扣款引用不完整");
|
||||
var prior = listForBusiness(request.customerId(), "RELABEL_ORDER", request.businessId()).stream()
|
||||
.filter(f -> "RELABEL_SERVICE".equals(f.getFeeItemCode()) && "RECEIVABLE".equals(f.getDirection())
|
||||
&& "ACTIVE".equals(f.getSourceStatus())).findFirst();
|
||||
if (prior.isPresent()) {
|
||||
var value = mapper.lockCurrent(prior.get().getSourceFeeId());
|
||||
if (value == null || value.getAmount() == null || value.getAmount().compareTo(request.amount()) != 0)
|
||||
throw conflict("历史换标应收与扣款金额不一致,请核对");
|
||||
if (value.getWalletSettlement() != null && !value.getWalletSettlement().equals(settlement))
|
||||
throw conflict("历史换标已关联其他扣款,请核对");
|
||||
value.setWalletSettlement(settlement); value.setUpdatedBy(operator); value.setUpdatedAt(now()); mapper.updateById(value);
|
||||
return value;
|
||||
}
|
||||
return publishSettled(operator,key,request,currencyConverter.convert(request.currency(),request.amount(),request.businessDate()),settlement);
|
||||
}
|
||||
|
||||
private record SettledRequest(SaveRequest fee, OverseasFeeCurrencyConverter.Conversion conversion,
|
||||
com.baiyue.common.event.WalletSettlementReference settlement) { }
|
||||
|
||||
/** Internal business entry: money has already succeeded and its confirmed FX must not be recalculated. */
|
||||
/** 实际出库先登记待审核仓租,沿用冻结汇率,不发账单或扣款。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource createPendingStorageRent(String key, SaveRequest request,
|
||||
OverseasFeeCurrencyConverter.Conversion conversion) {
|
||||
var prior = replay("STORAGE_RENT", "RENT_CREATE", key, request);
|
||||
if (prior != null) return current(prior.getSourceFeeId());
|
||||
if (!"FEE_TYPE_077".equals(request.feeItemCode()) || !"RECEIVABLE".equals(request.direction()))
|
||||
throw bad("仓租费用项目或方向无效");
|
||||
validate(request); businessGuard.requireOwned(request);
|
||||
var fee = build(IdWorker.getId(), IdWorker.getId(), 1, "STORAGE_RENT", request, conversion);
|
||||
fee.setReviewStatus("PENDING"); mapper.insert(fee);
|
||||
command("STORAGE_RENT", "RENT_CREATE", key, request, fee.getSourceFeeId(), 1);
|
||||
return fee;
|
||||
}
|
||||
|
||||
/** 实扣成功后登记海外费用,沿用已确认汇率和资金流水,不再推送账单结算。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource publishSettled(String operatorId, String key, SaveRequest request,
|
||||
OverseasFeeCurrencyConverter.Conversion conversion,
|
||||
@@ -139,23 +178,24 @@ public class OverseasFeeSourceService {
|
||||
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, conversion);
|
||||
value.setWalletSettlement(settlement);
|
||||
value.setReviewStatus("APPROVED"); value.setPublicationStatus("PUBLISHED");
|
||||
value.setReviewerId(operatorId); value.setReviewedAt(now());
|
||||
value.setReviewReason("已核定业务费用扣款成功后发布");
|
||||
value.setReviewerId(operatorId); value.setReviewerName(operatorId); value.setReviewedAt(now());
|
||||
value.setReviewReason("业务费用已扣款");
|
||||
mapper.insert(value);
|
||||
outbox.publish(value, "PUBLISHED");
|
||||
command(operatorId, "PUBLISH_SETTLED", key, confirmed, sourceFeeId, 1);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 旧提交入口同样以驳回为终态,保留原审核记录。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
|
||||
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
|
||||
if (replay != null) return current(replay.getSourceFeeId());
|
||||
OverseasFeeSource value = lock(sourceFeeId);
|
||||
requireNonProcurement(value.getSourceType(),value.getBusinessType());
|
||||
if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus()))
|
||||
throw conflict("只有草稿或已驳回费用可以提交");
|
||||
value.setReviewStatus("PENDING"); value.setReviewReason(null);
|
||||
// 驳回代表本条费用终结,录错后必须新建费用,不能覆盖原审核记录。
|
||||
if (!"DRAFT".equals(value.getReviewStatus()))
|
||||
throw conflict("已驳回费用不能重新提交,请重新新增费用");
|
||||
value.setReviewStatus("PENDING"); value.setReviewReason(null); value.setReviewerId(null); value.setReviewerName(null); value.setReviewedAt(null);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
|
||||
command(operatorId, "SUBMIT", key, sourceFeeId, sourceFeeId, value.getSourceVersion());
|
||||
return value;
|
||||
@@ -163,16 +203,26 @@ public class OverseasFeeSourceService {
|
||||
|
||||
@Transactional
|
||||
public OverseasFeeSource review(long sourceFeeId, String operatorId, ReviewRequest request) {
|
||||
return review(sourceFeeId, operatorId, operatorId, request);
|
||||
}
|
||||
|
||||
/** 兼容已有内部调用,并保存审核人展示名称快照。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource review(long sourceFeeId, String operatorId, String reviewerName, ReviewRequest request) {
|
||||
OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request);
|
||||
if (replay != null) return current(replay.getSourceFeeId());
|
||||
OverseasFeeSource value = lock(sourceFeeId);
|
||||
requireNonProcurement(value.getSourceType(),value.getBusinessType());
|
||||
if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()))
|
||||
throw conflict("只有待审核的有效费用可以审核");
|
||||
if (request.expectedVersion() != null && !Objects.equals(value.getSourceVersion(), request.expectedVersion()))
|
||||
throw conflict("费用版本已变化,请刷新后重新审核");
|
||||
boolean approved = "APPROVE".equals(request.decision());
|
||||
if (!approved && text(request.reason()) == null) throw bad("请填写驳回原因");
|
||||
value.setReviewStatus(approved ? "APPROVED" : "REJECTED");
|
||||
value.setPublicationStatus(approved ? "PUBLISHED" : "UNPUBLISHED");
|
||||
value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId); value.setReviewedAt(now());
|
||||
value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId);
|
||||
value.setReviewerName(text(reviewerName) == null ? operatorId : text(reviewerName)); value.setReviewedAt(now());
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
|
||||
if (approved) outbox.publish(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED");
|
||||
command(operatorId, "REVIEW", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion());
|
||||
@@ -228,6 +278,7 @@ public class OverseasFeeSourceService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 保存费用及业务说明快照,和审核原因分别记录。 */
|
||||
private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request,
|
||||
OverseasFeeCurrencyConverter.Conversion confirmedConversion) {
|
||||
OverseasFeeSource value = new OverseasFeeSource();
|
||||
@@ -238,6 +289,7 @@ public class OverseasFeeSourceService {
|
||||
value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setInboundCode(text(request.inboundCode()));
|
||||
value.setContainerId(request.containerId()); value.setContainerBookingId(request.containerBookingId()); value.setContainerNo(text(request.containerNo()));
|
||||
value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim());
|
||||
value.setFeeRemark(text(request.feeRemark()));
|
||||
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
|
||||
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
|
||||
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
|
||||
@@ -255,6 +307,7 @@ public class OverseasFeeSourceService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 校验业务来源、费用项目与应收应付信息,不允许不同业务类型混挂。 */
|
||||
private void validate(SaveRequest value) {
|
||||
String expected = switch (value.businessType()) {
|
||||
case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE";
|
||||
@@ -262,7 +315,7 @@ public class OverseasFeeSourceService {
|
||||
case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE";
|
||||
case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE";
|
||||
case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE";
|
||||
case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE";
|
||||
case "WAREHOUSE_SERVICE", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "DISCARD_ORDER" -> "OVERSEAS_WAREHOUSE_FEE";
|
||||
case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE";
|
||||
case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE";
|
||||
case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE";
|
||||
@@ -270,6 +323,11 @@ public class OverseasFeeSourceService {
|
||||
default -> "";
|
||||
};
|
||||
if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配");
|
||||
// 采购、本土卡车和本土散板都是客户消费类业务:只能形成应收,并由钱包余额/授信扣款。
|
||||
if (Set.of("OVERSEAS_PROCUREMENT", "PROCUREMENT_FEE", "US_TRUCK", "LOCAL_LOOSEBOARD").contains(value.businessType())
|
||||
&& !"RECEIVABLE".equals(value.direction())) {
|
||||
throw bad("采购、本土卡车和本土散板费用只能登记应收");
|
||||
}
|
||||
if ("OVERSEAS_CONTAINER".equals(value.businessType())
|
||||
&& !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim()))
|
||||
throw bad("海外货柜费用项目不受支持");
|
||||
@@ -282,9 +340,11 @@ public class OverseasFeeSourceService {
|
||||
boolean payable = "PAYABLE".equals(value.direction());
|
||||
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
|
||||
if (!payable) {
|
||||
if (value.supplierId() != null || text(value.supplierName()) != null || text(value.settlementMode()) != null
|
||||
// 应收结算选择不代表供应商已付事实,两类信息仍严格隔离。
|
||||
if (value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode())
|
||||
|| value.supplierId() != null || text(value.supplierName()) != null || text(value.supplierCategory()) != null
|
||||
|| value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files)
|
||||
throw bad("应收费用不得携带供应商或付款信息");
|
||||
throw bad("应收费用只能选择推送结算或余额扣费,不得携带付款信息");
|
||||
return;
|
||||
}
|
||||
if (value.supplierId() == null || text(value.supplierName()) == null || !Set.of("ON_ACCOUNT", "DIRECT_PAID").contains(value.settlementMode()))
|
||||
|
||||
+528
@@ -0,0 +1,528 @@
|
||||
package com.baiyue.lastleg.financefee.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
import com.baiyue.common.event.WalletSettlementReference;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.*;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository;
|
||||
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
|
||||
import com.baiyue.lastleg.wallet.WalletResultHandler;
|
||||
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 人工收费先锁款、再审核;只有 Finance 确认实扣后才发布已收费用。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasManualFeeService implements WalletResultHandler {
|
||||
@jakarta.annotation.Resource private StorageRentFeeWorkflow rentReview;
|
||||
private static final String WALLET_BUSINESS = "OVERSEAS_MANUAL_FEE";
|
||||
private final OverseasFeeSourceService sources;
|
||||
private final OverseasManualFeeRepository repository;
|
||||
private final OverseasFeeBusinessRepository businesses;
|
||||
private final FeeCatalogGateway catalog;
|
||||
private final WalletCommandOutboxService wallet;
|
||||
private final OverseasFeeOutboxService outbox;
|
||||
private final ObjectMapper json;
|
||||
private final OverseasFeeCancellation cancellation;
|
||||
private final OverseasFeeCustomerNames customerNames;
|
||||
private final OverseasFeeDeliveryService delivery;
|
||||
|
||||
/** 分页筛选所有当前费用,包含自动仓租和资金处理进度。 */
|
||||
public PageResult<OverseasFeeSource> page(int page, int size, String customerId, String businessType,
|
||||
String businessNo, String feeItemCode, String reviewStatus, String walletStatus, String direction, String processingStatus) {
|
||||
return page(page,size,customerId,businessType,businessNo,feeItemCode,reviewStatus,walletStatus,direction,processingStatus,false);
|
||||
}
|
||||
|
||||
/** 仓租专页按真实出库计租来源过滤,不能仅凭费用项目名称判断自动仓租。 */
|
||||
public PageResult<OverseasFeeSource> page(int page, int size, String customerId, String businessType,
|
||||
String businessNo, String feeItemCode, String reviewStatus, String walletStatus, String direction, String processingStatus, boolean storageRentOnly) {
|
||||
if (page < 1 || size < 1 || size > 200) throw bad("分页参数无效");
|
||||
Map<String, Object> query = new HashMap<>();
|
||||
query.put("customerId", text(customerId)); query.put("businessType", text(businessType));
|
||||
query.put("businessNo", text(businessNo)); query.put("feeItemCode", text(feeItemCode));
|
||||
query.put("reviewStatus", text(reviewStatus)); query.put("walletStatus", text(walletStatus));
|
||||
if (direction != null && !List.of("RECEIVABLE", "PAYABLE").contains(direction)) throw bad("收支方向无效");
|
||||
query.put("direction", direction == null ? "RECEIVABLE" : direction);
|
||||
if (text(processingStatus) != null && !Set.of("WAITING", "PROCESSING", "COMPLETED", "FAILED", "CLOSED").contains(processingStatus)) throw bad("费用状态无效");
|
||||
query.put("processingStatus", text(processingStatus));
|
||||
query.put("storageRentOnly",storageRentOnly);
|
||||
query.put("offset", ((long) page - 1) * size); query.put("size", size);
|
||||
long total = repository.count(query);
|
||||
return new PageResult<>(describe(repository.page(query)), page, size, total, (total + size - 1) / size);
|
||||
}
|
||||
|
||||
/** 费用详情与列表使用一致的资金状态和可操作性。 */
|
||||
public OverseasFeeSource detail(long id) { return describe(List.of(required(repository.current(id)))).get(0); }
|
||||
|
||||
/** 换标申请作为收费主业务后,履约详情仍需读取同客户的申请费用。 */
|
||||
public List<OverseasFeeSource> relabelFeesForOrder(String customerId, long orderId) {
|
||||
var business = businesses.relabelForOrder(orderId, customerId);
|
||||
return business == null ? List.of() : sources.listForBusiness(customerId, "RELABEL_ORDER", business.businessId());
|
||||
}
|
||||
|
||||
/** 批量补充资金状态和业务分类;客户显示名称以主档为准,不回写原收费凭据。 */
|
||||
public List<OverseasFeeSource> describe(List<OverseasFeeSource> fees) {
|
||||
cancellation.describe(fees);
|
||||
var funding = repository.funding(fees.stream().map(OverseasFeeSource::getSourceFeeId).toList())
|
||||
.stream().collect(Collectors.toMap(OverseasFeeFunding::getSourceFeeId, Function.identity()));
|
||||
for (var fee : fees) applyDescription(fee, funding.get(fee.getSourceFeeId()));
|
||||
var names = customerNames.resolve(fees.stream()
|
||||
.map(fee -> new ContainerCustomerOption(fee.getCustomerId(), fee.getCustomerName())).toList());
|
||||
var relabelIds = new HashSet<>(businesses.relabelFeeIds(fees.stream()
|
||||
.filter(fee -> "LASTLEG_ORDER".equals(fee.getBusinessType()))
|
||||
.map(OverseasFeeSource::getSourceFeeId).distinct().toList()));
|
||||
for (var fee : fees) {
|
||||
fee.setDisplayBusinessType(relabelIds.contains(fee.getSourceFeeId()) ? "RELABEL_ORDER" : fee.getBusinessType());
|
||||
fee.setCustomerName(names.getOrDefault(fee.getCustomerId(), fee.getCustomerName()));
|
||||
}
|
||||
if(rentReview!=null)rentReview.describe(fees);
|
||||
return fees;
|
||||
}
|
||||
|
||||
/** 客户范围来自真实业务,名称与费用详情统一取主档,不增加客户管理权限。 */
|
||||
public List<ContainerCustomerOption> customers() {
|
||||
var customers = businesses.customers();
|
||||
var names = customerNames.resolve(customers);
|
||||
return customers.stream().map(customer -> new ContainerCustomerOption(customer.customerId(),
|
||||
names.getOrDefault(customer.customerId(), customer.customerName()))).toList();
|
||||
}
|
||||
|
||||
/** 候选使用真实服务业务单和当前客户名称,不允许以车辆车次合并客户费用。 */
|
||||
public List<BusinessOption> businessOptions(String type, String customerId, String keyword) {
|
||||
if (!Set.of("LASTLEG_ORDER", "RELABEL_ORDER", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "US_TRUCK", "LOCAL_LOOSEBOARD", "DISCARD_ORDER", "OVERSEAS_INBOUND", "DELIVERY_ORDER").contains(type))
|
||||
throw bad("请选择实际服务业务单");
|
||||
var options = businesses.options(type, text(customerId), text(keyword));
|
||||
var names = customerNames.resolve(options.stream()
|
||||
.map(option -> new ContainerCustomerOption(option.customerId(), option.customerName())).toList());
|
||||
return options.stream().map(option -> new BusinessOption(option.businessId(), option.businessType(),
|
||||
option.businessNo(), option.customerId(), names.getOrDefault(option.customerId(), option.customerName()),
|
||||
option.warehouseId(), option.orderId(), option.orderType())).toList();
|
||||
}
|
||||
|
||||
/** 换标履约只作为关联订单,收费主业务必须使用真实换标申请。 */
|
||||
private SaveRequest resolveServiceBusiness(SaveRequest request) {
|
||||
if (!"LASTLEG_ORDER".equals(request.businessType())
|
||||
|| !"RELABEL_APPLICATION".equals(businesses.orderType(request.businessId(), request.customerId()))) return request;
|
||||
if (!"OVERSEAS_ORDER_FEE".equals(request.sourceType())
|
||||
|| businesses.owned("LASTLEG_ORDER", request.businessId(), request.customerId(), request.businessNo()) != 1
|
||||
|| request.orderId() != null && !request.orderId().equals(request.businessId()))
|
||||
throw bad("换标费用关联的履约订单不一致");
|
||||
var business = businesses.relabelForOrder(request.businessId(), request.customerId());
|
||||
if (business == null) throw bad("换标订单缺少真实换标申请关联,无法登记费用");
|
||||
return new SaveRequest(request.customerId(), request.customerName(), "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER",
|
||||
business.businessId(), business.businessNo(), request.businessId(), request.inboundId(), request.inboundCode(),
|
||||
request.containerId(), request.containerBookingId(), request.containerNo(), request.warehouseId(),
|
||||
request.feeItemCode(), request.feeItemName(), request.feeRemark(), request.direction(), request.supplierId(), request.supplierName(),
|
||||
request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(),
|
||||
request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(), request.currency(),
|
||||
request.amount(), request.chargeableQuantity(), request.quantityUnit(), request.unitPrice(), request.periodStart(), request.periodEnd());
|
||||
}
|
||||
|
||||
/** 创建立即进入待审核;应收异步冻结资金,头程附加费保留账单结算。 */
|
||||
@Transactional
|
||||
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()));
|
||||
OverseasFeeFunding state = repository.lockFunding(fee.getSourceFeeId());
|
||||
if (state != null) return described(fee, state);
|
||||
// 幂等重放先返回原结果;新费用在任何冻结前校验重复项目的业务说明。
|
||||
requireFeeRemark(fee);
|
||||
requireNoLegacyDuplicate(fee);
|
||||
state = initialize(fee);
|
||||
fee.setReviewStatus("PENDING"); fee.setUpdatedAt(now()); repository.updateFee(fee);
|
||||
if ("WALLET".equals(state.getCollectionMode())) send(fee, state, "FREEZE");
|
||||
return described(fee, state);
|
||||
}
|
||||
|
||||
/** 同业务同项目的旧自动收费需先核对,人工链自身允许合法分次收费。 */
|
||||
private void requireNoLegacyDuplicate(OverseasFeeSource fee) {
|
||||
if (!"RECEIVABLE".equals(fee.getDirection())) return;
|
||||
boolean relabelProject = Set.of("FEE_TYPE_071","RELABEL_SERVICE").contains(Objects.toString(fee.getFeeItemCode(),""))
|
||||
|| Set.of("贴标费","换标费").contains(Objects.toString(fee.getFeeItemName(),""));
|
||||
boolean relabelBusiness = Set.of("RELABEL_ORDER","LASTLEG_ORDER").contains(fee.getBusinessType());
|
||||
if (relabelProject && relabelBusiness && repository.legacyRelabel(fee.getCustomerId(),fee.getBusinessType(),fee.getBusinessId())>0
|
||||
|| repository.legacyProject(fee.getCustomerId(),fee.getBusinessType(),fee.getBusinessId(),fee.getFeeItemCode(),fee.getFeeItemName(),fee.getSourceFeeId())>0)
|
||||
throw conflict("此项目已有旧版收费,请先核对原费用,避免重复收取");
|
||||
}
|
||||
|
||||
/** 兼容历史人工来源时同样建立资金编排,禁止旧接口绕过锁款。 */
|
||||
private OverseasFeeFunding initialize(OverseasFeeSource fee) {
|
||||
if (fee.getWalletSettlement() != null) throw conflict("已扣费用不能重新锁款");
|
||||
OverseasFeeFunding state = new OverseasFeeFunding();
|
||||
state.setSourceFeeId(fee.getSourceFeeId()); state.setCustomerId(fee.getCustomerId());
|
||||
boolean bill = bill(fee);
|
||||
state.setCollectionMode(bill ? "BILL" : "WALLET"); state.setWalletStatus(bill ? "NOT_REQUIRED" : "FREEZE_PENDING");
|
||||
state.setCommandVersion(0L); state.setCommandHistory(new LinkedHashMap<>());
|
||||
state.setCreatedAt(now()); state.setUpdatedAt(now());
|
||||
repository.insertFunding(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 草稿可提交,驳回费用终结并保留原审核记录。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource submit(long id, String operator, String key) {
|
||||
OverseasFeeSource fee = required(repository.lockFee(id));
|
||||
requireManual(fee);
|
||||
OverseasFeeFunding state = repository.lockFunding(id);
|
||||
if (replayed(operator, "MANUAL_SUBMIT", key, id, id)) return described(fee, state);
|
||||
boolean legacy = state == null;
|
||||
if (!"ACTIVE".equals(fee.getSourceStatus()) || fee.getWalletSettlement() != null
|
||||
|| !("DRAFT".equals(fee.getReviewStatus()) || legacy && "PENDING".equals(fee.getReviewStatus())))
|
||||
throw conflict("当前费用不能提交");
|
||||
if (legacy) { requireNoLegacyDuplicate(fee); state = initialize(fee); }
|
||||
if ("WALLET".equals(state.getCollectionMode())) {
|
||||
if (!legacy && !Set.of("RELEASED", "FREEZE_FAILED").contains(state.getWalletStatus())) throw conflict("请先完成资金释放");
|
||||
send(fee, state, "FREEZE");
|
||||
}
|
||||
fee.setReviewStatus("PENDING"); fee.setReviewReason(null); fee.setReviewerId(null); fee.setReviewerName(null); fee.setReviewedAt(null); touch(fee, operator);
|
||||
record(operator, "MANUAL_SUBMIT", key, id, id, fee.getSourceVersion());
|
||||
return described(fee, state);
|
||||
}
|
||||
|
||||
/** 审核批准只发扣款指令,异步扣款失败时不得伪装为已收。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource review(long id, String operator, ReviewRequest request) {
|
||||
return review(id, operator, operator, request);
|
||||
}
|
||||
|
||||
/** 保留审核人ID与展示名称,并按审核结果推进锁款或释放。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource review(long id, String operator, String reviewerName, ReviewRequest request) {
|
||||
var current=repository.current(id);
|
||||
if(current!=null&&StorageRentFeeWorkflow.isRent(current))
|
||||
return describe(List.of(rentReview.review(id,operator,reviewerName,request))).get(0);
|
||||
OverseasFeeSource fee = required(repository.lockFee(id));
|
||||
requireManual(fee);
|
||||
OverseasFeeFunding state = repository.lockFunding(id);
|
||||
if (state == null) {
|
||||
if (!bill(fee)) throw conflict("请先重新提交费用并锁定资金");
|
||||
requireLegacyCommandOwner(operator, "REVIEW", request.idempotencyKey(), id);
|
||||
return described(sources.review(id, operator, reviewerName, request), null);
|
||||
}
|
||||
if (replayed(operator, "MANUAL_REVIEW", request.idempotencyKey(), id, request)) return described(fee, state);
|
||||
if (request.expectedVersion() != null && !Objects.equals(fee.getSourceVersion(), request.expectedVersion()))
|
||||
throw conflict("费用版本已变化,请刷新后重新审核");
|
||||
if (!"ACTIVE".equals(fee.getSourceStatus()) || !"PENDING".equals(fee.getReviewStatus())) throw conflict("只有待审核费用可以审核");
|
||||
if (!Set.of("APPROVE", "REJECT").contains(request.decision())) throw bad("审核操作无效");
|
||||
boolean approved = "APPROVE".equals(request.decision());
|
||||
if (!approved && text(request.reason()) == null) throw bad("请填写驳回原因");
|
||||
boolean walletCollection = "WALLET".equals(state.getCollectionMode());
|
||||
if (approved && walletCollection && !"FROZEN".equals(state.getWalletStatus())) throw conflict("费用资金尚未锁定,暂不能通过审核");
|
||||
fee.setReviewStatus(approved ? "APPROVED" : "REJECTED");
|
||||
fee.setPublicationStatus(approved && !walletCollection ? "PUBLISHED" : "UNPUBLISHED");
|
||||
fee.setReviewerId(operator); fee.setReviewerName(text(reviewerName) == null ? operator : text(reviewerName));
|
||||
fee.setReviewedAt(now()); fee.setReviewReason(text(request.reason())); touch(fee, operator);
|
||||
if (approved && walletCollection) send(fee, state, "SETTLE");
|
||||
else if (approved) outbox.publish(fee, fee.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED");
|
||||
else if ("FROZEN".equals(state.getWalletStatus())) send(fee, state, "RELEASE");
|
||||
record(operator, "MANUAL_REVIEW", request.idempotencyKey(), id, request, fee.getSourceVersion());
|
||||
return described(fee, state);
|
||||
}
|
||||
|
||||
/** 未收取费用可取消;冻结在途时等待匹配回执后自动释放。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource cancel(long id, String operator, String key) {
|
||||
OverseasFeeSource fee = required(repository.lockFee(id));
|
||||
requireManual(fee);
|
||||
requireNotRejected(fee);
|
||||
OverseasFeeFunding state = repository.lockFunding(id);
|
||||
if (state == null) throw conflict("该来源请通过原业务处理");
|
||||
if (replayed(operator, "MANUAL_CANCEL", key, id, id)) return described(fee, state);
|
||||
if (fee.getWalletSettlement() != null || "APPROVED".equals(fee.getReviewStatus()) || "PUBLISHED".equals(fee.getPublicationStatus()))
|
||||
throw conflict("费用已审核或已收取,不能取消");
|
||||
fee.setSourceStatus("VOIDED"); touch(fee, operator);
|
||||
if ("FROZEN".equals(state.getWalletStatus())) send(fee, state, "RELEASE");
|
||||
record(operator, "MANUAL_CANCEL", key, id, id, fee.getSourceVersion());
|
||||
return described(fee, state);
|
||||
}
|
||||
|
||||
/** 失败指令显式重试;不改变已经成功的资金事实。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource retry(long id, String operator, String key) {
|
||||
return retry(id, operator, operator, key);
|
||||
}
|
||||
|
||||
/** 重试操作人与新资金指令同事务留痕,重复请求不增加重试记录。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource retry(long id, String operator, String operatorName, String key) {
|
||||
OverseasFeeSource fee = required(repository.lockFee(id));
|
||||
requireManual(fee);
|
||||
OverseasFeeFunding state = repository.lockFunding(id);
|
||||
if (state == null || !"WALLET".equals(state.getCollectionMode())) throw conflict("该费用无需处理余额");
|
||||
if (replayed(operator, "MANUAL_FUNDING_RETRY", key, id, id)) return described(fee, state);
|
||||
String operation = switch (state.getWalletStatus()) {
|
||||
case "FREEZE_FAILED" -> "FREEZE";
|
||||
case "SETTLE_FAILED" -> "SETTLE";
|
||||
case "RELEASE_FAILED" -> "RELEASE";
|
||||
default -> throw conflict("当前资金状态无需重试");
|
||||
};
|
||||
if ("FREEZE".equals(operation) && (!"ACTIVE".equals(fee.getSourceStatus()) || !"PENDING".equals(fee.getReviewStatus())))
|
||||
throw conflict("已驳回或取消的费用不能继续锁款");
|
||||
send(fee, state, operation);
|
||||
delivery.recordFundingRetry(fee, state, operatorName, key);
|
||||
record(operator, "MANUAL_FUNDING_RETRY", key, id, id, fee.getSourceVersion());
|
||||
return described(fee, state);
|
||||
}
|
||||
|
||||
/** 只接收人工费用独立资金身份,不接管换标和采购原冻结记录。 */
|
||||
@Override
|
||||
public boolean supports(String businessType) { return WALLET_BUSINESS.equals(businessType); }
|
||||
|
||||
/** 校验客户、业务身份、指令及金额;重复回执不能重复扣款或发布。 */
|
||||
@Override
|
||||
@Transactional
|
||||
public void handle(WalletCommandCompletedV1 result) {
|
||||
long id;
|
||||
try { id = Long.parseLong(result.businessNo().split("-", 2)[0]); }
|
||||
catch (RuntimeException exception) { throw conflict("费用资金业务编号无效"); }
|
||||
OverseasFeeSource fee = required(repository.lockFee(id));
|
||||
OverseasFeeFunding state = repository.lockFunding(id);
|
||||
if (state == null || !supports(result.businessType()) || !fee.getCustomerId().equals(String.valueOf(result.customerId())))
|
||||
throw conflict("资金结果与费用客户不匹配");
|
||||
requireCnySnapshot(fee);
|
||||
if (!"WALLET".equals(state.getCollectionMode()) || result.result() == null || !Set.of("SUCCEEDED", "FAILED").contains(result.result())
|
||||
|| result.completedAt() == null || result.operation() == null || !Set.of("FREEZE", "SETTLE", "RELEASE").contains(result.operation()))
|
||||
throw conflict("费用资金结果字段不完整");
|
||||
String expected = state.getCommandHistory().get(result.commandEventId());
|
||||
String completed = result.operation() + ':' + hash(result);
|
||||
if (completed.equals(expected)) return;
|
||||
if (!Objects.equals(state.getCommandEventId(), result.commandEventId())
|
||||
|| !Objects.equals(state.getWalletBusinessNo(), result.businessNo())
|
||||
|| !Objects.equals(state.getWalletStatus(), result.operation() + "_PENDING")
|
||||
|| !Objects.equals(expected, result.operation())) throw conflict("资金结果与当前费用指令不匹配");
|
||||
boolean succeeded = "SUCCEEDED".equals(result.result());
|
||||
if (succeeded && (result.amount() == null || fee.getAmount().compareTo(result.amount()) != 0
|
||||
|| text(result.walletHoldId()) == null)) throw conflict("资金成功结果金额或冻结引用不匹配");
|
||||
if (succeeded && !"FREEZE".equals(result.operation()) && !Objects.equals(state.getWalletHoldId(), result.walletHoldId()))
|
||||
throw conflict("资金结果的冻结引用与原锁款不匹配");
|
||||
if (succeeded && "SETTLE".equals(result.operation())
|
||||
&& (!"APPROVED".equals(fee.getReviewStatus()) || !"ACTIVE".equals(fee.getSourceStatus()) || !"UNPUBLISHED".equals(fee.getPublicationStatus())))
|
||||
throw conflict("当前费用不能接收扣款成功结果");
|
||||
state.getCommandHistory().put(result.commandEventId(), completed);
|
||||
String error = text(result.errorMessage());
|
||||
state.setErrorMessage(succeeded ? null : error == null ? "资金处理失败" : error.substring(0, Math.min(500, error.length())));
|
||||
if (!succeeded) state.setWalletStatus(result.operation() + "_FAILED");
|
||||
else {
|
||||
state.setWalletHoldId(result.walletHoldId());
|
||||
switch (result.operation()) {
|
||||
case "FREEZE" -> {
|
||||
if (!"FROZEN".equals(result.holdStatus())) throw conflict("冻结回执状态无效");
|
||||
state.setWalletStatus("FROZEN");
|
||||
if ("VOIDED".equals(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus())) send(fee, state, "RELEASE");
|
||||
}
|
||||
case "SETTLE" -> {
|
||||
if (!"SETTLED".equals(result.holdStatus()) || text(result.walletLedgerId()) == null)
|
||||
throw conflict("扣款回执缺少已扣流水");
|
||||
state.setWalletStatus("SETTLED"); state.setWalletLedgerId(result.walletLedgerId());
|
||||
fee.setWalletSettlement(new WalletSettlementReference(result.commandEventId(), result.walletHoldId(), result.walletLedgerId(), WALLET_BUSINESS, state.getWalletBusinessNo()));
|
||||
fee.setPublicationStatus("PUBLISHED"); touch(fee, fee.getUpdatedBy());
|
||||
// 余额及授信实扣即完成,只保留海外费用和资金流水引用,不再进入账单结算。
|
||||
}
|
||||
case "RELEASE" -> {
|
||||
if (!"RELEASED".equals(result.holdStatus())) throw conflict("释放回执状态无效");
|
||||
state.setWalletStatus("RELEASED");
|
||||
}
|
||||
default -> throw conflict("费用资金操作不受支持");
|
||||
}
|
||||
}
|
||||
state.setUpdatedAt(now()); repository.updateFunding(state);
|
||||
}
|
||||
|
||||
/** 已扣款及自动仓租不允许从通用调价入口篡改。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource adjust(long id, String operator, AdjustRequest request) {
|
||||
OverseasFeeSource fee = required(repository.lockFee(id));
|
||||
requireManual(fee);
|
||||
requireNotRejected(fee);
|
||||
OverseasFeeFunding state = repository.lockFunding(id);
|
||||
if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode()))
|
||||
throw conflict("余额费用请取消重建;已扣费用请通过退款处理");
|
||||
if (!bill(fee)) throw conflict("历史海外费用请先完成原账单作废,再重新创建余额费用");
|
||||
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()))
|
||||
throw conflict("调整不能更换客户、业务订单或收支方向");
|
||||
requireLegacyCommandOwner(operator, "ADJUST", request.idempotencyKey(), id);
|
||||
return described(sources.adjust(id, operator, request), state);
|
||||
}
|
||||
|
||||
/** 已扣费用不可作废,账单费用仍等待 Finance 确认作废。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource voidSource(long id, String operator, VoidRequest request) {
|
||||
OverseasFeeSource fee = required(repository.lockFee(id));
|
||||
requireManual(fee);
|
||||
requireNotRejected(fee);
|
||||
OverseasFeeFunding state = repository.lockFunding(id);
|
||||
if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode()))
|
||||
throw conflict("已扣费用请通过退款处理,未扣费用请取消");
|
||||
requireLegacyCommandOwner(operator, "VOID", request.idempotencyKey(), id);
|
||||
return described(sources.voidSource(id, operator, request), state);
|
||||
}
|
||||
|
||||
/** 资金指令与费用变更同事务写 Outbox,冻结与业务操作互不抢用身份。 */
|
||||
private void send(OverseasFeeSource fee, OverseasFeeFunding state, String operation) {
|
||||
requireCnySnapshot(fee);
|
||||
long version = state.getCommandVersion() + 1;
|
||||
if ("FREEZE".equals(operation)) state.setWalletBusinessNo(fee.getSourceFeeId() + "-" + version);
|
||||
String event = wallet.publish(operation, Long.parseLong(fee.getCustomerId()), WALLET_BUSINESS,
|
||||
state.getWalletBusinessNo(), "FREEZE".equals(operation) ? fee.getAmount() : null,
|
||||
"海外费用:" + fee.getBusinessNo() + " / " + fee.getFeeItemName(), version);
|
||||
state.setCommandVersion(version); state.setCommandEventId(event); state.setWalletStatus(operation + "_PENDING");
|
||||
state.setErrorMessage(null); state.getCommandHistory().put(event, operation);
|
||||
state.setUpdatedAt(now()); repository.updateFunding(state);
|
||||
}
|
||||
|
||||
/** 可操作性只描述业务资格,最终权限仍由 Controller 校验。 */
|
||||
private void applyDescription(OverseasFeeSource fee, OverseasFeeFunding state) {
|
||||
if (state != null) {
|
||||
fee.setCollectionMode(state.getCollectionMode()); fee.setWalletStatus(state.getWalletStatus()); fee.setWalletErrorMessage(state.getErrorMessage());
|
||||
} else if (fee.getWalletSettlement() != null) {
|
||||
fee.setCollectionMode("WALLET"); fee.setWalletStatus("SETTLED");
|
||||
} else if ("PUBLISHED".equals(fee.getPublicationStatus()) || bill(fee)) {
|
||||
fee.setCollectionMode("BILL"); fee.setWalletStatus("NOT_REQUIRED");
|
||||
} else {
|
||||
fee.setCollectionMode("WALLET");
|
||||
}
|
||||
List<String> actions = new ArrayList<>();
|
||||
if ("ACTIVE".equals(fee.getSourceStatus()) && fee.getWalletSettlement() == null && !procurement(fee)
|
||||
&& !Set.of("PENDING_FINANCE", "FINANCE_ATTENTION").contains(Objects.toString(fee.getVoidStatus(), ""))) {
|
||||
if ("PENDING".equals(fee.getReviewStatus())) {
|
||||
actions.add("REJECT");
|
||||
if (state == null && bill(fee) || state != null && ("BILL".equals(state.getCollectionMode()) || "FROZEN".equals(state.getWalletStatus()))) actions.add("APPROVE");
|
||||
if (state == null && !bill(fee)) { actions.remove("REJECT"); actions.add("SUBMIT"); }
|
||||
}
|
||||
if (state != null && "FREEZE_FAILED".equals(state.getWalletStatus()) && "PENDING".equals(fee.getReviewStatus())) actions.add("RETRY_FREEZE");
|
||||
if (state != null && "SETTLE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_SETTLE");
|
||||
if ("DRAFT".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode())
|
||||
|| Set.of("RELEASED", "FREEZE_FAILED").contains(state.getWalletStatus()))) actions.add("SUBMIT");
|
||||
if ("APPROVED".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode()))) {
|
||||
if (bill(fee)) actions.add("ADJUST"); actions.add("VOID");
|
||||
}
|
||||
}
|
||||
if (state != null && "RELEASE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_RELEASE");
|
||||
// 驳回不能重开收费,但资金释放失败仍须向操作端提供补偿入口。
|
||||
if ("REJECTED".equals(fee.getReviewStatus())) actions.removeIf(action -> !"RETRY_RELEASE".equals(action));
|
||||
fee.setAllowedActions(List.copyOf(actions));
|
||||
if(StorageRentFeeWorkflow.isRent(fee)){
|
||||
// 仓租沿原出库冻结审核,不开放取消重建、通用调价或人工资金身份。
|
||||
var rentActions=new ArrayList<String>();
|
||||
if(state!=null&&"ACTIVE".equals(fee.getSourceStatus())&&"PENDING".equals(fee.getReviewStatus())&&"FROZEN".equals(state.getWalletStatus()))
|
||||
rentActions.addAll(List.of("APPROVE","REJECT","ADJUST_RENT"));
|
||||
fee.setAllowedActions(List.copyOf(rentActions));
|
||||
}
|
||||
}
|
||||
|
||||
/** 重复作业或补收必须解释用途,普通首次录费可不填说明。 */
|
||||
private void requireFeeRemark(OverseasFeeSource fee) {
|
||||
if (text(fee.getFeeRemark()) == null && repository.hasSameProject(fee))
|
||||
throw bad("此单已有相同费用项目,请填写费用说明后继续添加");
|
||||
}
|
||||
|
||||
/** 禁止取消或改写驳回记录,录错后应新建费用。 */
|
||||
private void requireNotRejected(OverseasFeeSource fee) {
|
||||
if ("REJECTED".equals(fee.getReviewStatus())) throw conflict("已驳回费用不能操作,请重新新增费用");
|
||||
}
|
||||
|
||||
/** 只有实际头程服务订单的附加费走账单,海外新工单始终独立扣款。 */
|
||||
private boolean bill(OverseasFeeSource fee) {
|
||||
if ("RECEIVABLE".equals(fee.getDirection()) && "WALLET".equals(fee.getSettlementMode())) return false;
|
||||
if ("RECEIVABLE".equals(fee.getDirection()) && "BILL".equals(fee.getSettlementMode())) return true;
|
||||
return !"RECEIVABLE".equals(fee.getDirection()) || "LASTLEG_ORDER".equals(fee.getBusinessType())
|
||||
&& "FIRST_LEG".equals(businesses.orderType(fee.getBusinessId(), fee.getCustomerId()));
|
||||
}
|
||||
|
||||
/** 采购已有专用锁款与履约结算,不允许通用费用入口重复收取。 */
|
||||
private void requireManual(OverseasFeeSource fee) {
|
||||
if(StorageRentFeeWorkflow.isRent(fee))throw conflict("自动仓租请通过仓租审核或调整入口处理");
|
||||
if (procurement(fee)) throw conflict("采购费用请通过采购履约或财务节点处理");
|
||||
}
|
||||
|
||||
/** 识别采购来源及其工单类型,兼容历史费用数据。 */
|
||||
private boolean procurement(OverseasFeeSource fee) {
|
||||
return "OVERSEAS_PROCUREMENT_FEE".equals(fee.getSourceType()) || "OVERSEAS_PROCUREMENT".equals(fee.getBusinessType())
|
||||
|| "PROCUREMENT_FEE".equals(fee.getBusinessType());
|
||||
}
|
||||
|
||||
/** 仓租只由实际出库自动生成,人工收费和调价不得重复录入。 */
|
||||
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
|
||||
|| fee.getAmount().signum() <= 0 || fee.getOriginalAmount() == null || fee.getOriginalAmount().signum() <= 0
|
||||
|| fee.getExchangeRate() == null || fee.getExchangeRate().signum() <= 0 || text(fee.getExchangeRateMonth()) == null
|
||||
|| fee.getBusinessDate() == null || !fee.getBusinessDate().toString().substring(0, 7).equals(fee.getExchangeRateMonth())
|
||||
|| fee.getOriginalCurrency() == null || !Set.of("CNY", "USD").contains(fee.getOriginalCurrency())
|
||||
|| "CNY".equals(fee.getOriginalCurrency()) && fee.getExchangeRate().compareTo(BigDecimal.ONE) != 0
|
||||
|| fee.getOriginalAmount().multiply(fee.getExchangeRate()).setScale(2, RoundingMode.HALF_UP).compareTo(fee.getAmount()) != 0)
|
||||
throw conflict("费用人民币结算快照无效");
|
||||
}
|
||||
|
||||
/** 为单条变更结果补充与列表一致的操作资格。 */
|
||||
private OverseasFeeSource described(OverseasFeeSource fee, OverseasFeeFunding state) { applyDescription(fee, state); return fee; }
|
||||
/** 兼容旧载荷哈希,同时禁止跨费用复用旧审核、调整或作废操作标识。 */
|
||||
private void requireLegacyCommandOwner(String operator, String action, String key, long id) {
|
||||
if (text(key) == null || key.length() > 128) throw bad("操作标识无效");
|
||||
var existing = repository.command(operator, action, key.trim());
|
||||
if (existing != null && !Objects.equals(existing.getSourceFeeId(), id))
|
||||
throw conflict("操作标识已被其他费用使用");
|
||||
}
|
||||
/** 核对重复操作的业务ID及载荷,禁止用相同键操作其他费用。 */
|
||||
private boolean replayed(String operator, String action, String key, long id, Object payload) {
|
||||
if (text(key) == null || key.length() > 128) throw bad("操作标识无效");
|
||||
OverseasFeeCommand existing = repository.command(operator, action, key.trim());
|
||||
if (existing != null && (!Objects.equals(existing.getSourceFeeId(), id) || !existing.getRequestHash().equals(hash(List.of(id, payload)))))
|
||||
throw conflict("操作标识已被其他请求使用");
|
||||
return existing != null;
|
||||
}
|
||||
/** 本地变更完成后保存操作幂等记录。 */
|
||||
private void record(String operator, String action, String key, long id, Object payload, int version) {
|
||||
OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator);
|
||||
value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(List.of(id, payload)));
|
||||
value.setSourceFeeId(id); value.setSourceVersion(version); value.setCreatedAt(now()); repository.insertCommand(value);
|
||||
}
|
||||
/** 状态变更保留操作人及时间。 */
|
||||
private void touch(OverseasFeeSource fee, String operator) { fee.setUpdatedBy(operator); fee.setUpdatedAt(now()); repository.updateFee(fee); }
|
||||
/** 不暴露不存在的费用。 */
|
||||
private OverseasFeeSource required(OverseasFeeSource value) { if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "费用不存在"); return value; }
|
||||
/** 固定载荷哈希用于操作与结果防重。 */
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw new IllegalStateException("费用操作快照生成失败", exception); }
|
||||
}
|
||||
/** 统一使用 UTC 保存业务时刻。 */
|
||||
private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
|
||||
/** 统一可选文本。 */
|
||||
private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
/** 返回参数错误。 */
|
||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
/** 返回业务状态冲突。 */
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.financefee.service;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest;
|
||||
import java.util.List;
|
||||
/** 费用域通过端口委托仓租审核,避免依赖仓租实现。 */
|
||||
public interface StorageRentFeeWorkflow {
|
||||
/** 仓租来源标记,写操作仍核对持久化归属。 */
|
||||
static boolean isRent(OverseasFeeSource fee) { return "STORAGE_RENT".equals(fee.getCreatedBy()) && "FEE_TYPE_077".equals(fee.getFeeItemCode()); }
|
||||
/** 附加原计租批次和实际出库明细。 */
|
||||
void describe(List<OverseasFeeSource> rows);
|
||||
/** 按原冻结审核仓租,批准后申请实际扣款。 */
|
||||
OverseasFeeSource review(long id,String operator,String name,ReviewRequest request);
|
||||
}
|
||||
+5
@@ -39,6 +39,8 @@ import java.util.stream.Collectors;
|
||||
/** Converts final PDA package counts into inbound-order/pallet inventory. */
|
||||
@Service
|
||||
public class InboundStockReceiptService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private LastlegOrderDetailMapper orderDetails;
|
||||
@Resource private InboundStockReceiptMapper receipts;
|
||||
@@ -222,6 +224,7 @@ public class InboundStockReceiptService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 真实库存入账后同步创建不可重置库龄的原计租批次。 */
|
||||
private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
|
||||
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
|
||||
LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) {
|
||||
@@ -295,6 +298,8 @@ public class InboundStockReceiptService {
|
||||
receipt.setReceivedAt(receivedAt);
|
||||
receipt.setCreatedAt(receivedAt);
|
||||
receipts.insert(receipt);
|
||||
// 实际实收与原计租批次同事务落地,重复凭证不增加租金基数。
|
||||
storageRent.registerInbound(receipt, order);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
|
||||
+3
-24
@@ -1,9 +1,6 @@
|
||||
package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -27,8 +24,6 @@ public class DestuffingPalletCostService {
|
||||
static final String RECEIVED_PALLET_IDS_SQL = "SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL AND pp.actual_package_count>0 AND pp.actual_volume_cbm>0 ORDER BY pp.warehouse_pallet_id";
|
||||
private final JdbcTemplate jdbc;
|
||||
private final PalletPhysicalGateway pallets;
|
||||
private final OverseasFeeSourceMapper feeSources;
|
||||
private final OverseasFeeOutboxService feeOutbox;
|
||||
|
||||
public List<String> readyBookingIds() {
|
||||
return jdbc.queryForList(READY_BOOKINGS_SQL, String.class);
|
||||
@@ -59,10 +54,9 @@ public class DestuffingPalletCostService {
|
||||
for (int i=0; i<ordered.size(); i++) {
|
||||
CustomerVolume volume = ordered.get(i);
|
||||
BigDecimal amount = amounts.get(i);
|
||||
OverseasFeeSource fee = fee(costId, bookingId, containerNo, warehouseId, volume, amount, operatorId);
|
||||
feeSources.insert(fee); feeOutbox.publish(fee, "PUBLISHED");
|
||||
jdbc.update("INSERT INTO ll_destuff_pallet_cost_allocation(id,cost_id,customer_id,actual_volume_cbm,allocated_amount,fee_source_id,created_at) VALUES(?,?,?,?,?,?,NOW(3))",
|
||||
IdWorker.getId(), costId, volume.customerId(), volume.volume(), amount, fee.getSourceFeeId());
|
||||
// 保留实际成本分摊供人工核价,不自动生成客户应收。
|
||||
jdbc.update("INSERT INTO ll_destuff_pallet_cost_allocation(id,cost_id,customer_id,actual_volume_cbm,allocated_amount,fee_source_id,created_at) VALUES(?,?,?,?,?,NULL,NOW(3))",
|
||||
IdWorker.getId(), costId, volume.customerId(), volume.volume(), amount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,20 +72,5 @@ public class DestuffingPalletCostService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private OverseasFeeSource fee(long costId, String bookingId, String containerNo, Long warehouseId, CustomerVolume volume, BigDecimal amount, String operator) {
|
||||
LocalDateTime now=LocalDateTime.now(ZoneOffset.UTC); OverseasFeeSource v=new OverseasFeeSource();
|
||||
v.setId(IdWorker.getId()); v.setSourceFeeId(IdWorker.getId()); v.setSourceVersion(1); v.setCustomerId(volume.customerId());
|
||||
v.setSourceType("OVERSEAS_CONTAINER_FEE"); v.setSourceNo("DESTUFF-"+bookingId); v.setBusinessType(DESTUFF_BUSINESS_TYPE);
|
||||
v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo);
|
||||
v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE");
|
||||
v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount);
|
||||
v.setOriginalCurrency("CNY"); v.setOriginalAmount(amount); v.setExchangeRate(BigDecimal.ONE.setScale(6));
|
||||
v.setExchangeRateMonth(LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM")));
|
||||
v.setChargeableQuantity(volume.volume()); v.setQuantityUnit("CBM");
|
||||
v.setUnitPrice(volume.volume().signum() > 0 ? amount.divide(volume.volume(), 6, RoundingMode.HALF_UP) : null);
|
||||
v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator);
|
||||
v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v;
|
||||
}
|
||||
private static Long parseLong(String value){try{return Long.parseLong(value);}catch(Exception ignored){return null;}}
|
||||
private record CustomerVolume(String customerId, BigDecimal volume){}
|
||||
}
|
||||
|
||||
+2
-2
@@ -60,9 +60,9 @@ public class PlatformOverseasProcurementController {
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
|
||||
@Operation(summary = "List procurement warehouse options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
|
||||
public ApiResponse<List<OverseasProcurementResponses.WarehouseOption>> warehouseOptions(
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String procurementDirection) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.warehouseOptions());
|
||||
return ApiResponse.success(service.warehouseOptions(procurementDirection));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
|
||||
+16
-2
@@ -28,7 +28,15 @@ public final class OverseasProcurementRequests {
|
||||
@NotBlank @Size(max = 128) String contactName,
|
||||
@NotBlank @Size(max = 64) @jakarta.validation.constraints.Pattern(regexp="[+0-9][0-9 ()-]{5,63}") String contactPhone,
|
||||
@Size(max = 1000) String remark,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) {
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
|
||||
@jakarta.validation.constraints.Pattern(regexp="OVERSEAS_TO_DOMESTIC|DOMESTIC_TO_OVERSEAS") String procurementDirection) {
|
||||
public Create(String customerName, String purchaseCategory, String productName, String productSpecification,
|
||||
String productUrl, Long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
|
||||
String receivingAddress, String contactName, String contactPhone, String remark, String idempotencyKey) {
|
||||
this(customerName, purchaseCategory, productName, productSpecification, productUrl, purchaseQuantity,
|
||||
budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, remark, idempotencyKey, null);
|
||||
}
|
||||
@com.fasterxml.jackson.annotation.JsonAnySetter
|
||||
public void rejectUnknownField(String name, JsonNode value) {
|
||||
throw new IllegalArgumentException("采购下单仅支持单商品、单规格及一次购买批次,不支持扩展字段:" + name);
|
||||
@@ -57,7 +65,13 @@ public final class OverseasProcurementRequests {
|
||||
@NotBlank String decision,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark,
|
||||
@Positive Long purchaseWarehouseId) {}
|
||||
@Positive Long purchaseWarehouseId,
|
||||
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
|
||||
@jakarta.validation.constraints.Pattern(regexp="OVERSEAS_TO_DOMESTIC|DOMESTIC_TO_OVERSEAS") String procurementDirection) {
|
||||
public Review(Long version, String decision, String idempotencyKey, String remark, Long purchaseWarehouseId) {
|
||||
this(version, decision, idempotencyKey, remark, purchaseWarehouseId, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record Update(
|
||||
@NotNull @Min(0) Long version,
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ public final class OverseasProcurementResponses {
|
||||
String purchaseCategory, String productName, String productSpecification,
|
||||
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
|
||||
String purchaseWarehouseId, String purchaseWarehouseName,
|
||||
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, String procurementDirection) {}
|
||||
|
||||
public record CustomerOption(String customerId, String customerName) {}
|
||||
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ public class OverseasProcurementOrder {
|
||||
private String customerId;
|
||||
private String customerName;
|
||||
private String orderNo;
|
||||
private String procurementDirection;
|
||||
private String purchaseCategory;
|
||||
private String productName;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification;
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.procurement.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/** 方向变更只读门禁,购买与收货事实仍归原履约模型。 */
|
||||
@Mapper
|
||||
public interface ProcurementDirectionMapper {
|
||||
/** 已发生的购买或起运仓事实禁止通过审核切换方向。 */
|
||||
@Select("SELECT EXISTS(SELECT 1 FROM ll_procurement_flow WHERE order_id=#{orderId} AND "
|
||||
+ "(purchased_at IS NOT NULL OR actual_warehouse_id IS NOT NULL OR overseas_shipped_at IS NOT NULL))")
|
||||
boolean hasStarted(@Param("orderId") long orderId);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.procurement.repository;
|
||||
|
||||
import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 采购方向审核的数据访问边界。 */
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class ProcurementDirectionRepository {
|
||||
private final ProcurementDirectionMapper mapper;
|
||||
/** 在订单行锁内读取履约事实,避免改向重解释原始货物来源。 */
|
||||
public boolean hasStarted(long orderId) { return mapper.hasStarted(orderId); }
|
||||
}
|
||||
+34
-8
@@ -50,12 +50,13 @@ public class OverseasProcurementService {
|
||||
public static final String WAIT_PURCHASE = "WAIT_PURCHASE";
|
||||
public static final String REJECTED = "REJECTED";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "WAIT_OVERSEAS_RECEIPT",
|
||||
"OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "INTERNATIONAL_TRANSIT",
|
||||
"DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED");
|
||||
"OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "WAIT_OVERSEAS_SHIPMENT", "INTERNATIONAL_TRANSIT",
|
||||
"DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "WAIT_CUSTOMER_SHIPMENT", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED");
|
||||
|
||||
@Resource private OverseasProcurementOrderMapper orders;
|
||||
@Resource private OverseasProcurementStatusHistoryMapper histories;
|
||||
@Resource private ProcurementWarehouseDirectory warehouseDirectory;
|
||||
@Resource private com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private OverseasProcurementFileMapper procurementFiles;
|
||||
@Resource private OverseasProcurementFeeMapper procurementFees;
|
||||
@@ -76,6 +77,7 @@ public class OverseasProcurementService {
|
||||
order.setCustomerId(customerId);
|
||||
order.setCustomerName(trim(request.customerName()));
|
||||
order.setOrderNo(orderNoGenerator.next("CG"));
|
||||
order.setProcurementDirection(ProcurementDirection.normalize(request.procurementDirection()));
|
||||
order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim());
|
||||
order.setProductName(request.productName().trim());
|
||||
order.setProductSpecification(trim(request.productSpecification()));
|
||||
@@ -115,8 +117,12 @@ public class OverseasProcurementService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions() {
|
||||
return warehouseDirectory.options().stream()
|
||||
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions() { return warehouseOptions(null); }
|
||||
|
||||
/** 按采购方向读取权威起运收货仓目录。 */
|
||||
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions(String direction) {
|
||||
return (ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction))
|
||||
? warehouseDirectory.options(direction) : warehouseDirectory.options()).stream()
|
||||
.map(value -> new OverseasProcurementResponses.WarehouseOption(String.valueOf(value.warehouseId()),
|
||||
value.warehouseCode(), value.warehouseName(), value.countryCode()))
|
||||
.toList();
|
||||
@@ -226,12 +232,23 @@ public class OverseasProcurementService {
|
||||
}
|
||||
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
|
||||
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
|
||||
String direction = request.procurementDirection() == null ? ProcurementDirection.normalize(order.getProcurementDirection())
|
||||
: ProcurementDirection.normalize(request.procurementDirection());
|
||||
if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection()))) {
|
||||
throw conflict("审核不能修改采购方向,请按客户下单方向选择采购仓");
|
||||
}
|
||||
if (REJECTED.equals(next) && trim(request.remark()) == null) {
|
||||
throw badRequest("请填写拒绝原因");
|
||||
}
|
||||
Map<String, Object> before = reviewSnapshot(order);
|
||||
if (WAIT_PURCHASE.equals(next)) {
|
||||
if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择有效海外收货仓");
|
||||
var warehouse = warehouseDirectory.required(request.purchaseWarehouseId());
|
||||
if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择与采购方向匹配的收货仓");
|
||||
var warehouse = ProcurementDirection.EXPORT.equals(direction)
|
||||
? warehouseDirectory.required(request.purchaseWarehouseId(), direction) : warehouseDirectory.required(request.purchaseWarehouseId());
|
||||
order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));
|
||||
order.setPurchaseWarehouseName(warehouse.warehouseName());
|
||||
}
|
||||
order.setProcurementDirection(direction);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
order.setStatus(next);
|
||||
order.setReviewedBy(operatorId);
|
||||
@@ -242,7 +259,7 @@ public class OverseasProcurementService {
|
||||
touch(order, now);
|
||||
orders.updateById(order);
|
||||
history(order, "REVIEW", PENDING_REVIEW, next, operatorId,
|
||||
key, null, request.remark(), now);
|
||||
key, changeDetail(before, reviewSnapshot(order)), request.remark(), now);
|
||||
return detail(customerId, orderNo);
|
||||
}
|
||||
|
||||
@@ -282,7 +299,7 @@ public class OverseasProcurementService {
|
||||
order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(),
|
||||
order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(),
|
||||
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(),
|
||||
displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
|
||||
displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), ProcurementDirection.normalize(order.getProcurementDirection()));
|
||||
}
|
||||
|
||||
private void history(OverseasProcurementOrder order, String operationType, String from, String to,
|
||||
@@ -303,6 +320,15 @@ public class OverseasProcurementService {
|
||||
histories.insert(history);
|
||||
}
|
||||
|
||||
/** 审核流水记录采购方向和收货仓变更,不仅记录状态。 */
|
||||
private Map<String, Object> reviewSnapshot(OverseasProcurementOrder order) {
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("procurementDirection", ProcurementDirection.normalize(order.getProcurementDirection()));
|
||||
snapshot.put("purchaseWarehouseId", order.getPurchaseWarehouseId());
|
||||
snapshot.put("purchaseWarehouseName", order.getPurchaseWarehouseName());
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private void validateQuery(OverseasProcurementRequests.Query query) {
|
||||
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status().trim().toUpperCase())) {
|
||||
throw badRequest("Unsupported procurement status");
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ public class ProcurementControlService {
|
||||
if(lifecycle.replay(order,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request))return snapshot(order);
|
||||
version(order,request.version());proof(order,request.voucherFileId());var flow=lifecycle.flow(order.getId());
|
||||
if(flow.get("actual_warehouse_id")==null)throw conflict("尚无实际收货仓,请修改计划仓");
|
||||
var warehouse=warehouses.required(request.warehouseId());
|
||||
var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId()));
|
||||
long old=((Number)flow.get("actual_warehouse_id")).longValue();if(old==request.warehouseId())throw bad("更正仓库与原仓库相同");
|
||||
Integer downstream=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_carton WHERE order_id=?",Integer.class,order.getId());
|
||||
boolean appendOnly=downstream>0 || flow.get("overseas_shipped_at")!=null || Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED").contains(order.getStatus());
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.procurement.service;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** 采购方向决定起运仓类型,历史两段编码分别代表起运段和目的地段。 */
|
||||
public final class ProcurementDirection {
|
||||
public static final String IMPORT = "OVERSEAS_TO_DOMESTIC";
|
||||
public static final String EXPORT = "DOMESTIC_TO_OVERSEAS";
|
||||
private ProcurementDirection() { }
|
||||
/** 旧订单缺少方向时保持原进口流程。 */
|
||||
public static String normalize(String value) {
|
||||
if (value == null) return IMPORT;
|
||||
if (!IMPORT.equals(value) && !EXPORT.equals(value)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购方向无效");
|
||||
return value;
|
||||
}
|
||||
/** 展示区域随方向切换,不改历史技术段编码和关联。 */
|
||||
public static String display(String text, String direction) {
|
||||
if (text == null || !EXPORT.equals(normalize(direction))) return text;
|
||||
return text.replace("海外", "__ORIGIN__").replace("国内", "海外").replace("__ORIGIN__", "国内");
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -124,7 +124,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
|
||||
fee.setFeeNo(documentNumbers.next("PF"));
|
||||
fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1);
|
||||
fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code);
|
||||
fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : request.purpose().label());
|
||||
fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : ProcurementDirection.display(request.purpose().label(), order.getProcurementDirection()));
|
||||
fee.setOriginalCurrency(conversion.originalCurrency()); fee.setOriginalAmount(conversion.originalAmount());
|
||||
fee.setExchangeRate(conversion.exchangeRate()); fee.setExchangeRateMonth(conversion.exchangeRateMonth());
|
||||
fee.setAmount(conversion.settlementAmount()); fee.setCurrency("CNY"); fee.setBusinessDate(request.businessDate());
|
||||
@@ -450,7 +450,7 @@ public class ProcurementFinanceService implements WalletResultHandler {
|
||||
private void publishSource(OverseasProcurementOrder order, OverseasProcurementFee fee) {
|
||||
var request = new OverseasFeeSourceDtos.SaveRequest(fee.getCustomerId(), order.getCustomerName(),
|
||||
"OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getWalletBusinessId(), fee.getWalletBusinessNo(),
|
||||
order.getId(), null, null, null, null, null, null, fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE",
|
||||
order.getId(), null, null, null, null, null, null, fee.getFeeItemCode(), fee.getFeeItemName(), null, "RECEIVABLE",
|
||||
null, null, null, null, null, null, null, List.of(), fee.getBusinessDate(), fee.getOriginalCurrency(),
|
||||
fee.getOriginalAmount(), BigDecimal.ONE, "ITEM", fee.getOriginalAmount(), null, null);
|
||||
var source = sources.publishSettled(fee.getCreatedBy(), "PROCUREMENT:" + fee.getFeeNo(), request,
|
||||
@@ -460,14 +460,13 @@ public class ProcurementFinanceService implements WalletResultHandler {
|
||||
fee.setSourceFeeId(source.getSourceFeeId());
|
||||
}
|
||||
|
||||
/** 余额实扣并登记本地费用来源后即可履约,无需等待财务账单推送。 */
|
||||
public boolean sourceLinked(OverseasProcurementFee fee) {
|
||||
if(fee==null || fee.getWalletStatus()==null)return false;
|
||||
if (Set.of("ZERO", "NOT_APPLICABLE", "REFUNDED").contains(fee.getWalletStatus())) return true;
|
||||
if ("RELEASED".equals(fee.getWalletStatus()) && fee.getAmount().signum() == 0) return true;
|
||||
if (!"SETTLED".equals(fee.getWalletStatus()) || fee.getSourceFeeId() == null) return false;
|
||||
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox WHERE aggregate_type='OVERSEAS_PROCUREMENT_FEE' AND aggregate_id=? AND status='PUBLISHED'",
|
||||
Integer.class, String.valueOf(fee.getSourceFeeId()));
|
||||
return count != null && count > 0;
|
||||
return "SETTLED".equals(fee.getWalletStatus()) && fee.getSourceFeeId() != null
|
||||
&& fee.getWalletLedgerId() != null && fee.getSettledCommandEventId() != null;
|
||||
}
|
||||
|
||||
/** Called under the procurement order lock before the final physical handoff or exception closure. */
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ public class ProcurementLifecycleService {
|
||||
if(replay(order,"PLAN_WAREHOUSE",request.idempotencyKey(),request)) return outcome(order);
|
||||
version(order,request.version());open(order);ensureFlow(order);
|
||||
if(flow(order.getId()).get("actual_warehouse_id")!=null) throw conflict("首次实收已冻结实际仓库,请使用事实更正操作");
|
||||
var warehouse=warehouses.required(request.warehouseId());
|
||||
var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId()));
|
||||
order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));order.setPurchaseWarehouseName(warehouse.warehouseName());
|
||||
complete(order,operator,"PLAN_WAREHOUSE",request.idempotencyKey(),request.reason(),request);
|
||||
return outcome(order);
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ public class ProcurementPhysicalService {
|
||||
LocalDateTime originAt;
|
||||
if(request.leg()==Leg.OVERSEAS) {
|
||||
if(request.warehouseId()==null || request.sourceCartonId()!=null) throw bad("海外实收须选择实际收货仓,不能指定国内来源箱");
|
||||
var warehouse=warehouses.required(request.warehouseId());
|
||||
var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId()));
|
||||
if(flow.get("actual_warehouse_id")!=null && ((Number)flow.get("actual_warehouse_id")).longValue()!=request.warehouseId()) throw conflict("本单实际收货仓已冻结,不支持转仓");
|
||||
jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?",
|
||||
warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId());
|
||||
|
||||
+5
-4
@@ -29,7 +29,7 @@ public class ProcurementQueryService {
|
||||
String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency,
|
||||
String receivingAddress,String contactName,String contactPhone,LocalDateTime estimatedArrivalAt,
|
||||
String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version,
|
||||
LocalDateTime createdAt,LocalDateTime updatedAt) { }
|
||||
LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { }
|
||||
public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { }
|
||||
public record Progress(String key,String title,LocalDateTime completedAt) { }
|
||||
public record Box(String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm,BigDecimal grossWeightKg) { }
|
||||
@@ -60,14 +60,14 @@ public class ProcurementQueryService {
|
||||
var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map<Long,String> feeNos=new HashMap<>();fees.forEach(f->feeNos.put(f.getId(),f.getFeeNo()));
|
||||
var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),order.getProductSpecification(),order.getProductUrl(),
|
||||
order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(),
|
||||
(String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt());
|
||||
(String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt(),ProcurementDirection.normalize(order.getProcurementDirection()));
|
||||
var budgets=jdbc.query("SELECT authorized_amount,currency,reviewed_at FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1",
|
||||
(rs,n)->new Budget(rs.getBigDecimal(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime()),order.getId());
|
||||
var budget=budgets.isEmpty()?new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null):budgets.get(0);
|
||||
List<Progress> progress=new ArrayList<>();progress.add(new Progress("CREATED","客户下单",order.getCreatedAt()));progress.add(new Progress("REVIEWED","采购审核",order.getReviewedAt()));
|
||||
for(var step:List.of(new String[]{"purchased_at","购买完成"},new String[]{"overseas_received_at","海外收货完成"},new String[]{"overseas_shipped_at","海外实际交寄"},
|
||||
new String[]{"domestic_received_at","国内收货完成"},new String[]{"charges_confirmed_at","国内费用核定"},new String[]{"customer_shipped_at","已寄客户"},new String[]{"closed_at","异常关闭"})) {
|
||||
progress.add(new Progress(step[0],step[1],date(flow.get(step[0]))));
|
||||
progress.add(new Progress(step[0],ProcurementDirection.display(step[1],order.getProcurementDirection()),date(flow.get(step[0]))));
|
||||
}
|
||||
var manifest=physical.currentManifest(order.getId());
|
||||
boolean confirmed=manifest!=null && "CONFIRMED".equals(manifest.get("status"));
|
||||
@@ -124,7 +124,8 @@ public class ProcurementQueryService {
|
||||
action(result,permissions,"FINANCE_ADJUST","费用更正与退款","finance-adjust",null);
|
||||
action(result,permissions,"CANCEL","购买前取消","cancel",closed?stopped:purchased?"购买后不能取消,请处理实物资金后异常关闭":null);
|
||||
action(result,permissions,"EXCEPTION_CLOSE","异常关闭","manage",closed?stopped:!purchased?"购买前请取消订单":blockers!=null?blockers:reason(()->finance.requireShipmentReady(order.getId())));
|
||||
return List.copyOf(result);
|
||||
return result.stream().map(a -> new Action(a.key(), ProcurementDirection.display(a.label(), order.getProcurementDirection()),
|
||||
a.permission(), a.allowed(), ProcurementDirection.display(a.reason(), order.getProcurementDirection()))).toList();
|
||||
}
|
||||
private static void action(List<Action> result,Set<String> permissions,String key,String label,String suffix,String block){String code="platform:overseas-business:purchase:"+suffix;String reason=permissions==null || !permissions.contains(code)?"无此操作权限":block;result.add(new Action(key,label,code,reason==null,reason));}
|
||||
private static String reason(Runnable action){try{action.run();return null;}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}}
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ public class ProcurementTransportService {
|
||||
if(request.leg()==Leg.OVERSEAS) {
|
||||
var id=(Number)lifecycle.flow(order.getId()).get("actual_warehouse_id");
|
||||
if(id==null)throw conflict("尚无实际海外收货仓");
|
||||
sender=warehouses.sender(id.longValue());
|
||||
sender=ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.sender(id.longValue(), order.getProcurementDirection()) : warehouses.sender(id.longValue());
|
||||
} else {
|
||||
var original=internationalAddresses(order);
|
||||
sender=original.receiver();
|
||||
|
||||
+28
-4
@@ -18,6 +18,26 @@ public class ProcurementWarehouseDirectory {
|
||||
if(result==null || !result.successful() || result.data()==null) throw unavailable();
|
||||
return result.data();
|
||||
}
|
||||
/** 国内采购消费国内仓目录,旧方向仍使用原海外收货仓目录。 */
|
||||
public List<DomesticWarehouseDirectoryClient.ReceivingWarehouse> options(String direction) {
|
||||
if (!ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction))) return options();
|
||||
var result = client.domesticWarehouses(token);
|
||||
if (result == null || !result.successful() || result.data() == null) throw unavailable();
|
||||
return result.data().stream().map(w -> new DomesticWarehouseDirectoryClient.ReceivingWarehouse(
|
||||
w.id(), w.warehouseCode(), w.warehouseName(), "CN", "ACTIVE")).toList();
|
||||
}
|
||||
/** 写入前核验公共作用域、启用状态和仓库类型,不信任前端候选。 */
|
||||
public DomesticWarehouseDirectoryClient.ReceivingWarehouse required(long id, String direction) {
|
||||
if (!ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction))) return required(id);
|
||||
var result = client.activeWarehouse(id, token);
|
||||
if (result == null || !result.successful() || result.data() == null || result.data().warehouse() == null) throw unavailable();
|
||||
var w = result.data().warehouse();
|
||||
if (!Long.valueOf(id).equals(w.id()) || !Long.valueOf(0).equals(w.tenantId())
|
||||
|| !"DOMESTIC".equals(w.warehouseType()) || !"ACTIVE".equals(w.status())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "国内采购须选择有效国内公共仓");
|
||||
}
|
||||
return new DomesticWarehouseDirectoryClient.ReceivingWarehouse(w.id(), w.warehouseCode(), w.warehouseName(), w.countryCode(), w.status());
|
||||
}
|
||||
public DomesticWarehouseDirectoryClient.ReceivingWarehouse required(long id) {
|
||||
var result=client.receivingWarehouse(id,token);
|
||||
if(result==null || !result.successful() || result.data()==null) throw unavailable();
|
||||
@@ -28,19 +48,23 @@ public class ProcurementWarehouseDirectory {
|
||||
return warehouse;
|
||||
}
|
||||
public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id) {
|
||||
required(id);
|
||||
return sender(id, ProcurementDirection.IMPORT);
|
||||
}
|
||||
/** 从起运仓主档生成发件信息,两种方向均校验作业地址。 */
|
||||
public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id, String direction) {
|
||||
required(id, direction);
|
||||
var result=client.activeWarehouse(id,token);
|
||||
if(result==null || !result.successful() || result.data()==null || result.data().warehouse()==null) throw unavailable();
|
||||
var warehouse=result.data().warehouse();
|
||||
if(!Long.valueOf(id).equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId())
|
||||
|| !"OVERSEAS".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) throw unavailable();
|
||||
|| !(ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction)) ? "DOMESTIC" : "OVERSEAS").equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) throw unavailable();
|
||||
var addresses=result.data().addresses();
|
||||
var operating=addresses==null?List.<DomesticWarehouseDirectoryClient.Address>of():addresses.stream()
|
||||
.filter(a->"OPERATING".equals(a.addressType())).toList();
|
||||
if(operating.size()!=1 || warehouse.contactName()==null || warehouse.contactName().isBlank()
|
||||
|| warehouse.contactPhone()==null || !warehouse.contactPhone().matches("[+0-9 ()-]{5,64}")
|
||||
|| operating.get(0).addressLine1()==null || operating.get(0).addressLine1().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"实际海外仓的联系人、电话或唯一作业地址不完整,请先维护仓库资料");
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"实际起运仓的联系人、电话或唯一作业地址不完整,请先维护仓库资料");
|
||||
}
|
||||
var a=operating.get(0);
|
||||
String address=java.util.stream.Stream.of(a.countryCode(),a.stateProvince(),a.city(),a.district(),a.addressLine1(),a.addressLine2(),a.postalCode())
|
||||
@@ -48,6 +72,6 @@ public class ProcurementWarehouseDirectory {
|
||||
return new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address(warehouse.contactName(),warehouse.contactPhone(),address);
|
||||
}
|
||||
private static ResponseStatusException unavailable() {
|
||||
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"海外收货仓目录暂不可用,请稍后重试");
|
||||
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"采购收货仓目录暂不可用,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
+31
-22
@@ -490,7 +490,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
String operationWarehouseId = request.operationWarehouseId().trim();
|
||||
String relabelNo = compactNumbers.next("TB");
|
||||
BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO);
|
||||
boolean requiresWallet = chargeAmount.signum() > 0;
|
||||
// 首版仅保留客户报价快照;应收须在海外费用登记并审核。
|
||||
int holdIndex = 0;
|
||||
for (SourceInventory source : sources) {
|
||||
if (source.returnCarton() != null) continue;
|
||||
@@ -524,7 +524,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setNotifiedPackageCount(notified);
|
||||
application.setChargeAmount(chargeAmount);
|
||||
application.setChargeCurrency("CNY");
|
||||
application.setWalletStatus(requiresWallet ? "FREEZE_PENDING" : "NOT_REQUIRED");
|
||||
application.setWalletStatus("NOT_REQUIRED");
|
||||
application.setIdempotencyKey(request.idempotencyKey().trim());
|
||||
application.setRequestHash(requestHash);
|
||||
application.setCreatedBy(trim(operatorId));
|
||||
@@ -550,12 +550,6 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresWallet) {
|
||||
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
|
||||
relabelNo, chargeAmount, "换标订单费用冻结", 1L);
|
||||
application.setWalletCommandEventId(walletEventId);
|
||||
applications.updateById(application);
|
||||
}
|
||||
|
||||
for (SourceInventory source : sources) {
|
||||
insertItem(customerId, applicationId, source, now);
|
||||
@@ -876,20 +870,10 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
private void publishCompletionFeesAndSettlement(RelabelApplication application, String operatorId,
|
||||
List<LastlegOrder> linkedOrders) {
|
||||
if (Set.of("NOT_REQUIRED", "SETTLE_PENDING", "SETTLED").contains(Objects.toString(application.getWalletStatus(), ""))) return;
|
||||
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
|
||||
String customerId = application.getCustomerId(), relabelNo = application.getRelabelNo();
|
||||
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
|
||||
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
|
||||
application.getLastlegOrderId(), null, null, null, null, null,
|
||||
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
|
||||
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
|
||||
application.getChargeCurrency(), application.getChargeAmount(),
|
||||
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
|
||||
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
|
||||
null, null));
|
||||
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
|
||||
if (!"FROZEN".equals(application.getWalletStatus())) throw conflict("旧换标费用尚未锁定,请先处理资金状态");
|
||||
String eventId = walletCommands.publish("SETTLE", Long.parseLong(application.getCustomerId()), "RELABEL_ORDER", application.getRelabelNo(), null,
|
||||
"换标完成,结算费用", 2L);
|
||||
application.setWalletCommandEventId(eventId);
|
||||
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
|
||||
}
|
||||
|
||||
@@ -1298,9 +1282,19 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
@Override
|
||||
@Transactional
|
||||
public void handle(WalletCommandCompletedV1 result) {
|
||||
RelabelApplication value = applications.findNo(String.valueOf(result.customerId()), result.businessNo());
|
||||
RelabelApplication value = applications.lockNo(String.valueOf(result.customerId()), result.businessNo());
|
||||
if (value == null) throw notFound("Relabel application not found for wallet result");
|
||||
if ("NOT_REQUIRED".equals(value.getWalletStatus())) throw conflict("本换标单不使用旧资金流程");
|
||||
if ("SETTLE".equals(result.operation()) && "SETTLED".equals(value.getWalletStatus())) return;
|
||||
if (value.getWalletCommandEventId() != null && !Objects.equals(value.getWalletCommandEventId(), result.commandEventId()))
|
||||
throw conflict("资金回执不属于当前指令");
|
||||
if ("SUCCEEDED".equals(result.result())) {
|
||||
if ("SETTLE".equals(result.operation())) {
|
||||
if (!"SETTLE_PENDING".equals(value.getWalletStatus()) || !"SETTLED".equals(result.holdStatus())
|
||||
|| result.walletHoldId() == null || result.walletLedgerId() == null || result.amount() == null
|
||||
|| result.amount().compareTo(value.getChargeAmount()) != 0) throw conflict("换标扣款回执不完整");
|
||||
publishLegacySettled(value, result);
|
||||
}
|
||||
value.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); });
|
||||
value.setWalletErrorCode(null); value.setWalletErrorMessage(null);
|
||||
if ("FREEZE".equals(result.operation()) && Set.of("REJECTED", "CANCELLED").contains(value.getStatus())) {
|
||||
@@ -1319,6 +1313,21 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
value.getWalletErrorMessage(), null);
|
||||
}
|
||||
|
||||
/** 旧冻结单仅在实扣成功后入账;已发布历史来源只补扣款引用,避免第二笔应收。 */
|
||||
private void publishLegacySettled(RelabelApplication app, WalletCommandCompletedV1 result) {
|
||||
var linked = tailOrders(app);
|
||||
var order = linked.get(0);
|
||||
var quantity = BigDecimal.valueOf(Objects.requireNonNullElse(app.getTotalPackageCount(), 0));
|
||||
var today = LocalDate.now();
|
||||
var request = new OverseasFeeSourceDtos.SaveRequest(app.getCustomerId(), order.getCustomerName(),
|
||||
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", app.getId(), app.getRelabelNo(), app.getLastlegOrderId(),
|
||||
null,null,null,null,null,parseLong(app.getOverseasWarehouseId()),"RELABEL_SERVICE","换标费", null,"RECEIVABLE",
|
||||
null,null,null,null,null,null,null,List.of(),today,"CNY",app.getChargeAmount(),
|
||||
quantity.signum()>0?quantity:null,quantity.signum()>0?"BOX":null,null,null,null);
|
||||
feeSources.completeLegacyRelabelSettlement("WALLET_CALLBACK", "RELABEL:"+app.getRelabelNo(), request,
|
||||
new com.baiyue.common.event.WalletSettlementReference(result.commandEventId(),result.walletHoldId(),result.walletLedgerId()));
|
||||
}
|
||||
|
||||
private RelabelResponses.Item item(RelabelItem value) {
|
||||
return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(),
|
||||
value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(),
|
||||
|
||||
+9
@@ -26,12 +26,15 @@ public class ReturnSkuConversionService {
|
||||
@Resource private ReturnPackingTraceMapper traces;
|
||||
@Resource private ReturnInventoryOperationMapper operations;
|
||||
@Resource private ObjectMapper json;
|
||||
@Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
|
||||
/** 换标实作与原商品及计租来源在同一事务转换。 */
|
||||
@Transactional
|
||||
public void apply(RelabelApplication app, List<RelabelItem> items, List<PdaRelabelUnitFact> facts, String round) {
|
||||
apply(app, items, facts, round, List.of());
|
||||
}
|
||||
|
||||
/** 换标实作与原商品及计租来源在同一事务转换。 */
|
||||
@Transactional
|
||||
public void apply(RelabelApplication app, List<RelabelItem> items, List<PdaRelabelUnitFact> facts, String round,
|
||||
List<PdaRelabelHistory> scans) {
|
||||
@@ -75,6 +78,7 @@ public class ReturnSkuConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 正向转换保存精确原托份额快照,重复作业不追加份额。 */
|
||||
private void convert(RelabelApplication app, RelabelItem item, ReturnInventoryCarton carton,
|
||||
String oldSku, String newSku, long quantity, String round, List<ReturnSkuConversion> existing) {
|
||||
oldSku = code(oldSku); newSku = code(newSku);
|
||||
@@ -118,6 +122,8 @@ public class ReturnSkuConversionService {
|
||||
if (remaining != 0) throw conflict("SKU 换标来源追溯数量不足");
|
||||
try { row.setOwnershipJson(json.writeValueAsString(movedOwners)); }
|
||||
catch (java.io.IOException failure) { throw new IllegalStateException("Cannot save SKU conversion ownership", failure); }
|
||||
// 库存与原计租份额同事务转换,保留精确来源以支持审核退回。
|
||||
row.setStorageRentSharesJson(storageRent.convertReturnSkuShares(carton.getCustomerId(),carton.getOverseasWarehouseId(),carton.getCartonMark(),oldSku,newSku,quantity));
|
||||
conversions.insert(row); existing.add(row); traceConversion(row, movedOwners, false);
|
||||
}
|
||||
|
||||
@@ -128,6 +134,7 @@ public class ReturnSkuConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核退回按原转换快照恢复库存及计租归属。 */
|
||||
@Transactional
|
||||
public void reverse(RelabelApplication app) {
|
||||
var rows = new ArrayList<>(conversions.lockApplication(app.getCustomerId(), app.getId())); Collections.reverse(rows);
|
||||
@@ -152,6 +159,8 @@ public class ReturnSkuConversionService {
|
||||
target.setQuantity(quantity); target.setPackingReservedQuantity(0L); target.setRelabelledQuantity(0L); contents.insert(target);
|
||||
} else { target.setQuantity(Math.addExact(n(target.getQuantity()), quantity)); contents.updateById(target); }
|
||||
adjustBalances(carton, row.getNewSkuCode(), row.getOldSkuCode(), row.getProductNameZh(), quantity, removed, added);
|
||||
// 必须恢复原份额,不能将目标 SKU 混合后的计租来源反向估算。
|
||||
storageRent.reverseReturnSkuShares(carton.getCustomerId(),carton.getOverseasWarehouseId(),carton.getCartonMark(),row.getOldSkuCode(),row.getNewSkuCode(),row.getStorageRentSharesJson());
|
||||
traceConversion(row, ownership, true);
|
||||
row.setStatus("REVERSED"); row.setReversedAt(LocalDateTime.now()); conversions.updateById(row); refreshTypes(carton);
|
||||
}
|
||||
|
||||
+2
@@ -22,6 +22,8 @@ public class ReturnSkuConversion {
|
||||
private Long quantity;
|
||||
private String status;
|
||||
private String ownershipJson;
|
||||
/** 原仓租批次迁移快照,审核退回时精确恢复。 */
|
||||
private String storageRentSharesJson;
|
||||
private LocalDateTime completedAt;
|
||||
private LocalDateTime confirmedAt;
|
||||
private LocalDateTime reversedAt;
|
||||
|
||||
+5
@@ -18,6 +18,8 @@ import java.util.*;
|
||||
/** Box quantities are released together after reconciliation to the immutable pallet receipt totals. */
|
||||
@Service
|
||||
public class ReturnCartonCountService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
|
||||
@Resource private ReturnReceiptMapper receipts;
|
||||
@Resource private ReturnOrderMapper orders;
|
||||
@Resource private ReturnReceiptService returnReceiptService;
|
||||
@@ -137,6 +139,8 @@ public class ReturnCartonCountService {
|
||||
}
|
||||
}
|
||||
if (!expected.equals(actual)) throw error(HttpStatus.CONFLICT, "各箱 SKU 数量汇总必须与本托签收数量一致;签收=" + expected + ",清点=" + actual);
|
||||
// 只在原签收和全部待清点箱仍完整时恢复经明确授权的历史计租批次,失败与本次清点一并回滚。
|
||||
storageRent.ensureReturnCountLot(receipt.getId());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (CountBox input : request.boxes()) {
|
||||
ReturnInventoryCarton box = byMark.get(input.cartonMark()); items.deleteCarton(box.getId());
|
||||
@@ -156,6 +160,7 @@ public class ReturnCartonCountService {
|
||||
line.setBoxCount(newCounts.getOrDefault(line.getSkuCode(), 0L));
|
||||
receiptItems.updateById(line);
|
||||
}
|
||||
storageRent.rebuildReturnShares(receipt.getId());
|
||||
returnReceiptService.refreshCountedTailCargo(receipt);
|
||||
if (sessions.release(identity.operatorKey(), receipt.getId()) != 1) throw error(HttpStatus.CONFLICT, "清点会话已变化,请重新核对");
|
||||
Task result = task(receipt, boxes);
|
||||
|
||||
+5
@@ -52,6 +52,8 @@ import java.util.HashMap;
|
||||
/** Return stock keeps its own receipt ownership, carton inventory and putaway facts. */
|
||||
@Service
|
||||
public class ReturnInventoryService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
|
||||
public static final String AVAILABLE = "AVAILABLE";
|
||||
public static final String MERGED = "MERGED";
|
||||
|
||||
@@ -1176,6 +1178,9 @@ public class ReturnInventoryService {
|
||||
if (request.height() != null) target.setHeight(request.height());
|
||||
target.setUpdatedAt(now); target.setLocationCode(null); target.setPutawayAt(null); cartons.updateById(target); cartons.clearPutaway(targetId);
|
||||
}
|
||||
// 合箱保留每个原签收托的计租份额,不能按目标箱重新起租。
|
||||
for (TraceTransfer transfer : transfers) storageRent.moveReturnShares(customerId, target.getOverseasWarehouseId(),
|
||||
transfer.source(), target.getCartonMark(), transfer.sku(), transfer.quantity());
|
||||
for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(),
|
||||
transfer.source(), target.getCartonMark(), transfer.returnNo(), transfer.sku(), transfer.quantity(), now);
|
||||
for (ReturnInventoryCarton source : sources) if (completedRelabelSources.contains(source.getId()) && MERGED.equals(source.getStatus()) && source.getRelabelApplicationId() == null)
|
||||
|
||||
+4
@@ -57,6 +57,8 @@ import java.util.Set;
|
||||
/** Customer return forecasts and PDA receipt facts. A receipt batch is the storage-fee start fact. */
|
||||
@Service
|
||||
public class ReturnReceiptService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
|
||||
public static final String PENDING_RECEIPT = "PENDING_RECEIPT";
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
|
||||
@@ -709,6 +711,8 @@ public class ReturnReceiptService {
|
||||
batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo());
|
||||
batch.setOverseasWarehouseId(order.getOverseasWarehouseId()); batch.setStorageBillingStatus("PENDING_PRICING");
|
||||
batch.setStorageStartedAt(now); batch.setCreatedAt(now); batches.insert(batch);
|
||||
// 签收即启动原托仓租,各分箱共享此原始时间与原托计费基数。
|
||||
storageRent.registerReturn(order, receipt);
|
||||
return confirmed(order, receipt, completed);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ public class LastlegOrderService {
|
||||
@Resource private LastlegOrderDetailMapper detailMapper;
|
||||
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
|
||||
@Resource private OverseasFeeSourceService feeSources;
|
||||
@Resource private com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees;
|
||||
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
|
||||
@Resource private LastlegOrderManualNodeMapper manualNodeMapper;
|
||||
@Resource private InboundPlanCartonMapper cartonMapper;
|
||||
@@ -400,6 +401,7 @@ public class LastlegOrderService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回订单详情及统一费用审核、余额锁扣状态。 */
|
||||
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
|
||||
LastlegOrder order = findOwned(customerId, orderId);
|
||||
returnOrderReferences.populateForRead(List.of(order));
|
||||
@@ -412,7 +414,12 @@ public class LastlegOrderService {
|
||||
.eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())
|
||||
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
|
||||
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
|
||||
List<OverseasFeeSource> fees = feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId);
|
||||
List<OverseasFeeSource> orderFees = new java.util.ArrayList<>(
|
||||
feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId));
|
||||
// 换标费用以申请为主业务,履约详情同时保留旧订单费用和新申请费用。
|
||||
if ("RELABEL_APPLICATION".equals(order.getOrderType()))
|
||||
orderFees.addAll(manualFees.relabelFeesForOrder(order.getCustomerId(), orderId));
|
||||
List<OverseasFeeSource> fees = manualFees.describe(orderFees);
|
||||
List<LastlegOrderOperationLog> operations = operationLogMapper.selectList(
|
||||
new LambdaQueryWrapper<LastlegOrderOperationLog>()
|
||||
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
|
||||
@@ -438,22 +445,22 @@ public class LastlegOrderService {
|
||||
? returnReceipts.detail(order.getCustomerId(), order.getSourceOrderNo()) : null);
|
||||
}
|
||||
|
||||
/** 订单详情统一委托人工费用服务,头程待审核入账,海外业务先锁款。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
|
||||
String operatorName, String idempotencyKey) {
|
||||
LastlegOrder order = findOwned(customerId, orderId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
OverseasFeeSource fee = feeSources.create(operatorName, idempotencyKey,
|
||||
OverseasFeeSource fee = manualFees.create(operatorName, idempotencyKey,
|
||||
new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(),
|
||||
"OVERSEAS_ORDER_FEE", "LASTLEG_ORDER", orderId, order.getOrderNo(), orderId,
|
||||
null, order.getInboundCode(), null, parseLong(order.getSourceContainerBookingId()),
|
||||
order.getContainerNo(), parseLong(order.getOverseasWarehouseId()), request.feeItemCode(),
|
||||
request.feeItemName(), request.direction(), request.supplierId(), request.supplierName(),
|
||||
request.feeItemName(), request.feeRemark(), request.direction(), request.supplierId(), request.supplierName(),
|
||||
request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(),
|
||||
request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(),
|
||||
request.currency(), request.amount(), request.chargeableQuantity(), request.quantityUnit(),
|
||||
request.unitPrice(), request.periodStart(), request.periodEnd()));
|
||||
fee = feeSources.submit(fee.getSourceFeeId(), operatorName, childKey(idempotencyKey, "submit"));
|
||||
|
||||
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
|
||||
operation.setId(IdWorker.getId());
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.storagerent.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.Pricing;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/** 仓租价格权威仍为国内仓库服务,不在海外维护第二份客户报价。 */
|
||||
@FeignClient(name="warehouse-service",contextId="customerStoragePricingClient",url="${baiyue.clients.domestic-warehouse.url:}")
|
||||
public interface CustomerStoragePricingClient {
|
||||
/** 使用内部服务令牌读取指定真实库存客户的当前仓租价格。 */
|
||||
@GetMapping("/internal/warehouse/v1/customer-overseas-storage-fees/{customerId}/pricing")
|
||||
ApiResponse<Pricing> pricing(@PathVariable("customerId") long customerId,
|
||||
@RequestHeader("X-Internal-Service-Token") String token);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.lastleg.storagerent.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
|
||||
import com.baiyue.lastleg.storagerent.service.*;
|
||||
import com.baiyue.lastleg.storagerent.service.preparation.StorageRentPreparationService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/** PDA 仓租只允许操作本人绑定仓。 */
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/pda/v1/last-leg/storage-rent")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public class PdaStorageRentController {
|
||||
private final StorageRentService rents;
|
||||
private final StorageRentPreparationService preparations;
|
||||
/** 扫码前确认仓租与余额授信冻结结果。 */
|
||||
@PostMapping("/preparations")
|
||||
public ApiResponse<PreparationResult> prepare(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody PrepareRequest request) {
|
||||
return ApiResponse.success(preparations.prepare(PdaWarehouseIdentity.require(user),request));
|
||||
}
|
||||
/** 查询已提交预占的真实财务回执状态。 */
|
||||
@GetMapping("/preparations/{id}")
|
||||
public ApiResponse<PreparationResult> status(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
|
||||
return ApiResponse.success(rents.preparation(String.valueOf(PdaWarehouseIdentity.require(user).overseasWarehouseId()),id));
|
||||
}
|
||||
/** 放弃扫码释放尚未使用的仓租占用。 */
|
||||
@DeleteMapping("/preparations/{id}")
|
||||
public ApiResponse<PreparationResult> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
|
||||
return ApiResponse.success(rents.cancel(String.valueOf(PdaWarehouseIdentity.require(user).overseasWarehouseId()),id));
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.lastleg.storagerent.controller;
|
||||
|
||||
import com.baiyue.common.core.api.*;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
|
||||
import com.baiyue.lastleg.storagerent.service.StorageRentService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
|
||||
/** 海外仓租原批次及逐次出库费用查询。 */
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/platform/v1/last-leg/storage-rent")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-fee:list')")
|
||||
public class PlatformStorageRentController {
|
||||
private final StorageRentService service;
|
||||
private final com.baiyue.lastleg.storagerent.service.StorageRentReviewService reviews;
|
||||
/** 审核前修改仓租金额并强制保存原因,入口权限与其他费用编辑一致。 */
|
||||
@PostMapping("/fees/{sourceFeeId}/adjust")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
|
||||
public ApiResponse<com.baiyue.lastleg.financefee.entity.OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long sourceFeeId,@jakarta.validation.Valid @RequestBody AdjustRentRequest request){
|
||||
platform(user);return ApiResponse.success(reviews.adjust(sourceFeeId,String.valueOf(user.userId()),request));
|
||||
}
|
||||
/** 按客户、业务及库存状态分页查询。 */
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<LotView>> list(@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute Query query) {
|
||||
platform(user);return ApiResponse.success(service.list(query));
|
||||
}
|
||||
/** 一个原批次下只列本批实际产生的费用。 */
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<LotDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
|
||||
platform(user);return ApiResponse.success(service.detail(id));
|
||||
}
|
||||
/** 查询已有计租批次对应仓库。 */
|
||||
@GetMapping("/warehouse-options")
|
||||
public ApiResponse<List<WarehouseOption>> warehouses(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
platform(user);return ApiResponse.success(service.warehouseOptions());
|
||||
}
|
||||
/** 资金与客户信息只允许平台作用域查询。 */
|
||||
private static void platform(AuthenticatedUser user) {
|
||||
if(user==null||user.tenantId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if(!"PLATFORM".equals(user.appCode())||user.tenantId()!=0)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可查询仓租");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.storagerent.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 仓租列表、来源快照与 PDA 预校验协议。 */
|
||||
public final class StorageRentDtos {
|
||||
/** 仅承载协议定义。 */
|
||||
private StorageRentDtos() { }
|
||||
public record PrepareRequest(@NotBlank @Pattern(regexp="DISPATCH|OUTBOUND|OUTBOUND_PALLET|DISCARD") String mode,
|
||||
@NotBlank @Size(max=128) String businessNo, Long itemId, Long positionId, @Positive Long packages,
|
||||
String cartonMark, String palletNo, @NotBlank @Size(max=255) String idempotencyKey,
|
||||
List<String> cartonMarks, String skuCode, @PositiveOrZero Long productQuantity,
|
||||
List<com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos.ActualLine> lines,
|
||||
List<Long> photoFileIds,List<String> scannedCartonMarks,Boolean confirmAllCartons) {
|
||||
/** 普通扫码不承载废弃作业的申请明细。 */
|
||||
public PrepareRequest(String mode,String businessNo,Long itemId,Long positionId,Long packages,
|
||||
String cartonMark,String palletNo,String idempotencyKey,List<String> cartonMarks,String skuCode,Long productQuantity) {
|
||||
this(mode,businessNo,itemId,positionId,packages,cartonMark,palletNo,idempotencyKey,cartonMarks,skuCode,productQuantity,null,null,null,null);
|
||||
}
|
||||
}
|
||||
public record PreparationResult(String preparationId, String status, boolean ready, BigDecimal amount,
|
||||
String currency, BigDecimal originalAmount, String originalCurrency, BigDecimal exchangeRate,
|
||||
String exchangeRateMonth, String message) { }
|
||||
public record Query(String customerId, String businessNo, String warehouseId, String businessType,
|
||||
String status, Long page, Long size) { }
|
||||
public record WarehouseOption(String id, String warehouseName) { }
|
||||
public record AdjustRentRequest(@NotNull @Min(1) Integer expectedVersion,
|
||||
@NotNull @DecimalMin("0.00") @Digits(integer=12,fraction=2) BigDecimal amount,
|
||||
@NotBlank @Size(max=500) String reason, @NotBlank @Size(max=128) String idempotencyKey) { }
|
||||
public record Owner(String customerId, String customerName, String businessType, Long businessId,
|
||||
String businessNo, Long orderId, String inboundCode, String warehouseId) { }
|
||||
public record Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount,
|
||||
LocalDateTime inboundAt, long days) { }
|
||||
public record PricingTier(String id,Integer startDay,Integer endDay,BigDecimal unitPrice,Integer sortNo,
|
||||
String feeType,String billingUnit) { }
|
||||
public record Pricing(String configId,Long customerId,Integer version,String currency,List<PricingTier> tiers,String source) {
|
||||
/** 固化不可变阶梯快照,防止调用方修改已确认的计费依据。 */
|
||||
public Pricing { if(tiers!=null)tiers=List.copyOf(tiers); }
|
||||
}
|
||||
public record Plan(Owner owner, List<Allocation> allocations, LocalDateTime calculatedAt, BigDecimal amount,Pricing pricing) {
|
||||
/** 本轮旧测试构造继续明确使用报价表默认值,实际预占必须读取配置服务。 */
|
||||
public Plan(Owner owner,List<Allocation> allocations,LocalDateTime calculatedAt,BigDecimal amount) {
|
||||
this(owner,allocations,calculatedAt,amount,com.baiyue.lastleg.storagerent.service.StorageRentCalculator.defaults(owner==null?null:Long.valueOf(owner.customerId())));
|
||||
}
|
||||
}
|
||||
public record CurrencyAmount(String currency,BigDecimal amount) { }
|
||||
public record LotView(Long id,String customerId,String customerName,String businessType,String businessNo,
|
||||
String warehouseId,String warehouseName,String batchNo,OffsetDateTime inboundAt,long storageDays,
|
||||
BigDecimal originalQuantity,BigDecimal remainingQuantity,String quantityUnit,BigDecimal billingQuantity,
|
||||
String billingUnit,String ruleCode,String status,BigDecimal generatedAmount,String generatedCurrency,List<CurrencyAmount> generatedAmounts,
|
||||
BigDecimal settledAmount,long chargeCount,long settledChargeCount) { }
|
||||
public record ChargeView(Long id,String outboundNo,String businessNo,BigDecimal quantity,long storageDays,
|
||||
BigDecimal originalAmount,String currency,BigDecimal settlementAmount,BigDecimal currencyRate,String status,
|
||||
OffsetDateTime createdAt,OffsetDateTime outboundAt) { }
|
||||
public record LotDetail(LotView lot,List<ChargeView> charges) { }
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.lastleg.storagerent.entity;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 当前计费服务单只读投影,仓租不操作其他业务聚合。 */
|
||||
@Data
|
||||
public class StorageRentBusinessSource {
|
||||
private Long id;
|
||||
private String businessNo;
|
||||
private Long lastlegOrderId;
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.lastleg.storagerent.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 实收原批次,库龄及计费基数不可被换标或移库重置。 */
|
||||
@Data
|
||||
@TableName("ll_storage_rent_lot")
|
||||
public class StorageRentLot {
|
||||
/** 原批次主键。 */
|
||||
@TableId(type=IdType.INPUT)
|
||||
private Long id;
|
||||
/** 实收来源幂等键。 */
|
||||
private String sourceKey;
|
||||
/** 客户标识。 */
|
||||
private String customerId;
|
||||
/** 客户名称。 */
|
||||
private String customerName;
|
||||
/** 海外仓标识。 */
|
||||
private String warehouseId;
|
||||
/** 海外仓名称。 */
|
||||
private String warehouseName;
|
||||
/** 原入库业务类型。 */
|
||||
private String businessType;
|
||||
/** 原入库业务主键。 */
|
||||
private Long businessId;
|
||||
/** 原入库业务单号。 */
|
||||
private String businessNo;
|
||||
/** 尾程订单主键。 */
|
||||
private Long orderId;
|
||||
/** 入仓码。 */
|
||||
private String inboundCode;
|
||||
/** 普通库存余额主键。 */
|
||||
private Long balanceId;
|
||||
/** 原实收批次。 */
|
||||
private String batchNo;
|
||||
/** 商品编码。 */
|
||||
private String skuCode;
|
||||
/** 仓租规则。 */
|
||||
private String ruleCode;
|
||||
/** 实收原始数量。 */
|
||||
private BigDecimal originalQuantity;
|
||||
/** 未出库原始数量。 */
|
||||
private BigDecimal remainingQuantity;
|
||||
/** 原批次计费体积或托数。 */
|
||||
private BigDecimal originalUnits;
|
||||
/** 实际入库时间。 */
|
||||
private LocalDateTime inboundAt;
|
||||
/** 创建时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
/** 更新时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.baiyue.lastleg.storagerent.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 一次扫码的资金冻结与不可变仓租快照。 */
|
||||
@Data
|
||||
@TableName("ll_storage_rent_preparation")
|
||||
public class StorageRentPreparation {
|
||||
/** 预校验主键。 */
|
||||
@TableId(type=IdType.INPUT)
|
||||
private Long id;
|
||||
/** 作业海外仓。 */
|
||||
private String warehouseId;
|
||||
/** 费用客户。 */
|
||||
private String customerId;
|
||||
/** 扫码类型。 */
|
||||
private String mode;
|
||||
/** 实际作业单号。 */
|
||||
private String executionNo;
|
||||
/** 原扫码幂等键。 */
|
||||
private String requestKey;
|
||||
/** 原扫码内容哈希。 */
|
||||
private String requestHash;
|
||||
/** 冻结尝试次数。 */
|
||||
private Integer attempt;
|
||||
/** 本次应收业务类型。 */
|
||||
private String businessType;
|
||||
/** 本次应收业务主键。 */
|
||||
private Long businessId;
|
||||
/** 本次应收业务单号。 */
|
||||
private String businessNo;
|
||||
/** 资金与扫码状态。 */
|
||||
private String status;
|
||||
/** 原批次计费不可变快照。 */
|
||||
private String planJson;
|
||||
/** 美元仓租金额。 */
|
||||
private BigDecimal originalAmount;
|
||||
/** 人民币结算金额。 */
|
||||
private BigDecimal amount;
|
||||
/** 冻结确认汇率。 */
|
||||
private BigDecimal exchangeRate;
|
||||
/** 汇率月份。 */
|
||||
private String exchangeRateMonth;
|
||||
/** 冻结指令事件。 */
|
||||
private String freezeEventId;
|
||||
/** 扣款指令事件。 */
|
||||
private String settleEventId;
|
||||
/** 解冻指令事件。 */
|
||||
private String releaseEventId;
|
||||
/** 解冻回执状态,失败后由恢复任务重发。 */
|
||||
private String releaseStatus;
|
||||
/** 同一预占资金命令版本,重试使用新版本保留审计。 */
|
||||
private Long walletVersion;
|
||||
/** 财务冻结主键。 */
|
||||
private String holdId;
|
||||
/** 财务扣款流水。 */
|
||||
private String ledgerId;
|
||||
/** 资金处理结果。 */
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS)
|
||||
private String errorMessage;
|
||||
/** 财务费用来源主键。 */
|
||||
private Long sourceFeeId;
|
||||
/** 预校验时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
/** 实际扫描出库时间。 */
|
||||
private LocalDateTime consumedAt;
|
||||
/** 更新时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.storagerent.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 退货箱内商品与原计租批次的数量归属。 */
|
||||
@Data
|
||||
@TableName("ll_storage_rent_share")
|
||||
public class StorageRentShare {
|
||||
/** 普通库存当前余额,退货箱份额为空。 */
|
||||
private Long balanceId;
|
||||
/** 普通库存当前批次,换标搬移保持原计租批次引用。 */
|
||||
private String batchNo;
|
||||
/** 份额主键。 */
|
||||
@TableId(type=IdType.INPUT)
|
||||
private Long id;
|
||||
/** 原计租批次主键。 */
|
||||
private Long lotId;
|
||||
/** 客户标识。 */
|
||||
private String customerId;
|
||||
/** 海外仓标识。 */
|
||||
private String warehouseId;
|
||||
/** 当前箱唛。 */
|
||||
private String cartonMark;
|
||||
/** 商品编码。 */
|
||||
private String skuCode;
|
||||
/** 剩余商品数量。 */
|
||||
private BigDecimal quantity;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.lastleg.storagerent.gateway;
|
||||
|
||||
import com.baiyue.lastleg.storagerent.client.CustomerStoragePricingClient;
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
|
||||
/** 无配置时只接受权威服务明确返回的默认报价,网络或非法配置不能降级免租。 */
|
||||
@Component
|
||||
public class StorageRentPricingGateway {
|
||||
@Resource private CustomerStoragePricingClient client;
|
||||
@Value("${baiyue.security.internal-service-token}") private String token;
|
||||
/** 不缓存客户报价;每次新预占读取当前版本,消费只用已保存的快照。 */
|
||||
public Pricing current(String customer) {
|
||||
try {
|
||||
long id=Long.parseLong(customer);var response=client.pricing(id,token);
|
||||
if(response==null||!response.successful()||response.data()==null)throw invalid();
|
||||
var pricing=response.data();validate(pricing,id);return pricing;
|
||||
} catch(ResponseStatusException failure){throw failure;}
|
||||
catch(Exception failure){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"客户仓租配置暂不可用,不能出库",failure);}
|
||||
}
|
||||
/** 保存和重放都校验完整阶梯,避免缺档、错单位或混币产生低收费。 */
|
||||
public static void validate(Pricing pricing,long customer) {
|
||||
if(pricing==null||!Objects.equals(pricing.customerId(),customer)||!("USD".equals(pricing.currency())||"CNY".equals(pricing.currency()))
|
||||
||!("CUSTOMER".equals(pricing.source())||"DEFAULT".equals(pricing.source()))||pricing.tiers()==null||pricing.tiers().isEmpty()
|
||||
||pricing.version()!=null&&pricing.version()<0||"CUSTOMER".equals(pricing.source())&&(pricing.version()==null||pricing.configId()==null||pricing.configId().isBlank()))throw invalid();
|
||||
for(var tier:pricing.tiers())if(tier==null||tier.startDay()==null||tier.startDay()<1||tier.unitPrice()==null||tier.unitPrice().signum()<0
|
||||
||!("STORAGE".equals(tier.feeType())&&"CBM".equals(tier.billingUnit())||"RETURN_STORAGE".equals(tier.feeType())&&"PALLET".equals(tier.billingUnit())))throw invalid();
|
||||
for(String type:List.of("STORAGE","RETURN_STORAGE")) {
|
||||
var tiers=pricing.tiers().stream().filter(t->type.equals(t.feeType())).sorted(Comparator.comparing(PricingTier::startDay)).toList();
|
||||
if(tiers.isEmpty())throw invalid();long next=1;
|
||||
for(int i=0;i<tiers.size();i++){var t=tiers.get(i);if(t.startDay()!=next||t.endDay()!=null&&t.endDay()<t.startDay())throw invalid();
|
||||
if(t.endDay()==null){if(i!=tiers.size()-1)throw invalid();next=-1;}else next=(long)t.endDay()+1;}
|
||||
if(next!=-1)throw invalid();
|
||||
}
|
||||
}
|
||||
/** 配置异常是出库门禁,绝不返回空阶梯或零金额替代。 */
|
||||
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.CONFLICT,"客户仓租配置不完整,不能出库");}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.storagerent.mapper;
|
||||
|
||||
import com.baiyue.lastleg.storagerent.entity.StorageRentBusinessSource;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
/** 同服务本地只读业务归属投影,不借用其他聚合的写 Mapper。 */
|
||||
@Mapper
|
||||
public interface StorageRentBusinessSourceMapper {
|
||||
/** 换标出库号解析该次独立服务单。 */
|
||||
@Select("SELECT id,relabel_no AS business_no,lastleg_order_id FROM ll_relabel_application WHERE customer_id=#{customer} AND outbound_order_no=#{outbound} LIMIT 1")
|
||||
StorageRentBusinessSource relabel(@Param("customer") String customer,@Param("outbound") String outbound);
|
||||
/** 排车来源换标单号精确匹配当前客户。 */
|
||||
@Select("SELECT id,relabel_no AS business_no,lastleg_order_id FROM ll_relabel_application WHERE customer_id=#{customer} AND relabel_no=#{number} LIMIT 1")
|
||||
StorageRentBusinessSource relabelNo(@Param("customer") String customer,@Param("number") String number);
|
||||
/** 仓储转运独立业务单归属。 */
|
||||
@Select("SELECT id,transfer_no AS business_no,lastleg_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} LIMIT 1")
|
||||
StorageRentBusinessSource transfer(@Param("customer") String customer,@Param("number") String number);
|
||||
/** 原尾程投影映射唯一仓储转运业务。 */
|
||||
@Select("SELECT id,transfer_no AS business_no,lastleg_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND lastleg_order_id=#{id} LIMIT 1")
|
||||
StorageRentBusinessSource transferByTail(@Param("customer") String customer,@Param("id") Long id);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.storagerent.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.storagerent.entity.StorageRentLot;
|
||||
|
||||
/** 实收原批次,库龄及计费基数不可被换标或移库重置持久化。 */
|
||||
@Mapper
|
||||
public interface StorageRentLotMapper extends BaseMapper<StorageRentLot> {
|
||||
/** 当前读实收计租登记,补入库及出库后重放均不重复累计。 */
|
||||
@org.apache.ibatis.annotations.Select("SELECT lot_id FROM ll_storage_rent_inbound WHERE receipt_id=#{receiptId} FOR UPDATE")
|
||||
Long inboundRentLot(@org.apache.ibatis.annotations.Param("receiptId") long receiptId);
|
||||
/** 同一实收凭证仅登记一次,异常回滚同时撤销批次和份额增量。 */
|
||||
@org.apache.ibatis.annotations.Insert("INSERT INTO ll_storage_rent_inbound(receipt_id,lot_id,quantity,created_at) VALUES(#{receiptId},#{lotId},#{quantity},UTC_TIMESTAMP(3))")
|
||||
int recordInboundRent(@org.apache.ibatis.annotations.Param("receiptId") long receiptId,@org.apache.ibatis.annotations.Param("lotId") long lotId,@org.apache.ibatis.annotations.Param("quantity") java.math.BigDecimal quantity);
|
||||
/** 仓库名称是原业务引用快照,选项不构建独立仓库主档。 */
|
||||
@org.apache.ibatis.annotations.Select("SELECT warehouse_id AS id,MAX(warehouse_name) AS warehouse_name FROM ll_storage_rent_lot GROUP BY warehouse_id ORDER BY warehouse_name,warehouse_id")
|
||||
java.util.List<com.baiyue.lastleg.storagerent.dto.StorageRentDtos.WarehouseOption> warehouseOptions();
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.storagerent.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.storagerent.entity.StorageRentPreparation;
|
||||
|
||||
/** 一次扫码的资金冻结与不可变仓租快照持久化。 */
|
||||
@Mapper
|
||||
public interface StorageRentPreparationMapper extends BaseMapper<StorageRentPreparation> {
|
||||
/** 关联原批次快照保留所有实际出库费用;直接展开避免关联子查询优化导致漏行。 */
|
||||
@org.apache.ibatis.annotations.Select("SELECT DISTINCT p.* FROM ll_storage_rent_preparation p JOIN JSON_TABLE(p.plan_json,'$.allocations[*]' COLUMNS(lot_id BIGINT PATH '$.lotId')) a ON a.lot_id=#{lotId} WHERE p.consumed_at IS NOT NULL ORDER BY p.consumed_at DESC,p.id DESC")
|
||||
java.util.List<StorageRentPreparation> charges(@org.apache.ibatis.annotations.Param("lotId") long lotId);
|
||||
/** 分页批量展开快照,多个份额命中同笔费用时去重,防止重复累计扣款。 */
|
||||
@org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT p.* FROM ll_storage_rent_preparation p JOIN JSON_TABLE(p.plan_json,'$.allocations[*]' COLUMNS(lot_id BIGINT PATH '$.lotId')) a ON a.lot_id IN <foreach collection='lotIds' item='id' open='(' close=')' separator=','>#{id}</foreach> WHERE p.consumed_at IS NOT NULL ORDER BY p.consumed_at DESC,p.id DESC</script>")
|
||||
java.util.List<StorageRentPreparation> chargesForLots(@org.apache.ibatis.annotations.Param("lotIds") java.util.List<Long> lotIds);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.storagerent.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.storagerent.entity.StorageRentShare;
|
||||
|
||||
/** 退货箱内商品与原计租批次的数量归属持久化。 */
|
||||
@Mapper
|
||||
public interface StorageRentShareMapper extends BaseMapper<StorageRentShare> { }
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.baiyue.lastleg.storagerent.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.lastleg.storagerent.entity.*;
|
||||
import com.baiyue.lastleg.storagerent.mapper.*;
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.inventory.entity.*;
|
||||
import com.baiyue.lastleg.inventory.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.entity.*;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.*;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.*;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.*;
|
||||
|
||||
/** 仓租全部持久化及同服务来源解析,服务不直接访问 Mapper。 */
|
||||
@Repository
|
||||
public class StorageRentRepository {
|
||||
@Resource private StorageRentLotMapper lots;
|
||||
@Resource private StorageRentShareMapper shares;
|
||||
@Resource private StorageRentPreparationMapper preparations;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private InventoryBalanceMapper balances;
|
||||
@Resource private DispatchAggregateMapper dispatches;
|
||||
@Resource private DispatchItemMapper dispatchItems;
|
||||
@Resource private OutboundPdaTaskMapper tasks;
|
||||
@Resource private OutboundOrderMapper outbounds;
|
||||
@Resource private OutboundPalletItemMapper outboundItems;
|
||||
@Resource private ReturnInventoryCartonMapper cartons;
|
||||
@Resource private ReturnInventoryCartonItemMapper cartonItems;
|
||||
@Resource private ReturnOrderMapper returns;
|
||||
@Resource private ReturnReceiptMapper returnReceipts;
|
||||
@Resource private ReturnReceiptItemMapper returnReceiptItems;
|
||||
@Resource private ReturnReceiptBatchMapper returnBatches;
|
||||
@Resource private StorageRentBusinessSourceMapper businessSources;
|
||||
|
||||
/** 按唯一实收来源读取,防止重复入库增加仓租基数。 */
|
||||
public StorageRentLot source(String key) { return lots.selectOne(new LambdaQueryWrapper<StorageRentLot>().eq(StorageRentLot::getSourceKey,key)); }
|
||||
/** 当前读锁定合并批次,避免事务快照漏掉其他入库事务刚建的批次。 */
|
||||
public StorageRentLot lockSource(String key) {return lots.selectOne(new LambdaQueryWrapper<StorageRentLot>().eq(StorageRentLot::getSourceKey,key).last("FOR UPDATE"));}
|
||||
/** 头程同单补入库串行化,不使用库存剩余量判定凭证是否已处理。 */
|
||||
public LastlegOrder lockOrder(long id) {return orders.selectOne(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getId,id).last("FOR UPDATE"));}
|
||||
/** 读取已登记的实收凭证,出库清空份额后仍保持幂等。 */
|
||||
public Long inboundRentLot(long receiptId) {return lots.inboundRentLot(receiptId);}
|
||||
/** 登记本次实收对原计租批次的增量,同入库事务提交。 */
|
||||
public void recordInboundRent(long receiptId,long lotId,java.math.BigDecimal quantity) {lots.recordInboundRent(receiptId,lotId,quantity);}
|
||||
/** 补入库查找含零数量的原库存份额,避免违反同批次唯一约束。 */
|
||||
public StorageRentShare lockStockShare(long lotId,long balanceId,String batchNo) {return shares.selectOne(new LambdaQueryWrapper<StorageRentShare>()
|
||||
.eq(StorageRentShare::getLotId,lotId).eq(StorageRentShare::getBalanceId,balanceId).eq(StorageRentShare::getBatchNo,batchNo).last("FOR UPDATE"));}
|
||||
/** 保存新的原始计租批次。 */
|
||||
public void insert(StorageRentLot lot) { lots.insert(lot); }
|
||||
/** 锁定原批次,出库扣减与实际库存处于同一事务。 */
|
||||
public StorageRentLot lockLot(long id) { return lots.selectOne(new LambdaQueryWrapper<StorageRentLot>().eq(StorageRentLot::getId,id).last("FOR UPDATE")); }
|
||||
/** 普通库存只解析同一原余额和原批次,不能跨入仓码猜来源。 */
|
||||
public List<StorageRentLot> stockLots(long balanceId,String batchNo) { return lots.selectList(new LambdaQueryWrapper<StorageRentLot>().eq(StorageRentLot::getBalanceId,balanceId).eq(StorageRentLot::getBatchNo,batchNo).gt(StorageRentLot::getRemainingQuantity,0).orderByAsc(StorageRentLot::getId).last("FOR UPDATE")); }
|
||||
/** 当前库存的原实收份额跨 SKU 和批次搬移后仍引用原计租批次。 */
|
||||
public List<StorageRentShare> stockShares(String customer,String warehouse,long balanceId,String batchNo) {
|
||||
return shares.selectList(new LambdaQueryWrapper<StorageRentShare>().eq(StorageRentShare::getCustomerId,customer)
|
||||
.eq(StorageRentShare::getWarehouseId,warehouse).eq(StorageRentShare::getBalanceId,balanceId).eq(StorageRentShare::getBatchNo,batchNo)
|
||||
.gt(StorageRentShare::getQuantity,0).orderByAsc(StorageRentShare::getLotId,StorageRentShare::getId).last("FOR UPDATE"));
|
||||
}
|
||||
/** 更新已实际出库后的原批次剩余数量。 */
|
||||
public void save(StorageRentLot lot) { lots.updateById(lot); }
|
||||
/** 保存原托商品归属,合箱不复制计租基数。 */
|
||||
public void insert(StorageRentShare share) { shares.insert(share); }
|
||||
/** 锁定某箱某商品的原批次归属,顺序固定避免并发合箱与出库互相覆盖。 */
|
||||
public List<StorageRentShare> shares(String customer,String warehouse,String mark,String sku) {
|
||||
return shares.selectList(new LambdaQueryWrapper<StorageRentShare>().eq(StorageRentShare::getCustomerId,customer)
|
||||
.eq(StorageRentShare::getWarehouseId,warehouse).eq(StorageRentShare::getCartonMark,mark)
|
||||
.eq(sku!=null,StorageRentShare::getSkuCode,sku).gt(StorageRentShare::getQuantity,0)
|
||||
.orderByAsc(StorageRentShare::getLotId,StorageRentShare::getId).last("FOR UPDATE"));
|
||||
}
|
||||
/** 按主键锁定冻结时记录的商品归属。 */
|
||||
public StorageRentShare lockShare(long id) { return shares.selectOne(new LambdaQueryWrapper<StorageRentShare>().eq(StorageRentShare::getId,id).last("FOR UPDATE")); }
|
||||
/** 更新或清空原托在当前箱的剩余份额。 */
|
||||
public void save(StorageRentShare share) { shares.updateById(share); }
|
||||
/** 签收后首次清点可重建该批次分箱,业务层确保尚未出库。 */
|
||||
public void deleteShares(long lotId) { shares.delete(new LambdaQueryWrapper<StorageRentShare>().eq(StorageRentShare::getLotId,lotId)); }
|
||||
/** 锁定同一次扫码的最新资金尝试,保留旧尝试供解冻回执关联。 */
|
||||
public StorageRentPreparation latest(String warehouse,String mode,String key) {
|
||||
return preparations.selectOne(new LambdaQueryWrapper<StorageRentPreparation>().eq(StorageRentPreparation::getWarehouseId,warehouse)
|
||||
.eq(StorageRentPreparation::getMode,mode).eq(StorageRentPreparation::getRequestKey,key)
|
||||
.orderByDesc(StorageRentPreparation::getAttempt).last("LIMIT 1 FOR UPDATE"));
|
||||
}
|
||||
/** 回执及取消按主键锁定原冻结记录。 */
|
||||
public StorageRentPreparation lockPreparation(long id) { return preparations.selectOne(new LambdaQueryWrapper<StorageRentPreparation>().eq(StorageRentPreparation::getId,id).last("FOR UPDATE")); }
|
||||
/** 费用列表批量读取本次出库的精确计费依据。 */
|
||||
public List<StorageRentPreparation> forFees(Collection<Long> ids) { return ids.isEmpty()?List.of():preparations.selectList(new LambdaQueryWrapper<StorageRentPreparation>().in(StorageRentPreparation::getSourceFeeId,ids)); }
|
||||
/** 创建资金尝试,唯一键保证扫码重试不会重复冻结。 */
|
||||
public void insert(StorageRentPreparation value) { preparations.insert(value); }
|
||||
/** 保存冻结、扣款、解冻状态及原事件身份。 */
|
||||
public void save(StorageRentPreparation value) { preparations.updateById(value); }
|
||||
/** 取消作业释放未使用的冻结。 */
|
||||
public List<StorageRentPreparation> pending(String mode,String execution) { return preparations.selectList(new LambdaQueryWrapper<StorageRentPreparation>().eq(StorageRentPreparation::getMode,mode).eq(StorageRentPreparation::getExecutionNo,execution).in(StorageRentPreparation::getStatus,"FREEZE_PENDING","READY","FAILED").orderByAsc(StorageRentPreparation::getId).last("FOR UPDATE")); }
|
||||
/** 列表只查询指定客户、仓库与业务类型,避免全量内存分页。 */
|
||||
public Page<StorageRentLot> page(Query q,long page,long size) {
|
||||
var filter=new LambdaQueryWrapper<StorageRentLot>().eq(text(q.customerId()),StorageRentLot::getCustomerId,q.customerId())
|
||||
.like(text(q.businessNo()),StorageRentLot::getBusinessNo,q.businessNo()).eq(text(q.warehouseId()),StorageRentLot::getWarehouseId,q.warehouseId())
|
||||
.eq(text(q.businessType()),StorageRentLot::getBusinessType,q.businessType());
|
||||
if("IN_STOCK".equals(q.status()))filter.gt(StorageRentLot::getRemainingQuantity,0);
|
||||
if("CLEARED".equals(q.status())||"OUTBOUNDED".equals(q.status()))filter.eq(StorageRentLot::getRemainingQuantity,0);
|
||||
return lots.selectPage(new Page<>(page,size),filter.orderByDesc(StorageRentLot::getInboundAt,StorageRentLot::getId));
|
||||
}
|
||||
/** 详情读取原始批次。 */
|
||||
public StorageRentLot lot(long id) { return lots.selectById(id); }
|
||||
/** 批量读取费用快照引用的原始计租来源,避免逐条明细查询。 */
|
||||
public List<StorageRentLot> lots(Collection<Long> ids) {return ids.isEmpty()?List.of():lots.selectBatchIds(ids);}
|
||||
/** 根据快照中原批次编号查出库费用,JSON_SEARCH 不依赖模糊数字匹配。 */
|
||||
public List<StorageRentPreparation> charges(long lotId) { return preparations.charges(lotId); }
|
||||
/** 当前页一次读取所有已出库快照,避免每行执行 JSON_TABLE。 */
|
||||
public List<StorageRentPreparation> charges(List<Long> lotIds) {return lotIds.isEmpty()?List.of():preparations.chargesForLots(lotIds);}
|
||||
/** 限量查出过期或失败记录,业务恢复逐条在独立事务锁定复核。 */
|
||||
public List<Long> recoveryCandidates(java.time.LocalDateTime expiry,java.time.LocalDateTime retryBefore) {
|
||||
return preparations.selectList(new LambdaQueryWrapper<StorageRentPreparation>()
|
||||
.and(w->w.and(x->x.in(StorageRentPreparation::getStatus,"FREEZE_PENDING","READY").lt(StorageRentPreparation::getCreatedAt,expiry))
|
||||
.or(x->x.and(y->y.in(StorageRentPreparation::getStatus,"SETTLE_FAILED","REVIEW_FREEZE_FAILED").or().eq(StorageRentPreparation::getReleaseStatus,"FAILED")).lt(StorageRentPreparation::getUpdatedAt,retryBefore)))
|
||||
.orderByAsc(StorageRentPreparation::getUpdatedAt).last("LIMIT 100")).stream().map(StorageRentPreparation::getId).toList();
|
||||
}
|
||||
/** 仓库选项来自当前计租业务引用,不建立第二套仓库主档。 */
|
||||
public List<WarehouseOption> warehouseOptions() { return lots.warehouseOptions(); }
|
||||
/** 业务来源订单。 */
|
||||
public LastlegOrder order(Long id) { return id==null?null:orders.selectById(id); }
|
||||
/** 原扫码选择的真实库存位置。 */
|
||||
public InventoryPosition position(Long id) { return id==null?null:positions.selectById(id); }
|
||||
/** 原库存余额所属订单。 */
|
||||
public InventoryBalance balance(Long id) { return id==null?null:balances.selectById(id); }
|
||||
/** 排车仓库及状态锁。 */
|
||||
public DispatchAggregate dispatch(String number) { return dispatches.lock(number); }
|
||||
/** 排车单明细,用于解析真实费用客户和业务单。 */
|
||||
public DispatchItem dispatchItem(Long id) { return id==null?null:dispatchItems.selectById(id); }
|
||||
/** PDA 出库工单与绑定仓校验。 */
|
||||
public OutboundPdaTask task(String warehouse,String number) { return tasks.lockByNoAndWarehouse(number,warehouse); }
|
||||
/** 实际出库单。 */
|
||||
public OutboundOrder outbound(Long id) { return outbounds.selectById(id); }
|
||||
/** 出库明细的原商品与原申请快照。 */
|
||||
public OutboundPalletItem outboundItem(Long id) { return id==null?null:outboundItems.selectById(id); }
|
||||
/** 原退货箱,商品数量和份额单独锁定。 */
|
||||
public ReturnInventoryCarton carton(String mark) { return cartons.lockMark(mark); }
|
||||
/** 原签收分箱列表。 */
|
||||
public List<ReturnInventoryCarton> receiptCartons(long id) { return cartons.lockReceipt(id); }
|
||||
/** 箱内每商品的当前实物数量。 */
|
||||
public List<ReturnInventoryCartonItem> items(long id) { return cartonItems.lockCarton(id); }
|
||||
/** 历史清点修复也锁定原始签收凭证,不根据箱码推断签收时间或原托数。 */
|
||||
public ReturnReceipt lockReturnReceipt(long id) {return returnReceipts.selectOne(new LambdaQueryWrapper<ReturnReceipt>().eq(ReturnReceipt::getId,id).last("FOR UPDATE"));}
|
||||
/** 原退货业务归属必须与签收、库存箱和计租批次完全相同。 */
|
||||
public ReturnOrder returnOrder(long id) {return returns.selectById(id);}
|
||||
/** 原签收商品明细是件数权威,不能从清点输入补造原数量。 */
|
||||
public List<ReturnReceiptItem> returnReceiptItems(long id) {return returnReceiptItems.selectList(new LambdaQueryWrapper<ReturnReceiptItem>().eq(ReturnReceiptItem::getReceiptId,id).last("FOR UPDATE"));}
|
||||
/** 已保存的签收计费起点用于交叉核实历史时间和仓库。 */
|
||||
public List<ReturnReceiptBatch> returnReceiptBatches(long id) {return returnBatches.selectList(new LambdaQueryWrapper<ReturnReceiptBatch>().eq(ReturnReceiptBatch::getReceiptId,id).last("FOR UPDATE"));}
|
||||
/** 本次退货应收归属。 */
|
||||
public ReturnOrder returnOrder(String customer,String no) { return returns.selectOne(new LambdaQueryWrapper<ReturnOrder>().eq(ReturnOrder::getCustomerId,customer).eq(ReturnOrder::getReturnNo,no)); }
|
||||
/** 从换标出库单解析当前业务申请。 */
|
||||
public StorageRentBusinessSource relabel(String customer,String outbound) { return businessSources.relabel(customer,outbound); }
|
||||
/** 排车明细使用明确换标业务单号匹配当前独立服务申请。 */
|
||||
public StorageRentBusinessSource relabelNo(String customer,String no) { return businessSources.relabelNo(customer,no); }
|
||||
/** 按明确的转运单号解析归属。 */
|
||||
public StorageRentBusinessSource transfer(String customer,String no) { return businessSources.transfer(customer,no); }
|
||||
/** 原订单映射其独立仓储转运工单。 */
|
||||
public StorageRentBusinessSource transferByTail(String customer,Long id) { return businessSources.transferByTail(customer,id); }
|
||||
/** 不把空字符串用于查询过滤。 */
|
||||
private static boolean text(String value) { return value!=null&&!value.isBlank(); }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.storagerent.scheduler;
|
||||
|
||||
import com.baiyue.lastleg.storagerent.service.StorageRentService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 未使用预占自动释放,资金失败保留原账龄并恢复处理。 */
|
||||
@Slf4j
|
||||
@Component
|
||||
public class StorageRentRecoveryScheduler {
|
||||
@Resource private StorageRentService service;
|
||||
/** 单条失败不阻断其余客户恢复,事务锁保证多实例不会重复发布。 */
|
||||
@Scheduled(fixedDelayString="${lastleg.storage-rent.recovery-delay-ms:60000}")
|
||||
public void recover() {
|
||||
for(Long id:service.recoveryCandidates())try{service.recover(id);}catch(Exception failure){log.warn("仓租资金恢复失败 preparationId={}",id,failure);}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.lastleg.storagerent.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
|
||||
|
||||
/** 仓租按实收时刻起每满或不足二十四小时进一天,分段累加。 */
|
||||
public final class StorageRentCalculator {
|
||||
/** 纯计费工具不允许实例化。 */
|
||||
private StorageRentCalculator() { }
|
||||
/** 零时长不收费,任何正时长向上取整,禁止以自然日期代替库龄。 */
|
||||
public static long days(LocalDateTime inbound, LocalDateTime outbound) {
|
||||
if (inbound == null || outbound == null || outbound.isBefore(inbound)) throw new IllegalArgumentException("计租时间无效");
|
||||
Duration elapsed = Duration.between(inbound, outbound);
|
||||
long seconds = elapsed.getSeconds();
|
||||
return seconds / 86400 + (seconds % 86400 > 0 || elapsed.getNano() > 0 ? 1 : 0);
|
||||
}
|
||||
/** 返回每单位的累计租金,区间端点与报价表保持一致。 */
|
||||
public static BigDecimal unitAmount(String rule, long days) {
|
||||
return unitAmount(defaults(null),rule,days);
|
||||
}
|
||||
/** 已确认的客户阶梯按业务类型分组累加,不能混用退货和中转单位。 */
|
||||
public static BigDecimal unitAmount(Pricing pricing,String rule,long days) {
|
||||
if(days<0)throw new IllegalArgumentException("库龄不能为负数");
|
||||
String type=switch(rule){case "TRANSIT"->"STORAGE";case "RETURN"->"RETURN_STORAGE";default->throw new IllegalArgumentException("仓租规则无效");};
|
||||
var tiers=pricing.tiers().stream().filter(t->type.equals(t.feeType())).toList();
|
||||
if(tiers.isEmpty())throw new IllegalArgumentException("仓租报价缺少业务阶梯");
|
||||
return tiers.stream().map(t->BigDecimal.valueOf(Math.max(0,Math.min(days,t.endDay()==null?Long.MAX_VALUE:t.endDay())-t.startDay()+1)).multiply(t.unitPrice())).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
}
|
||||
/** 每次出库只承担原批次对应份额,退货原托的拆分不重新向上取托数。 */
|
||||
public static BigDecimal amount(String rule, LocalDateTime inbound, LocalDateTime outbound,
|
||||
BigDecimal originalUnits, BigDecimal originalQuantity, BigDecimal shippedQuantity) {
|
||||
return amount(defaults(null),rule,inbound,outbound,originalUnits,originalQuantity,shippedQuantity);
|
||||
}
|
||||
/** 实际出库使用预占所保存的价格快照,不读取客户当前报价。 */
|
||||
public static BigDecimal amount(Pricing pricing,String rule,LocalDateTime inbound,LocalDateTime outbound,
|
||||
BigDecimal originalUnits,BigDecimal originalQuantity,BigDecimal shippedQuantity) {
|
||||
if (originalUnits == null || originalUnits.signum() <= 0 || originalQuantity == null || originalQuantity.signum() <= 0
|
||||
|| shippedQuantity == null || shippedQuantity.signum() <= 0 || shippedQuantity.compareTo(originalQuantity) > 0)
|
||||
throw new IllegalArgumentException("计租体积或原批次数量不完整");
|
||||
return unitAmount(pricing,rule, days(inbound, outbound)).multiply(originalUnits).multiply(shippedQuantity)
|
||||
.divide(originalQuantity, 12, RoundingMode.HALF_UP);
|
||||
}
|
||||
/** 两张报价表仅作为明确默认报价及本轮测试构造,服务故障不调用此默认值。 */
|
||||
public static Pricing defaults(Long customer) {
|
||||
return new Pricing(null,customer,null,"USD",List.of(
|
||||
tier("STORAGE","CBM",1,7,"0"),tier("STORAGE","CBM",8,30,"0.8"),tier("STORAGE","CBM",31,60,"1"),tier("STORAGE","CBM",61,90,"1.5"),tier("STORAGE","CBM",91,null,"2"),
|
||||
tier("RETURN_STORAGE","PALLET",1,30,"1.5"),tier("RETURN_STORAGE","PALLET",31,60,"2"),tier("RETURN_STORAGE","PALLET",61,90,"3"),tier("RETURN_STORAGE","PALLET",91,null,"4")),"DEFAULT");
|
||||
}
|
||||
/** 默认阶梯也保留业务和单位,使快照协议完全一致。 */
|
||||
private static PricingTier tier(String type,String unit,int start,Integer end,String rate){return new PricingTier(null,start,end,new BigDecimal(rate),start,type,unit);}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
package com.baiyue.lastleg.storagerent.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
import com.baiyue.common.event.WalletSettlementReference;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.*;
|
||||
import com.baiyue.lastleg.financefee.entity.*;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository;
|
||||
import com.baiyue.lastleg.financefee.service.*;
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
|
||||
import com.baiyue.lastleg.storagerent.entity.StorageRentPreparation;
|
||||
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
|
||||
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** 出库仓租沿原冻结审核和调整;资金成功回执才确认实扣,不生成第二笔应收。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class StorageRentReviewService implements StorageRentFeeWorkflow {
|
||||
private final StorageRentRepository rents;
|
||||
private final OverseasManualFeeRepository fees;
|
||||
private final OverseasFeeSourceService sources;
|
||||
private final OverseasFeeCurrencyConverter converter;
|
||||
private final WalletCommandOutboxService wallet;
|
||||
private final ObjectMapper json;
|
||||
|
||||
/** 来源标记只用于路由,实际写入还要核对仓租和费用持久化关联。 */
|
||||
public static boolean isRent(OverseasFeeSource fee) {
|
||||
return "STORAGE_RENT".equals(fee.getCreatedBy()) && "FEE_TYPE_077".equals(fee.getFeeItemCode());
|
||||
}
|
||||
/** 旧版已扣仓租没有审核资金编排,仍由原回执状态机收尾。 */
|
||||
public boolean managed(StorageRentPreparation rent){return rent.getSourceFeeId()!=null&&fees.funding(rent.getSourceFeeId())!=null;}
|
||||
|
||||
/** 出库事务内登记应收和原资金占用,尚未审核时不发结算指令。 */
|
||||
public void create(StorageRentPreparation rent, SaveRequest request, Plan plan) {
|
||||
var conversion = new OverseasFeeCurrencyConverter.Conversion(plan.pricing().currency(),rent.getOriginalAmount(),
|
||||
rent.getExchangeRate(),rent.getExchangeRateMonth(),rent.getAmount());
|
||||
var fee = sources.createPendingStorageRent(rent.getId().toString(),request,conversion);
|
||||
rent.setSourceFeeId(fee.getSourceFeeId()); rent.setStatus("PENDING_REVIEW");
|
||||
var funding = new OverseasFeeFunding(); funding.setSourceFeeId(fee.getSourceFeeId());
|
||||
funding.setCustomerId(rent.getCustomerId()); funding.setCollectionMode("WALLET");
|
||||
funding.setWalletStatus("FROZEN"); funding.setWalletBusinessNo(rent.getId().toString());
|
||||
funding.setWalletHoldId(rent.getHoldId()); funding.setCommandVersion(rent.getWalletVersion());
|
||||
funding.setCommandHistory(new LinkedHashMap<>()); funding.setCreatedAt(now()); funding.setUpdatedAt(now());
|
||||
funding.getCommandHistory().put("holdAmount",rent.getAmount().toPlainString());
|
||||
if(rent.getFreezeEventId()!=null)funding.getCommandHistory().put("initialFreeze",rent.getFreezeEventId());
|
||||
fees.insertFunding(funding);
|
||||
}
|
||||
|
||||
/** 列表及订单明细共用精确入库、出库及实扣依据,零元不伪造扣款流水。 */
|
||||
public void describe(List<OverseasFeeSource> rows) {
|
||||
var ids=rows.stream().filter(StorageRentReviewService::isRent).map(OverseasFeeSource::getSourceFeeId).toList();
|
||||
var preparations=new HashMap<Long,StorageRentPreparation>();
|
||||
for(var rent:rents.forFees(ids))preparations.put(rent.getSourceFeeId(),rent);
|
||||
var plans=new HashMap<Long,Plan>();
|
||||
for(var rent:preparations.values())plans.put(rent.getId(),plan(rent));
|
||||
var lotIds=plans.values().stream().flatMap(p->p.allocations().stream()).map(Allocation::lotId).distinct().toList();
|
||||
var lots=new HashMap<Long,com.baiyue.lastleg.storagerent.entity.StorageRentLot>();
|
||||
for(var lot:rents.lots(lotIds))lots.put(lot.getId(),lot);
|
||||
for(var fee:rows){var rent=preparations.get(fee.getSourceFeeId());if(rent==null)continue;
|
||||
var plan=plans.get(rent.getId());
|
||||
fee.setStorageRent(new OverseasFeeSource.RentDetail(rent.getExecutionNo(),rent.getConsumedAt().atOffset(ZoneOffset.UTC),
|
||||
plan.amount(),"SETTLED".equals(rent.getStatus())?fee.getAmount():null,
|
||||
plan.allocations().stream().map(a->{
|
||||
var lot=lots.get(a.lotId());
|
||||
// 只展示同客户、同仓库的原批次来源,缺失关联不猜测为当前换标单。
|
||||
boolean owned=lot!=null&&Objects.equals(lot.getCustomerId(),fee.getCustomerId())
|
||||
&&Objects.equals(lot.getWarehouseId(),rent.getWarehouseId());
|
||||
return new OverseasFeeSource.RentLine(a.lotId(),a.inboundAt().atOffset(ZoneOffset.UTC),a.quantity(),a.days(),a.amount(),
|
||||
owned?lot.getBusinessType():null,owned?lot.getBusinessNo():null,owned?lot.getBatchNo():null);
|
||||
}).toList()));
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核与幂等命令同事务保存;审批通过后才申请按确认金额结算。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource review(long id,String operator,String name,ReviewRequest request){
|
||||
var rent=requiredRent(id);var fee=requiredFee(rent);var funding=fees.lockFunding(id);
|
||||
if(replayed(operator,"RENT_REVIEW",request.idempotencyKey(),id,request))return fee;
|
||||
if(!Objects.equals(fee.getSourceVersion(),request.expectedVersion()))throw conflict("仓租金额版本已变化,请刷新后重新审核");
|
||||
if(!"PENDING".equals(fee.getReviewStatus())||!"PENDING_REVIEW".equals(rent.getStatus())
|
||||
||funding==null||!"FROZEN".equals(funding.getWalletStatus()))throw conflict("仓租尚未锁定或已审核,请刷新");
|
||||
if(!Set.of("APPROVE","REJECT").contains(request.decision()))throw conflict("审核操作无效");
|
||||
boolean approved="APPROVE".equals(request.decision());
|
||||
if(!approved&&(request.reason()==null||request.reason().isBlank()))throw conflict("请填写驳回原因");
|
||||
fee.setReviewStatus(approved?"APPROVED":"REJECTED");fee.setReviewerId(operator);fee.setReviewerName(name);
|
||||
fee.setReviewedAt(now());fee.setReviewReason(request.reason());fee.setUpdatedBy(operator);fee.setUpdatedAt(now());
|
||||
if(!approved){rent.setStatus("REJECTED");if(rent.getHoldId()!=null)send(rent,funding,"RELEASE");else funding.setWalletStatus("RELEASED");}
|
||||
else if(fee.getAmount().signum()==0){
|
||||
if(rent.getHoldId()!=null){rent.setStatus("CONSUMED");send(rent,funding,"RELEASE");}
|
||||
else{rent.setStatus("SETTLED");funding.setWalletStatus("SETTLED");fee.setPublicationStatus("PUBLISHED");}
|
||||
}else{rent.setStatus("CONSUMED");send(rent,funding,"SETTLE_ACTUAL");}
|
||||
save(rent,fee,funding);record(operator,"RENT_REVIEW",request.idempotencyKey(),id,request,fee.getSourceVersion());return fee;
|
||||
}
|
||||
|
||||
/** 审核前改费生成不可覆盖的新版本,固定原币和原汇率,并强制保存说明。 */
|
||||
@Transactional
|
||||
public OverseasFeeSource adjust(long id,String operator,AdjustRentRequest request){
|
||||
var rent=requiredRent(id);var old=requiredFee(rent);var funding=fees.lockFunding(id);
|
||||
if(replayed(operator,"RENT_ADJUST",request.idempotencyKey(),id,request))return old;
|
||||
if(funding==null||!"FROZEN".equals(funding.getWalletStatus())||!"PENDING_REVIEW".equals(rent.getStatus())||!"PENDING".equals(old.getReviewStatus())
|
||||
||!Objects.equals(old.getSourceVersion(),request.expectedVersion()))throw conflict("仅当前待审核仓租可以调整,请刷新");
|
||||
if(request.reason()==null||request.reason().isBlank()||request.reason().trim().length()>500
|
||||
||request.amount()==null||request.amount().signum()<0||request.amount().scale()>2)throw conflict("请填写有效金额和调整说明");
|
||||
var next=json.convertValue(old,OverseasFeeSource.class);next.setId(IdWorker.getId());next.setSourceVersion(old.getSourceVersion()+1);
|
||||
next.setOriginalAmount(request.amount());next.setFeeRemark(request.reason().trim());next.setUpdatedBy(operator);next.setUpdatedAt(now());next.setStorageRent(null);
|
||||
if(next.getExchangeRate()==null&&request.amount().signum()>0){
|
||||
var converted=converter.convert(next.getOriginalCurrency(),request.amount(),next.getBusinessDate());
|
||||
next.setExchangeRate(converted.exchangeRate());next.setExchangeRateMonth(converted.exchangeRateMonth());
|
||||
rent.setExchangeRate(converted.exchangeRate());rent.setExchangeRateMonth(converted.exchangeRateMonth());
|
||||
}
|
||||
next.setAmount(request.amount().signum()==0?BigDecimal.ZERO.setScale(2):request.amount().multiply(next.getExchangeRate()).setScale(2,RoundingMode.HALF_UP));
|
||||
rent.setOriginalAmount(next.getOriginalAmount());rent.setAmount(next.getAmount());
|
||||
// 原非零冻结不提前释放;Finance 按实额结算时原子校验差额,余额不足保留原冻结。
|
||||
if(rent.getHoldId()==null&&next.getAmount().signum()>0){rent.setStatus("REVIEW_FREEZE_PENDING");send(rent,funding,"FREEZE");}
|
||||
old.setSourceStatus("SUPERSEDED");fees.updateFee(old);fees.insertFee(next);
|
||||
rent.setUpdatedAt(now());rents.save(rent);funding.setUpdatedAt(now());fees.updateFunding(funding);
|
||||
record(operator,"RENT_ADJUST",request.idempotencyKey(),id,request,next.getSourceVersion());return next;
|
||||
}
|
||||
|
||||
/** 原冻结身份接收资金结果,只有批准且匹配原指令的回执可登记已扣金额。 */
|
||||
public void handle(StorageRentPreparation rent,WalletCommandCompletedV1 event){
|
||||
var fee=requiredFee(rent);var funding=fees.lockFunding(fee.getSourceFeeId());
|
||||
if("FREEZE".equals(event.operation())&&Objects.equals(funding.getCommandHistory().get("initialFreeze"),event.commandEventId())){
|
||||
if(!Objects.equals(event.businessNo(),rent.getId().toString())||!"STORAGE_RENT".equals(event.businessType())
|
||||
||!fee.getCustomerId().equals(String.valueOf(event.customerId()))||!"SUCCEEDED".equals(event.result())
|
||||
||!"FROZEN".equals(event.holdStatus())||!Objects.equals(rent.getHoldId(),event.walletHoldId())
|
||||
||event.amount()==null||event.amount().compareTo(new BigDecimal(funding.getCommandHistory().get("holdAmount")))!=0)
|
||||
throw conflict("原仓租冻结回执不一致");
|
||||
return;
|
||||
}
|
||||
String expected=funding.getCommandHistory().get(event.commandEventId());String fingerprint=event.operation()+":"+hash(event);
|
||||
if(fingerprint.equals(expected))return;
|
||||
if(!Objects.equals(funding.getCommandEventId(),event.commandEventId())||!Objects.equals(expected,event.operation())
|
||||
||!Objects.equals(event.businessNo(),funding.getWalletBusinessNo())||!"STORAGE_RENT".equals(event.businessType())
|
||||
||!fee.getCustomerId().equals(String.valueOf(event.customerId()))||event.completedAt()==null
|
||||
||!Set.of("SUCCEEDED","FAILED").contains(Objects.toString(event.result(),"")))throw conflict("仓租资金回执与原指令不一致");
|
||||
boolean success="SUCCEEDED".equals(event.result());
|
||||
var expectedAmount="RELEASE".equals(event.operation())?new BigDecimal(funding.getCommandHistory().get("holdAmount")):rent.getAmount();
|
||||
if(success&&(event.amount()==null||event.amount().compareTo(expectedAmount)!=0))throw conflict("仓租资金金额不一致");
|
||||
if(success&&!"FREEZE".equals(event.operation())&&!Objects.equals(rent.getHoldId(),event.walletHoldId()))throw conflict("仓租冻结凭据不一致");
|
||||
if(success)switch(event.operation()){
|
||||
case "FREEZE"->{if(!"FROZEN".equals(event.holdStatus())||event.walletHoldId()==null)throw conflict("仓租冻结凭据不完整");rent.setHoldId(event.walletHoldId());funding.setWalletHoldId(event.walletHoldId());funding.getCommandHistory().put("holdAmount",event.amount().toPlainString());rent.setStatus("PENDING_REVIEW");funding.setWalletStatus("FROZEN");}
|
||||
case "SETTLE_ACTUAL"->{
|
||||
if(!"APPROVED".equals(fee.getReviewStatus())||!"SETTLED".equals(event.holdStatus())||event.walletLedgerId()==null)throw conflict("仓租尚未审核或实扣凭据不完整");
|
||||
fee.setWalletSettlement(new WalletSettlementReference(event.commandEventId(),event.walletHoldId(),event.walletLedgerId(),"STORAGE_RENT",rent.getId().toString()));
|
||||
fee.setPublicationStatus("PUBLISHED");rent.setStatus("SETTLED");rent.setLedgerId(event.walletLedgerId());funding.setWalletLedgerId(event.walletLedgerId());funding.setWalletStatus("SETTLED");
|
||||
}
|
||||
case "RELEASE"->{if(!"RELEASED".equals(event.holdStatus()))throw conflict("仓租解冻凭据不完整");rent.setReleaseStatus("SUCCEEDED");funding.setWalletStatus("RELEASED");
|
||||
if("APPROVED".equals(fee.getReviewStatus())&&fee.getAmount().signum()==0){rent.setStatus("SETTLED");fee.setPublicationStatus("PUBLISHED");funding.setWalletStatus("SETTLED");}}
|
||||
default->throw conflict("仓租资金操作无效");
|
||||
}else{funding.setWalletStatus(("SETTLE_ACTUAL".equals(event.operation())?"SETTLE":event.operation())+"_FAILED");
|
||||
if("SETTLE_ACTUAL".equals(event.operation()))rent.setStatus("SETTLE_FAILED");
|
||||
if("FREEZE".equals(event.operation()))rent.setStatus("REVIEW_FREEZE_FAILED");
|
||||
if("RELEASE".equals(event.operation()))rent.setReleaseStatus("FAILED");}
|
||||
funding.getCommandHistory().put(event.commandEventId(),fingerprint);funding.setErrorMessage(success?null:event.errorMessage());rent.setErrorMessage(success?null:event.errorMessage());save(rent,fee,funding);
|
||||
}
|
||||
|
||||
/** 已知失败才产生新资金版本,不重放库存或重建费用;待审核冻结不因超时释放。 */
|
||||
public void recover(StorageRentPreparation rent){
|
||||
var fee=requiredFee(rent);var funding=fees.lockFunding(fee.getSourceFeeId());
|
||||
if("SETTLE_FAILED".equals(funding.getWalletStatus())&&"APPROVED".equals(fee.getReviewStatus())){rent.setStatus("CONSUMED");send(rent,funding,"SETTLE_ACTUAL");}
|
||||
else if("RELEASE_FAILED".equals(funding.getWalletStatus()))send(rent,funding,"RELEASE");
|
||||
else if("FREEZE_FAILED".equals(funding.getWalletStatus())&&"PENDING".equals(fee.getReviewStatus())){rent.setStatus("REVIEW_FREEZE_PENDING");send(rent,funding,"FREEZE");}
|
||||
else return;
|
||||
save(rent,fee,funding);
|
||||
}
|
||||
|
||||
/** 同一仓租复用原冻结;释放必须提交原冻结额,实扣提交审核后的实际金额。 */
|
||||
private void send(StorageRentPreparation rent,OverseasFeeFunding funding,String operation){
|
||||
long version=Math.addExact(rent.getWalletVersion(),1);rent.setWalletVersion(version);
|
||||
var amount="RELEASE".equals(operation)?null:rent.getAmount();
|
||||
String event=wallet.publish(operation,Long.parseLong(rent.getCustomerId()),"STORAGE_RENT",rent.getId().toString(),amount,"海外仓租审核结算",version);
|
||||
funding.setCommandEventId(event);funding.setCommandVersion(version);funding.getCommandHistory().put(event,operation);
|
||||
funding.setWalletStatus(("SETTLE_ACTUAL".equals(operation)?"SETTLE":operation)+"_PENDING");funding.setErrorMessage(null);
|
||||
if("RELEASE".equals(operation)){rent.setReleaseEventId(event);rent.setReleaseStatus("PENDING");}
|
||||
else if("FREEZE".equals(operation))rent.setFreezeEventId(event);else rent.setSettleEventId(event);
|
||||
}
|
||||
/** 写入口统一加锁顺序并核对费用来源,拒绝旧已扣费用进入新审核流程。 */
|
||||
private StorageRentPreparation requiredRent(long id){
|
||||
// 资金主键定位原预占主键,避免按无索引的费用引用扫描并锁住其他客户仓租。
|
||||
var state=fees.funding(id);
|
||||
if(state==null||state.getWalletBusinessNo()==null||!state.getWalletBusinessNo().matches("[1-9][0-9]{0,18}"))throw conflict("没有对应的出库仓租");
|
||||
var rent=rents.lockPreparation(Long.parseLong(state.getWalletBusinessNo()));
|
||||
if(rent==null||rent.getConsumedAt()==null||!Objects.equals(rent.getSourceFeeId(),id))throw conflict("没有对应的出库仓租");
|
||||
return rent;
|
||||
}
|
||||
/** 仓租不能借用其他客户或项目的费用来源。 */
|
||||
private OverseasFeeSource requiredFee(StorageRentPreparation rent){var fee=fees.lockFee(rent.getSourceFeeId());if(fee==null||!isRent(fee)||!Objects.equals(fee.getCustomerId(),rent.getCustomerId()))throw conflict("仓租费用来源不一致");return fee;}
|
||||
/** 状态、费用和资金占用在调用方同一事务内提交。 */
|
||||
private void save(StorageRentPreparation rent,OverseasFeeSource fee,OverseasFeeFunding funding){rent.setUpdatedAt(now());rents.save(rent);fee.setUpdatedAt(now());fees.updateFee(fee);funding.setUpdatedAt(now());fees.updateFunding(funding);}
|
||||
/** 幂等签名包含费用身份,同键不能修改审核决定、金额或原因。 */
|
||||
private boolean replayed(String operator,String action,String key,long id,Object request){var prior=fees.command(operator,action,key);if(prior==null)return false;if(!Objects.equals(prior.getSourceFeeId(),id)||!Objects.equals(prior.getRequestHash(),hash(request)))throw conflict("操作标识已用于不同仓租请求");return true;}
|
||||
/** 保存每次审核和调整的独立操作标识,版本记录保留调整前后的金额和说明。 */
|
||||
private void record(String operator,String action,String key,long id,Object request,int version){var command=new OverseasFeeCommand();command.setId(IdWorker.getId());command.setOperatorId(operator);command.setAction(action);command.setIdempotencyKey(key);command.setSourceFeeId(id);command.setSourceVersion(version);command.setRequestHash(hash(request));command.setCreatedAt(now());fees.insertCommand(command);}
|
||||
/** 已保存的原批次计费依据不可重新定价。 */
|
||||
private Plan plan(StorageRentPreparation rent){try{return json.readValue(rent.getPlanJson(),Plan.class);}catch(Exception failure){throw new IllegalStateException("仓租快照读取失败",failure);}}
|
||||
/** 对持久化业务请求计算稳定签名,不接受内容变化的重试。 */
|
||||
private String hash(Object value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}catch(Exception failure){throw new IllegalStateException(failure);}}
|
||||
/** 仓租新记录统一保存 UTC。 */
|
||||
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
|
||||
/** 状态冲突交由统一接口协议返回。 */
|
||||
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
package com.baiyue.lastleg.storagerent.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
import com.baiyue.common.event.WalletSettlementReference;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.*;
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
|
||||
import com.baiyue.lastleg.storagerent.entity.*;
|
||||
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.wallet.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.*;
|
||||
import java.time.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
/** 实收计租、资金预占、扫码扣减与财务已扣来源发布的同源事务编排。 */
|
||||
@Service
|
||||
public class StorageRentService implements WalletResultHandler {
|
||||
@Resource private StorageRentRepository repository;
|
||||
@Resource private StorageRentSourceResolver resolver;
|
||||
@Resource private WalletCommandOutboxService wallet;
|
||||
@Resource private OverseasFeeCurrencyConverter converter;
|
||||
@Resource private OverseasFeeSourceService feeSources;
|
||||
@Resource private ObjectMapper json;
|
||||
@Resource private StorageRentReviewService rentReview;
|
||||
@Value("${baiyue.storage-rent.legacy-return-count-receipt-nos:}") private String legacyReturnCountReceiptNos="";
|
||||
@Value("${baiyue.storage-rent.legacy-return-time-zone:}") private String legacyReturnTimeZone="";
|
||||
private static final String WALLET_TYPE="STORAGE_RENT";
|
||||
|
||||
/** 仅自有仓目的地头程和仓储转运入库计租,普通直送货物不产生仓租。 */
|
||||
public static boolean eligible(LastlegOrder order) {
|
||||
return order!=null && ("WAREHOUSE_TRANSFER".equals(order.getOrderType())
|
||||
|| "FIRST_LEG".equals(order.getOrderType())&&"OVERSEAS".equals(order.getDestinationType()));
|
||||
}
|
||||
/** 旧管理端直出路径遇到计租来源必须改走 PDA,不能跳过资金冻结门禁。 */
|
||||
public void requirePdaOutbound(String customer,String warehouse,Long positionId){
|
||||
var position=repository.position(positionId);if(position==null||!customer.equals(position.getCustomerId())||!warehouse.equals(position.getOverseasWarehouseId()))throw conflict("出库库存不属于当前客户和仓库");
|
||||
var balance=repository.balance(position.getBalanceId());
|
||||
if(!repository.stockShares(customer,warehouse,position.getBalanceId(),position.getBatchNo()).isEmpty()||balance!=null&&eligible(repository.order(balance.getInboundOrderId())))throw conflict("计租库存请关联出库单并通过 PDA 扫码出库");
|
||||
}
|
||||
/** 头程同单同仓合并计租,其余业务按实际实收凭证分别起算库龄。 */
|
||||
@Transactional
|
||||
public void registerInbound(InboundStockReceipt receipt, LastlegOrder order) {
|
||||
if(!eligible(order))return;
|
||||
if("FIRST_LEG".equals(order.getOrderType())){registerFirstLegInbound(receipt,order);return;}
|
||||
String key="INBOUND:"+receipt.getId(); if(repository.source(key)!=null)return;
|
||||
var lot=base(key,order.getCustomerId(),order.getCustomerName(),order.getOverseasWarehouseId(),order.getOverseasWarehouseName(),
|
||||
"LASTLEG_ORDER",order.getId(),order.getOrderNo(),order.getId(),receipt.getInboundCode(),receipt.getBatchNo(),receipt.getSkuCode(),LocalDateTime.now(ZoneOffset.UTC));
|
||||
var transfer=repository.transferByTail(order.getCustomerId(),order.getId());
|
||||
if(transfer!=null){lot.setBusinessType("WAREHOUSE_TRANSFER");lot.setBusinessId(transfer.getId());lot.setBusinessNo(transfer.getBusinessNo());}
|
||||
lot.setBalanceId(receipt.getInventoryBalanceId());lot.setRuleCode("TRANSIT");
|
||||
lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedPackages()));lot.setRemainingQuantity(lot.getOriginalQuantity());
|
||||
// 一个订单的体积按原箱数分到每张实收凭证,不把整单 CBM 重复计入多个 SKU。
|
||||
lot.setOriginalUnits(order.getVolumeCbm()!=null&&order.getVolumeCbm().signum()>0&&order.getPackageCount()!=null&&order.getPackageCount()>0
|
||||
? order.getVolumeCbm().multiply(lot.getOriginalQuantity()).divide(BigDecimal.valueOf(order.getPackageCount()),12,RoundingMode.HALF_UP):null);
|
||||
repository.insert(lot);insertStockShare(lot,receipt.getInventoryBalanceId(),receipt.getBatchNo(),lot.getOriginalQuantity());
|
||||
}
|
||||
/** 头程补入库只增加原批次数量,首次起点、既有出库及计费单位比例保持不变。 */
|
||||
private void registerFirstLegInbound(InboundStockReceipt receipt,LastlegOrder order){
|
||||
// 订单行先串行化首次建批及补入库;登记表与库存份额同事务,重放不重复累计。
|
||||
var locked=repository.lockOrder(order.getId());
|
||||
if(locked==null||!eligible(locked)||!"FIRST_LEG".equals(locked.getOrderType())
|
||||
||!Objects.equals(locked.getCustomerId(),order.getCustomerId())
|
||||
||!Objects.equals(locked.getOverseasWarehouseId(),order.getOverseasWarehouseId()))throw conflict("头程计租订单归属已变化,请刷新后重试");
|
||||
if(repository.inboundRentLot(receipt.getId())!=null)return;
|
||||
if(receipt.getReceivedPackages()==null||receipt.getReceivedPackages()<=0)throw conflict("计租实收箱数必须大于零");
|
||||
String key="FIRST_LEG:"+locked.getId()+":"+locked.getOverseasWarehouseId();
|
||||
var lot=repository.lockSource(key);var quantity=BigDecimal.valueOf(receipt.getReceivedPackages());
|
||||
if(lot==null){
|
||||
lot=base(key,locked.getCustomerId(),locked.getCustomerName(),locked.getOverseasWarehouseId(),locked.getOverseasWarehouseName(),
|
||||
"LASTLEG_ORDER",locked.getId(),locked.getOrderNo(),locked.getId(),receipt.getInboundCode(),locked.getOrderNo(),null,LocalDateTime.now(ZoneOffset.UTC));
|
||||
lot.setBalanceId(receipt.getInventoryBalanceId());lot.setRuleCode("TRANSIT");
|
||||
lot.setOriginalQuantity(quantity);lot.setRemainingQuantity(quantity);
|
||||
lot.setOriginalUnits(locked.getVolumeCbm()!=null&&locked.getVolumeCbm().signum()>0&&locked.getPackageCount()!=null&&locked.getPackageCount()>0
|
||||
?locked.getVolumeCbm().multiply(quantity).divide(BigDecimal.valueOf(locked.getPackageCount()),12,RoundingMode.HALF_UP):null);
|
||||
repository.insert(lot);
|
||||
}else{
|
||||
// 不能重取改单后的整单体积,也不能将累计实收直接覆盖剩余箱数。
|
||||
if(lot.getOriginalUnits()!=null)lot.setOriginalUnits(lot.getOriginalUnits().add(
|
||||
lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP)));
|
||||
lot.setOriginalQuantity(lot.getOriginalQuantity().add(quantity));
|
||||
lot.setRemainingQuantity(lot.getRemainingQuantity().add(quantity));
|
||||
lot.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));repository.save(lot);
|
||||
}
|
||||
// 份额可能已被全部出库为零,补货时仍复用原行,不能受剩余量过滤影响。
|
||||
var share=repository.lockStockShare(lot.getId(),receipt.getInventoryBalanceId(),receipt.getBatchNo());
|
||||
if(share==null)insertStockShare(lot,receipt.getInventoryBalanceId(),receipt.getBatchNo(),quantity);
|
||||
else{share.setQuantity(share.getQuantity().add(quantity));repository.save(share);}
|
||||
repository.recordInboundRent(receipt.getId(),lot.getId(),quantity);
|
||||
}
|
||||
/** 外来转运实收采用测量后的该来源体积,避免等待客户确认时重置库龄。 */
|
||||
@Transactional
|
||||
public void registerTransfer(InboundStockReceipt receipt, com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder transfer,
|
||||
BigDecimal volume, String warehouseName) {
|
||||
String key="INBOUND:"+receipt.getId();if(repository.source(key)!=null)return;
|
||||
var order=repository.order(transfer.getLastlegOrderId());
|
||||
var lot=base(key,transfer.getCustomerId(),order==null?null:order.getCustomerName(),transfer.getOperatingWarehouseId(),warehouseName,
|
||||
"WAREHOUSE_TRANSFER",transfer.getId(),transfer.getTransferNo(),transfer.getLastlegOrderId(),receipt.getInboundCode(),receipt.getBatchNo(),receipt.getSkuCode(),LocalDateTime.now(ZoneOffset.UTC));
|
||||
lot.setBalanceId(receipt.getInventoryBalanceId());lot.setRuleCode("TRANSIT");lot.setOriginalUnits(volume);
|
||||
lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedPackages()));lot.setRemainingQuantity(lot.getOriginalQuantity());repository.insert(lot);
|
||||
insertStockShare(lot,receipt.getInventoryBalanceId(),receipt.getBatchNo(),lot.getOriginalQuantity());
|
||||
}
|
||||
/** 每张原始退货签收托为一个计费原托,箱内份额随实际商品数量分配。 */
|
||||
@Transactional
|
||||
public void registerReturn(ReturnOrder order, ReturnReceipt receipt) {
|
||||
String key="RETURN:"+receipt.getId();if(repository.source(key)!=null)return;
|
||||
var tail=repository.order(order.getLastlegOrderId());
|
||||
var lot=base(key,order.getCustomerId(),tail==null?null:tail.getCustomerName(),order.getOverseasWarehouseId(),tail==null?null:tail.getOverseasWarehouseName(),
|
||||
"RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,receipt.getReceiptBatchNo(),null,LocalDateTime.now(ZoneOffset.UTC));
|
||||
lot.setRuleCode("RETURN");lot.setOriginalUnits(BigDecimal.ONE);
|
||||
lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedQuantity()));lot.setRemainingQuantity(lot.getOriginalQuantity());repository.insert(lot);
|
||||
rebuildReturnShares(receipt.getId());
|
||||
}
|
||||
/** 首次清点只重分箱内商品归属,不能改变原托时间、托数或商品总量。 */
|
||||
@Transactional
|
||||
public void rebuildReturnShares(long receiptId) {
|
||||
var found=repository.source("RETURN:"+receiptId);if(found==null)throw conflict("退货签收缺少计租原批次");
|
||||
var lot=repository.lockLot(found.getId());
|
||||
if(lot.getOriginalQuantity().compareTo(lot.getRemainingQuantity())!=0)throw conflict("原批次已出库,不能重新分配计租份额");
|
||||
var cartons=repository.receiptCartons(receiptId);var newShares=new ArrayList<StorageRentShare>();BigDecimal total=BigDecimal.ZERO;
|
||||
for(var carton:cartons)for(var item:repository.items(carton.getId())){
|
||||
var share=newShare(lot,carton.getCartonMark(),item.getSkuCode(),BigDecimal.valueOf(item.getQuantity()));
|
||||
newShares.add(share);total=total.add(share.getQuantity());
|
||||
}
|
||||
if(total.compareTo(lot.getOriginalQuantity())!=0)throw conflict("清点商品数量与原计租批次不一致");
|
||||
repository.deleteShares(lot.getId());newShares.forEach(repository::insert);
|
||||
}
|
||||
/** 仅显式指定的历史签收可在正常清点事务内恢复缺失计租批次,新单缺失继续阻断。 */
|
||||
@Transactional
|
||||
public void ensureReturnCountLot(long receiptId) {
|
||||
if(repository.source("RETURN:"+receiptId)!=null)return;
|
||||
var receipt=repository.lockReturnReceipt(receiptId);
|
||||
var allowed=Arrays.stream(legacyReturnCountReceiptNos.split(",")).map(String::trim).filter(v->!v.isBlank()).toList();
|
||||
if(receipt==null||!allowed.contains(receipt.getReceiptNo()))throw conflict("退货签收缺少计租原批次");
|
||||
ZoneId zone;try{if(legacyReturnTimeZone.isBlank())throw new IllegalArgumentException();zone=ZoneId.of(legacyReturnTimeZone);}catch(Exception invalid){throw conflict("历史签收时区未核实,不能恢复计租批次");}
|
||||
if(!"SIGNED".equals(receipt.getStatus())||!"PALLET".equals(receipt.getUnitType())||receipt.getSignedAt()==null||receipt.getReturnOrderId()==null
|
||||
||receipt.getReceivedQuantity()==null||receipt.getReceivedQuantity()<=0||receipt.getTotalBoxCount()==null||receipt.getTotalBoxCount()<=0
|
||||
||receipt.getPalletCode()==null||receipt.getPalletCode().isBlank()||!Objects.equals(receipt.getPalletCode(),receipt.getUnitCode()))throw conflict("历史签收原托和数量凭证不完整");
|
||||
var offsets=zone.getRules().getValidOffsets(receipt.getSignedAt());if(offsets.size()!=1)throw conflict("历史签收时间存在时区歧义");
|
||||
var signedUtc=receipt.getSignedAt().atOffset(offsets.get(0)).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
|
||||
if(signedUtc.isAfter(LocalDateTime.now(ZoneOffset.UTC)))throw conflict("历史签收时间不能在未来");
|
||||
var order=repository.returnOrder(receipt.getReturnOrderId());
|
||||
if(order==null||!Objects.equals(order.getCustomerId(),receipt.getCustomerId())||order.getOverseasWarehouseId()==null||order.getRelabelApplicationId()!=null)throw conflict("历史签收业务归属或换标状态不满足恢复条件");
|
||||
var batches=repository.returnReceiptBatches(receiptId);
|
||||
if(batches.size()!=1)throw conflict("历史签收计费起点凭证不完整");var batch=batches.get(0);
|
||||
if(!Objects.equals(batch.getCustomerId(),receipt.getCustomerId())||!Objects.equals(batch.getReturnOrderId(),order.getId())
|
||||
||!Objects.equals(batch.getOverseasWarehouseId(),order.getOverseasWarehouseId())||!Objects.equals(batch.getBatchNo(),receipt.getReceiptBatchNo())
|
||||
||!Objects.equals(batch.getStorageStartedAt(),receipt.getSignedAt())||!"PENDING_PRICING".equals(batch.getStorageBillingStatus()))throw conflict("历史签收计费起点凭证与原签收不一致");
|
||||
var expected=new TreeMap<String,Long>();for(var line:repository.returnReceiptItems(receiptId)){
|
||||
if(!Objects.equals(line.getCustomerId(),receipt.getCustomerId())||line.getSkuCode()==null||line.getReceivedQuantity()==null||line.getReceivedQuantity()<=0)throw conflict("历史签收原商品数量凭证不完整");
|
||||
expected.merge(line.getSkuCode(),line.getReceivedQuantity(),Math::addExact);}
|
||||
if(!Objects.equals(expected.values().stream().reduce(0L,Math::addExact),receipt.getReceivedQuantity()))throw conflict("历史签收原商品总量不一致");
|
||||
var boxes=repository.receiptCartons(receiptId);if(boxes.size()!=receipt.getTotalBoxCount())throw conflict("历史签收原箱数量不完整");
|
||||
var actual=new TreeMap<String,Long>();
|
||||
for(var box:boxes){
|
||||
if(!Objects.equals(box.getCustomerId(),receipt.getCustomerId())||!Objects.equals(box.getOverseasWarehouseId(),order.getOverseasWarehouseId())
|
||||
||!Objects.equals(box.getSourceReceiptId(),receiptId)||!Objects.equals(box.getPalletCode(),receipt.getPalletCode())||!"PALLET".equals(box.getSourceUnitType())
|
||||
||!Objects.equals(box.getSourceBatchNo(),receipt.getReceiptBatchNo())||!Objects.equals(box.getSourceReturnNo(),order.getReturnNo())||!Objects.equals(box.getSourceUnitCode(),receipt.getUnitCode())
|
||||
||!"PENDING".equals(box.getCountStatus())||!"AVAILABLE".equals(box.getStatus())||box.getRelabelApplicationId()!=null||box.getRelabelNo()!=null
|
||||
||box.getCountedAt()!=null||box.getCountedBy()!=null||box.getRelabelCompletedAt()!=null
|
||||
||box.getMergedAt()!=null||box.getNewCartonMark()!=null||!repository.shares(receipt.getCustomerId(),order.getOverseasWarehouseId(),box.getCartonMark(),null).isEmpty())throw conflict("历史签收箱已清点、换标、合箱或发生其他作业,不能恢复原托");
|
||||
long total=0;for(var item:repository.items(box.getId())){
|
||||
if(!Objects.equals(item.getCustomerId(),receipt.getCustomerId())||item.getSkuCode()==null||item.getQuantity()==null||item.getQuantity()<=0
|
||||
||item.getPackingReservedQuantity()!=null&&item.getPackingReservedQuantity()>0)throw conflict("历史签收箱商品或预留状态不满足恢复条件");
|
||||
actual.merge(item.getSkuCode(),item.getQuantity(),Math::addExact);total=Math.addExact(total,item.getQuantity());}
|
||||
if(!Objects.equals(box.getTotalQuantity(),total))throw conflict("历史签收箱商品总量不一致");
|
||||
}
|
||||
if(!expected.equals(actual))throw conflict("历史签收商品已有变化,不能恢复原托计租");
|
||||
var tail=repository.order(order.getLastlegOrderId());
|
||||
var lot=base("RETURN:"+receiptId,receipt.getCustomerId(),tail==null?null:tail.getCustomerName(),order.getOverseasWarehouseId(),tail==null?null:tail.getOverseasWarehouseName(),
|
||||
"RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,receipt.getReceiptBatchNo(),null,signedUtc);
|
||||
lot.setRuleCode("RETURN");lot.setOriginalUnits(BigDecimal.ONE);lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedQuantity()));lot.setRemainingQuantity(lot.getOriginalQuantity());
|
||||
// 恢复创建时间记录本次正式事务,计租起点始终保留原签收时刻,不生成免租或伪造资金流水。
|
||||
lot.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));lot.setUpdatedAt(lot.getCreatedAt());repository.insert(lot);
|
||||
}
|
||||
/** 合箱按明确原商品份额搬移,不创建新托、不重新开始免费期。 */
|
||||
@Transactional
|
||||
public void moveReturnShares(String customer,String warehouse,String source,String target,String sku,long quantity) {
|
||||
if(Objects.equals(source,target))return;
|
||||
var available=repository.shares(customer,warehouse,source,sku);
|
||||
var total=available.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
var requested=BigDecimal.valueOf(quantity);if(total.compareTo(requested)<0)throw conflict("合箱来源缺少原计租份额");
|
||||
// 同箱同 SKU 混有多个原托时按数量比例保留各原托归属,不猜 FIFO 来源。
|
||||
BigDecimal allocated=BigDecimal.ZERO;
|
||||
for(int i=0;i<available.size();i++){
|
||||
var share=available.get(i);var moved=i==available.size()-1?requested.subtract(allocated)
|
||||
:requested.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);
|
||||
allocated=allocated.add(moved);share.setQuantity(share.getQuantity().subtract(moved));repository.save(share);
|
||||
if(moved.signum()==0)continue;
|
||||
var targetShare=repository.shares(customer,warehouse,target,sku).stream().filter(s->s.getLotId().equals(share.getLotId())).findFirst().orElse(null);
|
||||
if(targetShare==null)repository.insert(newShare(repository.lockLot(share.getLotId()),target,sku,moved));
|
||||
else {targetShare.setQuantity(targetShare.getQuantity().add(moved));repository.save(targetShare);}
|
||||
}
|
||||
}
|
||||
/** SKU 转换保留原租批次;精确迁移快照供审核退回恢复,避免混合目标 SKU 改变原托归属。 */
|
||||
@Transactional
|
||||
public String convertReturnSkuShares(String customer,String warehouse,String mark,String oldSku,String newSku,long quantity) {
|
||||
var source=repository.shares(customer,warehouse,mark,oldSku);var total=source.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
BigDecimal requested=BigDecimal.valueOf(quantity),used=BigDecimal.ZERO;if(quantity<=0||total.compareTo(requested)<0)throw conflict("换标来源缺少原计租份额");
|
||||
var movements=new ArrayList<SkuMovement>();
|
||||
for(int i=0;i<source.size();i++){var share=source.get(i);var moved=i==source.size()-1?requested.subtract(used):requested.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(moved);
|
||||
if(moved.signum()==0)continue;share.setQuantity(share.getQuantity().subtract(moved));repository.save(share);
|
||||
addReturnSkuShare(customer,warehouse,mark,newSku,share.getLotId(),moved);movements.add(new SkuMovement(share.getLotId(),moved));}
|
||||
return write(movements);
|
||||
}
|
||||
/** 仅按原转换快照逆向恢复,不从已混合的新 SKU 按比例重新分摊原托。 */
|
||||
@Transactional
|
||||
public void reverseReturnSkuShares(String customer,String warehouse,String mark,String oldSku,String newSku,String snapshot) {
|
||||
List<SkuMovement> movements;try{movements=json.readValue(snapshot,new com.fasterxml.jackson.core.type.TypeReference<List<SkuMovement>>(){});}catch(Exception failure){throw conflict("换标原计租份额快照不完整");}
|
||||
for(var movement:movements){var sources=repository.shares(customer,warehouse,mark,newSku).stream().filter(v->v.getLotId().equals(movement.lotId())).toList();
|
||||
var total=sources.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);if(total.compareTo(movement.quantity())<0)throw conflict("换标原计租份额已出库,不能退回");
|
||||
var remaining=movement.quantity();for(var share:sources){var amount=share.getQuantity().min(remaining);share.setQuantity(share.getQuantity().subtract(amount));repository.save(share);remaining=remaining.subtract(amount);if(remaining.signum()==0)break;}
|
||||
addReturnSkuShare(customer,warehouse,mark,oldSku,movement.lotId(),movement.quantity());}
|
||||
}
|
||||
/** 同箱同 SKU 的原批次合并份额,原托时间与计费基数始终只在 lot 保存。 */
|
||||
private void addReturnSkuShare(String customer,String warehouse,String mark,String sku,long lotId,BigDecimal quantity){
|
||||
var target=repository.shares(customer,warehouse,mark,sku).stream().filter(v->v.getLotId().equals(lotId)).findFirst().orElse(null);
|
||||
if(target==null)repository.insert(newShare(repository.lockLot(lotId),mark,sku,quantity));else{target.setQuantity(target.getQuantity().add(quantity));repository.save(target);}
|
||||
}
|
||||
/** 一次 SKU 转换实际迁移的原托数量份额。 */
|
||||
private record SkuMovement(Long lotId,BigDecimal quantity) { }
|
||||
/** 换标变更 SKU 或批次只搬移原库存份额,不启动新库龄、不重复创建原托。 */
|
||||
@Transactional
|
||||
public void moveStockShares(String customer,String warehouse,long oldBalance,String oldBatch,
|
||||
long newBalance,String newBatch,long quantity) {
|
||||
if(oldBalance==newBalance&&Objects.equals(oldBatch,newBatch))return;
|
||||
var source=repository.stockShares(customer,warehouse,oldBalance,oldBatch);
|
||||
if(source.isEmpty()){
|
||||
var old=repository.balance(oldBalance);if(old!=null&&eligible(repository.order(old.getInboundOrderId())))throw conflict("换标来源缺少原计租批次");return;
|
||||
}
|
||||
var total=source.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
BigDecimal requested=BigDecimal.valueOf(quantity),used=BigDecimal.ZERO;if(total.compareTo(requested)<0)throw conflict("换标来源计租数量不足");
|
||||
for(int i=0;i<source.size();i++){var share=source.get(i);var part=i==source.size()-1?requested.subtract(used):requested.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(part);
|
||||
share.setQuantity(share.getQuantity().subtract(part));repository.save(share);if(part.signum()==0)continue;
|
||||
var target=repository.stockShares(customer,warehouse,newBalance,newBatch).stream().filter(s->s.getLotId().equals(share.getLotId())).findFirst().orElse(null);
|
||||
if(target==null)insertStockShare(repository.lockLot(share.getLotId()),newBalance,newBatch,part);
|
||||
else{target.setQuantity(target.getQuantity().add(part));repository.save(target);}}
|
||||
}
|
||||
/** 提交预校验与冻结 Outbox 后正常返回,待回执阶段不能以抛错回滚冻结命令。 */
|
||||
@Transactional
|
||||
public PreparationResult prepare(String warehouse,PrepareRequest request) {
|
||||
validateRequest(request);String hash=hash(request);var old=repository.latest(warehouse,request.mode(),request.idempotencyKey());
|
||||
if(old!=null&&!old.getRequestHash().equals(hash))throw conflict("扫码幂等键已用于不同的货物或数量");
|
||||
if(old!=null&&old.getConsumedAt()!=null)return result(old);
|
||||
var now=LocalDateTime.now(ZoneOffset.UTC);
|
||||
boolean reuse=old!=null&&Set.of("FREEZE_PENDING","READY").contains(old.getStatus())&&old.getCreatedAt().isAfter(now.minusMinutes(15));
|
||||
var plan=reuse?resolver.resolve(warehouse,request,now,read(old).pricing()):resolver.resolve(warehouse,request,now);
|
||||
if(plan.allocations().isEmpty())return new PreparationResult(null,"NOT_REQUIRED",true,BigDecimal.ZERO,"CNY",BigDecimal.ZERO,plan.pricing()==null?null:plan.pricing().currency(),null,null,null);
|
||||
if(old!=null&&Set.of("FREEZE_PENDING","READY").contains(old.getStatus())){
|
||||
if(old.getCreatedAt().isAfter(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(15))&&sameBasis(read(old),plan)&&plan.amount().compareTo(old.getOriginalAmount())<=0)return result(old);
|
||||
release(old,"EXPIRED");
|
||||
// 重建尝试才读取客户新配置;有效的重复请求和原扫码消费都使用旧快照。
|
||||
if(reuse)plan=resolver.resolve(warehouse,request,now);
|
||||
}
|
||||
if(plan.owner()==null||plan.owner().businessId()==null||plan.owner().businessNo()==null)throw conflict("计租货物缺少对应业务单,不能出库");
|
||||
// 免费批次仍保存出库计租记录;没有应收金额时不要求汇率,不发布资金指令。
|
||||
var conversion=plan.amount().signum()==0
|
||||
?new OverseasFeeCurrencyConverter.Conversion(plan.pricing().currency(),BigDecimal.ZERO.setScale(2),null,plan.calculatedAt().toLocalDate().toString().substring(0,7),BigDecimal.ZERO.setScale(2))
|
||||
:converter.convert(plan.pricing().currency(),plan.amount(),plan.calculatedAt().toLocalDate());
|
||||
var value=new StorageRentPreparation();value.setId(IdWorker.getId());value.setWarehouseId(warehouse);value.setCustomerId(plan.owner().customerId());
|
||||
value.setMode(request.mode());value.setExecutionNo(request.businessNo());value.setRequestKey(request.idempotencyKey());value.setRequestHash(hash);
|
||||
value.setAttempt(old==null?1:Math.addExact(old.getAttempt(),1));value.setBusinessType(plan.owner().businessType());value.setBusinessId(plan.owner().businessId());value.setBusinessNo(plan.owner().businessNo());
|
||||
value.setPlanJson(write(plan));value.setOriginalAmount(conversion.originalAmount());value.setAmount(conversion.settlementAmount());
|
||||
value.setExchangeRate(conversion.exchangeRate());value.setExchangeRateMonth(conversion.exchangeRateMonth());value.setCreatedAt(plan.calculatedAt());value.setUpdatedAt(plan.calculatedAt());
|
||||
value.setWalletVersion(3L);value.setStatus(value.getAmount().signum()==0?"READY":"FREEZE_PENDING");
|
||||
try{repository.insert(value);}catch(DuplicateKeyException concurrent){var winner=repository.latest(warehouse,request.mode(),request.idempotencyKey());if(winner==null||!winner.getRequestHash().equals(hash))throw concurrent;return result(winner);}
|
||||
if(value.getAmount().signum()>0){value.setFreezeEventId(wallet.publish("FREEZE",customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租预占",1));repository.save(value);}
|
||||
return result(value);
|
||||
}
|
||||
/** 原始扫码事务内校验成功冻结和同一来源份额,任何异常使库存与租金一并回滚。 */
|
||||
@Transactional
|
||||
public void consume(String warehouse,PrepareRequest request) {
|
||||
var value=repository.latest(warehouse,request.mode(),request.idempotencyKey());
|
||||
var plan=value==null?resolver.resolve(warehouse,request,LocalDateTime.now(ZoneOffset.UTC))
|
||||
:resolver.resolve(warehouse,request,LocalDateTime.now(ZoneOffset.UTC),read(value).pricing());
|
||||
if(plan.allocations().isEmpty())return;
|
||||
if(value==null||!value.getRequestHash().equals(hash(request)))throw conflict("请先完成本次扫码的仓租预校验");
|
||||
if(value.getConsumedAt()!=null)throw conflict("本仓租扫码已扣减,请恢复原扫码结果");
|
||||
if(!"READY".equals(value.getStatus())||value.getCreatedAt().isBefore(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(15))||value.getAmount().signum()>0&&value.getHoldId()==null)throw conflict("仓租资金未冻结成功或预占已过期,请重新预校验");
|
||||
var saved=read(value);
|
||||
if(!sameBasis(saved,plan)||plan.amount().compareTo(value.getOriginalAmount())>0)throw conflict("仓租计费时间或货物已变化,请重新预校验");
|
||||
// 先按批次排序锁定,再逐份扣减,资金与实际库存使用调用者同一事务。
|
||||
for(var allocation:saved.allocations().stream().sorted(Comparator.comparingLong(Allocation::lotId)).toList()){
|
||||
var lot=repository.lockLot(allocation.lotId());
|
||||
if(lot==null||!value.getCustomerId().equals(lot.getCustomerId())||lot.getRemainingQuantity().compareTo(allocation.quantity())<0)throw conflict("原计租批次剩余数量已变化");
|
||||
if(allocation.shareId()!=null){var share=repository.lockShare(allocation.shareId());
|
||||
if(share==null||share.getQuantity().compareTo(allocation.quantity())<0)throw conflict("退货计租商品份额已变化");
|
||||
share.setQuantity(share.getQuantity().subtract(allocation.quantity()));repository.save(share);}
|
||||
lot.setRemainingQuantity(lot.getRemainingQuantity().subtract(allocation.quantity()));lot.setUpdatedAt(plan.calculatedAt());repository.save(lot);
|
||||
}
|
||||
value.setConsumedAt(plan.calculatedAt());value.setUpdatedAt(plan.calculatedAt());
|
||||
// 出库只生成待审核费用并沿用原冻结;审核通过后才发送真实扣款指令。
|
||||
rentReview.create(value,feeRequest(value,saved),saved);
|
||||
repository.save(value);
|
||||
}
|
||||
/** 取消扫描仅解冻未使用记录,已扣减的库存必须沿原业务撤销流程处理。 */
|
||||
@Transactional
|
||||
public PreparationResult cancel(String warehouse,long id) {var value=owned(warehouse,id);if(value.getConsumedAt()!=null)throw conflict("已出库仓租不能取消预占");release(value,"CANCELLED");return result(value);}
|
||||
/** 原工单取消后释放其所有未使用冻结,回执迟到也按取消态解冻。 */
|
||||
@Transactional
|
||||
public void cancelExecution(String mode,String execution) {for(var value:repository.pending(mode,execution))release(value,"CANCELLED");}
|
||||
/** 查询资金状态仍限制在 PDA 实际绑定仓。 */
|
||||
@Transactional
|
||||
public PreparationResult preparation(String warehouse,long id) {return result(owned(warehouse,id));}
|
||||
/** 资金消费者仅路由仓租专用业务身份。 */
|
||||
@Override public boolean supports(String type) {return WALLET_TYPE.equals(type);}
|
||||
/** 每次回执校验原指令、客户、金额和冻结身份,幂等更新不重复生成费用。 */
|
||||
@Override @Transactional
|
||||
public void handle(WalletCommandCompletedV1 event) {
|
||||
if(!supports(event.businessType()))return;
|
||||
var value=repository.lockPreparation(Long.parseLong(event.businessNo()));if(value==null)throw conflict("仓租资金来源不存在");
|
||||
if(value.getConsumedAt()!=null&&rentReview.managed(value)){rentReview.handle(value,event);return;}
|
||||
if(customer(value)!=event.customerId()||event.amount()==null||event.amount().compareTo(value.getAmount())!=0)throw conflict("仓租资金回执客户或金额不一致");
|
||||
if(!("SUCCEEDED".equals(event.result())||"FAILED".equals(event.result()))||event.completedAt()==null)throw conflict("仓租资金回执结果不完整");
|
||||
boolean success="SUCCEEDED".equals(event.result());
|
||||
switch(event.operation()){
|
||||
case "FREEZE" -> {
|
||||
if(!Objects.equals(value.getFreezeEventId(),event.commandEventId()))throw conflict("仓租冻结回执指令不一致");
|
||||
if(!Set.of("FREEZE_PENDING","EXPIRED","CANCELLED").contains(value.getStatus()))return;
|
||||
if(success&&!"FROZEN".equals(event.holdStatus()))throw conflict("仓租冻结结果缺少有效冻结状态");
|
||||
if(success){if(event.walletHoldId()==null)throw conflict("仓租冻结结果缺少资金占用凭据");value.setHoldId(event.walletHoldId());
|
||||
if(Set.of("EXPIRED","CANCELLED").contains(value.getStatus()))release(value,value.getStatus());else value.setStatus("READY");}
|
||||
else if("FREEZE_PENDING".equals(value.getStatus()))value.setStatus("FAILED");
|
||||
}
|
||||
case "SETTLE" -> {
|
||||
if(!Objects.equals(value.getSettleEventId(),event.commandEventId())||success&&!Objects.equals(value.getHoldId(),event.walletHoldId()))throw conflict("仓租扣款回执来源不一致");
|
||||
if("SETTLED".equals(value.getStatus()))return;
|
||||
if(value.getConsumedAt()==null)throw conflict("未出库仓租不能扣款");
|
||||
if(success){if(!"SETTLED".equals(event.holdStatus())||event.walletLedgerId()==null)throw conflict("仓租扣款凭据不完整");
|
||||
value.setLedgerId(event.walletLedgerId());var plan=read(value);var request=feeRequest(value,plan);
|
||||
var converted=new OverseasFeeCurrencyConverter.Conversion(plan.pricing().currency(),value.getOriginalAmount(),value.getExchangeRate(),value.getExchangeRateMonth(),value.getAmount());
|
||||
var fee=feeSources.publishSettled("STORAGE_RENT",value.getId().toString(),request,converted,new WalletSettlementReference(event.commandEventId(),event.walletHoldId(),event.walletLedgerId(),"STORAGE_RENT",value.getId().toString()));
|
||||
value.setSourceFeeId(fee.getSourceFeeId());value.setStatus("SETTLED");
|
||||
}else value.setStatus("SETTLE_FAILED");
|
||||
}
|
||||
case "RELEASE" -> {if(!Objects.equals(value.getReleaseEventId(),event.commandEventId())||success&&!Objects.equals(value.getHoldId(),event.walletHoldId()))throw conflict("仓租解冻回执指令不一致");
|
||||
if(success&&!"RELEASED".equals(event.holdStatus()))throw conflict("仓租解冻结果状态不完整");value.setReleaseStatus(success?"SUCCEEDED":"FAILED");}
|
||||
default -> throw conflict("仓租资金操作无效");
|
||||
}
|
||||
value.setErrorMessage(success?null:event.errorMessage());value.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));repository.save(value);
|
||||
}
|
||||
/** 列表只展示一个原始批次一行,不重复展示订单详情字段。 */
|
||||
public PageResult<LotView> list(Query query) {
|
||||
long page=query.page()==null?1:query.page(),size=query.size()==null?20:query.size();
|
||||
if(page<1||size<1||size>200)throw conflict("分页参数无效");var result=repository.page(query,page,size);
|
||||
var charges=repository.charges(result.getRecords().stream().map(StorageRentLot::getId).toList());
|
||||
var settled=new HashMap<Long,BigDecimal>();var counts=new HashMap<Long,Long>();var settledCounts=new HashMap<Long,Long>();
|
||||
var generated=new HashMap<Long,Map<String,BigDecimal>>();var lastOutbound=new HashMap<Long,LocalDateTime>();
|
||||
for(var value:charges){var amounts=lotAmounts(value,value.getOriginalAmount());for(var entry:amounts.entrySet()){
|
||||
counts.merge(entry.getKey(),1L,Long::sum);
|
||||
// 仅成功扣款回执计入已扣款,处理中和失败费用仍保留原币应收。
|
||||
if("SETTLED".equals(value.getStatus())){
|
||||
settled.merge(entry.getKey(),lotAmounts(value,value.getAmount()).get(entry.getKey()),BigDecimal::add);
|
||||
settledCounts.merge(entry.getKey(),1L,Long::sum);
|
||||
}
|
||||
generated.computeIfAbsent(entry.getKey(),key->new TreeMap<>()).merge(read(value).pricing().currency(),entry.getValue(),BigDecimal::add);
|
||||
if(value.getConsumedAt()!=null)lastOutbound.merge(entry.getKey(),value.getConsumedAt(),(a,b)->a.isAfter(b)?a:b);
|
||||
}
|
||||
}
|
||||
return new PageResult<>(result.getRecords().stream().map(lot->view(lot,generated.getOrDefault(lot.getId(),Map.of()),lastOutbound.get(lot.getId()),settled.getOrDefault(lot.getId(),BigDecimal.ZERO.setScale(2)),counts.getOrDefault(lot.getId(),0L),settledCounts.getOrDefault(lot.getId(),0L))).toList(),page,size,result.getTotal(),result.getPages());
|
||||
}
|
||||
/** 批次详情包含出库应收与资金状态,可追溯到原始扫码。 */
|
||||
public LotDetail detail(long id) {var lot=repository.lot(id);if(lot==null)throw missing("仓租批次不存在");var charges=repository.charges(id).stream().map(v->charge(v,id)).toList();var last=charges.stream().map(ChargeView::outboundAt).filter(Objects::nonNull).max(Comparator.naturalOrder()).map(OffsetDateTime::toLocalDateTime).orElse(null);var generated=new TreeMap<String,BigDecimal>();for(var charge:charges)generated.merge(charge.currency(),charge.originalAmount(),BigDecimal::add);var settled=charges.stream().filter(c->"SETTLED".equals(c.status())).toList();return new LotDetail(view(lot,generated,last,settled.stream().map(ChargeView::settlementAmount).reduce(BigDecimal.ZERO.setScale(2),BigDecimal::add),charges.size(),settled.size()),charges);}
|
||||
/** 只读仓库选项由仓库引用快照组成。 */
|
||||
public List<WarehouseOption> warehouseOptions() {return repository.warehouseOptions();}
|
||||
/** 将内部规则和字段转成前端稳定展示协议。 */
|
||||
private LotView view(StorageRentLot lot,Map<String,BigDecimal> generated,LocalDateTime lastOutbound,BigDecimal settledAmount,long chargeCount,long settledChargeCount) {
|
||||
// 清仓后库龄固定在最后一次实际出库,查询时间不再增加已结清批次库龄。
|
||||
var end=lot.getRemainingQuantity().signum()==0&&lastOutbound!=null?lastOutbound:LocalDateTime.now(ZoneOffset.UTC);
|
||||
long days=StorageRentCalculator.days(lot.getInboundAt(),end.isBefore(lot.getInboundAt())?lot.getInboundAt():end);
|
||||
return new LotView(lot.getId(),lot.getCustomerId(),lot.getCustomerName(),lot.getBusinessType(),lot.getBusinessNo(),lot.getWarehouseId(),lot.getWarehouseName(),lot.getBatchNo(),utc(lot.getInboundAt()),days,
|
||||
lot.getOriginalQuantity(),lot.getRemainingQuantity(),"RETURN".equals(lot.getRuleCode())?"PIECE":"PACKAGE",lot.getOriginalUnits(),"RETURN".equals(lot.getRuleCode())?"PALLET":"CBM",lot.getRuleCode()+"_V1",lot.getRemainingQuantity().signum()==0?"CLEARED":"IN_STOCK",generated.size()>1?null:generated.values().stream().findFirst().orElse(BigDecimal.ZERO.setScale(2)),generated.size()==1?generated.keySet().iterator().next():null,generated.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e->new CurrencyAmount(e.getKey(),e.getValue())).toList(),settledAmount,chargeCount,settledChargeCount);
|
||||
}
|
||||
/** 混合批次同一扫码费用按原冻结原币份额呈现,扣款总额仍归原应收。 */
|
||||
private ChargeView charge(StorageRentPreparation value,long lotId) {
|
||||
var allocations=read(value).allocations().stream().filter(a->a.lotId()==lotId).toList();
|
||||
BigDecimal amount=lotAmounts(value,value.getOriginalAmount()).getOrDefault(lotId,BigDecimal.ZERO.setScale(2));
|
||||
BigDecimal settlement=lotAmounts(value,value.getAmount()).getOrDefault(lotId,BigDecimal.ZERO.setScale(2));
|
||||
return new ChargeView(value.getId(),value.getExecutionNo(),value.getBusinessNo(),allocations.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add),allocations.stream().mapToLong(Allocation::days).max().orElse(0),amount,read(value).pricing().currency(),settlement,value.getExchangeRate(),value.getStatus(),utc(value.getCreatedAt()),utc(value.getConsumedAt()));
|
||||
}
|
||||
/** 原资金总额按批次稳定分配到分,末批承接尾差,确保多批显示合计等于实际扣款。 */
|
||||
private Map<Long,BigDecimal> lotAmounts(StorageRentPreparation value,BigDecimal amount){
|
||||
var basis=new TreeMap<Long,BigDecimal>();for(var a:read(value).allocations())basis.merge(a.lotId(),a.amount(),BigDecimal::add);
|
||||
var total=basis.values().stream().reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
// 原免租批次人工补收时按实际数量分摊,不能把非零费用汇总成零。
|
||||
if(total.signum()==0){basis.clear();for(var a:read(value).allocations())basis.merge(a.lotId(),a.quantity(),BigDecimal::add);total=basis.values().stream().reduce(BigDecimal.ZERO,BigDecimal::add);}
|
||||
var result=new LinkedHashMap<Long,BigDecimal>();BigDecimal used=BigDecimal.ZERO;
|
||||
for(var entry:basis.entrySet()){var part=total.signum()==0?BigDecimal.ZERO.setScale(2):entry.getKey().equals(basis.lastKey())?amount.subtract(used):amount.multiply(entry.getValue()).divide(total,2,RoundingMode.DOWN);result.put(entry.getKey(),part);used=used.add(part);}return result;
|
||||
}
|
||||
/** 新仓租表统一记录 UTC,公开协议携带偏移,避免浏览器当成本地墙钟时间。 */
|
||||
private static OffsetDateTime utc(LocalDateTime value){return value==null?null:value.atOffset(ZoneOffset.UTC);}
|
||||
/** 财务费用记本次服务单;余额授信应收不携带仅应付使用的供应商结算方式。 */
|
||||
private SaveRequest feeRequest(StorageRentPreparation value,Plan plan) {
|
||||
var owner=plan.owner();String source=switch(owner.businessType()){case "LASTLEG_ORDER"->"OVERSEAS_ORDER_FEE";case "RELABEL_ORDER"->"OVERSEAS_RELABEL_FEE";default->"OVERSEAS_WAREHOUSE_FEE";};
|
||||
return new SaveRequest(owner.customerId(),owner.customerName(),source,owner.businessType(),owner.businessId(),owner.businessNo(),owner.orderId(),null,owner.inboundCode(),null,null,null,Long.valueOf(owner.warehouseId()),
|
||||
"FEE_TYPE_077","仓储费", null,"RECEIVABLE",null,null,null,null,null,null,null,null,
|
||||
plan.calculatedAt().toLocalDate(),plan.pricing().currency(),value.getOriginalAmount(),null,null,null,
|
||||
plan.allocations().stream().map(Allocation::inboundAt).min(Comparator.naturalOrder()).orElseThrow().toLocalDate(),value.getConsumedAt().toLocalDate());
|
||||
}
|
||||
/** 解冻命令只发布一次;冻结处理中取消时等待原冻结回执后补发解冻。 */
|
||||
private void release(StorageRentPreparation value,String status) {
|
||||
if(value.getConsumedAt()!=null)return;value.setStatus(status);
|
||||
if(value.getHoldId()!=null&&value.getReleaseEventId()==null&&value.getAmount().signum()>0){
|
||||
value.setReleaseStatus("PENDING");value.setReleaseEventId(wallet.publish("RELEASE",customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租取消预占",3));}
|
||||
value.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));repository.save(value);
|
||||
}
|
||||
/** 恢复任务只读取需要处理的主键,避免持有大批资金行锁。 */
|
||||
public List<Long> recoveryCandidates(){var now=LocalDateTime.now(ZoneOffset.UTC);return repository.recoveryCandidates(now.minusMinutes(15),now.minusMinutes(1));}
|
||||
/** 原已消费仓租只重试结算,未使用且过期的预占只释放资金。 */
|
||||
@Transactional
|
||||
public void recover(long id){
|
||||
var value=repository.lockPreparation(id);if(value==null)return;var now=LocalDateTime.now(ZoneOffset.UTC);
|
||||
if(value.getConsumedAt()!=null&&rentReview.managed(value)&&value.getUpdatedAt().isBefore(now.minusMinutes(1))){rentReview.recover(value);return;}
|
||||
if(value.getConsumedAt()==null&&Set.of("FREEZE_PENDING","READY").contains(value.getStatus())&&value.getCreatedAt().isBefore(now.minusMinutes(15))){release(value,"EXPIRED");return;}
|
||||
if(!value.getUpdatedAt().isBefore(now.minusMinutes(1)))return;
|
||||
String operation;
|
||||
if("SETTLE_FAILED".equals(value.getStatus())&&value.getConsumedAt()!=null){operation="SETTLE";value.setStatus("CONSUMED");}
|
||||
else if("FAILED".equals(value.getReleaseStatus())&&value.getConsumedAt()==null){operation="RELEASE";value.setReleaseStatus("PENDING");}
|
||||
else return;
|
||||
long version=Math.addExact(value.getWalletVersion()==null?3:value.getWalletVersion(),1);value.setWalletVersion(version);
|
||||
String event=wallet.publish(operation,customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租资金恢复",version);
|
||||
if("SETTLE".equals(operation))value.setSettleEventId(event);else value.setReleaseEventId(event);
|
||||
value.setErrorMessage(null);value.setUpdatedAt(now);repository.save(value);
|
||||
}
|
||||
/** 报价重新计算可降低金额,但不能增加已冻结金额或改变原商品份额。 */
|
||||
private static boolean sameBasis(Plan a,Plan b) {
|
||||
if(!Objects.equals(a.owner(),b.owner())||a.allocations().size()!=b.allocations().size())return false;
|
||||
for(int i=0;i<a.allocations().size();i++){var x=a.allocations().get(i);var y=b.allocations().get(i);
|
||||
if(x.lotId()!=y.lotId()||!Objects.equals(x.shareId(),y.shareId())||x.quantity().compareTo(y.quantity())!=0||x.days()!=y.days())return false;}
|
||||
return true;
|
||||
}
|
||||
/** 返回前端可执行标记;只有已确认冻结或原已消费请求可继续原扫码幂等恢复。 */
|
||||
private PreparationResult result(StorageRentPreparation v) {String status=v.getStatus();boolean consumed=v.getConsumedAt()!=null;
|
||||
return new PreparationResult(v.getId().toString(),consumed?"CONSUMED":status,"READY".equals(status)||consumed,v.getAmount(),"CNY",v.getOriginalAmount(),read(v).pricing().currency(),v.getExchangeRate(),v.getExchangeRateMonth(),v.getErrorMessage());}
|
||||
/** PDA 查询不得穿透绑定仓。 */
|
||||
private StorageRentPreparation owned(String warehouse,long id) {var v=repository.lockPreparation(id);if(v==null||!warehouse.equals(v.getWarehouseId()))throw missing("当前仓没有该仓租预校验");return v;}
|
||||
/** 资金协议只接收持久化来源客户,不能接受客户端传入客户。 */
|
||||
private static long customer(StorageRentPreparation v) {return Long.parseLong(v.getCustomerId());}
|
||||
/** 缺少扫码关键身份不允许创建孤立预占。 */
|
||||
private static void validateRequest(PrepareRequest r) {if(r==null||r.mode()==null||r.businessNo()==null||r.idempotencyKey()==null||r.idempotencyKey().isBlank())throw conflict("扫码预校验参数不完整");}
|
||||
/** 标准化请求用于跨接口原扫码核对,忽略调用方多传的空集合。 */
|
||||
private String hash(PrepareRequest r) {var marks=r.cartonMarks()==null?List.<String>of():r.cartonMarks().stream().filter(Objects::nonNull).sorted().toList();
|
||||
var lines=r.lines()==null?List.of():r.lines().stream().sorted(Comparator.comparing(com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos.ActualLine::itemId)).toList();
|
||||
var photos=r.photoFileIds()==null?List.of():r.photoFileIds().stream().sorted().toList();
|
||||
var scanned=r.scannedCartonMarks()==null?List.of():r.scannedCartonMarks().stream().sorted().toList();
|
||||
var parts=Arrays.asList(r.mode(),r.businessNo(),r.itemId(),r.positionId(),r.packages(),blank(r.cartonMark()),blank(r.palletNo()),r.idempotencyKey(),marks,blank(r.skuCode()),r.productQuantity(),lines,photos,scanned,Boolean.TRUE.equals(r.confirmAllCartons()));
|
||||
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(parts).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException("仓租请求签名失败",e);}}
|
||||
/** 空文本统一表达为未提交。 */
|
||||
private static String blank(String value){return value==null||value.isBlank()?null:value.trim();}
|
||||
/** 原快照序列化失败必须回滚,不能静默丢失资金依据。 */
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("仓租快照写入失败",e);}}
|
||||
/** 回执只使用原确认的快照,不重新读取汇率或报价。 */
|
||||
private Plan read(StorageRentPreparation v){try{return json.readValue(v.getPlanJson(),Plan.class);}catch(Exception e){throw new IllegalStateException("仓租快照读取失败",e);}}
|
||||
/** 原始批次通用身份快照。 */
|
||||
private static StorageRentLot base(String key,String customer,String name,String warehouse,String warehouseName,String type,Long businessId,String businessNo,Long orderId,String inboundCode,String batch,String sku,LocalDateTime at){
|
||||
var v=new StorageRentLot();v.setId(IdWorker.getId());v.setSourceKey(key);v.setCustomerId(customer);v.setCustomerName(name);v.setWarehouseId(warehouse);v.setWarehouseName(warehouseName);v.setBusinessType(type);v.setBusinessId(businessId);v.setBusinessNo(businessNo);v.setOrderId(orderId);v.setInboundCode(inboundCode);v.setBatchNo(batch);v.setSkuCode(sku);v.setInboundAt(at);v.setCreatedAt(at);v.setUpdatedAt(at);return v;}
|
||||
/** 新分箱份额只引用原批次,不复制原托计费基数。 */
|
||||
private static StorageRentShare newShare(StorageRentLot lot,String mark,String sku,BigDecimal quantity){var s=new StorageRentShare();s.setId(IdWorker.getId());s.setLotId(lot.getId());s.setCustomerId(lot.getCustomerId());s.setWarehouseId(lot.getWarehouseId());s.setCartonMark(mark);s.setSkuCode(sku);s.setQuantity(quantity);return s;}
|
||||
/** 普通实收数量与原批次建立可搬移的库存份额。 */
|
||||
private void insertStockShare(StorageRentLot lot,long balance,String batch,BigDecimal quantity){var s=newShare(lot,null,lot.getSkuCode(),quantity);s.setBalanceId(balance);s.setBatchNo(batch);repository.insert(s);}
|
||||
/** 业务状态冲突沿现有 API 错误协议返回。 */
|
||||
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
/** 资源不存在。 */
|
||||
private static ResponseStatusException missing(String message){return new ResponseStatusException(HttpStatus.NOT_FOUND,message);}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.baiyue.lastleg.storagerent.service;
|
||||
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
|
||||
import com.baiyue.lastleg.storagerent.entity.*;
|
||||
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/** 与真实 PDA 原库存位置、原退货箱及当前服务业务单同源解析。 */
|
||||
@Service
|
||||
public class StorageRentSourceResolver {
|
||||
@Resource private StorageRentRepository repository;
|
||||
@Resource private com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway pricingGateway;
|
||||
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository discards;
|
||||
/** 客户、仓库及费用单号均从持久化来源取值,绝不信任前端客户字段。 */
|
||||
public Plan resolve(String warehouse,PrepareRequest request,LocalDateTime at) {
|
||||
var plan=resolveSources(warehouse,request,at);
|
||||
if(plan.allocations().isEmpty())return plan;
|
||||
return reprice(plan,pricingGateway.current(plan.owner().customerId()));
|
||||
}
|
||||
/** 原预占消费只使用已保存的客户价格;仍重新校验实际库存和业务归属。 */
|
||||
public Plan resolve(String warehouse,PrepareRequest request,LocalDateTime at,Pricing snapshot) {
|
||||
var plan=resolveSources(warehouse,request,at);
|
||||
if(plan.allocations().isEmpty())return plan;
|
||||
com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway.validate(snapshot,Long.parseLong(plan.owner().customerId()));
|
||||
return reprice(plan,snapshot);
|
||||
}
|
||||
/** 定价与来源解析分离,原批次体积、原托份额及实收时间始终保持不变。 */
|
||||
private Plan reprice(Plan plan,Pricing pricing) {
|
||||
var allocations=plan.allocations().stream().map(a->{var lot=repository.lockLot(a.lotId());
|
||||
return new Allocation(a.lotId(),a.shareId(),a.quantity(),StorageRentCalculator.amount(pricing,lot.getRuleCode(),lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),lot.getOriginalQuantity(),a.quantity()),a.inboundAt(),a.days());}).toList();
|
||||
return new Plan(plan.owner(),allocations,plan.calculatedAt(),allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP),pricing);
|
||||
}
|
||||
/** 先解析真实来源;此阶段默认金额只用于内部数量校验,不作为最终收费依据。 */
|
||||
private Plan resolveSources(String warehouse,PrepareRequest request,LocalDateTime at) {
|
||||
if("DISCARD".equals(request.mode()))return discard(warehouse,request,at);
|
||||
if(request.itemId()==null||request.packages()==null||request.packages()<=0)throw conflict("扫码来源明细与数量不能为空");
|
||||
Owner owner;String customer;String marks=null;String sku=null;Long pieceQuantity=null;Long palletId=null;
|
||||
Long expectedOrderId=null;String expectedInboundCode=null;Long expectedPositionId=null;
|
||||
if("DISPATCH".equals(request.mode())){
|
||||
var dispatch=repository.dispatch(request.businessNo());var item=repository.dispatchItem(request.itemId());
|
||||
if(dispatch==null||item==null||!Objects.equals(dispatch.getId(),item.getDispatchOrderId())
|
||||
||!warehouse.equals(dispatch.getOverseasWarehouseId())||!warehouse.equals(item.getOverseasWarehouseId()))throw conflict("扫码来源不属于当前仓排车单");
|
||||
if(!"ACTIVE".equals(dispatch.getStatus())||!"LOADING".equals(dispatch.getStage()))throw conflict("当前排车阶段不允许预占仓租");
|
||||
customer=item.getCustomerId();palletId=item.getWarehousePalletId();
|
||||
expectedOrderId=item.getOrderId();expectedInboundCode=item.getInboundCode();
|
||||
if("RETURN_RELABEL".equals(item.getSourceStage())){marks=request.cartonMark();if(marks==null)throw conflict("退货换标必须扫描原箱唛");}
|
||||
owner=business(customer,item.getCustomerName(),item.getSourceBusinessType(),item.getSourceBusinessNo(),item.getOrderId(),warehouse,item.getInboundCode());
|
||||
}else if("OUTBOUND".equals(request.mode())){
|
||||
var task=repository.task(warehouse,request.businessNo());var item=repository.outboundItem(request.itemId());
|
||||
if(task==null||item==null||!Objects.equals(task.getOutboundOrderId(),item.getOutboundOrderId())||!task.getCustomerId().equals(item.getCustomerId()))throw conflict("扫码来源不属于当前仓出库工单");
|
||||
var outbound=repository.outbound(task.getOutboundOrderId());
|
||||
if(outbound==null||!warehouse.equals(outbound.getOverseasWarehouseId())||!Set.of("PENDING","IN_PROGRESS").contains(task.getStatus()))throw conflict("当前出库工单不能预占仓租");
|
||||
customer=task.getCustomerId();palletId=item.getWarehousePalletId();
|
||||
expectedOrderId=item.getSourceOrderId();expectedInboundCode=item.getInboundCode();expectedPositionId=item.getSourceInventoryPositionId();
|
||||
if("RETURN_OUTBOUND".equals(outbound.getOutboundType())||"RELABEL_OUTBOUND".equals(outbound.getOutboundType())&&palletId==null){
|
||||
marks=item.getCartonMarks();sku=item.getSkuCode();pieceQuantity=item.getReturnSkuQuantity();
|
||||
// SKU 扫码的商品数量必须与出库单不可变快照一致,不能用商品条码代替任意箱。
|
||||
if(pieceQuantity!=null&&(!Objects.equals(sku,request.skuCode())||!Objects.equals(pieceQuantity,request.productQuantity())))throw conflict("扫码商品及数量与原出库单不一致");
|
||||
if(pieceQuantity==null&&(request.cartonMarks()==null||!request.cartonMarks().contains(marks)))throw conflict("扫码箱唛与原出库单不一致");
|
||||
}
|
||||
if("RELABEL_OUTBOUND".equals(outbound.getOutboundType())){
|
||||
var relabel=repository.relabel(customer,outbound.getOutboundNo());if(relabel==null)throw conflict("出库单缺少对应换标申请");
|
||||
owner=new Owner(customer,null,"RELABEL_ORDER",relabel.getId(),relabel.getBusinessNo(),relabel.getLastlegOrderId(),item.getInboundCode(),warehouse);
|
||||
}else if("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())){
|
||||
var transfer=repository.transfer(customer,item.getOrderRefs());
|
||||
if(transfer==null&&item.getSourceOrderId()!=null)transfer=repository.transferByTail(customer,item.getSourceOrderId());
|
||||
if(transfer==null)throw conflict("出库单缺少对应仓储转运业务单");
|
||||
owner=new Owner(customer,null,"WAREHOUSE_TRANSFER",transfer.getId(),transfer.getBusinessNo(),transfer.getLastlegOrderId(),item.getInboundCode(),warehouse);
|
||||
}else if("RETURN_OUTBOUND".equals(outbound.getOutboundType())){
|
||||
var order=repository.returnOrder(customer,item.getInboundCode());if(order==null)throw conflict("出库单缺少对应退货业务单");
|
||||
owner=new Owner(customer,null,"RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,warehouse);
|
||||
}else owner=business(customer,null,"LASTLEG_ORDER",null,item.getSourceOrderId(),warehouse,item.getInboundCode());
|
||||
}else throw conflict("整托预校验应先展开为真实扫码来源");
|
||||
List<Allocation> allocations;
|
||||
if(marks!=null){if(request.positionId()!=null)throw conflict("退货扫码不能指定普通库存位置");allocations=returnAllocations(customer,warehouse,marks,sku,pieceQuantity,at);}
|
||||
else {
|
||||
var position=repository.position(request.positionId());
|
||||
if(position==null||!customer.equals(position.getCustomerId())||!warehouse.equals(position.getOverseasWarehouseId())
|
||||
||palletId!=null&&!palletId.equals(position.getPalletId()))throw conflict("仓租预校验库存位置不属于原客户和原托盘");
|
||||
var balance=repository.balance(position.getBalanceId());if(balance==null||!customer.equals(balance.getCustomerId()))throw conflict("仓租来源库存余额不存在");
|
||||
if(expectedOrderId!=null&&!Objects.equals(expectedOrderId,balance.getInboundOrderId()))throw conflict("扫码库存不属于业务单据关联的入库订单");
|
||||
if(expectedInboundCode!=null&&!expectedInboundCode.isBlank()&&!Objects.equals(expectedInboundCode,balance.getInboundCode()))throw conflict("扫码库存不属于业务单据关联的入仓码");
|
||||
if(expectedPositionId!=null&&!Objects.equals(expectedPositionId,position.getId()))throw conflict("扫码库存位置与出库单来源位置不一致");
|
||||
var original=repository.order(balance.getInboundOrderId());
|
||||
var shares=repository.stockShares(customer,warehouse,position.getBalanceId(),position.getBatchNo());
|
||||
if(shares.isEmpty()){
|
||||
if(original==null)throw conflict("原库存缺少有效业务订单,不能判定计租范围");
|
||||
if(!StorageRentService.eligible(original))return new Plan(owner,List.of(),at,BigDecimal.ZERO.setScale(2));
|
||||
throw conflict("原实收批次计租数量不完整,不能出库");
|
||||
}
|
||||
var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
if(total.compareTo(BigDecimal.valueOf(request.packages()))<0)throw conflict("原实收批次计租数量不足,不能出库");
|
||||
allocations=proportionShares(shares,BigDecimal.valueOf(request.packages()),total,at);
|
||||
if(owner.businessId()==null||owner.businessNo()==null)owner=business(customer,balance.getCustomerName(),"LASTLEG_ORDER",null,balance.getInboundOrderId(),warehouse,balance.getInboundCode());
|
||||
}
|
||||
return new Plan(owner,allocations,at,allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP));
|
||||
}
|
||||
/** 废弃同样属于实物出仓,按原申请客户、仓位和退货原托份额结算。 */
|
||||
private Plan discard(String warehouse,PrepareRequest request,LocalDateTime at) {
|
||||
var application=discards.lockApplication(request.businessNo(),warehouse);
|
||||
if(application==null||!"APPROVED".equals(application.getStatus()))throw conflict("废弃申请未审核或不属于当前仓库");
|
||||
if(request.lines()==null||request.lines().isEmpty()||request.palletNo()==null)throw conflict("废弃托盘与实际明细不能为空");
|
||||
String customer=application.getCustomerId();var result=new ArrayList<Allocation>();var seen=new HashSet<Long>();
|
||||
for(var line:request.lines()){
|
||||
if(!seen.add(line.itemId()))throw conflict("废弃明细重复");
|
||||
var item=discards.item(line.itemId());
|
||||
if(item==null||!customer.equals(item.getCustomerId())||!application.getId().equals(item.getApplicationId())||line.actualPackages()<0||line.actualPackages()>item.getDiscardPackages())throw conflict("废弃数量或来源与申请不一致");
|
||||
String pallet;Long positionId=item.getInventoryPositionId();
|
||||
if(item.getReturnCartonId()!=null){var carton=discards.returnCarton(item.getReturnCartonId());
|
||||
if(carton==null||!customer.equals(carton.getCustomerId())||!warehouse.equals(carton.getOverseasWarehouseId()))throw conflict("退货废弃来源不属于当前客户和仓库");
|
||||
pallet=carton.getPalletCode();
|
||||
if(line.actualPackages()>0)result.addAll(returnAllocations(customer,warehouse,carton.getCartonMark(),item.getSkuCode(),(long)line.actualPackages(),at));
|
||||
}else{
|
||||
if(positionId==null){var profile=discards.profile(item.getProfileId());if(profile==null||!customer.equals(profile.getCustomerId()))throw conflict("废弃原托盘不存在");positionId=profile.getInventoryPositionId();}
|
||||
var position=repository.position(positionId);
|
||||
if(position==null||!customer.equals(position.getCustomerId())||!warehouse.equals(position.getOverseasWarehouseId()))throw conflict("废弃原库存不属于当前仓库");
|
||||
pallet=position.getPalletNo();var shares=repository.stockShares(customer,warehouse,position.getBalanceId(),position.getBatchNo());
|
||||
if(shares.isEmpty()){
|
||||
var balance=repository.balance(position.getBalanceId());var order=balance==null?null:repository.order(balance.getInboundOrderId());
|
||||
if(order==null||StorageRentService.eligible(order))throw conflict("废弃原实收批次缺少计租记录");
|
||||
}else if(line.actualPackages()>0){var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);if(total.compareTo(BigDecimal.valueOf(line.actualPackages()))<0)throw conflict("废弃原计租批次数量不足");result.addAll(proportionShares(shares,BigDecimal.valueOf(line.actualPackages()),total,at));}
|
||||
}
|
||||
if(!Objects.equals(pallet==null||pallet.isBlank()?"散箱":pallet.trim(),request.palletNo().trim()))throw conflict("废弃明细不属于本次托盘");
|
||||
}
|
||||
var owner=new Owner(customer,null,"DISCARD_ORDER",application.getId(),application.getDiscardNo(),null,null,warehouse);
|
||||
return new Plan(owner,result,at,result.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP));
|
||||
}
|
||||
/** 当前独立服务工单优先,不能把新换标或转运应收记回原头程运输单。 */
|
||||
private Owner business(String customer,String name,String type,String no,Long orderId,String warehouse,String inbound) {
|
||||
if("RELABEL_ORDER".equals(type)){var value=repository.relabelNo(customer,no);if(value==null)throw conflict("排车来源换标业务单不存在");return new Owner(customer,name,type,value.getId(),value.getBusinessNo(),value.getLastlegOrderId(),inbound,warehouse);}
|
||||
if("WAREHOUSE_TRANSFER".equals(type)){var value=repository.transfer(customer,no);if(value==null)throw conflict("排车来源转运业务单不存在");return new Owner(customer,name,type,value.getId(),value.getBusinessNo(),value.getLastlegOrderId(),inbound,warehouse);}
|
||||
if("RETURN_ORDER".equals(type)){var value=repository.returnOrder(customer,no);if(value==null)throw conflict("排车来源退货业务单不存在");return new Owner(customer,name,type,value.getId(),value.getReturnNo(),value.getLastlegOrderId(),inbound,warehouse);}
|
||||
LastlegOrder order=repository.order(orderId);
|
||||
if(order==null)return new Owner(customer,name,"LASTLEG_ORDER",null,null,orderId,inbound,warehouse);
|
||||
if(!customer.equals(order.getCustomerId()))throw conflict("原订单不属于扫码客户");
|
||||
if("WAREHOUSE_TRANSFER".equals(order.getOrderType())){var value=repository.transferByTail(customer,order.getId());if(value==null)throw conflict("原转运订单缺少独立业务工单");return new Owner(customer,name,"WAREHOUSE_TRANSFER",value.getId(),value.getBusinessNo(),order.getId(),inbound,warehouse);}
|
||||
return new Owner(customer,name==null?order.getCustomerName():name,"LASTLEG_ORDER",order.getId(),order.getOrderNo(),order.getId(),inbound,warehouse);
|
||||
}
|
||||
/** 原批次内部混入多次实收时按剩余来源份额分配,不能跨来源用 FIFO 猜箱。 */
|
||||
private List<Allocation> proportionShares(List<StorageRentShare> shares,BigDecimal quantity,BigDecimal total,LocalDateTime at) {
|
||||
var result=new ArrayList<Allocation>();BigDecimal used=BigDecimal.ZERO;
|
||||
for(int i=0;i<shares.size();i++){var share=shares.get(i);var part=i==shares.size()-1?quantity.subtract(used):quantity.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(part);if(part.signum()>0)result.add(allocation(repository.lockLot(share.getLotId()),share.getId(),part,at));}
|
||||
return result;
|
||||
}
|
||||
/** 退货按当前箱内 SKU 的原托份额计费,部分 SKU 出库不会新增一个计费整托。 */
|
||||
private List<Allocation> returnAllocations(String customer,String warehouse,String mark,String sku,Long quantity,LocalDateTime at) {
|
||||
var carton=repository.carton(mark);if(carton==null||!customer.equals(carton.getCustomerId())||!warehouse.equals(carton.getOverseasWarehouseId()))throw conflict("原退货箱不属于当前客户和仓库");
|
||||
var shares=repository.shares(customer,warehouse,mark,sku);var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
BigDecimal requested=quantity==null?total:BigDecimal.valueOf(quantity);
|
||||
if(shares.isEmpty()||requested.signum()<=0||total.compareTo(requested)<0)throw conflict("退货原托计租份额不完整,不能出库");
|
||||
var result=new ArrayList<Allocation>();BigDecimal used=BigDecimal.ZERO;
|
||||
for(int i=0;i<shares.size();i++){var share=shares.get(i);var part=i==shares.size()-1?requested.subtract(used):requested.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(part);if(part.signum()>0)result.add(allocation(repository.lockLot(share.getLotId()),share.getId(),part,at));}
|
||||
return result;
|
||||
}
|
||||
/** 单份金额保留十二位,仅在完整扫码汇总时按分取整。 */
|
||||
private static Allocation allocation(StorageRentLot lot,Long share,BigDecimal quantity,LocalDateTime at) {
|
||||
if(lot==null)throw conflict("原仓租批次不存在");
|
||||
try{return new Allocation(lot.getId(),share,quantity,StorageRentCalculator.amount(lot.getRuleCode(),lot.getInboundAt(),at,lot.getOriginalUnits(),lot.getOriginalQuantity(),quantity),lot.getInboundAt(),StorageRentCalculator.days(lot.getInboundAt(),at));}
|
||||
catch(IllegalArgumentException e){throw conflict(e.getMessage());}
|
||||
}
|
||||
/** 来源不一致不得降级为免租。 */
|
||||
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.lastleg.storagerent.service.preparation;
|
||||
|
||||
import com.baiyue.lastleg.storagerent.service.StorageRentService;
|
||||
|
||||
import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
|
||||
/** 整托和普通扫码共用实际出库规划,避免资金预占与库存扣减来源不同。 */
|
||||
@Service
|
||||
public class StorageRentPreparationService {
|
||||
@Resource private StorageRentService rents;
|
||||
@Resource private DeliveryTransferService delivery;
|
||||
/** 整托每条原扫码分别冻结,只有全部成功才允许调用原整托接口。 */
|
||||
@Transactional
|
||||
public PreparationResult prepare(PdaWarehouseIdentity identity,PrepareRequest request) {
|
||||
String warehouse=String.valueOf(identity.overseasWarehouseId());
|
||||
if(!"OUTBOUND_PALLET".equals(request.mode()))return rents.prepare(warehouse,request);
|
||||
var scans=delivery.storageRentPalletRequests(warehouse,identity.operatorKey(),request.businessNo(),request.palletNo(),new DeliveryRequests.PdaPalletPick(request.idempotencyKey()));
|
||||
var results=new ArrayList<PreparationResult>();
|
||||
for(var scan:scans)results.add(rents.prepare(warehouse,new PrepareRequest("OUTBOUND",request.businessNo(),scan.outboundPalletItemId(),scan.inventoryPositionId(),scan.packages(),null,null,scan.idempotencyKey(),scan.cartonMarks(),scan.skuCode(),scan.productQuantity())));
|
||||
var blocker=results.stream().filter(r->!r.ready()).findFirst().orElse(null);
|
||||
var currencies=results.stream().filter(r->!"NOT_REQUIRED".equals(r.status())).map(PreparationResult::originalCurrency).distinct().toList();
|
||||
boolean single=currencies.size()==1;
|
||||
var priced=single?results.stream().filter(r->r.exchangeRate()!=null).findFirst().orElse(null):null;
|
||||
boolean sameRate=priced!=null&&results.stream().filter(r->r.exchangeRate()!=null).allMatch(r->r.exchangeRate().compareTo(priced.exchangeRate())==0&&Objects.equals(r.exchangeRateMonth(),priced.exchangeRateMonth()));
|
||||
return new PreparationResult(null,blocker==null?"READY":blocker.status(),blocker==null,
|
||||
results.stream().map(PreparationResult::amount).reduce(BigDecimal.ZERO,BigDecimal::add),"CNY",
|
||||
single?results.stream().map(PreparationResult::originalAmount).reduce(BigDecimal.ZERO,BigDecimal::add):currencies.isEmpty()?BigDecimal.ZERO:null,single?currencies.get(0):null,
|
||||
sameRate?priced.exchangeRate():null,sameRate?priced.exchangeRateMonth():null,blocker==null?null:blocker.message());
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -22,6 +22,8 @@ import java.util.*;
|
||||
/** Runs in customer confirmation's transaction; preserves original selections and follows actual pallet moves. */
|
||||
@Service
|
||||
public class WarehouseTransferRelabelCompletionService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
|
||||
@Resource private WarehouseTransferInventoryAllocationMapper allocations;
|
||||
@Resource private WarehouseTransferRelabelBindingMapper bindings;
|
||||
@Resource private InventoryCommandMapper inventoryRows;
|
||||
@@ -86,7 +88,11 @@ public class WarehouseTransferRelabelCompletionService {
|
||||
balance.getProductNameZh(), balance.getProductNameEn(), current.getPalletId(), current.getPalletNo(),
|
||||
key + ":OUTPUT", current.getWarehouseLocationId(), current.getWarehouseLocationCode(), count,
|
||||
transfer.getTransferNo(), key, key + ":TARGET", "本票换标新SKU转入"));
|
||||
current = inventoryRows.lockPosition(customer, output.positionId());
|
||||
// 变更 SKU 后搬移原计租份额,原入库时间和免费期不变。
|
||||
var rentTarget = inventoryRows.lockPosition(customer, output.positionId());
|
||||
storageRent.moveStockShares(customer, balance.getOverseasWarehouseId(), balance.getId(), current.getBatchNo(),
|
||||
output.balanceId(), rentTarget.getBatchNo(), count);
|
||||
current = rentTarget;
|
||||
balance = inventoryRows.lockBalance(customer, output.balanceId());
|
||||
if (current == null || balance == null || !Objects.equals(current.getBalanceId(), balance.getId())
|
||||
|| !Objects.equals(balance.getInboundOrderId(), allocation.getInboundOrderId()))
|
||||
|
||||
+8
@@ -92,6 +92,8 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class WarehouseTransferService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent;
|
||||
|
||||
@Resource private TransferQuantityService transferQuantityService;
|
||||
@Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService;
|
||||
@Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTaskService;
|
||||
@@ -1344,6 +1346,11 @@ public class WarehouseTransferService {
|
||||
receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId());
|
||||
receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now);
|
||||
stockReceipts.insert(receipt);
|
||||
// 外来货进入自有海外仓后立即计租;按该实托实际体积分到原 SKU 箱数。
|
||||
storageRent.registerTransfer(receipt, transfer,
|
||||
pallet.getVolumeCbm()!=null&&pallet.getVolumeCbm().signum()>0&&pallet.getCartonCount()!=null&&pallet.getCartonCount()>0
|
||||
?pallet.getVolumeCbm().multiply(java.math.BigDecimal.valueOf(row.getPackageCount()))
|
||||
.divide(java.math.BigDecimal.valueOf(pallet.getCartonCount()), 12, java.math.RoundingMode.HALF_UP):null, warehouse.warehouseName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1543,6 +1550,7 @@ public class WarehouseTransferService {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (WarehouseTransferInventoryAllocation allocation : allocations) {
|
||||
if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed");
|
||||
storageRent.requirePdaOutbound(customerId,transfer.getOperatingWarehouseId(),allocation.getInventoryPositionId());
|
||||
String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId();
|
||||
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(
|
||||
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
|
||||
|
||||
@@ -880,7 +880,8 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
|
||||
inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID',
|
||||
container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照',
|
||||
warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照',
|
||||
fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
|
||||
fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', fee_remark VARCHAR(500) NULL COMMENT '费用说明,补收或重复作业原因',
|
||||
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
|
||||
supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照',
|
||||
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
|
||||
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
|
||||
@@ -888,19 +889,20 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
|
||||
wallet_settlement JSON NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用',
|
||||
business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY',
|
||||
amount DECIMAL(20,6) NOT NULL COMMENT '人民币结算金额', original_currency CHAR(3) NOT NULL COMMENT '业务原币种,支持CNY或USD',
|
||||
original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1',
|
||||
original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NULL COMMENT '原币折算人民币汇率,人民币固定为1,自动零元仓租可空',
|
||||
exchange_rate_month CHAR(7) NOT NULL COMMENT '汇率月份yyyy-MM', chargeable_quantity DECIMAL(20,6) NULL COMMENT '计费数量,如箱数、托数、体积或天数',
|
||||
quantity_unit VARCHAR(32) NULL COMMENT '计费数量单位', unit_price DECIMAL(20,6) NULL COMMENT '原币计费单价',
|
||||
period_start DATE NULL COMMENT '仓租等期间费用开始日期', period_end DATE NULL COMMENT '仓租等期间费用结束日期',
|
||||
review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
|
||||
publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
|
||||
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
|
||||
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewer_name VARCHAR(255) NULL COMMENT '审核人名称快照', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version),
|
||||
KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at),
|
||||
KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at),
|
||||
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
|
||||
CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')),
|
||||
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
|
||||
CONSTRAINT chk_ll_finance_fee_original_currency CHECK(original_currency IN('CNY','USD')),
|
||||
CONSTRAINT chk_ll_finance_fee_period CHECK((period_start IS NULL AND period_end IS NULL) OR (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start<=period_end)),
|
||||
CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')),
|
||||
@@ -2926,6 +2928,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history (
|
||||
) ENGINE=InnoDB COMMENT='货物废弃审批历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
|
||||
procurement_direction VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS_TO_DOMESTIC' COMMENT '采购方向:海外采购回国内/国内采购发海外',
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号',
|
||||
purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称',
|
||||
@@ -3261,7 +3264,7 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost (
|
||||
) ENGINE=InnoDB COMMENT='拆柜打托实际成本汇总';
|
||||
CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', cost_id BIGINT NOT NULL COMMENT '打托成本汇总ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收体积',
|
||||
allocated_amount DECIMAL(20,2) NOT NULL COMMENT '客户分摊金额', fee_source_id BIGINT NOT NULL COMMENT '海外费用来源ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
allocated_amount DECIMAL(20,2) NOT NULL COMMENT '客户分摊金额', fee_source_id BIGINT NULL COMMENT '人工审核后关联的海外费用来源ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id),
|
||||
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
|
||||
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
|
||||
@@ -3923,6 +3926,7 @@ CREATE TABLE IF NOT EXISTS ll_return_sku_conversion (
|
||||
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
|
||||
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
|
||||
storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照',
|
||||
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
|
||||
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
|
||||
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
|
||||
@@ -4672,3 +4676,126 @@ CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||
|
||||
-- 海外人工费用资金编排。金额与余额以 Finance 钱包为唯一事实,本表仅记录指令及回执状态。
|
||||
CREATE TABLE IF NOT EXISTS ll_finance_fee_funding (
|
||||
source_fee_id BIGINT NOT NULL COMMENT '关联海外费用稳定来源ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '费用所属客户隔离标识',
|
||||
collection_mode VARCHAR(16) NOT NULL COMMENT '客户收费方式:BILL账单结算,WALLET余额及授信',
|
||||
wallet_status VARCHAR(32) NOT NULL COMMENT '资金进度:待冻结、已冻结、待扣款、已扣款、待释放及失败状态',
|
||||
wallet_business_no VARCHAR(128) NULL COMMENT '本次冻结使用的独立业务编号,重提使用新编号',
|
||||
command_event_id VARCHAR(64) NULL COMMENT '当前待处理资金指令事件ID',
|
||||
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance确认的资金冻结记录ID',
|
||||
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance确认的已扣资金流水ID',
|
||||
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调递增版本,防止重试重复入账',
|
||||
error_message VARCHAR(500) NULL COMMENT '最新资金失败原因,成功后清空',
|
||||
command_history JSON NOT NULL COMMENT '已发资金指令及核验结果哈希,识别重复和错序回执',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '资金编排创建UTC时间',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '资金编排最近变更UTC时间',
|
||||
PRIMARY KEY (source_fee_id),
|
||||
KEY idx_ll_fee_funding_customer_state (customer_id, wallet_status),
|
||||
KEY idx_ll_fee_funding_command (command_event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外人工费用资金冻结、审核扣款及释放编排';
|
||||
|
||||
-- 实收仓租原批次、份额与资金预占。
|
||||
CREATE TABLE IF NOT EXISTS ll_storage_rent_inbound (
|
||||
receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量',
|
||||
lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键',
|
||||
quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数',
|
||||
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
|
||||
PRIMARY KEY (receipt_id),
|
||||
KEY idx_storage_rent_inbound_lot (lot_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_storage_rent_lot (
|
||||
id bigint NOT NULL COMMENT '原批次主键',
|
||||
source_key varchar(80) NOT NULL COMMENT '实收来源幂等键',
|
||||
customer_id varchar(32) NOT NULL COMMENT '客户标识',
|
||||
customer_name varchar(255) DEFAULT NULL COMMENT '客户名称',
|
||||
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
|
||||
warehouse_name varchar(255) DEFAULT NULL COMMENT '海外仓名称',
|
||||
business_type varchar(32) NOT NULL COMMENT '原入库业务类型',
|
||||
business_id bigint NOT NULL COMMENT '原入库业务主键',
|
||||
business_no varchar(128) NOT NULL COMMENT '原入库业务单号',
|
||||
order_id bigint DEFAULT NULL COMMENT '尾程订单主键',
|
||||
inbound_code varchar(128) DEFAULT NULL COMMENT '入仓码',
|
||||
balance_id bigint DEFAULT NULL COMMENT '普通库存余额主键',
|
||||
batch_no varchar(128) DEFAULT NULL COMMENT '原实收批次',
|
||||
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
|
||||
rule_code varchar(32) NOT NULL COMMENT '仓租规则',
|
||||
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
|
||||
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
|
||||
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
|
||||
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
|
||||
created_at datetime(3) NOT NULL COMMENT '创建时间',
|
||||
updated_at datetime(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_rent_lot_source (source_key),
|
||||
KEY idx_storage_rent_stock (balance_id,batch_no),
|
||||
KEY idx_storage_rent_customer (customer_id,warehouse_id,inbound_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实收计租原批次';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_storage_rent_share (
|
||||
balance_id bigint DEFAULT NULL COMMENT '普通库存当前余额,退货箱份额为空',
|
||||
batch_no varchar(128) DEFAULT NULL COMMENT '普通库存当前批次,换标搬移保持原计租批次引用',
|
||||
id bigint NOT NULL COMMENT '份额主键',
|
||||
lot_id bigint NOT NULL COMMENT '原计租批次主键',
|
||||
customer_id varchar(32) NOT NULL COMMENT '客户标识',
|
||||
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
|
||||
carton_mark varchar(255) DEFAULT NULL COMMENT '当前箱唛',
|
||||
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
|
||||
quantity decimal(24,12) NOT NULL COMMENT '剩余商品数量',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_rent_return_share (lot_id,carton_mark,sku_code),
|
||||
UNIQUE KEY uk_storage_rent_stock_share (lot_id,balance_id,batch_no),
|
||||
KEY idx_storage_rent_carton (customer_id,warehouse_id,carton_mark),
|
||||
KEY idx_storage_rent_share_stock (customer_id,warehouse_id,balance_id,batch_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前库存与原计租批次份额';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_storage_rent_preparation (
|
||||
id bigint NOT NULL COMMENT '预校验主键',
|
||||
warehouse_id varchar(32) NOT NULL COMMENT '作业海外仓',
|
||||
customer_id varchar(32) NOT NULL COMMENT '费用客户',
|
||||
mode varchar(32) NOT NULL COMMENT '扫码类型',
|
||||
execution_no varchar(128) NOT NULL COMMENT '实际作业单号',
|
||||
request_key varchar(255) NOT NULL COMMENT '原扫码幂等键',
|
||||
request_hash varchar(64) NOT NULL COMMENT '原扫码内容哈希',
|
||||
attempt int NOT NULL COMMENT '冻结尝试次数',
|
||||
business_type varchar(32) NOT NULL COMMENT '本次应收业务类型',
|
||||
business_id bigint NOT NULL COMMENT '本次应收业务主键',
|
||||
business_no varchar(128) NOT NULL COMMENT '本次应收业务单号',
|
||||
status varchar(32) NOT NULL COMMENT '资金与扫码状态',
|
||||
plan_json json NOT NULL COMMENT '原批次计费不可变快照',
|
||||
original_amount decimal(24,12) NOT NULL COMMENT '原币仓租金额,币种见计费快照',
|
||||
amount decimal(24,12) NOT NULL COMMENT '人民币结算金额',
|
||||
exchange_rate decimal(24,12) NULL COMMENT '冻结确认汇率,免费出库为空',
|
||||
exchange_rate_month varchar(7) NOT NULL COMMENT '汇率月份',
|
||||
freeze_event_id varchar(128) DEFAULT NULL COMMENT '冻结指令事件',
|
||||
settle_event_id varchar(128) DEFAULT NULL COMMENT '扣款指令事件',
|
||||
release_event_id varchar(128) DEFAULT NULL COMMENT '解冻指令事件',
|
||||
release_status varchar(32) DEFAULT NULL COMMENT '解冻回执状态,失败后由恢复任务重发',
|
||||
wallet_version bigint NOT NULL COMMENT '同一预占资金命令版本,重试使用新版本保留审计',
|
||||
hold_id varchar(128) DEFAULT NULL COMMENT '财务冻结主键',
|
||||
ledger_id varchar(128) DEFAULT NULL COMMENT '财务扣款流水',
|
||||
error_message varchar(1000) DEFAULT NULL COMMENT '资金处理结果',
|
||||
source_fee_id bigint DEFAULT NULL COMMENT '财务费用来源主键',
|
||||
created_at datetime(3) NOT NULL COMMENT '预校验时间',
|
||||
consumed_at datetime(3) DEFAULT NULL COMMENT '实际扫描出库时间',
|
||||
updated_at datetime(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_rent_request (warehouse_id,mode,request_key,attempt),
|
||||
KEY idx_storage_rent_execution (mode,execution_no,status),
|
||||
KEY idx_storage_rent_business (customer_id,business_no),
|
||||
KEY idx_storage_rent_recovery (status,updated_at),
|
||||
KEY idx_storage_rent_release (release_status,updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照';
|
||||
|
||||
-- SKU 换标原计租份额快照,存量数据库可重复执行。
|
||||
SET @storage_rent_sku_column = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_sku_conversion' AND column_name='storage_rent_shares_json');
|
||||
SET @storage_rent_sku_ddl = IF(@storage_rent_sku_column=0, 'ALTER TABLE ll_return_sku_conversion ADD COLUMN storage_rent_shares_json LONGTEXT NULL COMMENT ''原仓租批次迁移快照'' AFTER ownership_json', 'SELECT 1');
|
||||
PREPARE storage_rent_sku_stmt FROM @storage_rent_sku_ddl;
|
||||
EXECUTE storage_rent_sku_stmt;
|
||||
DEALLOCATE PREPARE storage_rent_sku_stmt;
|
||||
|
||||
-- 首版拆柜成本只记录分摊依据,客户应收另行人工登记审核;重复执行保持相同定义。
|
||||
ALTER TABLE ll_destuff_pallet_cost_allocation MODIFY COLUMN fee_source_id BIGINT NULL COMMENT '人工审核后关联的海外费用来源ID';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 海外人工费用资金编排。金额与余额以 Finance 钱包为唯一事实,本表仅记录指令及回执状态。
|
||||
CREATE TABLE IF NOT EXISTS ll_finance_fee_funding (
|
||||
source_fee_id BIGINT NOT NULL COMMENT '关联海外费用稳定来源ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '费用所属客户隔离标识',
|
||||
collection_mode VARCHAR(16) NOT NULL COMMENT '客户收费方式:BILL账单结算,WALLET余额及授信',
|
||||
wallet_status VARCHAR(32) NOT NULL COMMENT '资金进度:待冻结、已冻结、待扣款、已扣款、待释放及失败状态',
|
||||
wallet_business_no VARCHAR(128) NULL COMMENT '本次冻结使用的独立业务编号,重提使用新编号',
|
||||
command_event_id VARCHAR(64) NULL COMMENT '当前待处理资金指令事件ID',
|
||||
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance确认的资金冻结记录ID',
|
||||
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance确认的已扣资金流水ID',
|
||||
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调递增版本,防止重试重复入账',
|
||||
error_message VARCHAR(500) NULL COMMENT '最新资金失败原因,成功后清空',
|
||||
command_history JSON NOT NULL COMMENT '已发资金指令及核验结果哈希,识别重复和错序回执',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '资金编排创建UTC时间',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '资金编排最近变更UTC时间',
|
||||
PRIMARY KEY (source_fee_id),
|
||||
KEY idx_ll_fee_funding_customer_state (customer_id, wallet_status),
|
||||
KEY idx_ll_fee_funding_command (command_event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外人工费用资金冻结、审核扣款及释放编排';
|
||||
|
||||
-- 首版拆柜成本只记录分摊依据,客户应收另行人工登记审核;重复执行保持相同定义。
|
||||
ALTER TABLE ll_destuff_pallet_cost_allocation MODIFY COLUMN fee_source_id BIGINT NULL COMMENT '人工审核后关联的海外费用来源ID';
|
||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS ll_return_sku_conversion (
|
||||
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
|
||||
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
|
||||
storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照',
|
||||
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
|
||||
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
|
||||
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
CREATE TABLE IF NOT EXISTS ll_storage_rent_inbound (
|
||||
receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量',
|
||||
lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键',
|
||||
quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数',
|
||||
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
|
||||
PRIMARY KEY (receipt_id),
|
||||
KEY idx_storage_rent_inbound_lot (lot_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_storage_rent_lot (
|
||||
id bigint NOT NULL COMMENT '原批次主键',
|
||||
source_key varchar(80) NOT NULL COMMENT '实收来源幂等键',
|
||||
customer_id varchar(32) NOT NULL COMMENT '客户标识',
|
||||
customer_name varchar(255) DEFAULT NULL COMMENT '客户名称',
|
||||
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
|
||||
warehouse_name varchar(255) DEFAULT NULL COMMENT '海外仓名称',
|
||||
business_type varchar(32) NOT NULL COMMENT '原入库业务类型',
|
||||
business_id bigint NOT NULL COMMENT '原入库业务主键',
|
||||
business_no varchar(128) NOT NULL COMMENT '原入库业务单号',
|
||||
order_id bigint DEFAULT NULL COMMENT '尾程订单主键',
|
||||
inbound_code varchar(128) DEFAULT NULL COMMENT '入仓码',
|
||||
balance_id bigint DEFAULT NULL COMMENT '普通库存余额主键',
|
||||
batch_no varchar(128) DEFAULT NULL COMMENT '原实收批次',
|
||||
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
|
||||
rule_code varchar(32) NOT NULL COMMENT '仓租规则',
|
||||
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
|
||||
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
|
||||
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
|
||||
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
|
||||
created_at datetime(3) NOT NULL COMMENT '创建时间',
|
||||
updated_at datetime(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_rent_lot_source (source_key),
|
||||
KEY idx_storage_rent_stock (balance_id,batch_no),
|
||||
KEY idx_storage_rent_customer (customer_id,warehouse_id,inbound_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实收计租原批次';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_storage_rent_share (
|
||||
balance_id bigint DEFAULT NULL COMMENT '普通库存当前余额,退货箱份额为空',
|
||||
batch_no varchar(128) DEFAULT NULL COMMENT '普通库存当前批次,换标搬移保持原计租批次引用',
|
||||
id bigint NOT NULL COMMENT '份额主键',
|
||||
lot_id bigint NOT NULL COMMENT '原计租批次主键',
|
||||
customer_id varchar(32) NOT NULL COMMENT '客户标识',
|
||||
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
|
||||
carton_mark varchar(255) DEFAULT NULL COMMENT '当前箱唛',
|
||||
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
|
||||
quantity decimal(24,12) NOT NULL COMMENT '剩余商品数量',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_rent_return_share (lot_id,carton_mark,sku_code),
|
||||
UNIQUE KEY uk_storage_rent_stock_share (lot_id,balance_id,batch_no),
|
||||
KEY idx_storage_rent_carton (customer_id,warehouse_id,carton_mark),
|
||||
KEY idx_storage_rent_share_stock (customer_id,warehouse_id,balance_id,batch_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前库存与原计租批次份额';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_storage_rent_preparation (
|
||||
id bigint NOT NULL COMMENT '预校验主键',
|
||||
warehouse_id varchar(32) NOT NULL COMMENT '作业海外仓',
|
||||
customer_id varchar(32) NOT NULL COMMENT '费用客户',
|
||||
mode varchar(32) NOT NULL COMMENT '扫码类型',
|
||||
execution_no varchar(128) NOT NULL COMMENT '实际作业单号',
|
||||
request_key varchar(255) NOT NULL COMMENT '原扫码幂等键',
|
||||
request_hash varchar(64) NOT NULL COMMENT '原扫码内容哈希',
|
||||
attempt int NOT NULL COMMENT '冻结尝试次数',
|
||||
business_type varchar(32) NOT NULL COMMENT '本次应收业务类型',
|
||||
business_id bigint NOT NULL COMMENT '本次应收业务主键',
|
||||
business_no varchar(128) NOT NULL COMMENT '本次应收业务单号',
|
||||
status varchar(32) NOT NULL COMMENT '资金与扫码状态',
|
||||
plan_json json NOT NULL COMMENT '原批次计费不可变快照',
|
||||
original_amount decimal(24,12) NOT NULL COMMENT '原币仓租金额,币种见计费快照',
|
||||
amount decimal(24,12) NOT NULL COMMENT '人民币结算金额',
|
||||
exchange_rate decimal(24,12) NULL COMMENT '冻结确认汇率,免费出库为空',
|
||||
exchange_rate_month varchar(7) NOT NULL COMMENT '汇率月份',
|
||||
freeze_event_id varchar(128) DEFAULT NULL COMMENT '冻结指令事件',
|
||||
settle_event_id varchar(128) DEFAULT NULL COMMENT '扣款指令事件',
|
||||
release_event_id varchar(128) DEFAULT NULL COMMENT '解冻指令事件',
|
||||
release_status varchar(32) DEFAULT NULL COMMENT '解冻回执状态,失败后由恢复任务重发',
|
||||
wallet_version bigint NOT NULL COMMENT '同一预占资金命令版本,重试使用新版本保留审计',
|
||||
hold_id varchar(128) DEFAULT NULL COMMENT '财务冻结主键',
|
||||
ledger_id varchar(128) DEFAULT NULL COMMENT '财务扣款流水',
|
||||
error_message varchar(1000) DEFAULT NULL COMMENT '资金处理结果',
|
||||
source_fee_id bigint DEFAULT NULL COMMENT '财务费用来源主键',
|
||||
created_at datetime(3) NOT NULL COMMENT '预校验时间',
|
||||
consumed_at datetime(3) DEFAULT NULL COMMENT '实际扫描出库时间',
|
||||
updated_at datetime(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_rent_request (warehouse_id,mode,request_key,attempt),
|
||||
KEY idx_storage_rent_execution (mode,execution_no,status),
|
||||
KEY idx_storage_rent_business (customer_id,business_no),
|
||||
KEY idx_storage_rent_recovery (status,updated_at),
|
||||
KEY idx_storage_rent_release (release_status,updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照';
|
||||
|
||||
-- SKU 换标原计租份额快照,存量数据库可重复执行。
|
||||
SET @storage_rent_sku_column = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_sku_conversion' AND column_name='storage_rent_shares_json');
|
||||
SET @storage_rent_sku_ddl = IF(@storage_rent_sku_column=0, 'ALTER TABLE ll_return_sku_conversion ADD COLUMN storage_rent_shares_json LONGTEXT NULL COMMENT ''原仓租批次迁移快照'' AFTER ownership_json', 'SELECT 1');
|
||||
PREPARE storage_rent_sku_stmt FROM @storage_rent_sku_ddl;
|
||||
EXECUTE storage_rent_sku_stmt;
|
||||
DEALLOCATE PREPARE storage_rent_sku_stmt;
|
||||
@@ -2404,8 +2404,10 @@ paths:
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } }
|
||||
/api/platform/v1/last-leg/overseas-procurements/warehouse-options:
|
||||
get:
|
||||
summary: 查询有效公共海外收货仓候选
|
||||
description: 权限 platform:overseas-business:purchase:list;固定国内权威公共 OVERSEAS、ACTIVE、RECEIVING 仓,浏览器不访问内部 API。
|
||||
summary: 按采购方向查询有效公共起运收货仓候选
|
||||
description: 权限 platform:overseas-business:purchase:list;海外采购取 OVERSEAS、ACTIVE、RECEIVING 公共仓,国内采购取 DOMESTIC、ACTIVE 公共仓,均以 Warehouse 为准。
|
||||
parameters:
|
||||
- { name: procurementDirection, in: query, schema: { $ref: '#/components/schemas/ProcurementDirection' } }
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses:
|
||||
'200':
|
||||
@@ -3731,6 +3733,7 @@ components:
|
||||
properties:
|
||||
feeItemCode: { type: string, minLength: 1, maxLength: 64 }
|
||||
feeItemName: { type: string, minLength: 1, maxLength: 128 }
|
||||
feeRemark: { type: string, maxLength: 500, nullable: true, description: 费用说明;同单同方向已有相同项目时须填写补收或重复作业原因 }
|
||||
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
|
||||
currency: { type: string, enum: [CNY, USD] }
|
||||
amount: { type: number, format: decimal, minimum: 0.01 }
|
||||
@@ -3738,7 +3741,7 @@ components:
|
||||
supplierId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
supplierName: { type: string, maxLength: 200 }
|
||||
supplierCategory: { type: string, maxLength: 32 }
|
||||
settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] }
|
||||
settlementMode: { type: string, enum: [BILL, WALLET, ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 应收可选推送结算或余额扣费;应付使用账期或已付 }
|
||||
chargeableQuantity: { type: number, minimum: 0, exclusiveMinimum: true }
|
||||
quantityUnit: { type: string, maxLength: 32 }
|
||||
unitPrice: { type: number, minimum: 0, exclusiveMinimum: true }
|
||||
@@ -3755,6 +3758,7 @@ components:
|
||||
orderId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
feeItemCode: { type: string }
|
||||
feeItemName: { type: string }
|
||||
feeRemark: { type: string, maxLength: 500, nullable: true, description: 业务费用说明,与审核原因分开保存 }
|
||||
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
|
||||
currency: { type: string, enum: [CNY] }
|
||||
amount: { type: number, format: decimal }
|
||||
@@ -4227,7 +4231,7 @@ components:
|
||||
supplierId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
supplierName: { type: string, maxLength: 200 }
|
||||
supplierCategory: { type: string, maxLength: 32 }
|
||||
settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] }
|
||||
settlementMode: { type: string, enum: [BILL, WALLET, ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 应收可选推送结算或余额扣费;应付使用账期或已付 }
|
||||
chargeableQuantity: { type: number, minimum: 0, exclusiveMinimum: true }
|
||||
quantityUnit: { type: string, maxLength: 32 }
|
||||
unitPrice: { type: number, minimum: 0, exclusiveMinimum: true }
|
||||
@@ -4533,7 +4537,7 @@ components:
|
||||
labelDeadline: { type: string, format: date-time }
|
||||
contactName: { type: string, maxLength: 128 }
|
||||
contactPhone: { type: string, maxLength: 64 }
|
||||
chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 }
|
||||
chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 换标报价快照(CNY);不自动冻结或收费,应收由海外费用登记审核 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
idempotencyKey: { type: string, maxLength: 255 }
|
||||
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, AMAZON_PICKUP, CUSTOMER_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传;仅TRUCK进入平台预排车,其余方式直接创建出库任务 }
|
||||
@@ -4700,10 +4704,16 @@ components:
|
||||
- type: object
|
||||
required: [records]
|
||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } }
|
||||
ProcurementDirection:
|
||||
type: string
|
||||
enum: [OVERSEAS_TO_DOMESTIC, DOMESTIC_TO_OVERSEAS]
|
||||
default: OVERSEAS_TO_DOMESTIC
|
||||
description: 海外采购回国内或国内采购发海外;创建可选,待审核时可核定,通过后锁定。历史运输段编码保持不变,区域文案按方向展示。
|
||||
ProcurementSummary:
|
||||
type: object
|
||||
required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, status, version, createdAt, updatedAt]
|
||||
properties:
|
||||
procurementDirection: { $ref: '#/components/schemas/ProcurementDirection' }
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
orderNo: { type: string }
|
||||
customerId: { type: string }
|
||||
@@ -5067,12 +5077,13 @@ components:
|
||||
actualVolumeCbm: { type: number, nullable: true }
|
||||
status: { type: string }
|
||||
version: { type: integer }
|
||||
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, SHIPPED_TO_CUSTOMER, REJECTED, CANCELLED, EXCEPTION_CLOSED] }
|
||||
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, WAIT_OVERSEAS_SHIPMENT, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, WAIT_CUSTOMER_SHIPMENT, SHIPPED_TO_CUSTOMER, REJECTED, CANCELLED, EXCEPTION_CLOSED] }
|
||||
ProcurementReview:
|
||||
type: object
|
||||
required: [version, decision, idempotencyKey]
|
||||
description: 审核通过必须选择有效公共海外收货仓;拒绝不要求仓库。
|
||||
description: 审核通过必须选择与采购方向匹配的有效公共收货仓;国内采购选择国内仓,海外采购选择海外收货仓;拒绝不要求仓库,但 remark 必须填写非空白拒绝原因。审核不得修改客户下单采购方向。
|
||||
properties:
|
||||
procurementDirection: { $ref: '#/components/schemas/ProcurementDirection' }
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
decision: { type: string, enum: [APPROVE, REJECT] }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
@@ -5082,6 +5093,7 @@ components:
|
||||
type: object
|
||||
required: [productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey]
|
||||
properties:
|
||||
procurementDirection: { $ref: '#/components/schemas/ProcurementDirection' }
|
||||
customerName: { type: string, nullable: true, maxLength: 255 }
|
||||
purchaseCategory: { type: string, maxLength: 128 }
|
||||
productName: { type: string, maxLength: 255 }
|
||||
@@ -5090,7 +5102,7 @@ components:
|
||||
purchaseQuantity: { type: integer, format: int64, minimum: 1 }
|
||||
budgetUnitPrice: { type: number, minimum: 0, multipleOf: 0.01, description: 客户授权的货款预算单价;零表示仅授权免费样品 }
|
||||
currency: { type: string, enum: [CNY, USD] }
|
||||
receivingAddress: { type: string, minLength: 1, maxLength: 1000, description: 最终客户国内收件完整地址 }
|
||||
receivingAddress: { type: string, minLength: 1, maxLength: 1000, description: 最终客户目的地完整地址;国内采购发海外时填写海外地址 }
|
||||
contactName: { type: string, maxLength: 128 }
|
||||
contactPhone: { type: string, maxLength: 64 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
@@ -7709,6 +7721,7 @@ components:
|
||||
nullable: true
|
||||
DispatchFeeItem:
|
||||
type: object
|
||||
description: 派送应付费用。客户应收费用统一到海外费用登记并审核,派送完成不自动生成应收。
|
||||
x-java-record: DispatchDeliveryModels.FeeItem
|
||||
properties:
|
||||
feeType:
|
||||
@@ -7719,6 +7732,7 @@ components:
|
||||
nullable: true
|
||||
direction:
|
||||
type: string
|
||||
enum: [PAYABLE]
|
||||
nullable: true
|
||||
currency:
|
||||
type: string
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
openapi: 3.0.3
|
||||
info: { title: 海外录费公共费用项目, version: 1.0.0 }
|
||||
security: [{ bearerAuth: [] }]
|
||||
paths:
|
||||
/api/platform/v1/last-leg/finance-fee-sources/fee-items:
|
||||
get:
|
||||
summary: 按收付方向选择当前启用的海外费用项目
|
||||
description: '仅平台主体;需要platform:overseas-fee:list或platform:finance:customer-arap:list。复用Firstleg目录,不建立独立目录。录费时重新校验当前编码和名称。'
|
||||
parameters:
|
||||
- name: direction
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string, enum: [RECEIVABLE, PAYABLE], default: RECEIVABLE }
|
||||
responses:
|
||||
'400': { description: 收付方向无效 }
|
||||
'200': { description: '统一响应data为数组;每项code、name、defaultPrice。默认价仅CNY,USD录费不得直接套用。' }
|
||||
'403': { description: 身份或查询权限不满足 }
|
||||
'409': { description: 费用目录未就绪或响应无效 }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
@@ -1,6 +1,13 @@
|
||||
openapi: 3.0.3
|
||||
info: { title: Overseas Business Fee Source API, version: 1.0.0 }
|
||||
info: { title: Overseas Business Fee Source API, version: 1.1.0, description: '首版除仓租外均人工登记审核;换标报价、外接运费及拆柜成本不自动收费;旧资金链同项目已锁款或收取时拒绝重复登记。' }
|
||||
paths:
|
||||
/api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/retries:
|
||||
get:
|
||||
summary: 查询该费用每次人工重试的时间、操作人和实际结果
|
||||
description: 与费用详情使用相同列表权限。按客户及稳定费用身份隔离,包含各版本的推送重试和资金重试。SUCCESS依据投递接收或实际资金成功回执;FAILED保留失败原因;SUBMITTED只代表请求已提交。多次重试分别保留,后一次成功不覆盖前一次失败。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ in: path, name: sourceFeeId, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 'data为RetryRecord数组,按重试时间倒序' } }
|
||||
/api/platform/v1/last-leg/finance-fee-sources:
|
||||
get:
|
||||
summary: 查询海外业务费用来源当前版本
|
||||
@@ -12,12 +19,56 @@ paths:
|
||||
- { in: query, name: containerBookingId, description: 精确查询真实来源柜下全部客户的费用;可再传customerId收窄到单个客户, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } }
|
||||
post:
|
||||
summary: 创建海外业务费用来源草稿
|
||||
description: 采购费用仅由采购履约或财务节点实扣后自动发布,本入口拒绝采购来源或采购业务类型。客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。
|
||||
summary: 创建人工费用并锁定资金
|
||||
description: 需platform:finance:fee-source:edit。创建即PENDING。应收仅当前LASTLEG_ORDER且orderType=FIRST_LEG采用BILL,其余业务(含ONE_CLICK_DROPSHIP、退货、转运、柜级费用)为WALLET,发送异步FREEZE,本土卡车、本土散板应收同采购一样使用客户余额及授信扣款;余额及授信不足显示FREEZE_FAILED,不可审核通过。原USD按业务月有效汇率固化为CNY后冻结;原币与汇率快照保留。采购来源仍只允许采购业务节点处理。业务ID、客户、单号与补充订单/入仓关联由服务端核对;真实柜要求businessId=containerBookingId且businessNo=containerNo。调用重放不重复冻结。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } }
|
||||
responses: { '200': { description: 草稿费用来源 }, '400': { description: 业务归属或付款事实无效 } }
|
||||
responses: { '200': { description: 待审核费用,含collectionMode、walletStatus、allowedActions }, '400': { description: 业务归属或付款事实无效 } }
|
||||
/api/platform/v1/last-leg/finance-fee-sources/page:
|
||||
get:
|
||||
summary: 分页查询海外费用
|
||||
description: 需platform:overseas-fee:list或platform:finance:customer-arap:list。只返回各稳定来源最新版本,按创建时间及ID倒序;应收是默认方向。资金状态批量加载。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { in: query, name: page, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { in: query, name: size, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { in: query, name: customerId, schema: { type: string } }
|
||||
- { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } }
|
||||
- { in: query, name: businessNo, schema: { type: string } }
|
||||
- { in: query, name: feeItemCode, schema: { type: string } }
|
||||
- { in: query, name: storageRentOnly, description: 只查询已实际出库生成的仓租费用,分页总数使用相同口径, schema: { type: boolean, default: false } }
|
||||
- { in: query, name: reviewStatus, schema: { type: string, enum: [DRAFT, PENDING, APPROVED, REJECTED] } }
|
||||
- { in: query, name: walletStatus, schema: { type: string } }
|
||||
- { in: query, name: processingStatus, description: 业务费用状态,按当前版本事件及资金状态统一筛选并统计分页, schema: { type: string, enum: [WAITING, PROCESSING, COMPLETED, FAILED, CLOSED] } }
|
||||
- { in: query, name: direction, schema: { type: string, enum: [RECEIVABLE, PAYABLE], default: RECEIVABLE } }
|
||||
responses:
|
||||
'200':
|
||||
description: data为PageResult,包含records、page、size、total、pages
|
||||
/api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}:
|
||||
get:
|
||||
summary: 费用详情及服务端可执行动作
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ in: path, name: sourceFeeId, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 当前费用及资金、审核、作废确认状态 }, '404': { description: 费用不存在 } }
|
||||
/api/platform/v1/last-leg/finance-fee-sources/customer-options:
|
||||
get:
|
||||
summary: 查询已有海外业务或费用的客户候选
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses: { '200': { description: data为ContainerCustomerOption数组 } }
|
||||
/api/platform/v1/last-leg/finance-fee-sources/business-options:
|
||||
get:
|
||||
summary: 查询真实服务业务单候选
|
||||
description: 只查Lastleg自有实体,每次最多50条;LASTLEG_ORDER保留真实orderType,区分FIRST_LEG与ONE_CLICK_DROPSHIP等业务。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { in: query, name: businessType, required: true, schema: { type: string, enum: [LASTLEG_ORDER, RELABEL_ORDER, WAREHOUSE_TRANSFER, RETURN_ORDER, US_TRUCK, LOCAL_LOOSEBOARD, DISCARD_ORDER, OVERSEAS_INBOUND, DELIVERY_ORDER] } }
|
||||
- { in: query, name: customerId, schema: { type: string } }
|
||||
- { in: query, name: keyword, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: 真实订单候选
|
||||
content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/BusinessOption' } } } } } }
|
||||
/api/platform/v1/last-leg/finance-fee-sources/container-customers:
|
||||
get:
|
||||
summary: 查询真实来源柜内客户选项
|
||||
@@ -33,16 +84,132 @@ paths:
|
||||
schema: { $ref: '#/components/schemas/ContainerCustomerListResponse' }
|
||||
/api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}:
|
||||
post:
|
||||
summary: 提交、审核、调整或作废海外业务费用来源
|
||||
description: 采购来源不接受通用提交、审核、调整或作废,须回到采购财务节点处理。审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。
|
||||
summary: 提交、审核、调整、作废或处理资金
|
||||
x-headleg-delivery-review: 首版头程派送费由审核员核对原报价是否已包含;当前未实现自动识别。已含费用不应再建单或通过审核,另收费用人工添加后审核进入头程账单。
|
||||
description: 编辑动作需platform:finance:fee-source:edit,review与void需platform:finance:fee-source:review。WALLET通过审核只发送SETTLE,实际匹配成功回执含冻结与账本引用后才PUBLISHED,此时仅本地费用完成、不推送财务账单;失败为SETTLE_FAILED,保持未收。驳回释放冻结,冻结在途时回执成功后自动释放。驳回后的费用只读,不能继续操作;如需重新收费应在业务单据中新增费用。头程BILL审核通过推送结算;已发布BILL作废等待Finance持久化确认才VOIDED。历史无资金记录的海外费用必须重新提交完成冻结,不允许审核绕过。采购来源仍由采购节点处理。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { in: path, name: sourceFeeId, required: true, schema: { type: string } }
|
||||
- { in: path, name: action, required: true, schema: { type: string, enum: [submit, review, adjust, void] } }
|
||||
- { in: path, name: action, required: true, schema: { type: string, enum: [submit, review, adjust, void, retry-funding, retry-delivery] } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
description: 请求格式随路径action选择;submit、cancel、retry-funding用ActionRequest,其他动作使用同名请求。
|
||||
anyOf:
|
||||
- { $ref: '#/components/schemas/ActionRequest' }
|
||||
- { $ref: '#/components/schemas/ReviewRequest' }
|
||||
- { $ref: '#/components/schemas/AdjustRequest' }
|
||||
- { $ref: '#/components/schemas/VoidRequest' }
|
||||
- { $ref: '#/components/schemas/RetryDeliveryRequest' }
|
||||
examples:
|
||||
review: { value: { decision: APPROVE, idempotencyKey: review-unique-key } }
|
||||
funding: { value: { idempotencyKey: retry-unique-key } }
|
||||
responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } }
|
||||
components:
|
||||
schemas:
|
||||
BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, PROCUREMENT_FEE, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] }
|
||||
RetryRecord:
|
||||
type: object
|
||||
properties:
|
||||
requestId: { type: string }
|
||||
requestedAt: { type: string, format: date-time, description: 系统 UTC 时间,明确携带 Z 时区 }
|
||||
operatorName: { type: string, description: 认证操作人名称及ID,非客户端提交 }
|
||||
action: { type: string }
|
||||
result: { type: string, enum: [SUBMITTED, SUCCESS, FAILED] }
|
||||
errorMessage: { type: string, nullable: true }
|
||||
RetryDeliveryRequest:
|
||||
type: object
|
||||
description: 需platform:finance:fee-source:edit。可重试当前费用对应的DEAD事件或已有失败原因的PENDING事件,校验费用版本、原事件ID及载荷摘要;沿用原事件及资金幂等身份,不创建费用或资金指令。同一请求UUID重复提交仅返回原操作,跨费用或事件复用拒绝。无失败原因的PENDING、PROCESSING和PUBLISHED不可新发起人工重试;每次重试失败后可再次提交新的请求UUID继续重推。写入既有event_delivery_retry_audit审计。
|
||||
required: [expectedVersion, eventId, idempotencyKey]
|
||||
properties:
|
||||
expectedVersion: { type: integer, minimum: 1 }
|
||||
eventId: { type: string, maxLength: 64 }
|
||||
idempotencyKey: { type: string, format: uuid }
|
||||
ActionRequest:
|
||||
type: object
|
||||
description: submit、cancel、retry-funding共用;标识需按费用及单次操作生成,重试原操作沿用。
|
||||
required: [idempotencyKey]
|
||||
properties:
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 128 }
|
||||
ReviewRequest:
|
||||
type: object
|
||||
required: [decision, idempotencyKey]
|
||||
properties:
|
||||
decision: { type: string, enum: [APPROVE, REJECT] }
|
||||
expectedVersion: { type: integer, minimum: 1, description: 仓租审核必填,必须为审核人看到的当前费用版本;调价后旧版审核被拒绝。 }
|
||||
reason: { type: string, maxLength: 500, nullable: true }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 128 }
|
||||
AdjustRequest:
|
||||
type: object
|
||||
required: [expectedVersion, fee, idempotencyKey]
|
||||
properties:
|
||||
expectedVersion: { type: integer, minimum: 1 }
|
||||
fee: { $ref: '#/components/schemas/SaveRequest' }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 128 }
|
||||
VoidRequest:
|
||||
type: object
|
||||
required: [expectedVersion, reason, idempotencyKey]
|
||||
properties:
|
||||
expectedVersion: { type: integer, minimum: 1 }
|
||||
reason: { type: string, minLength: 1, maxLength: 500 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 128 }
|
||||
BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, PROCUREMENT_FEE, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER, WAREHOUSE_TRANSFER, RETURN_ORDER, DISCARD_ORDER] }
|
||||
BusinessOption:
|
||||
type: object
|
||||
properties:
|
||||
businessId: { type: string }
|
||||
businessType: { $ref: '#/components/schemas/BusinessType' }
|
||||
businessNo: { type: string }
|
||||
customerId: { type: string }
|
||||
customerName: { type: string, nullable: true, description: 查询显示按客户ID读取IAM当前名称;主档不可用时回退历史名称,查询不回写费用或结算快照。 }
|
||||
warehouseId: { type: string, nullable: true }
|
||||
orderId: { type: string, nullable: true }
|
||||
orderType: { type: string, nullable: true }
|
||||
FundingDescription:
|
||||
type: object
|
||||
description: 海外费用来源返回值的资金补充字段。amount及currency始终为CNY结算快照;originalAmount、originalCurrency、exchangeRate、exchangeRateMonth保留原币依据。
|
||||
properties:
|
||||
displayBusinessType:
|
||||
allOf: [{ $ref: '#/components/schemas/BusinessType' }]
|
||||
readOnly: true
|
||||
description: 按真实业务事实解析的展示分类;分页业务类型筛选使用此分类。换标履约费用显示RELABEL_ORDER,原businessType/businessId保留收费凭据的关联身份。新登记换标履约费用以真实换标申请作为主业务关联。
|
||||
storageRent:
|
||||
type: object
|
||||
nullable: true
|
||||
readOnly: true
|
||||
description: 自动仓租的原始计费与实际出入库明细;审核前可通过仓租adjust入口留说明调价。chargedAmount仅成功实扣或确认零元后返回。
|
||||
properties:
|
||||
outboundNo: {type: string}
|
||||
outboundAt: {type: string, format: date-time}
|
||||
calculatedAmount: {type: number, description: 原快照计算金额,币种为originalCurrency}
|
||||
chargedAmount: {type: number, nullable: true, description: 实际扣费CNY,未成功时为null}
|
||||
lines:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
lotId: {type: string}
|
||||
sourceBusinessType: {type: string, nullable: true, description: 原计租入库来源类型,头程为LASTLEG_ORDER,退货为RETURN_ORDER}
|
||||
sourceBusinessNo: {type: string, nullable: true, description: 原计租来源单号,头程展示对应尾程订单号,退货展示退货单号;不以换标出库单替代}
|
||||
batchNo: {type: string, nullable: true, description: 原计租入库批次}
|
||||
inboundAt: {type: string, format: date-time}
|
||||
quantity: {type: number}
|
||||
days: {type: integer}
|
||||
calculatedAmount: {type: number, description: 本原批次原币计费依据}
|
||||
collectionMode: { type: string, enum: [BILL, WALLET] }
|
||||
walletStatus: { type: string, enum: [NOT_REQUIRED, FREEZE_PENDING, FROZEN, FREEZE_FAILED, SETTLE_PENDING, SETTLED, SETTLE_FAILED, RELEASE_PENDING, RELEASED, RELEASE_FAILED] }
|
||||
walletErrorMessage: { type: string, nullable: true }
|
||||
deliveryStatus: { type: string, nullable: true, enum: [PENDING, PROCESSING, PUBLISHED, DEAD], description: 当前版本原事件的实际投递状态;账单费用publicationStatus=PUBLISHED仅表示已入队,不代表Finance已接收;余额应收实扣成功即完成且deliveryStatus为空。 }
|
||||
deliveryKind: { type: string, nullable: true, enum: [FEE, FUNDING] }
|
||||
deliveryEventId: { type: string, nullable: true, description: 当前原事件ID,重试时必须原样提交 }
|
||||
deliveryErrorMessage: { type: string, nullable: true }
|
||||
allowedActions: { type: array, items: { type: string, enum: [SUBMIT, APPROVE, REJECT, ADJUST, ADJUST_RENT, VOID, RETRY_FREEZE, RETRY_SETTLE, RETRY_RELEASE, RETRY_PUBLISH, RETRY_FUNDING_DELIVERY] } }
|
||||
reviewerId: { type: string, nullable: true, description: 审核人ID }
|
||||
reviewerName: { type: string, nullable: true, description: 审核人名称快照 }
|
||||
reviewedAt: { type: string, format: date-time, nullable: true, description: 审核时间,UTC 且携带 Z 时区 }
|
||||
createdAt: { type: string, format: date-time, nullable: true, description: 创建时间,UTC 且携带 Z 时区 }
|
||||
updatedAt: { type: string, format: date-time, nullable: true, description: 更新时间,UTC 且携带 Z 时区 }
|
||||
OverseasContainerFeeItem:
|
||||
type: string
|
||||
description: 海外阶段柜级费用项目:港口费、堆存费、修箱费、吊机费、码头拖车费、其他。
|
||||
@@ -67,12 +234,12 @@ components:
|
||||
timestamp: { type: string, format: date-time }
|
||||
SaveRequest:
|
||||
type: object
|
||||
required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount]
|
||||
required: [customerId, sourceType, businessType, businessId, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount]
|
||||
properties:
|
||||
customerId: { type: string }
|
||||
sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] }
|
||||
businessType: { type: string, enum: [OVERSEAS_INBOUND, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] }
|
||||
businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true }
|
||||
businessType: { type: string, enum: [OVERSEAS_INBOUND, US_TRUCK, LOCAL_LOOSEBOARD, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER, WAREHOUSE_TRANSFER, RETURN_ORDER, DISCARD_ORDER] }
|
||||
businessId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
businessNo: { type: string, maxLength: 128 }
|
||||
containerBookingId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true }
|
||||
containerNo: { type: string, maxLength: 64, nullable: true }
|
||||
@@ -81,8 +248,9 @@ components:
|
||||
inboundCode: { type: string, maxLength: 64, nullable: true }
|
||||
feeItemCode:
|
||||
type: string
|
||||
description: 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] }
|
||||
businessDate: { type: string, format: date }
|
||||
currency: { type: string, enum: [CNY, USD], description: 业务原币;USD由服务端按业务月份有效汇率折算为CNY入池 }
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: 采购样品履约节点 API
|
||||
version: 1.0.0
|
||||
description: 客户范围、节点资格、版本与幂等由服务端校验;购买事实与资金结果分开保存。
|
||||
description: 客户范围、节点资格、版本与幂等由服务端校验;购买事实与资金结果分开保存。采购双方向复用两段技术编码:OVERSEAS 为起运段,DOMESTIC 为目的地段;按 procurementDirection 解释实际区域。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
@@ -2310,7 +2310,6 @@ components:
|
||||
maxLength: 500
|
||||
ChargeSelection:
|
||||
type: object
|
||||
required: []
|
||||
properties:
|
||||
feeId:
|
||||
type: string
|
||||
@@ -2977,6 +2976,8 @@ components:
|
||||
- status
|
||||
- version
|
||||
properties:
|
||||
procurementDirection:
|
||||
$ref: 'overseas-business-v1.yaml#/components/schemas/ProcurementDirection'
|
||||
orderNo:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 海外仓租与出库资金预校验
|
||||
version: 1.0.0
|
||||
description: |
|
||||
仅仓租自动计费。中转按CBM/天、退货按原托份额/天累进,首次或重建预校验读取客户当前仓租配置。
|
||||
未配置客户使用仓库服务明确返回的两张默认报价表;网络、无效配置或不支持币种均阻断出库,不降级免费。
|
||||
PlanJson保存配置ID、版本、币种和完整阶梯。有效预占重放与consume使用原快照及冻结金额,不受期间改价影响。
|
||||
后续部分出库采用客户最新配置,原入库时间与原托份额保持不变。原币支持USD/CNY,结算币为CNY。
|
||||
实际PDA入库开始计租,实际出库按本次数量生成待审核费用,审核通过后按确认金额扣款;所有海外仓租从余额及有效授信扣除。
|
||||
头程同单同仓多次入库合并计租,统一沿用首次实际入库时间;其他业务按各批实际入库时间分别计龄。
|
||||
每满24小时一天,不足24小时向上取整。公开时间均为ISO8601 UTC时间,携带Z,客户端按本地时区显示。
|
||||
普通业务错误沿用HTTP 200及非200业务code。平台接口要求平台tenant_id=0。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/storage-rent:
|
||||
get:
|
||||
summary: 原计租批次分页
|
||||
description: 权限platform:overseas-fee:list。一个原始实收批次一行。
|
||||
parameters:
|
||||
- {in: query, name: customerId, schema: {type: string}}
|
||||
- {in: query, name: businessNo, schema: {type: string}}
|
||||
- {in: query, name: warehouseId, schema: {type: string}}
|
||||
- {in: query, name: businessType, schema: {type: string, enum: [LASTLEG_ORDER, WAREHOUSE_TRANSFER, RETURN_ORDER]}}
|
||||
- {in: query, name: status, schema: {type: string, enum: [IN_STOCK, CLEARED]}}
|
||||
- {in: query, name: page, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {in: query, name: size, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
responses:
|
||||
'200':
|
||||
description: 统一响应data包含records、page、size、total、pages
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/Lot'}}
|
||||
page: {type: integer}
|
||||
size: {type: integer}
|
||||
total: {type: integer}
|
||||
pages: {type: integer}
|
||||
/api/platform/v1/last-leg/storage-rent/warehouse-options:
|
||||
get:
|
||||
summary: 计租仓库选项
|
||||
description: 权限platform:overseas-fee:list,只读取已有仓库快照。
|
||||
responses:
|
||||
'200':
|
||||
description: data为id、warehouseName对象数组
|
||||
/api/platform/v1/last-leg/storage-rent/{id}:
|
||||
get:
|
||||
summary: 原批次及逐次出库仓租
|
||||
description: 权限platform:overseas-fee:list;只返回已实际出库的收费记录,免费出库保留零金额。
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/id'}
|
||||
responses:
|
||||
'200':
|
||||
description: 统一响应
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
lot: {$ref: '#/components/schemas/Lot'}
|
||||
charges: {type: array, items: {$ref: '#/components/schemas/Charge'}}
|
||||
/api/platform/v1/last-leg/storage-rent/fees/{sourceFeeId}/adjust:
|
||||
post:
|
||||
summary: 审核前调整仓租金额并保存说明
|
||||
description: 权限platform:finance:fee-source:edit。仅当前待审核且资金已锁定版本可改;金额按原币填写,保留冻结汇率与原计算快照。生成新费用版本。原零元改为非零须先补冻结;原非零费用调高在审核结算时由Finance原子检查差额,余额不足保留原冻结,不登记实扣。
|
||||
parameters:
|
||||
- {in: path, name: sourceFeeId, required: true, schema: {type: string}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [expectedVersion, amount, reason, idempotencyKey]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 1}
|
||||
amount: {type: number, minimum: 0, maximum: 999999999999.99, multipleOf: 0.01}
|
||||
reason: {type: string, minLength: 1, maxLength: 500}
|
||||
idempotencyKey: {type: string, minLength: 1, maxLength: 128}
|
||||
responses:
|
||||
'200': {description: 统一响应,data为调整后的费用版本;状态冲突或缺少说明返回非200业务code}
|
||||
/api/pda/v1/last-leg/storage-rent/preparations:
|
||||
post:
|
||||
summary: 出库前计算仓租并提交余额授信冻结
|
||||
description: |
|
||||
权限lastleg:pda:operate及ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR。客户与仓库从绑定身份及真实业务来源解析。
|
||||
ready=false必须等待资金回执;仅ready=true可提交原扫码。先冻结后扣库存,实际扫码后生成待审核仓租,审核通过后实际扣款。
|
||||
同一原扫码复用idempotencyKey与全部明细;更改货物或数量必须新键。冻结15分钟有效,变更计费天数或汇率月份需重算。
|
||||
已实际出库的原键返回CONSUMED,调用原扫码接口恢复其幂等结果。零费用无需汇率及资金指令。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PrepareRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 统一响应data为预校验状态
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- properties:
|
||||
data: {$ref: '#/components/schemas/Preparation'}
|
||||
/api/pda/v1/last-leg/storage-rent/preparations/{id}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/id'}
|
||||
get:
|
||||
summary: 查询原冻结真实回执
|
||||
description: PDA权限及绑定仓校验同创建。
|
||||
responses:
|
||||
'200': {description: 统一响应data为Preparation}
|
||||
delete:
|
||||
summary: 取消未使用预占并解冻
|
||||
description: 已出库不能取消;冻结处理中取消将于迟到成功回执后补解冻。PDA权限及绑定仓校验同创建。
|
||||
responses:
|
||||
'200': {description: 统一响应data为Preparation}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
parameters:
|
||||
id: {in: path, name: id, required: true, schema: {type: string}}
|
||||
schemas:
|
||||
Envelope:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data: {type: object}
|
||||
Lot:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: string}
|
||||
customerId: {type: string}
|
||||
customerName: {type: string}
|
||||
businessType: {type: string}
|
||||
businessNo: {type: string, description: 原计租来源单号,头程为对应尾程订单号,退货为退货单号}
|
||||
warehouseId: {type: string}
|
||||
warehouseName: {type: string}
|
||||
batchNo: {type: string}
|
||||
inboundAt: {type: string, format: date-time, description: 实际入库UTC时间,带Z}
|
||||
storageDays: {type: integer, description: 已清仓按最后实际出库停止}
|
||||
originalQuantity: {type: number}
|
||||
remainingQuantity: {type: number}
|
||||
quantityUnit: {type: string, enum: [PACKAGE, PIECE]}
|
||||
billingQuantity: {type: number}
|
||||
billingUnit: {type: string, enum: [CBM, PALLET]}
|
||||
ruleCode: {type: string, enum: [TRANSIT_V1, RETURN_V1]}
|
||||
status: {type: string, enum: [IN_STOCK, CLEARED]}
|
||||
settledAmount: {type: number, description: 本原批次已成功扣款的CNY合计,不含处理中及失败记录}
|
||||
chargeCount: {type: integer, description: 本原批次实际出库仓租记录数,包含免租记录}
|
||||
settledChargeCount: {type: integer, description: 已完成结算的仓租记录数,包含免租记录}
|
||||
generatedAmount: {type: number, nullable: true, description: 单币种合计;无记录为0,多币种为null}
|
||||
generatedCurrency: {type: string, nullable: true, enum: [USD, CNY], description: 无记录或多币种为null}
|
||||
generatedAmounts:
|
||||
type: array
|
||||
description: 按原币分别汇总,禁止跨币相加;无记录为空数组
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
currency: {type: string, enum: [USD, CNY]}
|
||||
amount: {type: number}
|
||||
Charge:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: string}
|
||||
outboundNo: {type: string}
|
||||
businessNo: {type: string}
|
||||
quantity: {type: number}
|
||||
storageDays: {type: integer}
|
||||
originalAmount: {type: number}
|
||||
currency: {type: string, enum: [USD, CNY]}
|
||||
settlementAmount: {type: number, description: CNY结算金额}
|
||||
currencyRate: {type: number, nullable: true}
|
||||
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}
|
||||
PrepareRequest:
|
||||
type: object
|
||||
required: [mode, businessNo, idempotencyKey]
|
||||
properties:
|
||||
mode: {type: string, enum: [DISPATCH, OUTBOUND, OUTBOUND_PALLET, DISCARD]}
|
||||
businessNo: {type: string, maxLength: 128}
|
||||
itemId: {type: string}
|
||||
positionId: {type: string}
|
||||
packages: {type: integer, minimum: 1}
|
||||
cartonMark: {type: string}
|
||||
palletNo: {type: string}
|
||||
idempotencyKey: {type: string, maxLength: 255}
|
||||
cartonMarks: {type: array, items: {type: string}}
|
||||
skuCode: {type: string}
|
||||
productQuantity: {type: integer, minimum: 0}
|
||||
lines:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [itemId, actualPackages]
|
||||
properties:
|
||||
itemId: {type: string}
|
||||
actualPackages: {type: integer, minimum: 0}
|
||||
photoFileIds: {type: array, items: {type: string}}
|
||||
scannedCartonMarks: {type: array, items: {type: string}}
|
||||
confirmAllCartons: {type: boolean}
|
||||
Preparation:
|
||||
type: object
|
||||
properties:
|
||||
preparationId: {type: string, nullable: true}
|
||||
status: {type: string, enum: [NOT_REQUIRED, FREEZE_PENDING, READY, FAILED, EXPIRED, CANCELLED, CONSUMED]}
|
||||
ready: {type: boolean}
|
||||
amount: {type: number}
|
||||
currency: {type: string, enum: [CNY]}
|
||||
originalAmount: {type: number, nullable: true, description: 整托包含多种原币时为null,CNY amount始终可合计}
|
||||
originalCurrency: {type: string, nullable: true, enum: [USD, CNY]}
|
||||
exchangeRate: {type: number, nullable: true}
|
||||
exchangeRateMonth: {type: string, nullable: true}
|
||||
message: {type: string, nullable: true}
|
||||
+8
-10
@@ -48,22 +48,22 @@ class DispatchDeliveryFeesTest {
|
||||
}
|
||||
|
||||
/** 无引用时不强制Finance可用,已有外接约定组禁止新增派送应收。 */
|
||||
@Test void ordinaryNeedsPositiveFeeAndExternalQuoteDoesNotWaiveOtherCargo() {
|
||||
@Test void ordinaryAllowsLaterManualFeeAndOldReceivableInputIsRejected() {
|
||||
var row=cargo(1,"101","ONT8",51,2);var group=grouping.groups(context(List.of(row),false)).get(0);
|
||||
var input=input(group,List.of(),List.of());
|
||||
assertNull(service.coverage(group,input,"OP",ordinary()));verifyNoInteractions(finance);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,null,ordinary()));
|
||||
service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,ordinary());
|
||||
service.validate(group,input,null,ordinary());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,ordinary()));
|
||||
var quote=new FleetBookingFreight.Quote("BOOK-1",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO);
|
||||
var allExternal=new FleetBookingFreight.Preview("freight",false,List.of(quote),List.of());
|
||||
service.validate(group,input,null,allExternal);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,allExternal));
|
||||
var mixed=grouping.groups(context(List.of(row,cargo(2,"101","ONT8",51,3)),false)).get(0);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(mixed,input(mixed,List.of(),List.of()),null,allExternal));
|
||||
service.validate(mixed,input(mixed,List.of(),List.of()),null,allExternal);
|
||||
}
|
||||
|
||||
/** 正数报价发布到自己的客户目的地,零约定仅保存商业操作不造零费用。 */
|
||||
@Test void zeroAgreementRecordsFactAndPositiveFeeUsesOwnDelivery() {
|
||||
@Test void zeroAndPositiveQuotesKeepFactsWithoutGeneratingReceivables() {
|
||||
var row=cargo(1,"101","ONT8",51,2);var context=context(List.of(row),false);var group=grouping.groups(context).get(0);
|
||||
var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001");
|
||||
var zero=new FleetBookingFreight.Quote("BOOK-ZERO",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO);
|
||||
@@ -71,9 +71,7 @@ class DispatchDeliveryFeesTest {
|
||||
verify(repository).operation(any());verifyNoInteractions(sources);
|
||||
var positive=new FleetBookingFreight.Quote("BOOK-POSITIVE",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.TEN,BigDecimal.ONE,"单",BigDecimal.TEN);
|
||||
service.publish(context,group,input(group,List.of(),List.of()),delivery,null,new FleetBookingFreight.Preview("freight",false,List.of(positive),List.of()),"OP","ARR-1");
|
||||
var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class);verify(sources).publishApproved(eq("OP"),anyString(),saved.capture());
|
||||
assertEquals("101",saved.getValue().customerId());assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo());
|
||||
assertEquals(BigDecimal.TEN,saved.getValue().amount());
|
||||
verify(repository,times(2)).operation(any()); verifyNoInteractions(sources);
|
||||
}
|
||||
|
||||
/** 合托两票一笔原费只能覆盖对应来源,不得免掉另一票新增应收。 */
|
||||
@@ -88,7 +86,7 @@ class DispatchDeliveryFeesTest {
|
||||
var evidence=service.coverage(group,input,"OP",ordinary());
|
||||
assertEquals(2,evidence.cargo().size());
|
||||
verify(finance).sources("offline-test-token",new FinanceFeeCoverageClient.Query(101L,List.of(501L,502L)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,evidence,ordinary()));
|
||||
service.validate(group,input,evidence,ordinary());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group,
|
||||
input(group,List.of(input.feeCoverage().get(0),input.feeCoverage().get(0)),List.of()),"OP",ordinary()));
|
||||
}
|
||||
@@ -102,7 +100,7 @@ class DispatchDeliveryFeesTest {
|
||||
var group=grouping.groups(context).get(0);
|
||||
var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");
|
||||
delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001");
|
||||
service.publish(context,group,input(group,List.of(),List.of(fee("RECEIVABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL");
|
||||
service.publish(context,group,input(group,List.of(),List.of(fee("PAYABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL");
|
||||
var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class);
|
||||
verify(sources).publishApproved(eq("OP"),anyString(),saved.capture());
|
||||
assertEquals("DELIVERY_ORDER",saved.getValue().businessType());
|
||||
|
||||
+1
-16
@@ -133,6 +133,7 @@ class DeliveryTransferServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DeliveryTransferService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
var quantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository();
|
||||
ReflectionTestUtils.setField(quantities, "pallets", outboundPallets);
|
||||
ReflectionTestUtils.setField(service, "outboundQuantities", quantities);
|
||||
@@ -451,22 +452,6 @@ class DeliveryTransferServiceTest {
|
||||
|
||||
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"SAME_KEY", "NEW_KEY", "INSERT_RACE"})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "dropship-301");
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ class RelabelOutboundPickingTest {
|
||||
|
||||
/** 构造返工后新目标位:两箱冻结,账面混合SKU,与空出库SKU不作通配匹配。 */
|
||||
@BeforeEach void prepare() {
|
||||
service = new DeliveryTransferService(); commands = mock(InventoryCommandService.class);
|
||||
service = new DeliveryTransferService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); commands = mock(InventoryCommandService.class);
|
||||
picks=mock(OutboundPdaPickMapper.class); tasks=mock(OutboundPdaTaskMapper.class);
|
||||
orders=mock(OutboundOrderMapper.class); pallets=mock(OutboundPalletItemMapper.class);
|
||||
positions=mock(InventoryPositionMapper.class); balances=mock(InventoryBalanceMapper.class);
|
||||
|
||||
+2
@@ -57,6 +57,7 @@ class ReturnOutboundClosureTest {
|
||||
ReturnInventoryCarton carton;
|
||||
|
||||
@BeforeEach void setup() {
|
||||
ReflectionTestUtils.setField(service, "storageRent", mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
// 取消入口先查询幂等记录,手动组装的测试服务也需要注入该仓储。
|
||||
ReflectionTestUtils.setField(service, "cancellations", new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(
|
||||
outbounds, items, deliveries, tasks, mock(DeliveryOperationMapper.class), relabelApplications, cartons,
|
||||
@@ -263,6 +264,7 @@ class ReturnOutboundClosureTest {
|
||||
var relabelMapper = traced(sessions.getMapper(RelabelApplicationMapper.class), RelabelApplicationMapper.class, "lockByOutbound", "relabel");
|
||||
var cartonMapper = traced(sessions.getMapper(ReturnInventoryCartonMapper.class), ReturnInventoryCartonMapper.class, "lockMark", "carton");
|
||||
var target = new DeliveryTransferService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(target, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
ReflectionTestUtils.setField(target,"cancellations",new DeliveryCancellationRepository(outboundMapper,sessions.getMapper(OutboundPalletItemMapper.class),
|
||||
sessions.getMapper(DeliveryOrderMapper.class),sessions.getMapper(OutboundPdaTaskMapper.class),sessions.getMapper(DeliveryOperationMapper.class),relabelMapper,
|
||||
cartonMapper,sessions.getMapper(WarehouseTransferOrderMapper.class),sessions.getMapper(LastlegOrderMapper.class),sessions.getMapper(LastlegOrderStatusHistoryMapper.class)));
|
||||
|
||||
+2
@@ -65,6 +65,7 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
movementMapper = session.getMapper(ReturnInventoryMovementMapper.class);
|
||||
balanceMapper = session.getMapper(ReturnInventoryBalanceMapper.class);
|
||||
var target = new DeliveryTransferService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(target, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
ReflectionTestUtils.setField(target, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(target, "returnCartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class));
|
||||
ReflectionTestUtils.setField(target, "deliveryOrders", session.getMapper(DeliveryOrderMapper.class));
|
||||
@@ -74,6 +75,7 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
ReflectionTestUtils.setField(target, "pdaPicks", session.getMapper(OutboundPdaPickMapper.class));
|
||||
ReflectionTestUtils.setField(target, "returnCartons", session.getMapper(ReturnInventoryCartonMapper.class));
|
||||
var stock = new ReturnInventoryService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(stock, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
ReflectionTestUtils.setField(stock, "cartons", session.getMapper(ReturnInventoryCartonMapper.class));
|
||||
ReflectionTestUtils.setField(stock, "cartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class));
|
||||
ReflectionTestUtils.setField(stock, "balances", session.getMapper(ReturnInventoryBalanceMapper.class));
|
||||
|
||||
+1
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
|
||||
|
||||
@BeforeEach void setup() {
|
||||
service = new PdaCargoDiscardService();
|
||||
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);
|
||||
ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class));
|
||||
|
||||
+1
@@ -119,6 +119,7 @@ class CargoDiscardServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new CargoDiscardService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository();
|
||||
ReflectionTestUtils.setField(quantities, "items", items);
|
||||
ReflectionTestUtils.setField(service, "quantities", quantities);
|
||||
|
||||
+1
@@ -633,6 +633,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
service=new DispatchAggregateService(repository,
|
||||
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
|
||||
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger();
|
||||
when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers);
|
||||
|
||||
+15
@@ -23,6 +23,20 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
|
||||
class DispatchPickingPersistenceTest {
|
||||
/** 仓租冻结未确认时阻断真实扫码,任务、库存及幂等记录全部回滚。 */
|
||||
@Test void storageRentFundingGateBlocksActualInventoryAndRollsBackTask() throws Exception {
|
||||
var fixture=new Picking();
|
||||
var rent=mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class);
|
||||
doThrow(new com.baiyue.common.core.exception.BizException("仓租资金尚未冻结")).when(rent).consume(eq("9"),any());
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(fixture.service,"storageRent",rent);
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
|
||||
new DispatchCommands.Pick(fixture.version,"rent-funding-blocked",11L,3,0,null,null)))).hasMessageContaining("尚未冻结");
|
||||
verify(rent).consume(eq("9"),argThat(request->"rent-funding-blocked".equals(request.idempotencyKey())&&request.packages()==3L));
|
||||
verify(fixture.inventory,never()).ship(any(),any(),any());
|
||||
assertThat(fixture.f.db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=11",Long.class)).isEqualTo(3);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task",Long.class)).isZero();
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='rent-funding-blocked'",Long.class)).isZero();
|
||||
}
|
||||
/** 即使旧记录已经审核通过,真实扫码仍复验预约;拒绝时不物化、不扣库存。 */
|
||||
@Test void unconfirmedAppointmentBlocksPickingBeforeInventoryWrites() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(9,11);
|
||||
@@ -119,6 +133,7 @@ class DispatchPickingPersistenceTest {
|
||||
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
|
||||
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
|
||||
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
|
||||
}
|
||||
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ class DispatchRegionalCandidatesTest {
|
||||
@Test void serviceAcceptsRegionalQueriesAndRejectsMalformedScopes() {
|
||||
var sources=mock(DispatchService.class);var appointments=mock(DispatchAppointmentAllocations.class);
|
||||
var service=new DispatchAggregateService(null,null,sources,null,null,null,new com.fasterxml.jackson.databind.ObjectMapper());
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
ReflectionTestUtils.setField(service,"appointments",appointments);
|
||||
var actor=new Actor("7",ActorMode.PLATFORM,null);
|
||||
when(sources.candidatePage(eq("7"),any())).thenReturn(new DispatchService.CandidatePage(List.of(),41));
|
||||
|
||||
+2
-1
@@ -131,7 +131,8 @@ class DispatchRepairWorkflowTest {
|
||||
var execution=new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class),mapper(DeliveryOrderMapper.class));dependencies.put(DeliveryExecutionRepository.class,execution);dependencies.put(DeliveryExecutionGuard.class,new DeliveryExecutionGuard(execution));
|
||||
dependencies.put(com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository.class,new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(mapper(OutboundOrderMapper.class),mapper(OutboundPalletItemMapper.class),mapper(DeliveryOrderMapper.class),mapper(OutboundPdaTaskMapper.class),mapper(DeliveryOperationMapper.class),mapper(RelabelApplicationMapper.class),mapper(ReturnInventoryCartonMapper.class),mapper(WarehouseTransferOrderMapper.class),mapper(LastlegOrderMapper.class),mapper(LastlegOrderStatusHistoryMapper.class)));
|
||||
fees=mock(OverseasFeeSourceService.class);dependencies.put(OverseasFeeSourceService.class,fees);
|
||||
delivery=wire(new DeliveryTransferService());visits=wire(new CustomerVisitService());exceptions=wire(new ExceptionOrderService());
|
||||
delivery=wire(new DeliveryTransferService());
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(delivery, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));visits=wire(new CustomerVisitService());exceptions=wire(new ExceptionOrderService());
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("OP","", "platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:cancel","platform:overseas-warehouse:dispatch:transfer-delivery"));
|
||||
}
|
||||
/** 真实权限上下文不泄漏到其他测试。 */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user