This commit is contained in:
2026-10-19 17:56:36 +08:00
parent bb917c954c
commit dcfba1053c
232 changed files with 15655 additions and 6005 deletions
@@ -2,18 +2,26 @@ package com.baiyue.lastleg.appointmentwork;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Map;
/** Real dispatch allocation boundary, implemented by the original appointment domain. */
public interface AppointmentWorkDispatchPort {
record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId,
record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotNull @PositiveOrZero Long recordVersion,@NotBlank String customerId,
@NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { }
record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { }
record PhysicalItem(Long itemId,String customerId,Long planPalletId,String sourceStage,Integer packages) { }
record BatchSource(String sourceKey,String destinationKey,int packageCount) { }
record BatchReference(String batchKey,String batchNo,String customerId,List<BatchSource> sources) { }
record BatchReference(String batchKey,String batchNo,Long recordVersion,String customerId,List<BatchSource> sources) { }
record CandidatePhysical(String customerId,Long planPalletId) { }
/** sources数量由本托真实逐票量和原批次剩余量共同限定;不包含零余量的伪可选项。 */
record CandidateBatches(List<BatchReference> appointmentBatches,List<String> blockReasons,boolean occupied) { }
/** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
void bind(String customerId,long dispatchId,List<PhysicalItem> items,List<Allocation> allocations);
void requireReplay(String customerId,long dispatchId,List<Allocation> allocations);
/** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */
void lockBatches(Map<String,Long> expectedBatchVersions);
/** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */
void bindItems(long dispatchId,List<PhysicalItem> touchedItems,List<Allocation> allocations);
void requireDeparture(String dispatchNo);
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
String departureBlockReason(String dispatchNo);
@@ -6,6 +6,10 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import com.baiyue.lastleg.delivery.service.DispatchDeliveryService;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -22,23 +26,34 @@ import org.springframework.web.server.ResponseStatusException;
@Tag(name = "Outbound and delivery")
public class DeliveryTransferController {
@Resource private DeliveryTransferService service;
@Resource private DispatchDeliveryService dispatchDelivery;
/** 只读辅助入口复用同一整车组合预览,不保留单客户报价协议。 */
@GetMapping("/dispatches/{dispatchNo}/external-freight")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
public ApiResponse<com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> externalFreight(
public ApiResponse<java.util.List<PreviewGroup>> externalFreight(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(service.externalFreight(dispatchNo));
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups());
}
/** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */
@GetMapping("/dispatches/{dispatchNo}/delivery-arrangement")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
public ApiResponse<ArrangementPreview> arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo));
}
/** 平台一次安排本车全部客户,身份只从认证上下文获取。 */
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
@Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse")
public ApiResponse<DeliveryResponses.TransferResult> transfer(
@Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单")
public ApiResponse<CommandResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@Valid @RequestBody DeliveryRequests.Transfer request) {
@Valid @RequestBody ArrangeDelivery request) {
requirePlatform(user);
return ApiResponse.success(service.platformTransfer(dispatchNo, operator(user), request));
return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request));
}
@PostMapping("/outbound-orders/one-click-dropship")
@@ -160,6 +175,8 @@ public class DeliveryTransferController {
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
/** 平台安排不能使用请求体提供的客户或操作者身份。 */
private Actor actor(AuthenticatedUser user) { return new Actor(operator(user),ActorMode.PLATFORM,null); }
private String listScope(AuthenticatedUser user, String requestedCustomer) {
requirePlatform(user);
return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim();
@@ -31,14 +31,6 @@ public class MerchantDeliveryController {
return ApiResponse.success(service.deliveryPage(customer(user), query));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_CREATE + "')")
public ApiResponse<DeliveryResponses.TransferResult> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String dispatchNo,
@Valid @RequestBody DeliveryRequests.Transfer request) {
return ApiResponse.success(service.merchantTransfer(customer(user), dispatchNo, operator(user), request));
}
@GetMapping("/{deliveryNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')")
public ApiResponse<DeliveryResponses.DeliveryDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@@ -71,5 +63,4 @@ public class MerchantDeliveryController {
if (!"MERCHANT".equals(user.appCode()) || user.tenantId() <= 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -2,8 +2,10 @@ package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService;
import com.baiyue.lastleg.delivery.service.DispatchDeliveryService;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.PreviewGroup;
import com.baiyue.lastleg.security.OverseasPermissions;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@@ -16,13 +18,15 @@ import org.springframework.web.server.ResponseStatusException;
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/dispatches")
public class PlatformDeliveryFeeCoverageController {
private final DeliveryFeeCoverageService service;
private final DispatchDeliveryService service;
/** 原费辅助查询复用真实整车分组,不能再从头部推定单客户。 */
@GetMapping("/{dispatchNo}/fee-coverage")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
public ApiResponse<DeliveryFeeCoverageModels.Preview> preview(@AuthenticationPrincipal AuthenticatedUser user,
public ApiResponse<java.util.List<PreviewGroup>> preview(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return ApiResponse.success(service.preview(dispatchNo));
String operator=user.userId()==null?user.username():user.userId().toString();
return ApiResponse.success(service.preview(new Actor(operator,ActorMode.PLATFORM,null),dispatchNo).groups());
}
}
@@ -9,10 +9,6 @@ public final class DeliveryFeeCoverageModels {
private DeliveryFeeCoverageModels() { }
public record Cargo(Long dispatchItemId, Long warehousePalletId, String palletNo, Long orderId,
String sourceRegion, String sourceInboundId, String inboundCode, long packages, String dispatchInboundCode) { }
public record Preview(String dispatchNo, boolean sourceComplete, String blockReason, String cargoHash,
List<Cargo> cargo, List<FinanceFeeCoverageClient.Fee> feeSources) { }
public record Selection(@NotNull @Positive Long financeFeeId, @Min(1) int sourceVersion,
@NotBlank @Size(max=128) String sourceHash, @NotEmpty @Size(max=3) List<@NotBlank @Pattern(regexp="DELIVERY_FEE|LOADING_FEE|APPOINTMENT_FEE") String> services) { }
public record Reference(FinanceFeeCoverageClient.Fee source, List<String> services) { }
public record Evidence(String cargoHash, List<Cargo> cargo, List<Reference> references, String operatorId, Instant confirmedAt) { }
}
@@ -6,7 +6,6 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection;
import java.util.List;
import java.util.Collections;
import java.math.BigDecimal;
@@ -16,46 +15,6 @@ import java.time.LocalDate;
public final class DeliveryRequests {
private DeliveryRequests() {}
public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey,
@NotNull LocalDateTime plannedDeliveryAt,
@NotBlank @Size(max = 32) String deliveryMethod,
@Size(max = 128) String waybillNo,
@Size(max = 255) String carrierName,
@NotBlank @Size(max = 64) String pickupMethod,
@NotBlank @Size(max = 64) String loadingType,
@Size(max = 64) String vehicleType,
@jakarta.validation.constraints.Pattern(regexp = "15托|30托") String vehicleSize,
@Size(max = 128) String driverName,
@Size(max = 64) String driverPhone,
@Size(max = 64) String vehiclePlate,
@Size(max = 128) String appointmentAccount,
@Size(max = 128) String sellerName,
@Size(max = 1000) String remark,
@NotNull @Valid @Size(max = 20) List<FeeItem> fees,
@Size(max = 64) String externalFreightHash,
@Size(max = 64) String feeCoverageCargoHash,
@Size(max = 2000) List<@NotNull @Valid Selection> feeCoverage) {}
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
@NotBlank @Size(max = 128) String feeName,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "CNY|USD") String currency,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
@Min(1) Long supplierId,
@Size(max = 200) String supplierName,
@Size(max = 32) String supplierCategory,
@jakarta.validation.constraints.Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@jakarta.validation.constraints.Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
@Size(max = 32) String quantityUnit,
@jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd,
@Size(max = 500) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String documentNo, String lxCode, String warehouseId, String status,
String dispatchNo, String waybillNo, String vehiclePlate,
@@ -8,14 +8,6 @@ import com.fasterxml.jackson.annotation.JsonInclude;
public final class DeliveryResponses {
private DeliveryResponses() {}
public record TransferDelivery(String deliveryNo, String deliveryStatus,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, int palletCount, int packageCount) {}
public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus,
int palletCount, int packageCount,
List<TransferDelivery> deliveries, boolean replayed) {}
public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes,
String status, int palletCount, int packageCount, BigDecimal grossWeightKg,
BigDecimal volumeWeightKg, BigDecimal volumeCbm,
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.delivery.entity;
import lombok.Data;
import java.time.LocalDateTime;
/** 签收事务内保存的本地待汇总事实,不携带整车已完成结论。 */
@Data
public class DeliveryFactsChanged {
private Long id;
private String customerId;
private Long dispatchId;
private String dispatchNo;
private Long deliveryId;
private String deliveryNo;
private Long deliveryVersion;
private String factsHash;
private LocalDateTime occurredAt;
private String state;
private Integer attemptCount;
private LocalDateTime nextAttemptAt;
private String lastErrorCode;
private LocalDateTime processedAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.delivery.entity;
import lombok.Data;
import java.time.LocalDateTime;
/** 每趟车唯一安排事实,保留完整客户结果及真实来源。 */
@Data
public class DispatchDeliveryArrangement {
private Long id;
private Long dispatchOrderId;
private String dispatchNo;
private String arrangementKey;
private String businessHash;
private String cargoHash;
private String loadingCompletionHash;
private String originalCommandKey;
private String originalOperatorId;
private String resultJson;
private String cargoJson;
private LocalDateTime createdAt;
}
@@ -16,6 +16,14 @@ public class OutboundPalletItem {
private Long id;
private String customerId;
private Long outboundOrderId;
/** 排车出库明确关联原作业明细,不从托盘展示字段猜来源。 */
private Long dispatchItemId;
/** 对应本次真实PDA拣货归属,作为客户和数量核对的不可变来源。 */
private Long sourceAttributionId;
private Long sourceOrderId;
private Long sourceInventoryPositionId;
private Long sourceReturnReceiptId;
private Long sourceReturnCartonId;
private Long warehousePalletId;
private String palletNo;
private String destinationWarehouseId;
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
/** 本地签收信号的唯一写入、扫描、确认及失败记录。 */
@Mapper
public interface DeliveryFactsChangedMapper {
/** 同一派送版本只登记一次,不覆盖首次不可变事实。 */
@Insert("""
INSERT INTO ll_delivery_facts_changed
(id,customer_id,dispatch_id,dispatch_no,delivery_id,delivery_no,delivery_version,facts_hash,
occurred_at,state,attempt_count,next_attempt_at)
VALUES (#{id},#{customerId},#{dispatchId},#{dispatchNo},#{deliveryId},#{deliveryNo},
#{deliveryVersion},#{factsHash},#{occurredAt},'PENDING',0,#{occurredAt})
ON DUPLICATE KEY UPDATE id=id
""")
int insertOnce(DeliveryFactsChanged fact);
/** 唯一键等待后读取当前已保存事实,不能用旧快照确认异载荷重放。 */
@Select("""
SELECT * FROM ll_delivery_facts_changed
WHERE customer_id=#{customerId} AND delivery_id=#{deliveryId} AND delivery_version=#{version}
""")
DeliveryFactsChanged find(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId,
@Param("version") long version);
/** 按已扫描信号身份读取,不取得父级之前的子行锁。 */
@Select("SELECT * FROM ll_delivery_facts_changed WHERE id=#{id}")
DeliveryFactsChanged findById(@Param("id") long id);
/** 普通读候选,不在申请整车父锁之前锁定信号行。 */
@Select("""
SELECT * FROM ll_delivery_facts_changed
WHERE state='PENDING' AND next_attempt_at<=#{now}
ORDER BY next_attempt_at,id LIMIT #{limit}
""")
List<DeliveryFactsChanged> pending(@Param("now") LocalDateTime now, @Param("limit") int limit);
/** 父级汇总同事务仅确认指定的已核对信号,不能吞掉之后产生的版本。 */
@Update("""
UPDATE ll_delivery_facts_changed SET state='PROCESSED',processed_at=#{now},last_error_code=NULL
WHERE id=#{id} AND dispatch_id=#{dispatchId} AND state='PENDING'
""")
int acknowledge(@Param("id") long id, @Param("dispatchId") long dispatchId,
@Param("now") LocalDateTime now);
/** 汇总回滚后以独立短事务记录退避,不改成已完成。 */
@Update("""
UPDATE ll_delivery_facts_changed
SET attempt_count=attempt_count+1,last_error_code=#{errorCode},next_attempt_at=GREATEST(next_attempt_at,#{nextAttemptAt})
WHERE id=#{id} AND state='PENDING'
""")
int failed(@Param("id") long id, @Param("errorCode") String errorCode,
@Param("nextAttemptAt") LocalDateTime nextAttemptAt);
}
@@ -9,6 +9,18 @@ import java.util.List;
@Mapper
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
/** 整车签收事实使用完整真实客户单据集合。 */
@Select("SELECT * FROM ll_delivery_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id")
List<DeliveryOrder> findAllByDispatch(@Param("dispatchId") long dispatchId);
/** 取得全部出库锁后一次取得全部派送锁,禁止逐客户混取来源锁。 */
@Select("SELECT * FROM ll_delivery_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id FOR UPDATE")
List<DeliveryOrder> lockAllByDispatch(@Param("dispatchId") long dispatchId);
/** 服务内签收信号按整车及派送号定位真实客户,不接受浏览器客户范围。 */
@Select("SELECT * FROM ll_delivery_order WHERE dispatch_no=#{dispatchNo} AND delivery_no=#{deliveryNo}")
List<DeliveryOrder> findForSignal(@Param("dispatchNo") String dispatchNo, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id")
List<DeliveryOrder> findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
@@ -18,18 +18,22 @@ public interface DeliverySourceEvidenceMapper {
"WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND id=#{orderId} FOR UPDATE")
Source lockSource(@Param("customerId") String customerId, @Param("orderId") long orderId);
/** 从本客户真实扫码及逐票归属定位共享整车任务,不依赖不存在的任务头客户。 */
@Select("SELECT DISTINCT t.dispatch_order_id FROM ll_dispatch_pda_pick_order a " +
"JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " +
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
"JOIN ll_dispatch_pda_task t ON t.id=p.task_id " +
"WHERE a.customer_id=#{customerId} AND a.order_id=#{orderId} ORDER BY t.dispatch_order_id")
List<Long> dispatchIds(@Param("customerId") String customerId, @Param("orderId") long orderId);
@Select("SELECT d.id AS delivery_id,d.dispatch_order_id AS dispatch_id,d.delivery_group_key AS group_key," +
"d.package_count AS packages,d.signed_packages,d.signed_at,ds.depart_at AS departed_at FROM ll_delivery_order d " +
"LEFT JOIN ll_dispatch_order ds ON ds.customer_id=d.customer_id AND ds.id=d.dispatch_order_id " +
"d.package_count AS packages,d.signed_packages,d.signed_at,ds.departed_at AS departed_at FROM ll_delivery_order d " +
"LEFT JOIN ll_dispatch_order ds ON ds.id=d.dispatch_order_id " +
"AND EXISTS (SELECT 1 FROM ll_dispatch_item di_member WHERE di_member.dispatch_order_id=ds.id AND di_member.customer_id=d.customer_id) " +
"WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY d.id")
/** 头部不再代表单客户;由真实货物客户成员证明派送归属,保持单据客户过滤。 */
List<Delivery> deliveries(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
/** 共享任务下只读取同一真实客户的扫码、订单和回仓凭据,卸货组沿原权威规则生成。 */
@Select("SELECT a.id AS attribution_id,t.dispatch_order_id AS dispatch_id," +
"CASE WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL " +
"THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) " +
@@ -38,7 +42,7 @@ public interface DeliverySourceEvidenceMapper {
"WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) ELSE NULL END AS group_key," +
"a.order_id,a.packages,a.receipt_id,a.return_receipt_id,r.inventory_ledger_id AS return_ledger_id,r.received_packages AS return_packages FROM ll_dispatch_pda_pick_order a " +
"JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " +
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
"JOIN ll_dispatch_pda_task t ON t.id=p.task_id " +
"LEFT JOIN ll_dispatch_item di ON di.customer_id=p.customer_id AND di.id=p.dispatch_item_id AND di.dispatch_order_id=t.dispatch_order_id " +
"LEFT JOIN ll_return_stock_receipt r ON r.customer_id=a.customer_id AND r.id=a.return_receipt_id AND r.order_id=a.order_id " +
"AND EXISTS (SELECT 1 FROM ll_inventory_ledger rl JOIN ll_exception_order re ON re.customer_id=rl.customer_id " +
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baiyue.lastleg.delivery.entity.DispatchDeliveryArrangement;
import org.apache.ibatis.annotations.*;
/** 整车唯一安排事实的持久化,不以客户出库头替代整车身份。 */
@Mapper
public interface DispatchDeliveryArrangementMapper {
/** 普通读组合预览中的当前安排事实。 */
@Select("SELECT * FROM ll_dispatch_delivery_arrangement WHERE dispatch_order_id=#{dispatchId}")
DispatchDeliveryArrangement find(@Param("dispatchId") long dispatchId);
/** 调用方先持共同父锁,再读取唯一安排。 */
@Select("SELECT * FROM ll_dispatch_delivery_arrangement WHERE dispatch_order_id=#{dispatchId} FOR UPDATE")
DispatchDeliveryArrangement lock(@Param("dispatchId") long dispatchId);
/** 单车、安排号及首次请求键均由唯一约束防重复。 */
@Insert("""
INSERT INTO ll_dispatch_delivery_arrangement
(id,dispatch_order_id,dispatch_no,arrangement_key,business_hash,cargo_hash,loading_completion_hash,
original_command_key,original_operator_id,result_json,cargo_json,created_at)
VALUES (#{id},#{dispatchOrderId},#{dispatchNo},#{arrangementKey},#{businessHash},#{cargoHash},
#{loadingCompletionHash},#{originalCommandKey},#{originalOperatorId},#{resultJson},#{cargoJson},#{createdAt})
""")
int insert(DispatchDeliveryArrangement arrangement);
}
@@ -9,6 +9,14 @@ import java.util.List;
@Mapper
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
/** 整车跨客户读取全部出库单,不用第一张代表全车。 */
@Select("SELECT * FROM ll_outbound_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id")
List<OutboundOrder> findAllByDispatch(@Param("dispatchId") long dispatchId);
/** 父锁后先收齐所有客户出库锁,再锁派送和来源。 */
@Select("SELECT * FROM ll_outbound_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id FOR UPDATE")
List<OutboundOrder> lockAllByDispatch(@Param("dispatchId") long dispatchId);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1")
OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1")
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.delivery.model;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Destination;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.security.MessageDigest;
import java.util.*;
/** 整车按真实客户与目的地拆分,物理托盘和来源行分别统计。 */
@Component
public class DeliveryCargoGroups {
private final ObjectMapper json;
/** 使用服务统一序列化器计算稳定业务摘要。 */
public DeliveryCargoGroups(ObjectMapper json) { this.json=json; }
/** 本域分组结果不作为另一套公网DTO或跨域Entity。 */
public record Group(String key,String customerId,String customerName,Destination destination,
String cargoHash,List<CargoLine> cargo,long packages,long palletCount) { }
/** 只接受完整真实拣货来源,不从展示入仓码或计划数补齐事实。 */
public List<Group> groups(Context context) {
if (context==null || context.counts()==null || context.cargo()==null || context.cargo().isEmpty())
throw conflict("排车单没有真实待派送货物");
var groups=new TreeMap<String,List<CargoLine>>();
var attributions=new HashSet<Long>();
var palletDestinations=new HashMap<Long,String>();
for (var row:context.cargo()) {
if (row==null || row.dispatchItemId()==null || row.dispatchItemId()<=0 || blank(row.customerId()) || "0".equals(row.customerId())
|| row.orderId()==null || row.orderId()<=0 || row.packages()<=0 || row.pickId()==null || row.pickId()<=0 || row.attributionId()==null || row.attributionId()<=0
|| blank(row.sourceKey()) || row.destination()==null || blank(row.destination().key()))
throw conflict("货物缺少真实客户、拣货或来源归属");
if ("RETURN_RELABEL".equals(row.sourceStage())
? row.returnCartonId()==null || row.returnCartonId()<=0
: row.inventoryPositionId()==null || row.inventoryPositionId()<=0)
throw conflict("本次拣货缺少实际库存仓位或退货箱来源");
if (!attributions.add(row.attributionId())) throw conflict("本次派送包含重复拣货来源");
if (blank(row.destination().type()) || !Set.of("FBA","PRIVATE").contains(row.destination().type()))
throw conflict("货物派送目的地类型不完整");
if (row.warehousePalletId()!=null) {
var previous=palletDestinations.putIfAbsent(row.warehousePalletId(),row.destination().key());
if (previous!=null && !previous.equals(row.destination().key())) throw conflict("同一实物托盘不同目的地须先拆托");
}
if ("FBA".equals(row.destination().type()) && blank(row.destination().warehouseCode())
|| "PRIVATE".equals(row.destination().type()) && blank(row.destination().address()))
throw conflict("货物缺少真实目的仓或派送地址");
groups.computeIfAbsent(groupKey(row.customerId(),row.destination()),ignored->new ArrayList<>()).add(row);
}
long total=context.cargo().stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact);
if (!context.pickingReconciled() || total!=context.counts().packageCount()
|| total!=context.counts().pickedPackageCount() && total!=context.counts().shippedPackageCount())
throw conflict("实际拣货来源与本车箱数尚未核平");
var result=new ArrayList<Group>();
for (var entry:groups.entrySet()) {
var rows=entry.getValue().stream().sorted(Comparator.comparing(CargoLine::dispatchItemId)
.thenComparing(CargoLine::attributionId)).toList();
var first=rows.get(0);
if (rows.stream().anyMatch(row->!Objects.equals(row.destination(),first.destination())))
throw conflict("同组货物的目的地信息不一致,请核对后安排");
long packages=rows.stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact);
long pallets=rows.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count();
result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),first.destination(),
hash(rows),rows,packages,pallets));
}
return List.copyOf(result);
}
/** 客户是组身份的一部分,同目的仓的其他客户永不合单。 */
public String groupKey(String customerId,Destination destination) {
return hash(List.of(customerId,destination.key()));
}
/** 本地派送来源证明沿用单据分组键,明确映射既有FBA和私人地址语义。 */
public String documentGroupKey(Destination destination) {
if ("PRIVATE".equals(destination.type()) && !blank(destination.address()))
return "PRIVATE:"+destination.address().trim()+":"+Objects.toString(destination.postalCode(),"").trim();
if ("FBA".equals(destination.type()) && !blank(destination.warehouseCode()))
return "CODE:"+destination.warehouseCode().trim().toUpperCase(Locale.ROOT);
throw conflict("派送目的地缺少真实业务标识");
}
/** 摘要仅使用规范化领域值,不包含操作者或偶然集合遍历顺序。 */
public String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); }
catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) {
throw new IllegalStateException("派送业务摘要生成失败",error);
}
}
/** 缺失业务事实直接阻断,不用内部编号回退展示。 */
private static boolean blank(String value) { return value==null || value.isBlank(); }
/** 业务冲突不产生部分单据。 */
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); }
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.delivery.model;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.*;
/** 五阶段只接受原实发全部正常签收,不将真实退回当作签收数量。 */
@Component
public class NormalDeliveryCompletionPolicy {
private final DeliveryCargoGroups hashes;
/** 复用本域确定性摘要器,不申请历史来源订单锁。 */
public NormalDeliveryCompletionPolicy(DeliveryCargoGroups hashes) { this.hashes=hashes; }
/** 按已安排单据精确集合和原实发数量核对,空集合不算完成。 */
public NormalDeliveryFacts evaluate(Context context,ArrangementResult arrangement,List<NormalDeliveryFact> actual,Instant now) {
var reasons=new ArrayList<String>();
var expected=arrangement==null?List.<DeliveryReference>of():arrangement.deliveries();
var seen=new HashMap<String,NormalDeliveryFact>();
if (context.departedAt()==null || now==null || context.departedAt().isAfter(now)) reasons.add("尚无有效实际发车事实");
if (expected==null || expected.isEmpty()) reasons.add("缺少本车已安排派送单");
for (var fact:actual) if (fact==null || fact.deliveryNo()==null || seen.putIfAbsent(fact.deliveryNo(),fact)!=null)
reasons.add("派送事实缺失或重复");
var expectedNos=expected==null?List.<String>of():expected.stream().map(DeliveryReference::deliveryNo).sorted().toList();
if (new HashSet<>(expectedNos).size()!=expectedNos.size() || !seen.keySet().equals(new HashSet<>(expectedNos)))
reasons.add("派送单未精确覆盖本车安排");
long total=0; Instant completedAt=null;
if (expected!=null) for (var reference:expected) {
var fact=seen.get(reference.deliveryNo());
if (fact==null) continue;
if (!Objects.equals(fact.customerId(),reference.customerId()) || !Objects.equals(fact.groupKey(),reference.groupKey())
|| !Objects.equals(fact.outboundNo(),reference.outboundNo()) || fact.originalPackages()!=reference.packageCount())
reasons.add("派送客户、目的地或原实发来源不一致");
total=Math.addExact(total,fact.originalPackages());
// full签收字段是原运输事实;签后另退不重开,但签剩余/拒收/无签收永不冒充原全签。
if (!fact.outboundCompleted() || fact.status()==null || !Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(fact.status()) || fact.originalPackages()<=0
|| fact.signedPackages()!=fact.originalPackages() || fact.signedAt()==null)
reasons.add("仍有货物未正常全部签收,异常或退回不能代替签收");
else if (context.departedAt()==null || fact.signedAt().isBefore(context.departedAt())
|| now==null || fact.signedAt().isAfter(now)) reasons.add("签收时间与真实发车事实不一致");
else if (completedAt==null || fact.signedAt().isAfter(completedAt)) completedAt=fact.signedAt();
}
if (total<=0 || context.counts()==null || total!=context.counts().shippedPackageCount())
reasons.add("签收单据未完整覆盖本趟真实实发箱数");
var sorted=actual.stream().filter(Objects::nonNull).sorted(Comparator.comparing(NormalDeliveryFact::customerId,Comparator.nullsFirst(String::compareTo))
.thenComparing(NormalDeliveryFact::deliveryNo,Comparator.nullsFirst(String::compareTo))).toList();
boolean normal=reasons.isEmpty();
return new NormalDeliveryFacts(context.dispatchNo(),hashes.hash(Arrays.asList(context.dispatchNo(),expectedNos,sorted)),
normal,normal?completedAt:null,expectedNos,sorted,reasons.stream().distinct().toList());
}
}
@@ -0,0 +1,95 @@
package com.baiyue.lastleg.delivery.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.*;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
/** 未执行出库取消的数据访问,业务资格与费用撤销仍由Service编排。 */
@Repository
public class DeliveryCancellationRepository {
private final OutboundOrderMapper outbounds;
private final OutboundPalletItemMapper items;
private final DeliveryOrderMapper deliveries;
private final OutboundPdaTaskMapper tasks;
private final DeliveryOperationMapper operations;
private final RelabelApplicationMapper relabels;
private final ReturnInventoryCartonMapper cartons;
private final WarehouseTransferOrderMapper transfers;
private final LastlegOrderMapper sources;
private final LastlegOrderStatusHistoryMapper histories;
/** 装配原领域Mapper,不能将SQL搬到取消Service。 */
public DeliveryCancellationRepository(OutboundOrderMapper outbounds,OutboundPalletItemMapper items,DeliveryOrderMapper deliveries,
OutboundPdaTaskMapper tasks,DeliveryOperationMapper operations,RelabelApplicationMapper relabels,
ReturnInventoryCartonMapper cartons,WarehouseTransferOrderMapper transfers,LastlegOrderMapper sources,LastlegOrderStatusHistoryMapper histories) {
this.outbounds=outbounds;this.items=items;this.deliveries=deliveries;this.tasks=tasks;this.operations=operations;
this.relabels=relabels;this.cartons=cartons;this.transfers=transfers;this.sources=sources;this.histories=histories;
}
/** 先普通定位真实父身份。 */
public OutboundOrder find(String no) { return outbounds.findGlobalByNo(no); }
/** 父级协调完成后才申请出库锁。 */
public OutboundOrder lock(String no) { return outbounds.lockGlobalByNo(no); }
/** 独立换标出库保留其原域确认锁。 */
public RelabelApplication lockRelabel(OutboundOrder order) { return relabels.lockByOutbound(order.getCustomerId(),order.getOutboundNo()); }
/** 子明细始终按出库客户读取。 */
public List<OutboundPalletItem> items(OutboundOrder order) { return items.findByOutbound(order.getCustomerId(),order.getId()); }
/** 一次取得本出库全部派送锁,不边持来源锁边锁下一张派送。 */
public List<DeliveryOrder> deliveries(OutboundOrder order) { return deliveries.lockByOutbound(order.getCustomerId(),order.getId()); }
/** 退货实际箱必须在原域锁内核对。 */
public ReturnInventoryCarton lockCarton(String mark) { return cartons.lockMark(mark); }
/** 只释放尚未执行的预留箱,不生成退库流水。 */
public void save(ReturnInventoryCarton value) { cartons.updateById(value); }
/** 更新已锁派送取消状态。 */
public void save(DeliveryOrder value) { deliveries.updateById(value); }
/** PDA任务与出库同事务取消。 */
public OutboundPdaTask task(OutboundOrder value) { return tasks.lockByOutbound(value.getCustomerId(),value.getId()); }
/** 保存已锁任务状态。 */
public void save(OutboundPdaTask value) { tasks.updateById(value); }
/** 保存已锁出库状态。 */
public void save(OutboundOrder value) { outbounds.updateById(value); }
/** 保存尚未执行的出库明细状态。 */
public void save(OutboundPalletItem value) { items.updateById(value); }
/** 锁原转仓单以恢复其明确业务状态。 */
public WarehouseTransferOrder lockTransfer(String customer,String no) { return transfers.lockNo(customer,no); }
/** 保存转仓恢复状态。 */
public void save(WarehouseTransferOrder value) { transfers.updateById(value); }
/** 相关来源在费用锁前以数字ID顺序锁定。 */
public LastlegOrder lockSource(String customer,long id) { return sources.lockById(customer,id); }
/** 来源候选一次读齐并固定顺序,不能按展示入仓码挑一票。 */
public List<LastlegOrder> sources(String customer,String outboundNo) {
return sources.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId,customer)
.eq(LastlegOrder::getOutboundOrderNo,outboundNo).orderByAsc(LastlegOrder::getId));
}
/** 按真实转换历史恢复,而非统一写某个旧状态。 */
public LastlegOrderStatusHistory previous(String customer,long id,List<String> events) {
return histories.selectOne(new LambdaQueryWrapper<LastlegOrderStatusHistory>().eq(LastlegOrderStatusHistory::getCustomerId,customer)
.eq(LastlegOrderStatusHistory::getOrderId,id).in(LastlegOrderStatusHistory::getEventId,events)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt,LastlegOrderStatusHistory::getId).last("LIMIT 1"));
}
/** 显式清空业务关联并增加版本,防止MyBatis跳过null留下旧单号。 */
public boolean restore(LastlegOrder value,String outboundNo,String status,String subStatus,LocalDateTime now) {
return sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,value.getId())
.eq(LastlegOrder::getCustomerId,value.getCustomerId()).eq(LastlegOrder::getOutboundOrderNo,outboundNo)
.set(LastlegOrder::getCurrentStatus,status).set(LastlegOrder::getSubOrderStatus,subStatus)
.set(LastlegOrder::getOutboundOrderNo,null).set(LastlegOrder::getDeliveryOrderNo,null)
.set(LastlegOrder::getVersion,Objects.requireNonNullElse(value.getVersion(),0)+1).set(LastlegOrder::getUpdatedAt,now))==1;
}
/** 保存不可变取消历史。 */
public void history(LastlegOrderStatusHistory value) { histories.insert(value); }
/** 完整原因由业务层分段保存,不截断丢失。 */
public void operation(DeliveryOperation value) { operations.insert(value); }
}
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.delivery.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
/** 派送资料及签收关联的数据访问,业务事务和来源证明由Service编排。 */
@Repository
public class DeliveryDocumentRepository {
private final DeliveryOrderMapper orders;
private final OutboundOrderMapper outbounds;
private final DeliveryDocumentRevisionMapper revisions;
private final DeliveryOperationMapper operations;
private final LastlegOrderMapper sources;
private final DeliverySourceEvidenceMapper evidence;
/** 装配各表Mapper,不在资料服务直接访问持久化接口。 */
public DeliveryDocumentRepository(DeliveryOrderMapper orders, OutboundOrderMapper outbounds,
DeliveryDocumentRevisionMapper revisions, DeliveryOperationMapper operations,
LastlegOrderMapper sources, DeliverySourceEvidenceMapper evidence) {
this.orders=orders; this.outbounds=outbounds; this.revisions=revisions;
this.operations=operations; this.sources=sources; this.evidence=evidence;
}
/** 客户和派送号共同定位资料单。 */
public DeliveryOrder find(String customerId, String no) { return orders.findByNo(customerId,no); }
/** 签收先锁自己的派送,随后才能申请来源订单锁。 */
public DeliveryOrder lock(String customerId, String no) { return orders.lockByNo(customerId,no); }
/** 出库事实普通读,避免在派送锁之后反向申请出库锁。 */
public OutboundOrder outbound(String customerId, String no) { return outbounds.findByNo(customerId,no); }
/** 读取当前资料命令以保持原请求重放语义。 */
public DeliveryDocumentRevision command(String customerId,long id,String key) { return revisions.findCommand(customerId,id,key); }
/** 保存已校验的资料和签收字段。 */
public void save(DeliveryOrder order) { orders.updateById(order); }
/** 同事务保存不可变资料版本。 */
public void revision(DeliveryDocumentRevision revision) { revisions.insert(revision); }
/** 同事务保存业务历史。 */
public void operation(DeliveryOperation operation) { operations.insert(operation); }
/** 读取资料版本历史,不暴露其他客户附件。 */
public List<DeliveryDocumentRevision> history(String customerId,long id) { return revisions.history(customerId,id); }
/** 签收仍需拒绝未完成的真实退回流程。 */
public long activeReturns(String customerId,long id) { return operations.activeReturns(customerId,id); }
/** 只读取本派送的真实退回入账事实。 */
public List<DeliveryReturnReceiptFact> returns(String customerId,long id) { return operations.returnReceipts(customerId,id); }
/** 拣货来源按真实客户与整车读取,不拿当前订单指针替代历史。 */
public List<SourceDeliveryCompletionPolicy.Pick> picks(String customerId,long dispatchId) { return evidence.picks(customerId,dispatchId); }
/** 批量读取候选来源,再由Service复核客户归属。 */
public List<LastlegOrder> sourceOrders(Collection<Long> ids) { return ids.isEmpty()?List.of():sources.selectBatchIds(ids); }
/** 非头程本地单据仍读取其本客户的既有当前关系。 */
public List<LastlegOrder> locallyLinked(String customerId,String deliveryNo) {
return sources.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId,customerId)
.eq(LastlegOrder::getDeliveryOrderNo,deliveryNo));
}
/** 来源锁由证明服务持有后重新读取当前行,避免旧快照覆盖。 */
public LastlegOrder source(long id) { return sources.selectById(id); }
/** 只保存Service根据真实来源签收证明得到的子状态。 */
public void saveSource(LastlegOrder order) { sources.updateById(order); }
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged;
import com.baiyue.lastleg.delivery.mapper.DeliveryFactsChangedMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/** 信号持久化由派送域维护,主流程经正式端口消费。 */
@Repository
public class DeliveryFactsChangedRepository {
private final DeliveryFactsChangedMapper mapper;
private final DeliveryOrderMapper deliveries;
/** 装配唯一的MyBatis数据入口。 */
public DeliveryFactsChangedRepository(DeliveryFactsChangedMapper mapper, DeliveryOrderMapper deliveries) {
this.mapper = mapper; this.deliveries = deliveries;
}
/** 公共信号端口只认整车和派送号对应的唯一真实单据。 */
public List<DeliveryOrder> deliveries(String dispatchNo, String deliveryNo) {
return deliveries.findForSignal(dispatchNo, deliveryNo);
}
/** 确认前复核信号原身份,普通读不反向申请父锁。 */
public DeliveryFactsChanged find(long id) { return mapper.findById(id); }
/** 保存一次事实并返回当前唯一版本供业务校验。 */
public DeliveryFactsChanged insertOnce(DeliveryFactsChanged fact) {
mapper.insertOnce(fact);
return mapper.find(fact.getCustomerId(), fact.getDeliveryId(), fact.getDeliveryVersion());
}
/** 有界普通扫描,不在此处持有父锁前的子行锁。 */
public List<DeliveryFactsChanged> pending(LocalDateTime now, int limit) {
return mapper.pending(now, limit);
}
/** 只确认调用方已经核对的指定信号与排车身份。 */
public int acknowledge(long id, long dispatchId, LocalDateTime now) {
return mapper.acknowledge(id, dispatchId, now);
}
/** 记录可重试失败且保留PENDING,不能覆盖已成功确认的信号。 */
public void failed(long id, String code, LocalDateTime nextAttemptAt) {
mapper.failed(id, code, nextAttemptAt);
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 整车派送数据访问;锁顺序明确区分子单和来源,不直写主流程表。 */
@Repository
public class DispatchDeliveryRepository {
private final DispatchDeliveryArrangementMapper arrangements;
private final OutboundOrderMapper outbounds;
private final OutboundPalletItemMapper items;
private final DeliveryOrderMapper deliveries;
private final DeliveryOperationMapper operations;
private final LastlegOrderMapper sources;
private final LastlegOrderStatusHistoryMapper histories;
/** 装配派送域唯一持久化入口。 */
public DispatchDeliveryRepository(DispatchDeliveryArrangementMapper arrangements,OutboundOrderMapper outbounds,
OutboundPalletItemMapper items,DeliveryOrderMapper deliveries,DeliveryOperationMapper operations,
LastlegOrderMapper sources,LastlegOrderStatusHistoryMapper histories) {
this.arrangements=arrangements;this.outbounds=outbounds;this.items=items;
this.deliveries=deliveries;this.operations=operations;
this.sources=sources;this.histories=histories;
}
/** 父锁外预览仅普通读安排事实。 */
public DispatchDeliveryArrangement arrangement(long id,boolean lock) { return lock?arrangements.lock(id):arrangements.find(id); }
/** 固定顺序取得或读取全部客户出库。 */
public List<OutboundOrder> outbounds(long id,boolean lock) { return lock?outbounds.lockAllByDispatch(id):outbounds.findAllByDispatch(id); }
/** 仅在取得全体出库锁之后取得全体派送锁。 */
public List<DeliveryOrder> deliveries(long id,boolean lock) { return lock?deliveries.lockAllByDispatch(id):deliveries.findAllByDispatch(id); }
/** 一次安排仅插入一个客户自己的出库头。 */
public void create(OutboundOrder value) { outbounds.insert(value); }
/** 不可变出库来源明细在同一安排事务保存。 */
public void create(OutboundPalletItem value) { items.insert(value); }
/** 每客户目的地分别保存派送单。 */
public void create(DeliveryOrder value) { deliveries.insert(value); }
/** 仅更新已锁定并复验的客户出库执行事实。 */
public void save(OutboundOrder value) { outbounds.updateById(value); }
/** 仅更新已锁定并复验的客户派送执行事实。 */
public void save(DeliveryOrder value) { deliveries.updateById(value); }
/** 最后保存单车唯一安排,失败整体回滚不留半车。 */
public void create(DispatchDeliveryArrangement value) { arrangements.insert(value); }
/** 保存真实业务操作事实,不将零约定冒充费用。 */
public void operation(DeliveryOperation value) { operations.insert(value); }
/** 正常完成只普通读当前异常数量,不反向锁历史来源。 */
public long activeReturns(DeliveryOrder value) { return operations.activeReturns(value.getCustomerId(),value.getId()); }
/** 只读取本派送真实退回流水。 */
public List<com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact> returns(DeliveryOrder value) {
return operations.returnReceipts(value.getCustomerId(),value.getId());
}
/** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */
public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); }
/** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */
public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now) {
if (sources.update(null,new LambdaUpdateWrapper<LastlegOrder>().eq(LastlegOrder::getId,source.getId())
.eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo)
.set(LastlegOrder::getDeliveryOrderNo,deliveryNo)
.set(LastlegOrder::getSubOrderStatus,"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY")
.set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1)
.set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化");
}
/** 保存原状态事实供正式未执行取消核对,不把主流程状态改成派送子状态。 */
public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); }
}
@@ -5,12 +5,10 @@ import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.repository.DeliveryDocumentRepository;
import com.baiyue.lastleg.delivery.model.*;
import com.baiyue.lastleg.customervisit.service.CustomerVisitService;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -28,20 +26,18 @@ import java.util.*;
@Service
public class DeliveryDocumentService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private DeliveryOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private DeliveryDocumentRevisionMapper revisions;
@Resource private DeliveryOperationMapper operations;
@Resource private DeliveryDocumentRepository repository;
@Resource private com.baiyue.lastleg.dispatch.port.DispatchSignalPort signals;
@Resource private DeliveryDocumentFileGateway files;
@Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox;
@Resource private CustomerVisitService customerVisits;
@Resource private SourceDeliveryCompletionQuery sourceCompletion;
@Resource private DeliverySourceEvidenceMapper sourceEvidence;
/** 客户和派送号共同定位当前资料。 */
public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); }
/** 客户只查看本客户派送的签收类附件,不返回其他资料。 */
public MerchantReceipts merchantReceipts(String customerId, String deliveryNo) {
Detail snapshot = detail(customerId, deliveryNo);
return new MerchantReceipts(snapshot.deliveryNo(), snapshot.categories().stream()
@@ -49,23 +45,25 @@ public class DeliveryDocumentService {
.flatMap(category -> category.files().stream()).toList());
}
/** 下载仍复核业务与类别归属,不放开文件公共访问。 */
public Download merchantReceiptDownload(String customerId, String deliveryNo, long fileId) {
return download(customerId, deliveryNo, DeliveryDocumentCategory.RECEIPT, fileId, null);
}
/** 上传准备不等于资料保存或真实签收完成。 */
public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category,
String operatorId, MultipartFile file) {
DeliveryOrder order = require(customerId, deliveryNo);
return files.upload(customerId, order.getId(), category, operatorId, file);
}
/** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */
/** 先锁本派送并校验全部资料,签收及本地待汇总事实在同一事务提交。 */
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public Detail save(String customerId, String deliveryNo, String operatorId, Save request) {
DeliveryOrder order = orders.lockByNo(customerId, deliveryNo);
DeliveryOrder order = repository.lock(customerId, deliveryNo);
if (order == null) throw missing();
String hash = hash(request);
DeliveryDocumentRevision replay = revisions.findCommand(customerId, order.getId(), request.idempotencyKey());
DeliveryDocumentRevision replay = repository.command(customerId, order.getId(), request.idempotencyKey());
if (replay != null) {
if (!hash.equals(replay.getRequestHash())) throw conflict("相同幂等键的派送资料内容不同");
Detail saved = read(replay.getSnapshotJson());
@@ -96,11 +94,11 @@ public class DeliveryDocumentService {
if (autoCompleted) {
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED") && !"RETURN_PENDING".equals(order.getStatus()))
throw conflict("当前派送状态不允许通过签收文件完成");
// A read avoids reversing outbound -> delivery lock order. An uncommitted departure remains ineligible.
var outbound = outboundOrders.findByNo(customerId, order.getOutboundNo());
// 派送锁内只普通读出库,未提交的发车不构成真实出库资格,也不反向申请父锁。
var outbound = repository.outbound(customerId, order.getOutboundNo());
if (outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) throw conflict("请先完成实际出库,再保存签收资料完成派送");
if (operations.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收");
try { signedPackages = DeliveryCargoQuantities.from(order, operations.returnReceipts(customerId, order.getId())).remaining(); }
if (repository.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收");
try { signedPackages = DeliveryCargoQuantities.from(order, repository.returns(customerId, order.getId())).remaining(); }
catch (IllegalArgumentException error) { throw conflict(error.getMessage()); }
if (signedPackages <= 0) throw conflict("本次派送已无待签收货物");
}
@@ -113,7 +111,7 @@ public class DeliveryDocumentService {
order.setSignedAt(now);
order.setSignedPackages(signedPackages);
}
orders.updateById(order);
repository.save(order);
if (autoCompleted) {
var completion = completeRelatedOrders(order, operatorId, now);
fleetPool.signed(new SignedDelivery(
@@ -121,29 +119,34 @@ public class DeliveryDocumentService {
completion.sourcePackages(),operatorId,now);
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant());
customerVisits.ensureForCompletedDelivery(order, now);
// 只写持久信号;主流程提交后另事务汇总,不能在此持子锁回调父流程。
if (order.getDispatchOrderId() != null) signals.enqueue(order.getDispatchNo(), order.getDeliveryNo(),
order.getVersion(), now.atZone(executionZone).toInstant());
}
Detail result = detail(order, false);
DeliveryDocumentRevision revision = new DeliveryDocumentRevision();
revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion());
revision.setIdempotencyKey(request.idempotencyKey()); revision.setRequestHash(hash); revision.setSnapshotJson(write(result));
revision.setOperatorId(operatorId); revision.setCreatedAt(now); revisions.insert(revision);
revision.setOperatorId(operatorId); revision.setCreatedAt(now); repository.revision(revision);
DeliveryOperation operation = new DeliveryOperation();
operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId);
operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类");
operation.setCreatedAt(now); operations.insert(operation);
operation.setCreatedAt(now); repository.operation(operation);
if (autoCompleted) recordAutomaticCompletion(order, operatorId, now);
return result;
}
/** 资料历史保持不可变版本并受原客户范围约束。 */
public List<History> history(String customerId, String deliveryNo) {
DeliveryOrder order = require(customerId, deliveryNo);
return revisions.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(),
return repository.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(),
revision.getOperatorId(), revision.getCreatedAt(), read(revision.getSnapshotJson()))).toList();
}
/** 当前或历史附件必须确实绑定在本派送对应资料版本。 */
public Download download(String customerId, String deliveryNo, DeliveryDocumentCategory category, long fileId, Long revisionVersion) {
DeliveryOrder order = require(customerId, deliveryNo);
Detail snapshot = detail(order, false);
if (revisionVersion != null) {
snapshot = revisions.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion()))
snapshot = repository.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion()))
.findFirst().map(r -> read(r.getSnapshotJson())).orElseThrow(DeliveryDocumentService::missing);
}
DeliveryDocumentDtos.File file = snapshot.categories().stream().filter(c -> c.category() == category)
@@ -152,36 +155,39 @@ public class DeliveryDocumentService {
return new Download(file, files.download(customerId, order.getId(), category, fileId));
}
public record Download(DeliveryDocumentDtos.File file, byte[] content) { }
/** 业务编号不属于当前客户时按不存在处理。 */
private DeliveryOrder require(String customerId, String deliveryNo) {
DeliveryOrder order = orders.findByNo(customerId, deliveryNo);
DeliveryOrder order = repository.find(customerId, deliveryNo);
if (order == null) throw missing();
return order;
}
/** 三类资料只读投影不根据附件数量推断库存或整车阶段。 */
private Detail detail(DeliveryOrder order, boolean replayed) {
List<Category> categories = DeliveryDocumentSnapshot.categories(order.getDocumentsJson());
return new Detail(order.getDeliveryNo(), order.getCustomerId(), version(order), order.getBolNo(),
(int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed);
}
/** 原始单据版本0有效,后续保存按版本增加。 */
private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private record Completion(List<LastlegOrder> orders, Map<String, SourceDeliveryCompletionPolicy.Result> proofs,
Map<Long,Long> sourcePackages) { }
/** 先根据真实拣货收齐来源,再按跨趟来源锁协议证明正常签收。 */
private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) {
var related = new TreeMap<Long, LastlegOrder>();
Map<Long,Long> sourcePackages = new TreeMap<>();
if (delivery.getDispatchOrderId() != null) {
var actualPicks = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream()
var actualPicks = repository.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream()
.filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey())).toList();
for (var pick : actualPicks) sourcePackages.merge(pick.orderId(), pick.packages(), Math::addExact);
var ids = actualPicks.stream()
.map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList();
if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) {
if (!ids.isEmpty()) for (var tail : repository.sourceOrders(ids)) {
if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致");
related.put(tail.getId(), tail);
}
}
// Non-dispatch local workflows retain their established relation; never use this to prove a domestic source.
for (var tail : lastlegOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, delivery.getCustomerId()).eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()))) {
// 本地非排车单保留既有关系,不能把当前指针当作头程全来源签收证明。
for (var tail : repository.locallyLinked(delivery.getCustomerId(), delivery.getDeliveryNo())) {
if (!DeliveryCompletedOutboxService.domestic(tail)) related.putIfAbsent(tail.getId(),tail);
else if (!related.containsKey(tail.getId())) pendingCompletion(delivery, operatorId, now,
inboundLabel(tail) + ":缺少历史实际拣货来源,未发布整来源签收事件");
@@ -190,10 +196,10 @@ public class DeliveryDocumentService {
.map(LastlegOrder::getSourceInboundId).distinct().toList();
var proofs = sourceIds.isEmpty() ? Map.<String,SourceDeliveryCompletionPolicy.Result>of()
: sourceCompletion.proveAll(delivery.getCustomerId(),sourceIds);
// All source locks are now held. Re-read current rows instead of overwriting with pre-lock snapshots.
// 来源锁已按证明协议持有,重新读取当前行,不能用加锁前快照覆盖并发签收。
var current = new ArrayList<LastlegOrder>();
for (long id : related.keySet()) {
var tail = lastlegOrders.selectById(id);
var tail = repository.source(id);
if (tail == null || !delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源已变化");
if (DeliveryCompletedOutboxService.domestic(tail)) {
var proof = proofs.get(tail.getSourceInboundId());
@@ -205,38 +211,47 @@ public class DeliveryDocumentService {
}
} else tail.setSubOrderStatus(Objects.equals(delivery.getSignedPackages(),delivery.getPackageCount())
? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED");
tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail);
tail.setUpdatedAt(now); repository.saveSource(tail); current.add(tail);
}
return new Completion(List.copyOf(current),proofs,Map.copyOf(sourcePackages));
}
/** 入仓业务号缺失明确显示空值标记,不显示内部ID。 */
private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); }
/** 未核齐的来源只保留诊断事实,不伪造全来源完成。 */
private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) {
var operation = new DeliveryOperation(); operation.setCustomerId(delivery.getCustomerId());
operation.setDeliveryOrderId(delivery.getId()); operation.setOperatorId(operatorId);
operation.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); operation.setResult(reason);
operation.setCreatedAt(now); operations.insert(operation);
operation.setCreatedAt(now); repository.operation(operation);
}
/** 保存本次实际签收箱数,真实退回不得混入签收数量。 */
private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) {
DeliveryOperation operation = new DeliveryOperation();
operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId());
operation.setOperatorId(operatorId); operation.setOperatorName(operatorId);
operation.setOperationNode("RECEIPT_AUTO_COMPLETE");
operation.setResult("签收文件保存成功,本次实际签收" + order.getSignedPackages() + "箱;原实发" + order.getPackageCount() + "箱");
operation.setCreatedAt(now); operations.insert(operation);
operation.setCreatedAt(now); repository.operation(operation);
}
/** 资料原请求以严格载荷摘要重放,版本和文件集合不得改写。 */
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** 资料与操作结果必须可持久化,否则同事务回滚。 */
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw new IllegalStateException("无法保存派送资料", e); }
}
/** 损坏历史直接阻断,不回退为当前资料成功。 */
private Detail read(String value) {
try { return objectMapper.readValue(value, Detail.class); }
catch (JsonProcessingException e) { throw new IllegalStateException("派送资料历史损坏", e); }
}
/** 隐藏不存在或不属于本客户的业务资料。 */
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "派送单或已绑定资料不存在"); }
/** 同版本竞争或业务资格变化要求刷新核对。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
/** 参数范围不合法时不产生任何资料变更。 */
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
}
@@ -0,0 +1,118 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.repository.DeliveryFactsChangedRepository;
import com.baiyue.lastleg.dispatch.port.DispatchSignalPort;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.Signal;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.Instant;
import java.time.ZoneId;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
/** 签收只写持久信号,绝不持派送子锁回调整车完成服务。 */
@Service
public class DeliveryFactsChangedService implements DispatchSignalPort {
private final DeliveryFactsChangedRepository repository;
private final ObjectMapper json;
/** 装配事实持久化和确定性序列化。 */
public DeliveryFactsChangedService(DeliveryFactsChangedRepository repository, ObjectMapper json) {
this.repository = repository;
this.json = json;
}
/** 直接消费主流程实际骨架,信号仅在原签收事务内持久化。 */
@Override
@Transactional(propagation = Propagation.MANDATORY)
public void enqueue(String dispatchNo, String deliveryNo, long deliveryVersion, Instant occurredAt) {
if (blank(dispatchNo) || blank(deliveryNo) || occurredAt == null) throw new IllegalArgumentException("签收信号身份不完整");
var rows = repository.deliveries(dispatchNo, deliveryNo);
if (rows.size() != 1 || !Objects.equals(rows.get(0).getVersion(), deliveryVersion))
throw new IllegalStateException("签收信号与当前真实派送版本不一致");
signed(rows.get(0), LocalDateTime.ofInstant(occurredAt, ZoneId.systemDefault()));
}
/** 有界普通读取候选,不在父汇总事务前领取或锁定信号。 */
@Override
@Transactional(readOnly = true)
public List<Signal> due(Instant now, int limit) {
if (now == null || limit < 1 || limit > 200) throw new IllegalArgumentException("信号扫描范围不合法");
return repository.pending(LocalDateTime.ofInstant(now, ZoneId.systemDefault()), limit).stream()
.map(f -> new Signal(f.getId(),f.getDispatchNo(),f.getDeliveryNo(),f.getDeliveryVersion(),
f.getAttemptCount(),f.getNextAttemptAt().atZone(ZoneId.systemDefault()).toInstant())).toList();
}
/** 与主阶段CAS共同提交指定信号确认,不能提前确认后来产生的版本。 */
@Override
@Transactional(propagation = Propagation.MANDATORY)
public void acknowledge(Signal signal, Instant acknowledgedAt) {
var stored = requireSignal(signal);
if (acknowledgedAt == null) throw new IllegalArgumentException("信号确认时间不能为空");
repository.acknowledge(stored.getId(),stored.getDispatchId(),LocalDateTime.ofInstant(acknowledgedAt,ZoneId.systemDefault()));
}
/** 调用方汇总回滚后独立记录退避,已确认的信号不会退回PENDING。 */
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void failed(Signal signal, Instant nextRetryAt, String errorCode) {
var stored = requireSignal(signal);
if (nextRetryAt == null || errorCode == null || !errorCode.matches("[A-Z0-9_]{1,64}"))
throw new IllegalArgumentException("信号重试信息不合法");
repository.failed(stored.getId(),errorCode,LocalDateTime.ofInstant(nextRetryAt,ZoneId.systemDefault()));
}
/** 只允许操作扫描时的真实业务身份,不凭一个内部ID修改别的信号。 */
private DeliveryFactsChanged requireSignal(Signal signal) {
if (signal == null || signal.signalId() == null) throw new IllegalArgumentException("信号不能为空");
var stored = repository.find(signal.signalId());
if (stored == null || !Objects.equals(stored.getDispatchNo(),signal.dispatchNo())
|| !Objects.equals(stored.getDeliveryNo(),signal.deliveryNo())
|| !Objects.equals(stored.getDeliveryVersion(),signal.deliveryVersion()))
throw new IllegalStateException("信号原业务身份不一致");
return stored;
}
/** 与签收共同提交唯一版本,缺失真实排车身份不得补造信号。 */
@Transactional(propagation = Propagation.MANDATORY)
public void signed(DeliveryOrder delivery, LocalDateTime now) {
if (delivery.getDispatchOrderId() == null) return;
if (delivery.getId() == null || delivery.getDispatchOrderId() <= 0 || blank(delivery.getCustomerId())
|| blank(delivery.getDispatchNo()) || blank(delivery.getDeliveryNo()) || delivery.getVersion() == null
|| delivery.getVersion() < 1 || delivery.getSignedAt() == null || delivery.getSignedPackages() == null
|| delivery.getSignedPackages() <= 0 || now == null) throw new IllegalStateException("签收汇总信号缺少真实派送事实");
var fact = new DeliveryFactsChanged();
fact.setId(IdWorker.getId()); fact.setCustomerId(delivery.getCustomerId());
fact.setDispatchId(delivery.getDispatchOrderId()); fact.setDispatchNo(delivery.getDispatchNo());
fact.setDeliveryId(delivery.getId()); fact.setDeliveryNo(delivery.getDeliveryNo());
fact.setDeliveryVersion(delivery.getVersion()); fact.setOccurredAt(now);
fact.setFactsHash(hash(List.of(delivery.getCustomerId(), delivery.getDispatchOrderId(), delivery.getDispatchNo(),
delivery.getId(), delivery.getDeliveryNo(), delivery.getVersion(), delivery.getSignedPackages(), delivery.getSignedAt())));
var saved = repository.insertOnce(fact);
if (saved == null || !Objects.equals(saved.getDispatchId(), fact.getDispatchId())
|| !Objects.equals(saved.getDispatchNo(), fact.getDispatchNo())
|| !Objects.equals(saved.getDeliveryNo(), fact.getDeliveryNo())
|| !Objects.equals(saved.getFactsHash(), fact.getFactsHash()))
throw new IllegalStateException("同一签收版本对应的持久事实不一致");
}
/** 以规范化真实字段生成摘要,不保存整车完成的推断结论。 */
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(json.writeValueAsBytes(value))); }
catch (NoSuchAlgorithmException | JsonProcessingException error) { throw new IllegalStateException("签收信号摘要生成失败", error); }
}
/** 业务身份缺失直接拒绝,不使用内部ID代替业务编号。 */
private static boolean blank(String value) { return value == null || value.isBlank(); }
}
@@ -1,122 +0,0 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.client.FinanceFeeCoverageClient;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import java.time.Instant;
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
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.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class DeliveryFeeCoverageService {
private final DispatchOrderMapper dispatches;
private final DispatchItemMapper items;
private final DispatchFeeCargoMapper sources;
private final FinanceFeeCoverageClient finance;
private final ObjectMapper json;
private final String token;
public DeliveryFeeCoverageService(DispatchOrderMapper dispatches, DispatchItemMapper items, DispatchFeeCargoMapper sources,
FinanceFeeCoverageClient finance, ObjectMapper json, @Value("${baiyue.security.internal-service-token}") String token) {
this.dispatches=dispatches; this.items=items; this.sources=sources; this.finance=finance; this.json=json; this.token=token;
}
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public DeliveryFeeCoverageModels.Preview preview(String dispatchNo) {
var dispatch = dispatches.findGlobalByDispatchNo(dispatchNo.trim());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
var cargo = sources.list(dispatch.getCustomerId(), dispatch.getId());
String reason = incomplete(selected, cargo);
var inboundIds = new TreeSet<Long>();
if (reason == null) for (var row : cargo) if ("FIRSTLEG".equals(row.sourceRegion())) {
try { long id = Long.parseLong(row.sourceInboundId()); if (id < 1) throw new NumberFormatException(); inboundIds.add(id); }
catch (RuntimeException invalid) { reason = "货物缺少原头程入仓单关联,请先核对来源"; break; }
}
List<FinanceFeeCoverageClient.Fee> fees = List.of();
if (reason == null && !inboundIds.isEmpty()) fees = load(dispatch.getCustomerId(), List.copyOf(inboundIds));
try {
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
json.writeValueAsString(List.of(dispatch.getDispatchNo(), dispatch.getCustomerId(), cargo,
selected.stream().map(item -> Arrays.asList(item.getId(), item.getPackageCount(), item.getOverseasWarehouseId(),
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(),
item.getDeliveryAddress(), item.getPostalCode())).toList())).getBytes(StandardCharsets.UTF_8)));
return new DeliveryFeeCoverageModels.Preview(dispatch.getDispatchNo(), reason == null, reason, hash, cargo, fees);
} catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException(error); }
}
private List<FinanceFeeCoverageClient.Fee> load(String customerId, List<Long> inboundIds) {
try {
var result = finance.sources(token, new FinanceFeeCoverageClient.Query(Long.valueOf(customerId), inboundIds));
if (result == null || !result.successful() || result.data() == null) throw unavailable();
if (result.data().stream().anyMatch(fee -> fee.financeFeeId() == null || fee.financeFeeId() < 1 || fee.sourceFeeId() == null
|| !Objects.equals(fee.customerId(), Long.valueOf(customerId)) || fee.sourceVersion() < 1
|| !inboundIds.contains(fee.sourceInboundId()) || fee.sourceHash() == null || fee.sourceHash().isBlank()
|| fee.amount() == null || fee.amount().signum() < 0 || !"CNY".equals(fee.currency())
|| !Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(fee.sourceType())
|| !Set.of("AVAILABLE", "SETTLED").contains(fee.status()))) throw unavailable();
return result.data();
} catch (RuntimeException error) { throw unavailable(); }
}
private static String incomplete(List<DispatchItem> selected, List<DeliveryFeeCoverageModels.Cargo> cargo) {
if (selected.isEmpty()) return "排车单没有待发运货物";
var totals = cargo.stream().collect(Collectors.groupingBy(DeliveryFeeCoverageModels.Cargo::dispatchItemId,
Collectors.summingLong(DeliveryFeeCoverageModels.Cargo::packages)));
var ids = selected.stream().map(DispatchItem::getId).collect(Collectors.toSet());
if (!ids.equals(totals.keySet())) return "实际货物来源尚未齐全,请先完成收货并核对实物托盘";
for (var item : selected) if (item.getPackageCount() == null || totals.get(item.getId()).longValue() != item.getPackageCount())
return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围";
return null;
}
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<String> externalInboundCodes) {
public static Decision none() { return new Decision(Set.of(), null, Set.of()); }
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
if (evidence == null) return List.of();
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalInboundCodes)).toList();
}
}
/** 外接覆盖按实际入仓来源判定,不能免除同托普通货的派送费。 */
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<String> externalInboundCodes) {
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none();
var preview = preview(dispatchNo);
if (!preview.sourceComplete()) throw conflict(preview.blockReason());
if (!Objects.equals(preview.cargoHash(), request.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化,请重新核对覆盖范围");
var references = new ArrayList<DeliveryFeeCoverageModels.Reference>();
var scopes = new HashSet<String>();
for (var selection : request.feeCoverage()) {
var fee = preview.feeSources().stream().filter(row -> Objects.equals(row.financeFeeId(), selection.financeFeeId())).findFirst()
.orElseThrow(() -> conflict("原费用不属于本次实际货物,或已审核状态/有效性发生变化"));
if (fee.sourceVersion() != selection.sourceVersion() || !Objects.equals(fee.sourceHash(), selection.sourceHash()))
throw conflict("原费用版本已变化,请重新确认服务覆盖范围");
var services = new TreeSet<>(selection.services());
if (services.isEmpty() || !Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE").containsAll(services))
throw conflict("请选择本票原费用明确包含的服务");
for (var service : services) if (!scopes.add(fee.sourceInboundId() + ":" + service))
throw conflict("同一票同一服务只能引用一项原费用");
references.add(new DeliveryFeeCoverageModels.Reference(fee, List.copyOf(services)));
}
var included = new HashSet<String>();
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"))
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalInboundCodes))) included.add(service);
return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(),
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalInboundCodes));
}
private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service,
List<DeliveryFeeCoverageModels.Reference> references, Set<String> externalInboundCodes) {
if ("DELIVERY_FEE".equals(service) && externalInboundCodes.contains(cargo.inboundCode())) return true;
return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference ->
reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service));
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "原费用来源暂不可用,请稍后重试"); }
}
@@ -2,7 +2,6 @@ package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -94,7 +93,6 @@ import java.util.stream.Collectors;
@Service
public class DeliveryTransferService {
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
@Resource private DispatchOrderMapper dispatchOrders;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders;
@@ -131,282 +129,9 @@ public class DeliveryTransferService {
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private FleetBookingFreight bookingFreight;
@Resource private DeliveryFeeCoverageService feeCoverage;
@Resource private DeliveryExecutionGuard deliveryExecution;
@Resource private DispatchCargoSourceQuery cargoSources;
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(trim(dispatchNo));
if (dispatch == null) throw notFound("Dispatch order not found");
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
/** 外接运费与车次绑定使用相同的实际来源和数量。 */
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(),
cargoSources.sources(dispatch, items));
}
@Transactional
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
DispatchOrder dispatch = dispatchOrders.lockGlobalByDispatchNo(trim(dispatchNo));
if (dispatch == null) throw notFound("Dispatch order not found");
return transferResolved(dispatch, operatorId, request);
}
@Transactional
public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
if (request.feeCoverage() != null && !request.feeCoverage().isEmpty())
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "原运费服务覆盖由平台确认");
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
if (dispatch == null) throw notFound("Dispatch order not found");
return transferResolved(dispatch, operatorId, request);
}
/** 排车锁内创建完整目的地派送,历史重放不生成新单或重复发布费用。 */
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
DeliveryRequests.Transfer request) {
String customerId = dispatch.getCustomerId();
String requestHash = transferRequestHash(request);
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
return replayTransfer(replay, requestHash);
}
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (existing != null) return replayTransfer(existing, requestHash);
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
throw conflict("Dispatch cannot be transferred to delivery in its current status");
}
fleetBindings.requireMatchingAssignment(dispatch.getId(),
new FleetDispatchGateway.Assignment(dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone()),
new FleetDispatchGateway.Assignment(request.vehiclePlate(), request.driverName(), request.driverPhone()));
dispatchItems.bindReceivedPallets(customerId, dispatch.getId());
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
throw conflict("All dispatch pallets must be received before transfer to delivery");
}
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
var freight = externalFreight(dispatch, items);
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
var externalInboundCodes = freight.quotes().stream().map(FleetBookingFreight.Quote::inboundCode).collect(Collectors.toSet());
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalInboundCodes);
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId()));
outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo());
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY");
outbound.setLxCodes(resolveLxCodes(customerId, items));
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
outbound.setPalletCount(items.size());
outbound.setGrossWeightKg(sumKnownPhysicalWeight(items, DispatchItem::getGrossWeightKg));
outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg));
outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm));
outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setRequestHash(requestHash);
if (coverage.evidence() != null) {
try { outbound.setFeeCoverageJson(objectMapper.writeValueAsString(coverage.evidence())); }
catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法保存", exception); }
}
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
try {
outboundOrders.insert(outbound);
} catch (DuplicateKeyException e) {
// The unique keys are the final concurrent guard. A client retry must observe
// the document created by the winning transaction rather than receive a false failure.
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
return replayTransfer(concurrent, requestHash);
}
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (concurrent != null) {
return replayTransfer(concurrent, requestHash);
}
throw conflict("Dispatch was concurrently transferred to delivery");
}
for (DispatchItem source : items) {
OutboundPalletItem item = new OutboundPalletItem();
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo());
item.setDestinationWarehouseId(source.getDestinationWarehouseId());
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
item.setDestinationWarehouseName(source.getDestinationWarehouseName());
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0));
item.setGrossWeightKg(source.getGrossWeightKg()); item.setVolumeWeightKg(source.getVolumeWeightKg());
item.setVolumeCbm(source.getVolumeCbm()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
if (StringUtils.hasText(source.getCartonMarks())) {
item.setCartonMarks(source.getCartonMarks());
} else if (source.getWarehousePalletId() != null) {
String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream()
.map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(","));
item.setCartonMarks(StringUtils.hasText(marks) ? marks : null);
}
item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now);
outboundPallets.insert(item);
}
Map<String, List<DispatchItem>> destinationGroups = items.stream().collect(java.util.stream.Collectors.groupingBy(
this::deliveryGroupKey, LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<DeliveryOrder> deliveries = new ArrayList<>();
int groupIndex = 0;
for (Map.Entry<String, List<DispatchItem>> entry : destinationGroups.entrySet()) {
List<DispatchItem> groupItems = entry.getValue();
DispatchItem destination = groupItems.get(0);
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId()));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName());
delivery.setDestinationWarehouseId(trim(destination.getDestinationWarehouseId()));
delivery.setDestinationWarehouseCode(firstText(
firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()),
destination.getDestinationWarehouseId()));
delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName()));
delivery.setDestinationType(destination.getDestinationType());
delivery.setDeliveryAddress(destination.getDeliveryAddress());
delivery.setPostalCode(destination.getPostalCode());
delivery.setContactName(destination.getContactName());
delivery.setContactPhone(destination.getContactPhone());
delivery.setDeliveryGroupKey(entry.getKey());
LastlegOrder firstOrder = firstTailOrder(customerId, groupItems);
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim());
delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod()));
delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize()));
delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName()));
delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount()));
delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt());
delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
delivery.setPalletCount(groupItems.size());
delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm));
delivery.setTotalWeightKg(sumKnownPhysicalWeight(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now);
bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
deliveries.add(delivery);
}
for (var quote : freight.quotes()) {
var destination = deliveries.stream().filter(item -> Objects.equals(quote.destinationKey(), FleetDispatchGateway.destinationKey(
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseCode(),
item.getDeliveryAddress(), item.getPostalCode()))).findFirst()
.orElseThrow(() -> conflict("外接约定目的地未生成对应派送单"));
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
+ quote.currency() + " " + quote.amount().toPlainString(), now);
// An explicitly agreed free shipment remains a commercial fact, not a fabricated zero fee.
if (quote.amount().signum() == 0) continue;
feeSources.publishApproved(operatorId, "FLEET:" + quote.bookingNo() + ":" + dispatch.getDispatchNo() + ":FREIGHT",
new OverseasFeeSourceDtos.SaveRequest(customerId, quote.customerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", destination.getId(), destination.getDeliveryNo(),
null, null, quote.inboundCode(), null, null, null, parseLong(destination.getOverseasWarehouseId()),
"DELIVERY_FEE", "外接运费(" + quote.bookingNo() + ")", "RECEIVABLE", null, null, null,
null, null, null, null, null, LocalDate.now(), quote.currency(), quote.amount(), quote.quantity(),
quote.quantityUnit(), quote.unitPrice(), null, null));
}
DeliveryOrder feeDelivery = deliveries.get(0);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
var ordinaryCargo = freight.requiresDeliveryFee() ? cargoSources.sources(dispatch, items).stream()
.filter(source -> !externalInboundCodes.contains(source.inboundCode())).toList() : List.<FleetDispatchGateway.SourceCargo>of();
for (DeliveryRequests.FeeItem input : feeInputs) {
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
var uncovered = coverage.uncovered(feeCode);
var uncoveredItemIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::dispatchItemId).collect(Collectors.toSet());
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
? items.stream().filter(item -> ordinaryCargo.stream().anyMatch(source -> Objects.equals(source.warehousePalletId(),
item.getWarehousePalletId().toString()) && Objects.equals(source.destinationKey(), FleetDispatchGateway.destinationKey(
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))).toList() : items;
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
: feeDelivery;
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
// 合托后按真实普通来源归属费用;多票时只绑定派送,不借展示入仓码挂到外接货。
if ("RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && !externalInboundCodes.isEmpty()) {
var ordinaryOrders = cargoSources.ordinaryOrderIds(dispatch, externalInboundCodes);
firstOrder = ordinaryOrders.size() == 1 ? tailOrders.selectById(ordinaryOrders.iterator().next()) : null;
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
}
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
}
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.direction() + ":" + feeCode),
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDestination.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDestination.getId(), feeDestination.getDeliveryNo(),
firstOrder == null ? null : firstOrder.getId(), null,
firstOrder == null ? null : firstOrder.getInboundCode(), null,
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDestination.getOverseasWarehouseId()),
feeCode, input.feeName().trim(), input.direction(), input.supplierId(),
input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
input.periodStart(), input.periodEnd()));
}
return result(outbound, deliveries, false);
}
/** 取消后的同载荷重放仍返回原取消单据,绝不作为新建成功。 */
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
if (!requestHash.equals(outbound.getRequestHash()))
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
return result(outbound, requireDeliveries(outbound.getCustomerId(), outbound.getId()), true);
}
String transferRequestHash(DeliveryRequests.Transfer request) {
// A new command key cannot change an already transferred dispatch's business content.
com.fasterxml.jackson.databind.node.ObjectNode content = objectMapper.valueToTree(request);
content.remove("idempotencyKey");
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) {
content.remove("feeCoverage"); content.remove("feeCoverageCargoHash");
}
return hash(content);
}
private void validateTransferFees(DeliveryRequests.Transfer request, boolean requiresDeliveryFee, Set<String> covered) {
List<DeliveryRequests.FeeItem> inputs = request.fees() == null ? List.of() : request.fees();
Set<String> allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE");
Set<String> seen = new HashSet<>();
for (DeliveryRequests.FeeItem input : inputs) {
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
if (!seen.add(input.direction() + ":" + feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if ("RECEIVABLE".equals(input.direction()) && covered.contains(feeType)) throw conflict("本次货物的该服务已由原费用覆盖,请勿重复登记应收");
if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT)))
throw conflict("Delivery fees only support CNY or USD");
if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero");
}
if (requiresDeliveryFee && !covered.contains("DELIVERY_FEE") && !seen.contains("RECEIVABLE:DELIVERY_FEE")) throw conflict("尚未有约定运费的货物须填写派送费");
if (!requiresDeliveryFee && inputs.stream().anyMatch(item -> "DELIVERY_FEE".equalsIgnoreCase(item.feeType().trim())
&& "RECEIVABLE".equals(item.direction()))) throw conflict("本排车全部货物已带入外接约定运费,请勿重复填写派送应收");
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
&& !covered.contains("LOADING_FEE") && !seen.contains("RECEIVABLE:LOADING_FEE")) {
throw conflict("Loading fee is required for floor or mixed loading");
}
if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) {
if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment");
if (!covered.contains("APPOINTMENT_FEE") && !seen.contains("RECEIVABLE:APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
}
}
@Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution;
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations;
@Transactional
public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) {
@@ -1165,38 +890,44 @@ public class DeliveryTransferService {
}
}
@Transactional
/** 先取得真实整车父锁,再锁出库和派送;已扫码不得以取消出库伪造退库。 */
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
required(request == null ? null : request.reason(), "取消原因不能为空");
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
OutboundOrder observed = cancellations.find(trim(outboundNo));
if (observed == null) throw notFound("Outbound order not found");
if ("DISCARD_OUTBOUND".equals(observed.getOutboundType())) throw conflict("请通过废弃单取消,废弃出库单随申请同步取消");
// Match dispatch picking and relabel confirmation lock order before locking outbound.
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
RelabelApplication application = "RELABEL_OUTBOUND".equals(observed.getOutboundType())
? relabelApplications.lockByOutbound(observed.getCustomerId(), observed.getOutboundNo()) : null;
OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo));
// 服务端身份与父锁仅使用正式共同端口,不按客户去锁整车头部。
var dispatch = observed.getDispatchOrderId() == null ? null
: execution.lockForArrangement(new com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor(operatorId,
com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode.PLATFORM,null),observed.getDispatchNo());
RelabelApplication application = observed.getDispatchOrderId()==null && "RELABEL_OUTBOUND".equals(observed.getOutboundType())
? cancellations.lockRelabel(observed) : null;
OutboundOrder outbound = cancellations.lock(trim(outboundNo));
if (outbound == null) throw notFound("Outbound order not found");
if ("CANCELLED".equals(outbound.getStatus())) return;
if (outbound.getDispatchOrderId() != null) {
if (dispatch == null || !Objects.equals(dispatch.getId(), outbound.getDispatchOrderId())
|| !"PENDING_DEPARTURE".equals(dispatch.getStatus()) || value(dispatch.getPickedPackageCount()) > 0
|| value(dispatch.getShippedPackageCount()) > 0) throw conflict("排车已开始执行,不能直接取消出库");
fleetBindings.requireUnbound(dispatch.getId());
if (dispatch == null || !Objects.equals(dispatch.dispatchId(), outbound.getDispatchOrderId())
|| dispatch.status()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Status.ACTIVE
|| dispatch.stage()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING
|| dispatch.counts()==null || dispatch.counts().pickedPackageCount()>0 || dispatch.counts().shippedPackageCount()>0)
throw conflict("排车已开始执行,不能直接取消出库");
fleetBindings.requireUnbound(dispatch.dispatchId());
}
if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) {
throw conflict("Only an unexecuted outbound order can be cancelled");
}
String customerId = outbound.getCustomerId();
List<OutboundPalletItem> outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId());
List<OutboundPalletItem> outboundItems = cancellations.items(outbound);
if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> value(i.getPickedPackageCount()) > 0
|| !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) {
throw conflict("Outbound order cannot be cancelled after execution has started");
}
List<DeliveryOrder> existingDeliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId());
List<DeliveryOrder> existingDeliveries = cancellations.deliveries(outbound);
if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus())))
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
if (outbound.getDispatchOrderId()!=null && "RELABEL_OUTBOUND".equals(outbound.getOutboundType()))
application=cancellations.lockRelabel(outbound);
LocalDateTime now = LocalDateTime.now();
if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
if (application == null || !customerId.equals(application.getCustomerId())
@@ -1206,13 +937,13 @@ public class DeliveryTransferService {
for (OutboundPalletItem item : outboundItems) {
if (item.getWarehousePalletId() != null) continue;
for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) {
ReturnInventoryCarton carton = returnCartons.lockMark(mark);
ReturnInventoryCarton carton = cancellations.lockCarton(mark);
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !Objects.equals(application.getId(), carton.getRelabelApplicationId())
|| !"OUTBOUND_PENDING".equals(carton.getStatus()))
throw conflict("退货换标箱预留状态异常,不能取消出库");
carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now);
returnCartons.updateById(carton);
cancellations.save(carton);
}
}
relabelService.changeStatus(customerId, application.getRelabelNo(), operatorId,
@@ -1220,12 +951,23 @@ public class DeliveryTransferService {
}
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
for (String mark : outboundItems.stream().map(OutboundPalletItem::getCartonMarks).sorted().toList()) {
ReturnInventoryCarton carton = returnCartons.lockMark(mark);
ReturnInventoryCarton carton = cancellations.lockCarton(mark);
if (carton == null || !customerId.equals(carton.getCustomerId()) || !"OUTBOUND_PENDING".equals(carton.getStatus()))
throw conflict("退货箱预留状态异常,不能取消出库");
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); cancellations.save(carton);
}
}
// 已收齐子单后,先锁齐全部相关来源;费用锁之后不得再发现新的来源锁。
var cancelTransfers=new LinkedHashMap<String,WarehouseTransferOrder>();
if("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType()))
for(var no:outboundItems.stream().map(OutboundPalletItem::getOrderRefs).filter(StringUtils::hasText).distinct().sorted().toList()) {
var transfer=cancellations.lockTransfer(customerId,no);if(transfer!=null)cancelTransfers.put(no,transfer);
}
var cancelSources=new TreeMap<Long,LastlegOrder>();
for(var selected:cancellations.sources(customerId,outboundNo)) {
var current=cancellations.lockSource(customerId,selected.getId());
if(current!=null && outboundNo.equals(current.getOutboundOrderNo()))cancelSources.put(current.getId(),current);
}
for (DeliveryOrder delivery : existingDeliveries) {
int voidRequests = 0;
for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) {
@@ -1237,61 +979,58 @@ public class DeliveryTransferService {
voidRequests++;
}
}
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND",
recordCancellationOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND",
"出库取消;" + voidRequests + "笔未履行派送应收已申请撤销,其他费用保留待核对", now);
String reason = request.reason().trim();
for (int offset = 0; offset < reason.length(); offset += 480)
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON",
recordCancellationOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON",
"(" + (offset / 480 + 1) + ") " + reason.substring(offset, Math.min(offset + 480, reason.length())), now);
delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1);
delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
delivery.setUpdatedAt(now); cancellations.save(delivery);
}
OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId());
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); }
OutboundPdaTask task = cancellations.task(outbound);
if (task != null) { 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())) {
for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs)
.filter(StringUtils::hasText).distinct().toList()) {
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo);
WarehouseTransferOrder transfer = cancelTransfers.get(transferNo);
if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue;
String restored = "FBA".equals(transfer.getDestinationType()) || "PRIVATE".equals(transfer.getDestinationType())
? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION";
transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer);
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
transfer.setStatus(restored); transfer.setUpdatedAt(now); cancellations.save(transfer);
LastlegOrder order = cancelSources.get(transfer.getLastlegOrderId());
if (order != null && customerId.equals(order.getCustomerId())) {
order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored);
order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now);
tailOrders.updateById(order);
if(!cancellations.restore(order,outboundNo,restored,"WAREHOUSE_TRANSFER:"+restored,now))
throw conflict("转仓来源关联已变化,请刷新核对");
}
}
return;
}
if (!existingDeliveries.isEmpty() && !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList();
for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) {
var order = tailOrders.lockById(customerId, selected.getId());
for (LastlegOrder order : cancelSources.values()) {
if (order == null || !outboundNo.equals(order.getOutboundOrderNo())) continue;
var previous = histories.selectOne(new LambdaQueryWrapper<LastlegOrderStatusHistory>()
.eq(LastlegOrderStatusHistory::getCustomerId, customerId).eq(LastlegOrderStatusHistory::getOrderId, order.getId())
.in(LastlegOrderStatusHistory::getEventId, transferEvents)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt, LastlegOrderStatusHistory::getId).last("LIMIT 1"));
var previous = cancellations.previous(customerId,order.getId(),transferEvents);
if (previous == null || !StringUtils.hasText(previous.getFromStatus())) throw conflict("缺少本次转派送前的订单状态记录,请先核对来源");
String from = order.getCurrentStatus();
if (tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<LastlegOrder>()
.eq(LastlegOrder::getId, order.getId()).eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo)
.set(LastlegOrder::getCurrentStatus, previous.getFromStatus())
.set(LastlegOrder::getSubOrderStatus, "OUTBOUND:CANCELLED;DELIVERY:CANCELLED")
.set(LastlegOrder::getOutboundOrderNo, null).set(LastlegOrder::getDeliveryOrderNo, null)
.set(LastlegOrder::getVersion, value(order.getVersion()) + 1).set(LastlegOrder::getUpdatedAt, now)) != 1)
if (!cancellations.restore(order,outboundNo,previous.getFromStatus(),"OUTBOUND:CANCELLED;DELIVERY:CANCELLED",now))
throw conflict("订单关联已变化,请刷新后重试");
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); cancellations.history(history);
}
}
}
/** 取消事实按完整原因分段写入,不借通用Service直调Mapper。 */
private void recordCancellationOperation(String customerId,Long deliveryId,String operator,String node,String result,LocalDateTime now) {
var operation=new DeliveryOperation();operation.setCustomerId(customerId);operation.setDeliveryOrderId(deliveryId);
operation.setOperatorId(operator);operation.setOperatorName(operator);operation.setOperationNode(node);operation.setResult(result);operation.setCreatedAt(now);
cancellations.operation(operation);
}
public List<DeliveryResponses.PdaTask> pdaTasks(String warehouseId, String status) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream()
@@ -1591,49 +1330,7 @@ public class DeliveryTransferService {
} catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); }
}
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
String remark, LocalDateTime now) {
Set<Long> orderIds = new LinkedHashSet<>();
items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add);
List<Long> palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList();
if (!palletIds.isEmpty()) {
List<InboundPlanPalletAllocation> allocations = planAllocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>().in(InboundPlanPalletAllocation::getPalletId, palletIds));
List<Long> lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId)
.filter(Objects::nonNull).distinct().toList();
if (!lineIds.isEmpty()) {
planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId)
.filter(Objects::nonNull).forEach(orderIds::add);
}
}
if (orderIds.isEmpty()) return;
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) {
String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo);
order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now);
order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY");
tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId());
history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND");
history.setEventId("TRANSFER_DELIVERY:" + deliveryNo);
history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents");
history.setCreatedAt(now); histories.insert(history);
}
}
private List<DeliveryOrder> requireDeliveries(String customerId, Long outboundId) {
List<DeliveryOrder> values = deliveryOrders.lockByOutbound(customerId, outboundId);
if (values.isEmpty()) throw new IllegalStateException("Delivery documents are missing for outbound order");
return values;
}
private DeliveryResponses.TransferResult result(OutboundOrder o, List<DeliveryOrder> deliveries, boolean replayed) {
return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(),
o.getPalletCount(), o.getPackageCount(), deliveries.stream().map(d -> new DeliveryResponses.TransferDelivery(
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
}
/** 出库列表及详情头部按物理箱数汇总,退货件数不充当箱数。 */
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
@@ -1655,7 +1352,6 @@ public class DeliveryTransferService {
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
private String deliveryGroupKey(DispatchItem item) {
if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) {
return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim();
@@ -1665,16 +1361,6 @@ public class DeliveryTransferService {
if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim();
throw conflict("Dispatch pallet is missing its destination");
}
private String resolveLxCodes(String customerId, List<DispatchItem> items) {
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
if (inboundCodes.isEmpty()) return "";
return tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getInboundCode, inboundCodes))
.stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining(","));
}
private void updateOutboundPickingStatus(String customerId, OutboundOrder outbound) {
List<OutboundPalletItem> items = outboundPallets.findByOutbound(customerId, outbound.getId());
String nextStatus = !items.isEmpty() && items.stream().allMatch(item -> "PICKED".equals(item.getStatus()))
@@ -0,0 +1,179 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.client.FinanceFeeCoverageClient;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups;
import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups.Group;
import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import java.util.stream.Collectors;
/** 逐客户逐目的地确认报价与覆盖,费用只归属于该组真实派送。 */
@Service
public class DispatchDeliveryFees {
private final FleetBookingFreight external;
private final FinanceFeeCoverageClient finance;
private final OverseasFeeSourceService fees;
private final DeliveryCargoGroups groups;
private final DispatchDeliveryRepository repository;
private final String internalToken;
private static final Set<String> SERVICES=Set.of("DELIVERY_FEE","LOADING_FEE","APPOINTMENT_FEE");
/** 复用现有报价、费用发布和Finance只读契约,不引入另一套账务。 */
public DispatchDeliveryFees(FleetBookingFreight external,FinanceFeeCoverageClient finance,
OverseasFeeSourceService fees,DeliveryCargoGroups groups,DispatchDeliveryRepository repository,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.external=external;this.finance=finance;this.fees=fees;this.groups=groups;
this.repository=repository;this.internalToken=internalToken;
}
/** 一客户一次按全部真实来源校验外接实收,避免将一笔完整报价按目的地重复核价。 */
public Map<String,FleetBookingFreight.Preview> external(Context context) {
var byCustomer=context.cargo().stream().collect(Collectors.groupingBy(CargoLine::customerId,TreeMap::new,Collectors.toList()));
var result=new LinkedHashMap<String,FleetBookingFreight.Preview>();
for (var entry:byCustomer.entrySet()) {
var cargo=entry.getValue().stream().map(c->new FleetDispatchGateway.SourceCargo(c.inboundCode(),
c.warehousePalletId()==null?null:c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key())).toList();
result.put(entry.getKey(),external.preview(context.dispatchId(),context.dispatchNo(),entry.getKey(),cargo));
}
return result;
}
/** 原费候选按客户批量只读,响应越界或不可用直接失败,不能认作已覆盖。 */
public List<FinanceFeeCoverageClient.Fee> original(String customerId,List<CargoLine> cargo) {
var ids=new TreeSet<Long>();
for (var row:cargo) if ("FIRSTLEG".equals(row.sourceRegion())) {
try { long id=Long.parseLong(row.sourceInboundId());if(id<=0)throw new NumberFormatException();ids.add(id); }
catch (RuntimeException invalid) { throw conflict("原头程货物缺少真实来源关联"); }
}
if (ids.isEmpty()) return List.of();
try {
var response=finance.sources(internalToken,new FinanceFeeCoverageClient.Query(Long.valueOf(customerId),List.copyOf(ids)));
if (response==null || !response.successful() || response.data()==null) throw unavailable();
for (var fee:response.data()) if (fee==null || fee.financeFeeId()==null || fee.financeFeeId()<=0
|| fee.sourceFeeId()==null || !Objects.equals(fee.customerId(),Long.valueOf(customerId))
|| !ids.contains(fee.sourceInboundId()) || fee.sourceVersion()<1 || blank(fee.sourceHash())
|| fee.amount()==null || fee.amount().signum()<0 || !"CNY".equals(fee.currency())
|| !Set.of("INBOUND_FEE","CONTAINER_FEE").contains(fee.sourceType())
|| !Set.of("AVAILABLE","SETTLED").contains(fee.status())) throw unavailable();
return response.data();
} catch (RuntimeException unavailable) { throw unavailable(); }
}
/** 平台按真实组服务范围确认原费,返回不可变证据供客户出库保存。 */
public DeliveryFeeCoverageModels.Evidence coverage(Group group,ArrangeGroup input,String operatorId,
FleetBookingFreight.Preview freight) {
var selected=input.feeCoverage()==null?List.<CoverageSelection>of():input.feeCoverage();
if (selected.isEmpty()) return null;
if (!Objects.equals(group.cargoHash(),input.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化");
var candidates=original(group.customerId(),group.cargo());
var references=new ArrayList<DeliveryFeeCoverageModels.Reference>(); var scopes=new HashSet<String>();
for (var selection:selected) {
if (selection==null || selection.services()==null || selection.services().isEmpty()) throw conflict("请选择明确包含的服务");
var fee=candidates.stream().filter(f->Objects.equals(f.financeFeeId(),selection.financeFeeId())).findFirst()
.orElseThrow(()->conflict("原费用不属于本客户本次货物或已失效"));
if (fee.sourceVersion()!=selection.sourceVersion() || !Objects.equals(fee.sourceHash(),selection.sourceHash()))
throw conflict("原费用版本已变化,请重新核对");
var services=new TreeSet<>(selection.services());
if (services.size()!=selection.services().size() || !SERVICES.containsAll(services)) throw conflict("原费用服务范围重复或无效");
for (var service:services) if (!scopes.add(fee.sourceInboundId()+":"+service)) throw conflict("同一来源服务不得重复引用原费");
references.add(new DeliveryFeeCoverageModels.Reference(fee,List.copyOf(services)));
}
var cargo=group.cargo().stream().map(c->new DeliveryFeeCoverageModels.Cargo(c.dispatchItemId(),c.warehousePalletId(),
c.palletNo(),c.orderId(),c.sourceRegion(),c.sourceInboundId(),c.inboundCode(),c.packages(),c.inboundCode())).toList();
return new DeliveryFeeCoverageModels.Evidence(group.cargoHash(),cargo,List.copyOf(references),operatorId,Instant.now());
}
/** 按每条真实来源判断某服务是否已含,外接约定只覆盖派送服务。 */
private boolean covered(CargoLine cargo,String service,DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight) {
if ("DELIVERY_FEE".equals(service) && freight.quotes().stream().anyMatch(q->Objects.equals(q.inboundCode(),cargo.inboundCode()))) return true;
return evidence!=null && "FIRSTLEG".equals(cargo.sourceRegion()) && evidence.references().stream().anyMatch(r->
Objects.equals(r.source().sourceInboundId().toString(),cargo.sourceInboundId()) && r.services().contains(service));
}
/** 所有收费资格在创建子单前核验,新增应收只能针对未覆盖来源。 */
public void validate(Group group,ArrangeGroup input,DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight) {
if (input.plannedDeliveryAt()==null || blank(input.deliveryMethod()) || blank(input.pickupMethod()) || blank(input.loadingType()))
throw conflict("请补齐每个客户目的地的派送信息");
if (!Objects.equals(input.groupCargoHash(),group.cargoHash())) throw conflict("派送货物分组已变化");
if (!Objects.equals(input.externalFreightHash(),freight.snapshotHash())) throw conflict("外接实收或约定运费已变化");
var seen=new HashSet<String>();
for (var fee:input.fees()==null?List.<FeeItem>of():input.fees()) {
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 (!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("本组尚有未覆盖的服务费用,请核对" );
}
/** 子单及来源锁已经收齐后才核对原费用撤销状态,不能重复收费。 */
public void requireOriginalVoided(String customerId,FleetBookingFreight.Preview freight) {
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();
for (var quote:freight.quotes()) if (Objects.equals(quote.destinationKey(),group.destination().key())
&& group.cargo().stream().anyMatch(c->Objects.equals(c.inboundCode(),quote.inboundCode()))) {
var operation=new DeliveryOperation();operation.setCustomerId(group.customerId());operation.setDeliveryOrderId(delivery.getId());
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()) {
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();
fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),fee.direction(),fee.feeType())),
save(group,delivery,orders.size()==1?orders.get(0):null,fee.feeType(),fee.feeName(),fee.direction(),fee.currency(),
fee.amount(),fee.chargeableQuantity(),fee.quantityUnit(),fee.unitPrice(),fee,inbound.size()==1?inbound.get(0):null));
}
}
/** 多来源不借第一票挂账,供应商应付与客户覆盖独立。 */
private OverseasFeeSourceDtos.SaveRequest save(Group group,DeliveryOrder delivery,Long orderId,String code,String name,
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(),
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());
}
/** 只有真实业务标识可用于费用归属。 */
private static boolean blank(String value) { return value==null||value.isBlank(); }
/** 财务只读失败不降级为已覆盖或零费。 */
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"原费用来源暂不可用,请稍后核对"); }
/** 校验失败由外层事务回滚所有客户单据。 */
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); }
}
@@ -0,0 +1,354 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import com.baiyue.lastleg.delivery.model.*;
import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups.Group;
import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */
@Service
public class DispatchDeliveryService implements DispatchDeliveryPort {
private final DispatchExecutionPort execution;
private final DispatchDeliveryRepository repository;
private final DeliveryCargoGroups grouping;
private final DispatchDeliveryFees fees;
private final NormalDeliveryCompletionPolicy completion;
private final LastlegOrderNoGenerator numbers;
private final ObjectMapper json;
/** 消费主流程实际公共骨架,禁止注入对方Controller或具体Service。 */
public DispatchDeliveryService(@org.springframework.context.annotation.Lazy DispatchExecutionPort execution,DispatchDeliveryRepository repository,
DeliveryCargoGroups grouping,DispatchDeliveryFees fees,NormalDeliveryCompletionPolicy completion,
LastlegOrderNoGenerator numbers,ObjectMapper json) {
this.execution=execution;this.repository=repository;this.grouping=grouping;this.fees=fees;
this.completion=completion;this.numbers=numbers;this.json=json;
}
/** 组合GET的入口上下文和本地派送数据加入同一个只读快照。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ArrangementPreview preview(Actor actor,String dispatchNo) {
return preview(execution.preview(actor,dispatchNo));
}
/** 外部原费只读带版本证据,不能声称参与Lastleg数据库同刻快照。 */
@Override
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ArrangementPreview preview(Context context) {
var saved=repository.arrangement(context.dispatchId(),false);
var result=saved==null?null:read(saved.getResultJson(),ArrangementResult.class);
var gate=arrangementGate(context);
if (context.cargo()==null || context.cargo().isEmpty() || !context.pickingReconciled())
return new ArrangementPreview(context.dispatchNo(),context.recordVersion(),context.status(),context.stage(),
context.cargoHash(),context.loadingCompletionHash(),gate,List.of(),result);
var groups=grouping.groups(context); var external=fees.external(context);
var original=new HashMap<String,List<com.baiyue.lastleg.client.FinanceFeeCoverageClient.Fee>>();
for (var customer:groups.stream().map(Group::customerId).distinct().toList())
original.put(customer,fees.original(customer,context.cargo().stream().filter(c->customer.equals(c.customerId())).toList()));
var previews=new ArrayList<PreviewGroup>();
for (var group:groups) {
var freight=external.get(group.customerId());
var quotes=freight.quotes().stream().filter(q->Objects.equals(q.destinationKey(),group.destination().key())
&& group.cargo().stream().anyMatch(c->Objects.equals(c.inboundCode(),q.inboundCode())))
.map(q->new ExternalQuote(group.customerId(),group.key(),q.bookingNo(),q.inboundCode(),q.currency(),q.amount(),freight.snapshotHash())).toList();
var candidates=original.get(group.customerId()).stream().filter(f->group.cargo().stream().anyMatch(c->
Objects.equals(c.sourceInboundId(),f.sourceInboundId().toString())))
.map(f->new FeeCandidate(f.financeFeeId(),group.customerId(),f.currency(),f.amount(),f.sourceVersion(),f.sourceHash(),
f.status(),List.of("DELIVERY_FEE","LOADING_FEE","APPOINTMENT_FEE"),group.cargo().stream()
.filter(c->Objects.equals(c.sourceInboundId(),f.sourceInboundId().toString())).map(CargoLine::sourceKey).distinct().toList())).toList();
var required=group.cargo().stream().anyMatch(c->freight.quotes().stream().noneMatch(q->Objects.equals(q.inboundCode(),c.inboundCode())))
?List.of("DELIVERY_FEE"):List.<String>of();
previews.add(new PreviewGroup(group.key(),group.customerId(),group.customerName(),group.destination(),group.cargoHash(),group.cargo(),
freight.snapshotHash(),quotes,group.cargoHash(),candidates,required,List.of()));
}
return new ArrangementPreview(context.dispatchNo(),context.recordVersion(),context.status(),context.stage(),context.cargoHash(),
context.loadingCompletionHash(),gate,List.copyOf(previews),result);
}
/** 先共同父锁和严格命令恢复,再按客户与目的地原子创建全车单据。 */
@Override
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult arrange(Actor actor,String dispatchNo,ArrangeDelivery request) {
if (actor==null || actor.mode()!=ActorMode.PLATFORM) throw new ResponseStatusException(HttpStatus.FORBIDDEN,"整车派送由平台安排");
if (request==null || request.recordVersion()<0 || blank(request.idempotencyKey()) || request.idempotencyKey().length()>255
|| blank(request.cargoHash()) || blank(request.loadingCompletionHash()) || request.groups()==null || request.groups().isEmpty()
|| request.groups().size()>200) throw conflict("安排派送请求不完整");
var canonical=canonical(request);
var context=execution.lockForArrangement(actor,dispatchNo);
var identity=new CommandIdentity(Action.ARRANGE_DELIVERY,request.idempotencyKey(),request.recordVersion(),
grouping.hash(List.of(actor,Action.ARRANGE_DELIVERY,context.dispatchNo(),canonical)));
var replay=execution.replay(context,actor,identity);
if (replay.isPresent()) return replay.get();
String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),canonical.cargoHash(),canonical.loadingCompletionHash(),canonical.groups()));
var saved=repository.arrangement(context.dispatchId(),true);
if (saved!=null) {
if (context.recordVersion()!=request.recordVersion() || !Objects.equals(saved.getBusinessHash(),businessHash))
throw conflict("本车已安排派送,当前版本或业务内容不同");
var current=departureFacts(context,true);
// 发车后的原安排也可作为既有业务事实读取,但取消/缺项绝不能补建为成功。
if (context.status()!=Status.ACTIVE || current.outbounds().isEmpty()
|| current.outbounds().stream().anyMatch(o->"CANCELLED".equals(o.status()))
|| current.deliveries().stream().anyMatch(d->"CANCELLED".equals(d.status()))
|| !completeDocuments(context,saved,repository.outbounds(context.dispatchId(),false),repository.deliveries(context.dispatchId(),false)))
throw conflict("原安排单据不完整或已取消,请核对原单据");
return execution.recordArrangement(context,actor,identity,fact(saved),true);
}
execution.requireFreshArrangement(context,request.recordVersion());
if (!Objects.equals(context.cargoHash(),request.cargoHash()) || !Objects.equals(context.loadingCompletionHash(),request.loadingCompletionHash()))
throw conflict("装车或实际货物已经变化,请重新核对");
var groups=grouping.groups(context); var inputs=new HashMap<String,ArrangeGroup>();
for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复");
if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地");
// 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。
var existingOutbounds=repository.outbounds(context.dispatchId(),true);
var existingDeliveries=repository.deliveries(context.dispatchId(),true);
if (!existingOutbounds.isEmpty() || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对");
var external=fees.external(context);var coverages=new HashMap<String,DeliveryFeeCoverageModels.Evidence>();
for (var group:groups) {
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId()));
fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage);
}
var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>();
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);
order.setOutboundNo(numbers.next("OUTBOUND",context.warehouseId(),order.getId()));
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
order.setOutboundType("DISPATCH_DELIVERY");order.setStatus("WAIT_OUTBOUND");order.setLxCodes("");
order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum()));
order.setPalletCount((int)cargo.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count());
order.setGrossWeightKg(metric(cargo,CargoLine::grossWeightKg));order.setVolumeCbm(metric(cargo,CargoLine::volumeCbm));
order.setIdempotencyKey(grouping.hash(List.of(key,customer,"OUTBOUND")));order.setRequestHash(businessHash);
order.setCreatedBy(actor.operatorId());order.setCreatedAt(local(now));order.setUpdatedAt(local(now));
var covered=groups.stream().filter(g->g.customerId().equals(customer)).map(g->coverages.get(g.key())).filter(Objects::nonNull).toList();
if (!covered.isEmpty()) order.setFeeCoverageJson(write(mergeCoverage(covered,actor.operatorId())));
repository.create(order);outbounds.put(customer,order);
}
var deliveries=new LinkedHashMap<String,DeliveryOrder>();
for (var group:groups) {
var input=inputs.get(group.key());var outbound=outbounds.get(group.customerId());
var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now);
repository.create(delivery);deliveries.put(group.key(),delivery);
for (var row:group.cargo()) {
var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId());
item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId());
item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId());
item.setWarehousePalletId(row.warehousePalletId());item.setPalletNo(row.palletNo());item.setInboundCode(row.inboundCode());
item.setDestinationWarehouseId(group.destination().warehouseId());item.setDestinationWarehouseCode(group.destination().warehouseCode());
item.setDestinationWarehouseName(group.destination().warehouseName());item.setPackageCount(Math.toIntExact(row.packages()));
item.setPickedPackageCount(Math.toIntExact(row.packages()));item.setStatus("PICKED");
item.setGrossWeightKg(row.grossWeightKg());item.setVolumeCbm(row.volumeCbm());item.setCartonMarks(row.cartonMark());
item.setOrderRefs(row.orderNo());item.setCreatedAt(local(now));repository.create(item);
}
}
// 所有出库/派送均已插入且持有写锁,此后才申请本次全部来源锁。
bindSources(context,groups,outbounds,deliveries,now);
for (var customer:external.keySet()) fees.requireOriginalVoided(customer,external.get(customer));
for (var group:groups) fees.publish(context,group,inputs.get(group.key()),deliveries.get(group.key()),coverages.get(group.key()),
external.get(group.customerId()),actor.operatorId(),key);
var result=new ArrangementResult(context.dispatchNo(),context.recordVersion()+1,key,context.cargoHash(),now,
outboundRefs(outbounds.values(),groups),deliveryRefs(deliveries.values(),groups));
var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId());
arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash);
arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash());
arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId());
arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement);
return execution.recordArrangement(context,actor,identity,fact(arrangement),false);
}
/** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public DepartureFacts departureFacts(Context context,boolean lock) {
var arrangement=repository.arrangement(context.dispatchId(),lock);
var outbounds=repository.outbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock);
var groups=context.cargo()==null||context.cargo().isEmpty()?List.<Group>of():grouping.groups(context);
String reason=arrangement==null?"请先安排本车全部客户派送":
!Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash())
?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排":
outbounds.stream().anyMatch(o->!"WAIT_OUTBOUND".equals(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus())
|| d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null;
return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(),
arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups),
context.cargo(),new Gate(reason==null,reason==null?null:"DELIVERY_NOT_READY",reason));
}
/** 只更新已锁单据,库存扣减及订单归属转换不在此重复执行。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) {
if (departedAt==null) throw conflict("缺少实际发车时间");
var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason());
var outbounds=repository.outbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false);
for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); }
for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt));
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); }
var groups=grouping.groups(context);
return new DepartureResult(outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups),outbounds.stream().mapToLong(o->o.getPackageCount()).sum());
}
/** 签收汇总仅普通读取全部真实子单,不反向锁来源或再次发布费用。 */
@Override
@Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED)
public NormalDeliveryFacts normalDeliveryFacts(Context context) {
var saved=repository.arrangement(context.dispatchId(),false);
var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class);
var outbounds=repository.outbounds(context.dispatchId(),false);
var actual=new ArrayList<NormalDeliveryFact>();
for (var delivery:repository.deliveries(context.dispatchId(),false)) {
var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId())
&& Objects.equals(o.getCustomerId(),delivery.getCustomerId()) && Objects.equals(o.getOutboundNo(),delivery.getOutboundNo())).findFirst().orElse(null);
var destination=destination(delivery);String groupKey=grouping.groupKey(delivery.getCustomerId(),destination);
// 使用安排时原组身份,避免FBA领域键与单据CODE键的表示差异。
if (expected!=null) groupKey=expected.deliveries().stream().filter(d->d.deliveryNo().equals(delivery.getDeliveryNo()))
.map(DeliveryReference::groupKey).findFirst().orElse(groupKey);
long returned=repository.returns(delivery).stream().mapToLong(DeliveryReturnReceiptFact::packages).sum();
actual.add(new NormalDeliveryFact(delivery.getCustomerId(),groupKey,delivery.getOutboundNo(),delivery.getDeliveryNo(),
Objects.requireNonNullElse(delivery.getVersion(),0L),Objects.requireNonNullElse(delivery.getPackageCount(),0),
Objects.requireNonNullElse(delivery.getSignedPackages(),0),returned,repository.activeReturns(delivery),delivery.getStatus(),
DeliveryExecutionGuard.matches(delivery,outbound),delivery.getSignedAt()==null?null:instant(delivery.getSignedAt())));
}
var result=completion.evaluate(context,expected,actual,Instant.now());
if (saved!=null && !completeDocuments(context,saved,outbounds,repository.deliveries(context.dispatchId(),false))) {
var reasons=new ArrayList<>(result.blockReasons());reasons.add("本车出库及派送集合与原安排不一致");
return new NormalDeliveryFacts(result.dispatchNo(),result.factsHash(),false,null,result.expectedDeliveryNos(),result.deliveries(),List.copyOf(reasons));
}
return result;
}
/** 集合、客户、父引用和每组原始箱数全部相等,不仅比较全车总数。 */
private boolean completeDocuments(Context context,DispatchDeliveryArrangement arrangement,List<OutboundOrder> outbounds,List<DeliveryOrder> deliveries) {
var expected=read(arrangement.getResultJson(),ArrangementResult.class);
if (outbounds.size()!=expected.outbounds().size() || deliveries.size()!=expected.deliveries().size()) return false;
for (var ref:expected.outbounds()) if (outbounds.stream().filter(o->Objects.equals(o.getCustomerId(),ref.customerId())
&& Objects.equals(o.getOutboundNo(),ref.outboundNo()) && Objects.equals(o.getDispatchOrderId(),context.dispatchId())
&& Objects.equals(o.getOverseasWarehouseId(),context.warehouseId()) && Objects.equals((long)Objects.requireNonNullElse(o.getPackageCount(),0),ref.packageCount())).count()!=1) return false;
for (var ref:expected.deliveries()) if (deliveries.stream().filter(d->Objects.equals(d.getCustomerId(),ref.customerId())
&& Objects.equals(d.getDeliveryNo(),ref.deliveryNo()) && Objects.equals(d.getOutboundNo(),ref.outboundNo())
&& Objects.equals(d.getDispatchOrderId(),context.dispatchId()) && Objects.equals(d.getOverseasWarehouseId(),context.warehouseId())
&& Objects.equals(d.getDeliveryGroupKey(),grouping.documentGroupKey(ref.destination()))
&& Objects.equals((long)Objects.requireNonNullElse(d.getPackageCount(),0),ref.packageCount())
&& outbounds.stream().anyMatch(o->Objects.equals(o.getId(),d.getOutboundOrderId()) && Objects.equals(o.getCustomerId(),d.getCustomerId()))).count()!=1) return false;
return expected.cargoHash().equals(context.cargoHash());
}
/** 按原实物来源数字ID一次锁齐全部来源,随后绑定状态和历史。 */
private void bindSources(Context context,List<Group> groups,Map<String,OutboundOrder> outbounds,Map<String,DeliveryOrder> deliveries,Instant now) {
var sources=new TreeMap<Long,List<CargoLine>>();
for (var row:context.cargo()) if(row.orderId()!=null)sources.computeIfAbsent(row.orderId(),ignored->new ArrayList<>()).add(row);
var locked=new TreeMap<Long,com.baiyue.lastleg.entity.LastlegOrder>();
for (var entry:sources.entrySet()) {
var customer=entry.getValue().get(0).customerId();
if(entry.getValue().stream().anyMatch(c->!customer.equals(c.customerId())))throw conflict("同一来源订单出现不同客户");
var source=repository.lockSource(customer,entry.getKey());
if(source==null||!Objects.equals(source.getCustomerId(),customer)||Boolean.TRUE.equals(source.getDetained()))throw conflict("派送来源订单不存在、暂扣或归属错误");
locked.put(entry.getKey(),source);
}
for (var entry:sources.entrySet()) {
var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId());
var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey())))
.map(g->deliveries.get(g.key())).toList();
repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now));
for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId());
history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo());
history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);}
}
}
/** 客户、目的地和车辆司机仅来自权威事实,请求不能覆盖本趟资源。 */
private DeliveryOrder delivery(Context context,Group group,ArrangeGroup input,OutboundOrder outbound,String operator,String key,Instant now) {
var d=new DeliveryOrder();d.setId(IdWorker.getId());d.setCustomerId(group.customerId());d.setCustomerName(group.customerName());
d.setDeliveryNo(numbers.next("DELIVERY",context.warehouseId(),d.getId()));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo());
d.setDispatchOrderId(context.dispatchId());d.setDispatchNo(context.dispatchNo());d.setOverseasWarehouseId(context.warehouseId());d.setOverseasWarehouseName(context.warehouseName());
var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type());
d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName());
d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone());
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt()));
d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType());
d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark());
if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate());
if(context.resources().driver()!=null){d.setDriverName(context.resources().driver().name());d.setDriverPhone(context.resources().driver().phone());}}
d.setPackageCount(Math.toIntExact(group.packages()));d.setPalletCount(Math.toIntExact(group.palletCount()));
d.setTotalWeightKg(metric(group.cargo(),CargoLine::grossWeightKg));d.setTotalVolumeCbm(metric(group.cargo(),CargoLine::volumeCbm));
d.setIdempotencyKey(grouping.hash(List.of(key,group.key())));d.setCreatedBy(operator);d.setCreatedAt(local(now));d.setUpdatedAt(local(now));return d;
}
/** 输出全部真实客户出库,不能留单个outboundNo别名。 */
private List<OutboundReference> outboundRefs(Collection<OutboundOrder> rows,List<Group> groups) {
return rows.stream().map(o->new OutboundReference(o.getCustomerId(),groups.stream().filter(g->g.customerId().equals(o.getCustomerId()))
.map(Group::customerName).filter(Objects::nonNull).findFirst().orElse(null),o.getOutboundNo(),o.getStatus(),Objects.requireNonNullElse(o.getPackageCount(),0),Objects.requireNonNullElse(o.getPalletCount(),0))).toList();
}
/** 子单组身份由客户和目的地共同确定。 */
private List<DeliveryReference> deliveryRefs(Collection<DeliveryOrder> rows,List<Group> groups) {
return rows.stream().map(d->{var group=groups.stream().filter(g->g.customerId().equals(d.getCustomerId())
&& grouping.documentGroupKey(g.destination()).equals(d.getDeliveryGroupKey())).findFirst().orElse(null);
var dest=group==null?destination(d):group.destination();return new DeliveryReference(group==null?grouping.groupKey(d.getCustomerId(),dest):group.key(),
d.getCustomerId(),d.getCustomerName(),d.getOutboundNo(),d.getDeliveryNo(),dest,d.getStatus(),Objects.requireNonNullElse(d.getPackageCount(),0),Objects.requireNonNullElse(d.getPalletCount(),0));}).toList();
}
/** 读取客户资料时只保留本客户来源与证据,原费用本身不复制为新来源。 */
private DeliveryFeeCoverageModels.Evidence mergeCoverage(List<DeliveryFeeCoverageModels.Evidence> rows,String operator) {
return new DeliveryFeeCoverageModels.Evidence(grouping.hash(rows.stream().map(DeliveryFeeCoverageModels.Evidence::cargoHash).toList()),
rows.stream().flatMap(e->e.cargo().stream()).toList(),rows.stream().flatMap(e->e.references().stream()).distinct().toList(),operator,Instant.now());
}
/** 仅依据主流程阶段和真实核平显示资格,写入还需共同端口复验。 */
private Gate arrangementGate(Context c) {
boolean allowed=c.status()==Status.ACTIVE&&c.stage()==Stage.AFTER_LOADING&&c.pickingReconciled()&&c.loadingCompletedAt()!=null;
return new Gate(allowed,allowed?null:"ARRANGEMENT_NOT_READY",allowed?null:"请先完成真实拣货核平及装车确认");
}
/** 严格命令和业务摘要共用规范化集合,但原命令不移除版本和主体。 */
private ArrangeDelivery canonical(ArrangeDelivery request) {
var inputs=new ArrayList<ArrangeGroup>();
for(var g:request.groups()) { if(g==null||blank(g.groupKey()))throw conflict("派送分组身份不能为空");
if(g.fees()!=null && (g.fees().size()>20||g.fees().stream().anyMatch(Objects::isNull))
|| g.feeCoverage()!=null && (g.feeCoverage().size()>2000||g.feeCoverage().stream().anyMatch(s->s==null||s.services()==null||s.services().stream().anyMatch(Objects::isNull))))
throw conflict("派送费用或覆盖范围不合法");
var selections=g.feeCoverage()==null?List.<CoverageSelection>of():g.feeCoverage().stream()
.map(s->new CoverageSelection(s.financeFeeId(),s.sourceVersion(),s.sourceHash(),s.services().stream().sorted().toList()))
.sorted(Comparator.comparing(CoverageSelection::financeFeeId,Comparator.nullsFirst(Long::compareTo))).toList();
var feeInputs=g.fees()==null?List.<FeeItem>of():g.fees().stream().sorted(Comparator.comparing(FeeItem::direction,Comparator.nullsFirst(String::compareTo))
.thenComparing(FeeItem::feeType,Comparator.nullsFirst(String::compareTo))).toList();
inputs.add(new ArrangeGroup(g.groupKey(),g.groupCargoHash(),g.plannedDeliveryAt(),g.deliveryMethod(),g.waybillNo(),g.carrierName(),g.pickupMethod(),
g.loadingType(),g.vehicleType(),g.vehicleSize(),g.appointmentAccount(),g.sellerName(),g.remark(),g.externalFreightHash(),g.feeCoverageCargoHash(),selections,feeInputs));}
inputs.sort(Comparator.comparing(ArrangeGroup::groupKey));
return new ArrangeDelivery(request.recordVersion(),request.idempotencyKey(),request.cargoHash(),request.loadingCompletionHash(),List.copyOf(inputs));
}
/** 将持久安排还原为共同端口的完整不可变事实。 */
private ArrangementFact fact(DispatchDeliveryArrangement saved) { return new ArrangementFact(saved.getArrangementKey(),saved.getBusinessHash(),saved.getCargoHash(),
saved.getLoadingCompletionHash(),read(saved.getResultJson(),ArrangementResult.class)); }
/** 始终使用原目的地业务数据,不用内部ID回退。 */
private Destination destination(DeliveryOrder d) { return new Destination(d.getDeliveryGroupKey(),d.getDestinationType(),d.getDestinationWarehouseId(),
d.getDestinationWarehouseCode(),d.getDestinationWarehouseName(),d.getDeliveryAddress(),d.getPostalCode(),d.getContactName(),d.getContactPhone()); }
/** 未知物理量保持未知,不能补零或拿计费重量替代。 */
private BigDecimal metric(List<CargoLine> cargo,Function<CargoLine,BigDecimal> value) { return cargo.stream().anyMatch(c->value.apply(c)==null)?null:
cargo.stream().map(value).reduce(BigDecimal.ZERO,BigDecimal::add); }
/** 序列化不可变安排,不接受无法保存的半成品。 */
private String write(Object value) { try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException("派送事实无法保存",e);} }
/** 损坏的持久事实阻断,不能回退创建另一套单据。 */
private <T>T read(String value,Class<T> type) { try{return json.readValue(value,type);}catch(JsonProcessingException e){throw new IllegalStateException("派送事实无法读取",e);} }
/** 按既有服务端时区写本地单据时间。 */
private static LocalDateTime local(Instant value) { return LocalDateTime.ofInstant(value,ZoneId.systemDefault()); }
/** 按既有服务端时区读取实际事件时间。 */
private static Instant instant(LocalDateTime value) { return value.atZone(ZoneId.systemDefault()).toInstant(); }
/** 必要业务标识不允许空白。 */
private static boolean blank(String value) { return value==null||value.isBlank(); }
/** 任一客户失败由外层事务回滚全部单据和费用。 */
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); }
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
/** 只从认证主体构造平台操作身份,不接收请求中的客户/操作者参数。 */
public final class DispatchActorFactory {
/** 工具类不可实例化。 */
private DispatchActorFactory() { }
/** 验证平台技术范围,客户账号不能读取或安排整车。 */
public static Actor platform(AuthenticatedUser user) {
if(user==null || user.tenantId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"请先登录");
if(!"PLATFORM".equals(user.appCode()) || user.tenantId()!=0L) throw new BizException(ResultCode.FORBIDDEN,"仅平台可访问整车作业");
String operator=user.userId()==null?user.username():user.userId().toString();
if(operator==null || operator.isBlank()) throw new BizException(ResultCode.UNAUTHORIZED,"操作身份无效");
return new Actor(operator,ActorMode.PLATFORM,null);
}
/** PDA仓和操作者仅由既有仓作业身份校验器取出。 */
public static Actor pda(AuthenticatedUser user) {
var identity=com.baiyue.lastleg.pda.security.PdaWarehouseIdentity.require(user);
return new Actor(identity.operatorKey(),ActorMode.PDA,Long.toString(identity.overseasWarehouseId()));
}
}
@@ -0,0 +1,144 @@
package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.CommandResult;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** 真整车建单/加货入口,接收共同DTO且不保留客户子排车协议。 */
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches")
public class DispatchAggregateController {
private final DispatchAggregateService service;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure;
/** 注入本域应用服务。 */
public DispatchAggregateController(DispatchAggregateService service) { this.service=service; }
/** 空单与先选货建单使用同一个正式入口。 */
@PostMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')")
public ApiResponse<CommandResult> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody DispatchCommands.Create request) {
return ApiResponse.success(service.create(DispatchActorFactory.platform(user),request));
}
/** 主单原版本和来源摘要共同核对后加货。 */
@PostMapping("/{dispatchNo}/items")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> add(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@RequestBody DispatchCommands.AddCargo request) {
return ApiResponse.success(service.addCargo(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 版本化移货只接受真实明细身份。 */
@PostMapping("/{dispatchNo}/items/{itemId}/remove")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> remove(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable long itemId,@RequestBody DispatchCommands.ReasonCommand request) {
return ApiResponse.success(service.removeCargo(DispatchActorFactory.platform(user),dispatchNo,itemId,request));
}
/** 取消与主单版本及原命令键一起核对。 */
@PostMapping("/{dispatchNo}/cancel")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')")
public ApiResponse<CommandResult> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReasonCommand request) {
return ApiResponse.success(service.cancel(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 移货弹框读取真实明细及当前公共版本。 */
@GetMapping("/{dispatchNo}/items")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Items> items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo));
}
/** 读取实际起点和卸货顺序。 */
@GetMapping("/{dispatchNo}/route")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Route> route(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.route(DispatchActorFactory.platform(user),dispatchNo));
}
/** 调整既有目的地顺序,服务端保留真实名称地址和外接目的地。 */
@PutMapping("/{dispatchNo}/route")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> route(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.RouteCommand request) {
return ApiResponse.success(service.reorderRoute(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 实载弹框只读取本车实际拣货和测量证据。 */
@GetMapping("/{dispatchNo}/loading")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.LoadingPreview> loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo));
}
/** 显式确认装车,不复用最后扫码或实际发车操作。 */
@PostMapping("/{dispatchNo}/loading-complete")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')")
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */
@PostMapping("/{dispatchNo}/depart")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
public ApiResponse<CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) {
return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 本车证据先上传,主单引用随后走版本化编辑。 */
@PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File> uploadEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@RequestPart("file")org.springframework.web.multipart.MultipartFile file) {
return ApiResponse.success(service.uploadEvidence(DispatchActorFactory.platform(user),dispatchNo,kind,file));
}
/** 文件元数据不会暴露存储路径。 */
@GetMapping("/{dispatchNo}/evidence/{kind}/{fileId}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File> evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@PathVariable long fileId) {
return ApiResponse.success(service.evidenceMetadata(DispatchActorFactory.platform(user),dispatchNo,kind,fileId));
}
/** 每次内容读取重新核对本车范围,禁止匿名文件URL。 */
@GetMapping("/{dispatchNo}/evidence/{kind}/{fileId}/content")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public org.springframework.http.ResponseEntity<byte[]> evidenceContent(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@PathVariable long fileId) {
var actor=DispatchActorFactory.platform(user);var metadata=service.evidenceMetadata(actor,dispatchNo,kind,fileId);
return org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.parseMediaType(metadata.contentType()))
.cacheControl(org.springframework.http.CacheControl.noStore()).header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
org.springframework.http.ContentDisposition.inline().filename(metadata.fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString())
.body(service.evidenceContent(actor,dispatchNo,kind,fileId));
}
/** 提交本轮两级审核。 */
@PostMapping("/{dispatchNo}/submit-review")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> submit(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.SubmitReview request) {
return ApiResponse.success(service.submitReview(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 调度审核先于理货审核。 */
@PostMapping("/{dispatchNo}/dispatch-review")
@PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')")
public ApiResponse<CommandResult> dispatchReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReviewCommand request) {
return ApiResponse.success(service.review(DispatchActorFactory.platform(user),dispatchNo,false,request));
}
/** 理货审核通过后才允许真实扫码。 */
@PostMapping("/{dispatchNo}/loading-review")
@PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')")
public ApiResponse<CommandResult> loadingReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReviewCommand request) {
return ApiResponse.success(service.review(DispatchActorFactory.platform(user),dispatchNo,true,request));
}
/** 零扫码退回保留原整车编号。 */
@PostMapping("/{dispatchNo}/return-to-review")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> returnToReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReturnToReview request) {
return ApiResponse.success(service.returnToReview(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 原动作权限在服务层按持久动作再次检查,列表权限不能恢复他人命令。 */
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<CommandResult> command(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.command(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey));
}
}
@@ -2,70 +2,33 @@ package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch")
public class DispatchController {
@Resource private DispatchService service;
@Resource private com.baiyue.lastleg.dispatch.service.DispatchAggregateService aggregate;
@PutMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')")
@Operation(summary = "Edit vehicle, driver and appointment data; route is derived from assigned FBA warehouses")
public ApiResponse<DispatchResponse> update(
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchContracts.CommandResult> update(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchRequests.Update request) {
requirePlatform(user);
return ApiResponse.success(service.update(dispatchNo, request));
@Valid @RequestBody com.baiyue.lastleg.dispatch.port.DispatchCommands.Update request) {
return ApiResponse.success(aggregate.update(DispatchActorFactory.platform(user),dispatchNo,request));
}
@DeleteMapping("/{dispatchNo}/items/{itemId}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')")
@Operation(summary = "Remove one unpicked pallet before an outbound order is generated")
public ApiResponse<DispatchResponse> removeItem(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@PathVariable Long itemId) {
requirePlatform(user);
return ApiResponse.success(service.removeItem(dispatchNo, itemId));
}
@DeleteMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')")
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
requirePlatform(user);
service.cancel(dispatchNo);
return ApiResponse.success(null);
}
private 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 application required");
}
}
}
@@ -7,6 +7,8 @@ import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -34,27 +36,27 @@ import java.nio.charset.StandardCharsets;
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch query")
public class DispatchQueryController {
@Resource private DispatchService service;
@Resource private DispatchAggregateService aggregateService;
@Resource private DispatchBolPrintService printService;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page(
public ApiResponse<DispatchViews.Page> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid DispatchRequests.Query query) {
@Valid DispatchViews.Query query) {
requirePlatform(user);
return ApiResponse.success(service.platformPage(query));
return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query));
}
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@Operation(summary = "Get dispatch details and all pallet destinations")
public ApiResponse<DispatchResponse> detail(
public ApiResponse<DispatchViews.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(service.platformDetail(dispatchNo));
return ApiResponse.success(aggregateService.detail(DispatchActorFactory.platform(user),dispatchNo));
}
@GetMapping(value = "/{dispatchNo}/prints/bol", produces = MediaType.APPLICATION_PDF_VALUE)
@@ -2,154 +2,70 @@ package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchPlanningPort;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
/** 草稿仅走冻结的服务器版本化端口,候选继续由主流程提供真实来源。 */
@RestController
@ConditionalOnProperty(name = "lastleg.smart-dispatch-api.enabled", havingValue = "true")
@ConditionalOnProperty(name="lastleg.smart-dispatch-api.enabled",havingValue="true")
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg smart dispatch")
public class SmartDispatchController {
@Resource private DispatchService service;
@Resource private DispatchPlanningPort planning;
@Resource private DispatchAggregateService aggregate;
/** 读取实际候选,不在草稿域复制来源筛选逻辑。 */
@GetMapping("/candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch",
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) List<Long> planIds,
@RequestParam(required = false) String orderNo,
@RequestParam(required = false) String fbaWarehouseCode,
@RequestParam(required = false) String sourceStage,
@RequestParam(required = false) String draftToken) {
requirePlatform(user);
if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) {
return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken));
}
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
fbaWarehouseCode, sourceStage, draftToken));
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
@RequestParam(required=false)List<Long> planIds,@RequestParam(required=false)String orderNo,
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) {
return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken)));
}
/** 只列出当前拥有者未失效草稿。 */
@GetMapping("/drafts")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
public ApiResponse<List<com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse>> drafts(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user); return ApiResponse.success(service.activeDrafts(operatorId(user)));
}
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
public ApiResponse<List<Draft>> drafts(@AuthenticationPrincipal AuthenticatedUser user){return ApiResponse.success(planning.drafts(DispatchActorFactory.platform(user)));}
/** 以持久命令键创建服务器token及零版本。 */
@PostMapping("/drafts")
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')")
public ApiResponse<Draft> createDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody CreateDraft request){return ApiResponse.success(planning.createDraft(DispatchActorFactory.platform(user),request));}
/** 版本化布局保存只移动既有来源。 */
@PostMapping("/drafts/snapshot")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
public ApiResponse<Void> saveDraft(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftSnapshot request) {
requirePlatform(user); service.saveDraftSnapshot(operatorId(user), request); return ApiResponse.success(null);
}
@PostMapping("/pre-arrange")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets",
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH)
public ApiResponse<DispatchResponse> preArrange(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.PreArrange request) {
requirePlatform(user);
String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank()
? service.resolveCustomerScopeForSources(request.planPalletIds(), request.returnReceiptIds())
: service.resolveCustomerScopeForRelabel(request.relabelNo());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request));
}
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')")
public ApiResponse<Draft> saveDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSnapshot request){return ApiResponse.success(planning.saveSnapshot(DispatchActorFactory.platform(user),request));}
/** 一次转换全部容器,一容器一整车,不按客户拆头。 */
@PostMapping("/drafts/pre-arrange")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Atomically create one dispatch per loading container in an owned draft",
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH)
public ApiResponse<List<com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult>> preArrangeDraft(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.PreArrangeDraft request) {
requirePlatform(user);
return ApiResponse.success(service.preArrangeDraft(operatorId(user), request));
}
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')")
public ApiResponse<ConvertedDraft> preArrangeDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody ConvertDraft request){return ApiResponse.success(planning.convert(DispatchActorFactory.platform(user),request));}
/** 取得本草稿容器的真实来源排他占用。 */
@PostMapping("/draft-locks/lock")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Immediately lock one pallet in the current intelligent dispatch draft")
public ApiResponse<Void> lockDraftPallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftPallet request) {
requirePlatform(user);
String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId()));
service.lockDraftPallet(customerId, operatorId(user), request);
return ApiResponse.success(null);
}
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')")
public ApiResponse<Draft> lockDraftPallet(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSelection request){return ApiResponse.success(planning.select(DispatchActorFactory.platform(user),request));}
/** 新建容器返回同一草稿的新版本。 */
@PostMapping("/drafts/containers")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
public ApiResponse<com.baiyue.lastleg.dispatch.dto.DispatchContainerResponse> createContainer(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftContainer request) {
requirePlatform(user); return ApiResponse.success(service.createDraftContainer(operatorId(user), request));
}
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')")
public ApiResponse<Draft> createContainer(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody CreateContainer request){return ApiResponse.success(planning.createContainer(DispatchActorFactory.platform(user),request));}
/** DELETE同样带公共版本、原因及命令键,且只允许空容器。 */
@DeleteMapping("/drafts/{draftToken}/containers/{containerId}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
public ApiResponse<Void> deleteContainer(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String draftToken, @PathVariable Long containerId) {
requirePlatform(user); service.deleteDraftContainer(operatorId(user), draftToken, containerId); return ApiResponse.success(null);
}
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')")
public ApiResponse<Draft> deleteContainer(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String draftToken,@PathVariable Long containerId,
@Valid @RequestBody DispatchCommands.ReasonCommand request){return ApiResponse.success(planning.removeContainer(DispatchActorFactory.platform(user),draftToken,containerId,request));}
/** 解锁只作用于原草稿原容器及原来源身份。 */
@PostMapping("/draft-locks/unlock")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Unlock one pallet removed from the current intelligent dispatch draft")
public ApiResponse<Void> unlockDraftPallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftPallet request) {
requirePlatform(user);
String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId()));
service.unlockDraftPallet(customerId, operatorId(user), request);
return ApiResponse.success(null);
}
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')")
public ApiResponse<Draft> unlockDraftPallet(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSelection request){return ApiResponse.success(planning.unselect(DispatchActorFactory.platform(user),request));}
/** 释放本拥有者原版本草稿,不释放其他作业占用。 */
@PostMapping("/draft-locks/release")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Release every pallet lock held by the current intelligent dispatch draft")
public ApiResponse<Void> releaseDraft(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftRelease request) {
requirePlatform(user);
service.releaseDraft(operatorId(user), request.draftToken());
return ApiResponse.success(null);
}
private String operatorId(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private 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 application required");
}
}
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')")
public ApiResponse<Draft> releaseDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftRelease request){return ApiResponse.success(planning.release(DispatchActorFactory.platform(user),request));}
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.dispatch.entity;
import lombok.Data;
import java.time.LocalDateTime;
/** 真整车主单,不带客户头部;货物与来源各自保留真实客户。 */
@Data
public class DispatchAggregate {
private Long id;
private String dispatchNo;
private Long tripId;
private String overseasWarehouseId;
private String overseasWarehouseCode;
private String overseasWarehouseName;
private String status;
private String stage;
private Long recordVersion;
private Long cargoRevision;
private String cargoHash;
private String reviewState;
private Long reviewRound;
private String reviewBasisHash;
private String dispatchReviewJson;
private String loadingReviewJson;
private String resourcesJson;
private String vehiclePlate;
private String route;
private LocalDateTime plannedDepartureAt;
private LocalDateTime estimatedArrivalAt;
private LocalDateTime loadingCompletedAt;
private String loadingCompletionHash;
private String loadingConfirmationJson;
private String arrangementKey;
private String arrangementBusinessHash;
private LocalDateTime departedAt;
private LocalDateTime deliveryCompletedAt;
private Integer itemCount;
private Integer palletCount;
private Integer packageCount;
private Integer pickedPackageCount;
private Integer shippedPackageCount;
private String remark;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.dispatch.entity;
import java.time.LocalDateTime;
/** 全局请求键的不可变成功记录,客户归属不能作为键分片。 */
public record DispatchCommandRecord(Long id, Long dispatchId, String dispatchNo, String action,
String operatorId, String idempotencyKey, long recordVersion,
String commandHash, String resultJson, LocalDateTime createdAt) { }
@@ -13,8 +13,10 @@ public class DispatchDraft {
@TableId(type = IdType.INPUT)
private Long id;
private String draftToken;
private String customerId;
private String operatorId;
private String warehouseId;
private Long recordVersion;
private String sourcesJson;
private String planIdsJson;
private String containersJson;
private String status;
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.dispatch.entity;
import lombok.Data;
import java.time.LocalDateTime;
/** 草稿命令不可变身份及原始公开结果,与主单命令账本分域持久化。 */
@Data
public class DispatchDraftCommand {
private Long id;
private String idempotencyKey;
private String operatorId;
private String action;
private String targetToken;
private String requestHash;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -15,6 +15,7 @@ public class DispatchDraftContainer {
private String draftToken;
private Integer sequenceNo;
private Integer capacity;
private String sourceKeysJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -16,6 +16,10 @@ public class DispatchItem {
private Long id;
private Long dispatchOrderId;
private String customerId;
private String customerName;
private String sourceKey;
private String sourceVersion;
private String sourceSnapshotJson;
private Long planId;
private Long planPalletId;
private Long returnReceiptId;
@@ -1,275 +1,151 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
import com.baiyue.lastleg.fleet.trip.FleetCompletionModels;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import org.springframework.http.HttpStatus;
import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.fleet.exception.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import java.util.stream.Collectors;
import java.time.LocalDateTime;
import java.time.Instant;
import java.time.ZoneId;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;
import java.util.HashMap;
import java.util.Objects;
/** Fleet附加能力读取完整整车,所有客户归属均来自真实明细及客户单据。 */
@Component
@Transactional(propagation = Propagation.MANDATORY)
@Transactional(propagation=Propagation.MANDATORY)
public class DispatchFleetAdapter implements FleetDispatchGateway {
@jakarta.annotation.Resource
private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution;
@jakarta.annotation.Resource
private com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery cargoSources;
private final DispatchOrderMapper orders;
private final DispatchPdaTaskMapper tasks;
private final DispatchAggregateRepository aggregates;
private final OutboundOrderMapper outbounds;
private final DeliveryOrderMapper deliveries;
private final DeliveryOperationMapper deliveryOperations;
private final DispatchItemMapper items;
private final DispatchPdaPickMapper picks;
private final DispatchPdaWorkflowService workflow;
@jakarta.annotation.Resource @org.springframework.context.annotation.Lazy
private com.baiyue.lastleg.dispatch.service.DispatchService dispatchService;
public DispatchFleetAdapter(DispatchOrderMapper orders, DispatchPdaTaskMapper tasks,
OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries, DispatchItemMapper items,
DispatchPdaPickMapper picks, DispatchPdaWorkflowService workflow, DeliveryOperationMapper deliveryOperations) {
this.orders = orders; this.tasks = tasks; this.outbounds = outbounds; this.deliveries = deliveries;
this.items = items; this.picks = picks; this.workflow = workflow;
this.deliveryOperations = deliveryOperations;
private final DeliveryOperationMapper operations;
private final DispatchCargoSourceRepository sources;
private final DispatchAggregateService workflow;
private final ObjectMapper json;
public DispatchFleetAdapter(DispatchAggregateRepository aggregates,OutboundOrderMapper outbounds,
DeliveryOrderMapper deliveries,DeliveryOperationMapper operations,DispatchCargoSourceRepository sources,
@Lazy DispatchAggregateService workflow,ObjectMapper json) {
this.aggregates=aggregates;this.outbounds=outbounds;this.deliveries=deliveries;this.operations=operations;
this.sources=sources;this.workflow=workflow;this.json=json;
}
@Override
public void removeItem(String dispatchNo,long itemId) { dispatchService.removeItem(dispatchNo,itemId); }
@Override
public Snapshot lockForPlanning(String dispatchNo) {
return lockBeforeDeparture(dispatchNo, false);
/** 批量只读主单当前审核,绑定表没有审核副本。 */
@Override public Map<String,Workflow> workflows(List<String> numbers) {
if(numbers.size()>1000)throw conflict("整车审核批量读取超过上限");
Map<String,Workflow> result=new HashMap<>();
for(var row:aggregates.findMany(numbers))result.put(row.getDispatchNo(),new Workflow(row.getStatus(),row.getStage(),row.getRecordVersion(),review(row.getDispatchReviewJson()),review(row.getLoadingReviewJson())));
return Map.copyOf(result);
}
@Override
public Snapshot lockForAssignment(String dispatchNo) {
return lockBeforeDeparture(dispatchNo, true);
private FleetTripModels.ReviewState review(String stored) {
try {var value=json.readValue(stored,DispatchContracts.Review.class);return new FleetTripModels.ReviewState(value.status().name(),value.reason(),value.reviewerId(),value.reviewedAt(),null);}
catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("整车审核事实读取失败",error);}
}
private Snapshot lockBeforeDeparture(String dispatchNo, boolean allowPicking) {
var dispatch = orders.lockGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var allowedStatuses = allowPicking ? java.util.Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART") : java.util.Set.of("PENDING_DEPARTURE");
if (dispatch.getStatus() == null || !allowedStatuses.contains(dispatch.getStatus())
|| (!allowPicking && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
|| Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0)
throw conflict("排车单 " + dispatchNo + " 已开始执行,不能修改车次关联或改派");
// Keep the same dispatch -> outbound -> PDA task lock order as departure.
var outbound = outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId());
if (outbound != null && !"WAIT_OUTBOUND".equals(outbound.getStatus()))
throw conflict("排车单 " + dispatchNo + " 的出库状态不允许修改关联");
var task = tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId());
var allowedTaskStatuses = allowPicking ? java.util.Set.of("PDA_PENDING", "PICKING", "COMPLETED") : java.util.Set.of("PDA_PENDING");
if (task != null && (task.getStatus() == null || !allowedTaskStatuses.contains(task.getStatus())))
throw conflict("排车单 " + dispatchNo + " 已开始拣货,不能修改车次关联或改派");
if (task != null && !Objects.equals(task.getWarehouseId(), dispatch.getOverseasWarehouseId()))
throw conflict("拣货任务所属仓不一致");
if (task == null && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
throw conflict("排车单缺少已拣货的作业依据");
if (dispatch.getPackageCount() == null || dispatch.getPackageCount() <= 0)
throw conflict("排车单 " + dispatchNo + " 没有待发运货物");
return new Snapshot(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), dispatch.getOverseasWarehouseId(), dispatch.getPackageCount());
/** 根锁已由上游trip/资源取得,禁止在已有扫码后以旧计划命令调整。 */
@Override public Snapshot lockForPlanning(String number) {
var row=required(number,true);
if(!"ACTIVE".equals(row.getStatus()) || !"BEFORE_LOADING".equals(row.getStage()) || aggregates.realPicked(row.getId())!=0)
throw conflict("仅本车装车前且未扫码时可安排货物");
return new Snapshot(row.getId(),row.getDispatchNo(),row.getOverseasWarehouseId(),row.getPackageCount());
}
@Override
public void assign(Snapshot dispatch, Assignment assignment) {
var now = LocalDateTime.now();
// Explicit SET is needed when unbinding: MyBatis entity updates omit null fields.
if (orders.update(null, new LambdaUpdateWrapper<DispatchOrder>().eq(DispatchOrder::getId, dispatch.dispatchId())
.eq(DispatchOrder::getCustomerId, dispatch.customerId()).in(DispatchOrder::getStatus, "PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART")
.set(DispatchOrder::getVehiclePlate, assignment.vehiclePlate()).set(DispatchOrder::getDriverName, assignment.driverName())
.set(DispatchOrder::getDriverPhone, assignment.driverPhone()).set(DispatchOrder::getUpdatedAt, now)) != 1)
throw conflict("排车单已变化,请刷新后重试");
var outbound = outbounds.lockByDispatch(dispatch.customerId(), dispatch.dispatchId());
if (outbound == null) return;
for (var delivery : deliveries.lockByOutbound(dispatch.customerId(), outbound.getId())) {
if (!"WAIT_DELIVERY".equals(delivery.getStatus()) || delivery.getDispatchedAt() != null || delivery.getSignedAt() != null)
throw conflict("派送单 " + delivery.getDeliveryNo() + " 已执行,不能覆盖车辆司机");
if (deliveries.update(null, new LambdaUpdateWrapper<DeliveryOrder>().eq(DeliveryOrder::getId, delivery.getId())
.eq(DeliveryOrder::getCustomerId, dispatch.customerId()).eq(DeliveryOrder::getVersion, delivery.getVersion())
.set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName())
.set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, delivery.getVersion() + 1)
.set(DeliveryOrder::getUpdatedAt, now)) != 1) throw conflict("派送单已变化,请刷新后重试");
@Override public List<String> customers(String number) {
var row=required(number,false);return aggregates.cargo(row.getId()).stream().map(i->i.getCustomerId()).distinct().sorted(Comparator.comparingLong(Long::parseLong)).toList();
}
/** 逐客户读取全部来源组成,不用主单或整托展示的一个客户替代。 */
@Override public List<SourceCargo> sourceCargo(String number,String customer) {
var row=required(number,false);var items=aggregates.cargo(row.getId()).stream().filter(i->customer.equals(i.getCustomerId())).toList();
var facts=sources.sources(customer,row.getId());var result=new ArrayList<SourceCargo>();
for(var item:items) {
String destination=destination(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
if(item.getReturnReceiptId()!=null || Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(),""))) {
result.add(new SourceCargo(item.getInboundCode(),item.getWarehousePalletId()==null?null:item.getWarehousePalletId().toString(),item.getPalletNo(),item.getPackageCount(),destination));continue;
}
var selected=facts.stream().filter(f->Objects.equals(f.dispatchItemId(),item.getId())).toList();
if(selected.isEmpty() || selected.stream().mapToLong(f->f.packages()).sum()!=item.getPackageCount())throw conflict("本客户实际来源与排车箱数未核平");
for(var fact:selected) {
if(fact.inboundCode()==null || fact.inboundCode().isBlank() || fact.packages()<=0 || !Objects.equals(fact.warehousePalletId(),item.getWarehousePalletId()))throw conflict("本客户来源缺少真实入仓码或实托");
result.add(new SourceCargo(fact.inboundCode(),fact.warehousePalletId().toString(),fact.palletNo(),Math.toIntExact(fact.packages()),destination));
}
}
return List.copyOf(result);
}
@Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) {
var context=workflow.fleetContext(number,lock);var row=required(number,false);
var cargo=aggregates.cargo(row.getId()).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(),
destination(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),"PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():first(i.getDestinationWarehouseCode(),i.getFbaWarehouseCode()))).toList();
String reason=!"ACTIVE".equals(row.getStatus())?"排车已取消":!Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(row.getStage())?"请先核平扫码并确认装车完成":!context.pickingReconciled()?"本车逐来源扫码未核平":null;
var vehicle=context.resources().vehicle();var driver=context.resources().driver();
return new FleetExecutionModels.Manifest(number,row.getOverseasWarehouseId(),vehicle==null?null:vehicle.plate(),driver==null?null:driver.name(),driver==null?null:driver.phone(),cargo,reason);
}
/** 先收齐所有客户出库锁,再锁所有派送;客户和仓归属从每行复验。 */
private Facts facts(String number,boolean lock) {
var row=required(number,lock);
var outboundRows=(lock?outbounds.lockAllByDispatch(row.getId()):outbounds.findAllByDispatch(row.getId())).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList();
var deliveryRows=(lock?deliveries.lockAllByDispatch(row.getId()):deliveries.findAllByDispatch(row.getId())).stream().filter(d->!"CANCELLED".equals(d.getStatus())).toList();
var expected=aggregates.cargo(row.getId()).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount())));
var byCustomer=new HashMap<String,OutboundOrder>();
for(var outbound:outboundRows)if(byCustomer.put(outbound.getCustomerId(),outbound)!=null || !Objects.equals(outbound.getDispatchOrderId(),row.getId()) || !Objects.equals(outbound.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || !"OUTBOUNDED".equals(outbound.getStatus()) || !Objects.equals(expected.get(outbound.getCustomerId()),Long.valueOf(outbound.getPackageCount())))throw conflict("客户出库归属或数量与本车发运不一致");
if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物");
var counts=new HashMap<String,Long>();var numbers=new HashSet<String>();
for(var delivery:deliveryRows) {
var outbound=byCustomer.get(delivery.getCustomerId());
if(outbound==null || !numbers.add(delivery.getDeliveryNo()) || !Objects.equals(delivery.getDispatchOrderId(),row.getId()) || !Objects.equals(delivery.getOutboundOrderId(),outbound.getId()) || !Objects.equals(delivery.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || delivery.getPackageCount()==null || delivery.getPackageCount()<=0 || delivery.getVersion()==null)throw conflict("客户派送归属或数量与本车出库不一致");
counts.merge(delivery.getCustomerId(),delivery.getPackageCount().longValue(),Math::addExact);
}
if(!counts.equals(expected))throw conflict("本车全部客户派送数量未核平");
return new Facts(row,deliveryRows);
}
private record Facts(DispatchAggregate root,List<DeliveryOrder> deliveries) { }
@Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean lock) {
var row=required(number,lock);
if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成");
var facts=facts(number,lock);var outcomes=new ArrayList<FleetCompletionModels.Outcome>();
for(var delivery:facts.deliveries()) {
var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts);
Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null;
outcomes.add(new FleetCompletionModels.Outcome(number,delivery.getDeliveryNo(),delivery.getCustomerId(),"PRIVATE".equals(delivery.getDestinationType())?delivery.getDeliveryAddress():first(delivery.getDestinationWarehouseCode(),delivery.getDestinationWarehouseName()),originallySigned(delivery)?"DELIVERED":delivery.getStatus(),delivery.getPackageCount(),terminal,quantities.signed(),quantities.returned(),quantities.remaining()));
}
String reason=outcomes.stream().anyMatch(o->o.remainingPackages()!=0 || o.signedPackages()+o.returnedPackages()!=o.packages() || o.terminalAt()==null || !Set.of("DELIVERED","RETURNED").contains(o.status()))?"本车货物须实际签收或完成真实退回入仓":null;
return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.copyOf(outcomes),reason);
}
@Override public FleetTransitEvidence transitEvidence(String number,boolean lock) {
var facts=facts(number,lock);var row=facts.root();
if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null || row.getShippedPackageCount()<=0)throw conflict("交接必须具有本车真实发运依据");
var result=new ArrayList<FleetTransitEvidence.Delivery>();
for(var delivery:facts.deliveries()) {
if(!Set.of("DELIVERING","REJECTED","DELIVERED","RETURN_PENDING","RETURNED").contains(Objects.toString(delivery.getStatus(),"")))throw conflict("本车客户派送状态与实际发运不一致");
var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts);
Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null;
if(("DELIVERED".equals(delivery.getStatus()) || "RETURNED".equals(delivery.getStatus())) && (terminal==null || quantities.remaining()!=0))throw conflict("派送终态缺少完整真实签收或退回证据");
String key=destination(delivery.getDestinationType(),delivery.getDestinationWarehouseCode(),null,delivery.getDeliveryAddress(),delivery.getPostalCode());if(key==null)throw conflict("派送缺少真实卸货目的地");
var cargo=new FleetHandoverPolicy.Delivery(delivery.getCustomerId(),number,delivery.getDeliveryNo(),key,delivery.getPackageCount(),terminal,originallySigned(delivery)?List.of():receipts.stream().map(r->{if(r.dispatchItemId()==null || r.palletId()==null)throw conflict("实收退回缺少原明细和实托");return new FleetHandoverPolicy.ReturnedCargo(r.dispatchItemId().toString(),r.palletId().toString(),Math.toIntExact(r.packages()),instant(r.receivedAt()));}).toList());
result.add(new FleetTransitEvidence.Delivery(cargo,delivery.getStatus(),delivery.getVersion(),new FleetTransitEvidence.Transport(delivery.getVehiclePlate(),delivery.getDriverName(),delivery.getDriverPhone())));
}
return new FleetTransitEvidence(number,row.getOverseasWarehouseId(),row.getShippedPackageCount(),List.copyOf(result));
}
@Override public void handover(FleetTransitEvidence evidence,Assignment assignment) {
var current=transitEvidence(evidence.dispatchNo(),true);if(!current.equals(evidence))throw conflict("本车交接事实已变化,请重新核对");
for(var fact:evidence.deliveries()) {
if(fact.cargo().terminalAt()!=null)continue;
var row=deliveries.lockByNo(fact.cargo().customerId(),fact.cargo().deliveryNo());
if(row==null || !Objects.equals(row.getVersion(),fact.recordVersion()) || row.getSignedAt()!=null)throw conflict("本客户派送已变化");
if(deliveries.update(null,new LambdaUpdateWrapper<DeliveryOrder>().eq(DeliveryOrder::getId,row.getId()).eq(DeliveryOrder::getCustomerId,fact.cargo().customerId()).eq(DeliveryOrder::getVersion,fact.recordVersion()).eq(DeliveryOrder::getStatus,fact.status()).isNull(DeliveryOrder::getSignedAt).set(DeliveryOrder::getVehiclePlate,assignment.vehiclePlate()).set(DeliveryOrder::getDriverName,assignment.driverName()).set(DeliveryOrder::getDriverPhone,assignment.driverPhone()).set(DeliveryOrder::getVersion,fact.recordVersion()+1).set(DeliveryOrder::getUpdatedAt,LocalDateTime.now()))!=1)throw conflict("派送交接保存失败");
}
}
@Override
public FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock) {
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var task = lock ? tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : tasks.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
Map<Long, Long> picked = new HashMap<>();
if (task != null) for (var pick : picks.listTask(dispatch.getCustomerId(), task.getId())) picked.merge(pick.getDispatchItemId(), pick.getPackages(), Math::addExact);
var cargo = selected.stream().map(item -> new FleetExecutionModels.Cargo(dispatchNo, dispatch.getCustomerId(), item.getId().toString(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0),
destination(item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()),
"PRIVATE".equals(item.getDestinationType()) ? item.getDeliveryAddress() : first(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()))).toList();
String reason = null;
if (!"READY_TO_DEPART".equals(dispatch.getStatus()) || task == null || !"COMPLETED".equals(task.getStatus())) reason = "请完成拣货并核平全部箱数";
else if (!Objects.equals(dispatch.getOverseasWarehouseId(), task.getWarehouseId())) reason = "拣货任务所属仓不一致";
else if (selected.isEmpty() || selected.stream().anyMatch(item -> item.getPackageCount() == null || item.getPackageCount() <= 0
|| picked.getOrDefault(item.getId(), 0L).longValue() != item.getPackageCount().longValue())) reason = "每条货物须与实际拣货箱数核平";
else if (!picked.keySet().equals(selected.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchItem::getId).collect(java.util.stream.Collectors.toSet()))) reason = "拣货记录包含不属于当前发运的货物";
else if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单";
else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) reason = "实物托盘或目的地未确认";
if (reason == null) reason = deliveryExecution.departureBlockReason(outbound, lock);
return new FleetExecutionModels.Manifest(dispatchNo, dispatch.getOverseasWarehouseId(), dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone(), cargo, reason);
}
@Override
public FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator,
String idempotencyKey, String remark, Instant departedAt) {
var result = workflow.depart(warehouseId, dispatchNo, operator, new DispatchPdaRequests.Depart(idempotencyKey, remark),
LocalDateTime.ofInstant(departedAt, ZoneId.systemDefault()));
return new FleetExecutionModels.DepartedDispatch(result.dispatchNo(), result.shippedPackages());
}
private static String destination(String type, String code, String fba, String address, String postalCode) {
return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode);
}
/** 用逐入仓组成核对外接承诺,不使用整托展示来源。 */
@Override public java.util.List<SourceCargo> sourceCargo(String dispatchNo) {
var dispatch=orders.findGlobalByDispatchNo(dispatchNo);
if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");
return cargoSources.sources(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()));
}
@Override
public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) {
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var rows = outbound == null ? java.util.List.<DeliveryOrder>of() : lock
? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId());
if (outbound != null && (rows.stream().mapToLong(row -> Objects.requireNonNullElse(row.getPackageCount(), 0)).sum()
!= Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())))
return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), java.util.List.of(), "派送单数量未覆盖本次全部实发货物");
var outcomes = rows.stream().map(row -> {
var quantities = quantities(row, deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId()));
return new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(),
"PRIVATE".equals(row.getDestinationType()) ? row.getDeliveryAddress() : first(row.getDestinationWarehouseCode(), row.getDestinationWarehouseName()),
originallySigned(row) ? "DELIVERED" : row.getStatus(), Objects.requireNonNullElse(row.getPackageCount(), 0),
originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus())
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null,
quantities.signed(), quantities.returned(), quantities.remaining());
}).toList();
String reason = null;
if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) reason = "排车出库尚未完成";
else if (outcomes.isEmpty()) reason = "缺少对应派送单";
else if (outcomes.stream().mapToLong(FleetCompletionModels.Outcome::packages).sum() != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0)
|| !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) reason = "派送单数量未覆盖本次全部实发货物";
else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || row.remainingPackages() != 0
|| row.signedPackages() + row.returnedPackages() != row.packages()
|| !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null))
reason = "所有派送单须已签收,或完成真实退回入仓";
return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason);
}
// Existing delivery commands persist LocalDateTime.now(); convert in that server zone, never the browser zone.
@Override
public com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock) {
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId())
: outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())
|| dispatch.getShippedPackageCount() == null || dispatch.getShippedPackageCount() <= 0
|| !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount()))
throw conflict("交接必须具有已完成出库的真实发运依据");
var rows = lock ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId())
: deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId());
var facts = new java.util.ArrayList<com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery>();
long packages = 0;
for (var row : rows) {
if (!Objects.equals(row.getCustomerId(), dispatch.getCustomerId())
|| !Objects.equals(row.getDispatchOrderId(), dispatch.getId())
|| !Objects.equals(row.getOutboundOrderId(), outbound.getId())
|| !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId())
|| row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null
|| row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERING", "REJECTED", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus()))
throw conflict("派送货物归属或执行状态与原始发运不一致");
Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus())
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null;
if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null)
throw conflict("派送终态缺少真实签收或退回完成证据");
var receipts = deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId());
var quantities = quantities(row, receipts);
if (terminal != null && quantities.remaining() != 0) throw conflict("派送终态数量未覆盖原实发货物");
String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null,
row.getDeliveryAddress(), row.getPostalCode());
if (key == null) throw conflict("派送单缺少真实卸货目的地");
var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(),
dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal,
originallySigned(row) ? java.util.List.of() : receipts.stream().map(receipt -> {
if (receipt.dispatchItemId() == null || receipt.palletId() == null) throw conflict("实际退回缺少原发运实托明细归属");
return new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.ReturnedCargo(
receipt.dispatchItemId().toString(), receipt.palletId().toString(), Math.toIntExact(receipt.packages()), instant(receipt.receivedAt()));
}).toList());
facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(),
new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone())));
packages = Math.addExact(packages, row.getPackageCount());
}
if (facts.isEmpty() || packages != dispatch.getShippedPackageCount())
throw conflict("派送明细未核平本次实发货物");
return new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence(dispatchNo, dispatch.getCustomerId(),
dispatch.getOverseasWarehouseId(), dispatch.getShippedPackageCount(), java.util.List.copyOf(facts));
}
@Override
public void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment) {
var current = transitEvidence(evidence.dispatchNo(), true);
if (!current.equals(evidence)) throw conflict("交接货物已变化,请重新核对");
for (var fact : evidence.deliveries()) {
if (fact.cargo().terminalAt() != null) continue;
var row = deliveries.lockByNo(evidence.customerId(), fact.cargo().deliveryNo());
if (row == null || !Objects.equals(row.getVersion(), fact.recordVersion()) || row.getSignedAt() != null)
throw conflict("派送货物已变化,请重新核对交接");
if (deliveries.update(null, new LambdaUpdateWrapper<DeliveryOrder>()
.eq(DeliveryOrder::getId, row.getId()).eq(DeliveryOrder::getCustomerId, evidence.customerId())
.eq(DeliveryOrder::getVersion, fact.recordVersion()).eq(DeliveryOrder::getStatus, fact.status())
.isNull(DeliveryOrder::getSignedAt)
.set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName())
.set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, fact.recordVersion() + 1)
.set(DeliveryOrder::getUpdatedAt, LocalDateTime.now())) != 1)
throw conflict("派送交接保存失败,请刷新核对");
}
}
private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); }
private static DeliveryCargoQuantities quantities(DeliveryOrder row, java.util.List<com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact> receipts) {
try { return DeliveryCargoQuantities.from(row, receipts); }
catch (IllegalArgumentException error) { throw conflict(error.getMessage()); }
}
private static boolean originallySigned(DeliveryOrder order) {
return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus());
}
private static String first(String a, String b) { return a != null && !a.isBlank() ? a : b == null || b.isBlank() ? null : b; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private DispatchAggregate required(String number,boolean lock) {var row=lock?aggregates.lock(number):aggregates.find(number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");return row;}
private static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);}
/** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */
private static Instant instant(LocalDateTime value){return value==null?null:value.atZone(ZoneId.systemDefault()).toInstant();}
private static String first(String a,String b){return a!=null&&!a.isBlank()?a:b==null||b.isBlank()?null:b;}
private static boolean originallySigned(DeliveryOrder row){return row.getSignedAt()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(row.getStatus());}
private static DeliveryCargoQuantities quantities(DeliveryOrder row,List<com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact> receipts){try{return DeliveryCargoQuantities.from(row,receipts);}catch(IllegalArgumentException error){throw conflict(error.getMessage());}}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -1,58 +1,52 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.trip.*;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.*;
/** 外接预订绑定真实客户来源,整车身份由当前车次唯一关联解析。 */
@Component
@Transactional(propagation=Propagation.MANDATORY)
public class FleetDispatchPlanningAdapter implements FleetDispatchPlanningGateway {
private final FleetDispatchPlanningMapper mapper;
private final DispatchService dispatches;
private final FleetDispatchGateway cargo;
public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,@Lazy DispatchService dispatches,FleetDispatchGateway cargo) {
this.mapper=mapper;this.dispatches=dispatches;this.cargo=cargo;
private final FleetDispatchPlanningMapper mapper;private final DispatchSourceRepository sources;
private final DispatchService rules;private final FleetDispatchGateway cargo;private final DispatchAggregateService workflow;
public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,DispatchSourceRepository sources,@Lazy DispatchService rules,
FleetDispatchGateway cargo,@Lazy DispatchAggregateService workflow){this.mapper=mapper;this.sources=sources;this.rules=rules;this.cargo=cargo;this.workflow=workflow;}
@Override public String resolveIncoming(String tripNo,String customer,FleetBookingIncomingGateway.Evidence receipt,String operator,String key) {
if(receipt==null || receipt.inboundCode()==null || !"READY_FOR_DISPATCH".equals(receipt.status()) || receipt.pallets().isEmpty())throw conflict("请先完成真实收货和实收核对");
String number=mapper.dispatchForTrip(tripNo);if(number==null)throw conflict("本车缺少唯一整车主单");
var authentication=SecurityContextHolder.getContext().getAuthentication();
var actor=DispatchActorFactory.platform(authentication!=null && authentication.getPrincipal() instanceof AuthenticatedUser user?user:null);
if(!Objects.equals(actor.operatorId(),operator))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"外接安排操作者不一致");
var context=workflow.lockForArrangement(actor,number);var selected=new HashSet<>(mapper.selectedPlans(context.dispatchId(),customer));
var additions=new ArrayList<DispatchCommands.CargoSelection>();
for(var id:mapper.receivedPlanPallets(customer,receipt.inboundCode())) {
if(selected.contains(id))continue;var candidates=sources.selectedPlan(customer,id);
if(candidates.size()!=1)throw conflict("外接实收来源已变化,请重新核对");var candidate=candidates.get(0);
if(!Objects.equals(candidate.overseasWarehouseId(),context.warehouseId()))throw conflict("外接来源起运仓不一致");
additions.add(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,id,null,null,null,rules.wholeSourceVersion(candidate,"PLAN:"+id,false)));
}
if(!additions.isEmpty())workflow.addCargo(actor,number,new DispatchCommands.AddCargo(context.recordVersion(),key,additions,List.of()));
validateIncomingDispatch(number,customer,receipt);return number;
}
public PageResult<Candidate> candidates(String tripNo,String warehouseId,String keyword,int page,int size) {
var result=mapper.candidates(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page,size),tripNo,warehouseId,keyword);
return new PageResult<>(result.getRecords(),result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
}
public String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator) {
if(receipt==null||receipt.inboundCode()==null||!"READY_FOR_DISPATCH".equals(receipt.status())||receipt.pallets().isEmpty())
throw conflict("请先完成真实收货和实收核对");
var existing=mapper.sourceDispatches(customerId,receipt.inboundCode());
if(existing.size()>1)throw conflict("本次来货已有多张排车安排,请调度核查:"+String.join("、",existing));
String number;
if(existing.isEmpty()) {
var ids=mapper.receivedPlanPallets(customerId,receipt.inboundCode());
if(ids.isEmpty())throw conflict("实收来源没有可用于排车的真实托盘");
number=dispatches.preArrange(customerId,operator,new DispatchRequests.PreArrange(ids,null,null,null,null)).dispatchNo();
} else number=existing.get(0);
validateIncomingDispatch(number,customerId,receipt);
return number;
}
public void validateIncomingDispatch(String number,String customerId,FleetBookingIncomingGateway.Evidence receipt) {
var snapshot=cargo.lockForPlanning(number);
if(!customerId.equals(snapshot.customerId())||!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("排车客户或起运仓与来货不一致");
var expected=new TreeMap<String,Integer>();
for(var p:receipt.pallets())expected.merge(p.warehousePalletId(),p.packages(),Math::addExact);
@Override public void validateIncomingDispatch(String number,String customer,FleetBookingIncomingGateway.Evidence receipt) {
var snapshot=cargo.lockForPlanning(number);if(!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("整车起运仓与外接实收不一致");
var expected=new TreeMap<String,Integer>();for(var pallet:receipt.pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact);
var selected=new TreeMap<String,Integer>();
for(var item:cargo.sourceCargo(number)) {
if(!Objects.equals(receipt.inboundCode(),item.inboundCode())||!Objects.equals(receipt.destinationKey(),item.destinationKey())||item.warehousePalletId()==null)
throw conflict("候选排车包含其他来源或目的地,请调度核查 "+number);
for(var item:cargo.sourceCargo(number,customer))if(Objects.equals(receipt.inboundCode(),item.inboundCode())) {
if(item.warehousePalletId()==null || !Objects.equals(receipt.destinationKey(),item.destinationKey()))throw conflict("本客户外接实收托盘或目的地不一致");
selected.merge(item.warehousePalletId(),item.packages(),Math::addExact);
}
if(!expected.equals(selected))throw conflict("候选排车未完整覆盖本票全部实收货物,请调度核查 "+number);
if(!expected.equals(selected))throw conflict("本车未完整覆盖本客户本票外接实收来源");
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);}
}
@@ -1,28 +1,13 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 外接实收仅加入本车真实主单,不生成客户子排车。 */
@Mapper
public interface FleetDispatchPlanningMapper {
@Select({"<script>SELECT d.dispatch_no,d.customer_id,d.overseas_warehouse_id AS warehouse_id,d.package_count,d.route",
"FROM ll_dispatch_order d WHERE d.overseas_warehouse_id=#{warehouseId} AND d.status='PENDING_DEPARTURE'",
"AND d.package_count>0 AND COALESCE(d.picked_package_count,0)=0 AND COALESCE(d.shipped_package_count,0)=0",
"AND NOT EXISTS(SELECT 1 FROM ll_fleet_trip_dispatch b WHERE b.dispatch_id=d.id)",
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_fleet_external_booking eb ON eb.incoming_no=di.inbound_code",
"WHERE di.dispatch_order_id=d.id AND (eb.trip_no!=#{tripNo} OR eb.status!='RECEIVED'))",
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pda_task t WHERE t.dispatch_order_id=d.id AND t.status!='PDA_PENDING')",
"AND NOT EXISTS(SELECT 1 FROM ll_outbound_order o WHERE o.dispatch_order_id=d.id AND o.status!='WAIT_OUTBOUND')",
"<if test='keyword != null and keyword != &quot;&quot;'>AND (d.dispatch_no LIKE CONCAT('%',#{keyword},'%') OR d.route LIKE CONCAT('%',#{keyword},'%'))</if>",
"ORDER BY d.updated_at DESC,d.id DESC</script>"})
IPage<FleetDispatchPlanningGateway.Candidate> candidates(IPage<?> page,@Param("tripNo") String tripNo,@Param("warehouseId") String warehouseId,@Param("keyword") String keyword);
@Select("SELECT DISTINCT d.dispatch_no FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND i.customer_id=d.customer_id " +
"WHERE d.customer_id=#{customerId} AND i.inbound_code=#{inboundCode} AND d.status!='CANCELLED' ORDER BY d.dispatch_no")
List<String> sourceDispatches(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode);
@Select("SELECT dispatch_no FROM ll_fleet_trip_dispatch WHERE trip_no=#{tripNo}")
String dispatchForTrip(@Param("tripNo") String tripNo);
@Select("SELECT DISTINCT plan_pallet_id FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id IS NOT NULL")
List<Long> selectedPlans(@Param("dispatch") long dispatch,@Param("customer") String customer);
@Select("SELECT DISTINCT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"WHERE ip.customer_id=#{customerId} AND l.inbound_code=#{inboundCode} AND p.warehouse_pallet_id IS NOT NULL ORDER BY p.id")
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.dispatch.gateway;
import org.springframework.web.multipart.MultipartFile;
/** 临时车辆和司机证据属于本张整车,不能借司机主档或客户文件授权。 */
public interface DispatchEvidenceGateway {
enum Kind { VEHICLE, DRIVER }
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
/** 上传只写本车、本类证据。 */
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
/** 审核前逐文件复验平台、本车与类型复合归属。 */
File metadata(long dispatchId,Kind kind,long fileId);
/** 下载仍通过本车读权限入口。 */
byte[] download(long dispatchId,Kind kind,long fileId);
}
@@ -0,0 +1,96 @@
package com.baiyue.lastleg.dispatch.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/** 本域只调用File内部通用业务文件入口,不读取File表或返回存储地址。 */
@Component
public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
private static final long MAX=10L*1024*1024;
private static final Set<String> TYPES=Set.of("application/pdf","image/jpeg","image/png");
private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}";
private final RestClient client;
private final String token;
/** 复用既有内部客户端超时与身份配置,不新增环境地址。 */
public HttpDispatchEvidenceGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}")String url,@Value("${baiyue.security.internal-service-token:}")String token) {
client=factory.build(builder,url,properties.getCustomerVisitFile());this.token=token;
}
/** 类型由枚举生成,禁止请求指定任意File业务域。 */
private static String type(Kind kind) { return "OVERSEAS_DISPATCH_"+Objects.requireNonNull(kind).name()+"_EVIDENCE"; }
/** 本车证据上传完成后仍以File返回元数据核对复合归属。 */
@Override public File upload(long dispatch,Kind kind,String operator,MultipartFile file) {
if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid();
var body=new MultipartBodyBuilder();
try { body.part("file",new ByteArrayResource(file.getBytes()) {
/** 保留用户文件名供审阅,不把路径当业务事实。 */
@Override public String getFilename(){return file.getOriginalFilename();}
}).contentType(MediaType.parseMediaType(file.getContentType())); }catch(java.io.IOException error){throw invalid();}
var result=client.post().uri(PATH,type(kind),dispatch).header("X-Internal-Service-Token",token).header("X-Operator-Id",operator)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().onStatus(HttpStatusCode::isError,(q,r)->{throw unavailable(r.getStatusCode());}).body(Envelope.class);
return verified(result,dispatch,kind,null);
}
/** 不接受其他车、其他证据类型或失效文件。 */
@Override public File metadata(long dispatch,Kind kind,long id) {
var result=client.get().uri(PATH+"/{file}",type(kind),dispatch,id).header("X-Internal-Service-Token",token).retrieve()
.onStatus(HttpStatusCode::isError,(q,r)->{throw unavailable(r.getStatusCode());}).body(Envelope.class);
return verified(result,dispatch,kind,id);
}
/** 先核对元数据再读取有界内容。 */
@Override public byte[] download(long dispatch,Kind kind,long id) {
var expected=metadata(dispatch,kind,id);
return client.get().uri(PATH+"/{file}/content",type(kind),dispatch,id).header("X-Internal-Service-Token",token).exchange((request,response)->{
if(response.getStatusCode().isError())throw unavailable(response.getStatusCode());
// 只读取上限加一,防止损坏上游响应绕过已核实的10MiB元数据上限。
var bytes=response.getBody().readNBytes((int)MAX+1);
return verifiedContent(expected,response.getHeaders().getContentType(),response.getHeaders().getContentLength(),bytes);
});
}
/** HTTP200业务错误JSON不能伪装成PDF或图片;MIME、长度及签名必须与元数据一致。 */
static byte[] verifiedContent(File expected,MediaType contentType,long contentLength,byte[] bytes) {
if(bytes==null || bytes.length==0 || bytes.length>MAX)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
if(contentType!=null && (MediaType.APPLICATION_JSON.isCompatibleWith(contentType) || contentType.getSubtype().endsWith("+json"))) {
String code=null;try{code=new com.fasterxml.jackson.databind.ObjectMapper().readTree(bytes).path("code").asText(null);}catch(java.io.IOException ignored){/* 无法识别的上游错误响应按故障处理。 */}
throw businessFailure(code);
}
if(bytes.length!=expected.sizeBytes() || contentLength>=0 && contentLength!=bytes.length
|| contentType==null || !MediaType.parseMediaType(expected.contentType()).isCompatibleWith(contentType))throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
boolean signature=switch(expected.contentType()) {
case "application/pdf" -> bytes.length>=5 && new String(bytes,0,5,java.nio.charset.StandardCharsets.US_ASCII).equals("%PDF-");
case "image/png" -> bytes.length>=4 && (bytes[0]&255)==0x89 && bytes[1]==0x50 && bytes[2]==0x4e && bytes[3]==0x47;
case "image/jpeg" -> bytes.length>=2 && (bytes[0]&255)==0xff && (bytes[1]&255)==0xd8;
default -> false;
};
if(!signature)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);return bytes;
}
/** File只能证明本车文件事实,审核决策仍属于本域。 */
static File verified(Envelope envelope,long dispatch,Kind kind,Long id) {
if(envelope==null)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
if(!"200".equals(envelope.code()))throw businessFailure(envelope.code());
if(envelope.data()==null)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
var value=envelope.data();
if(!Objects.equals(value.tenantId(),0L) || !type(kind).equals(value.businessType()) || !Long.toString(dispatch).equals(value.businessId())
|| value.fileId()==null || value.fileId()<=0 || id!=null&&!id.equals(value.fileId()) || value.fileName()==null || value.fileName().isBlank()
|| !TYPES.contains(Objects.toString(value.contentType(),"")) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes());
}
/** 无效证据不能作为已核实资料。 */
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
/** File明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */
private static ResponseStatusException businessFailure(String code){return code!=null && code.startsWith("A04")
?new ResponseStatusException("A0404".equals(code)?HttpStatus.NOT_FOUND:HttpStatus.BAD_REQUEST,"证据文件不可用或归属核验失败")
:unavailable(HttpStatus.SERVICE_UNAVAILABLE);}
/** 上游不可用与业务文件不合格分别保留HTTP语义。 */
private static ResponseStatusException unavailable(HttpStatusCode status){return new ResponseStatusException(status.is4xxClientError()?HttpStatus.BAD_REQUEST:HttpStatus.SERVICE_UNAVAILABLE,"证据文件不可用或归属核验失败");}
record Envelope(String code,Metadata data) { }
record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes) { }
}
@@ -0,0 +1,105 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 真整车主单SQL,不按单客户头部筛选或创建。 */
@Mapper
public interface DispatchAggregateMapper {
/** Fleet附加能力批量读取当前整车审核事实,不读取绑定表旧审核。 */
@Select("<script>SELECT * FROM ll_dispatch_order WHERE dispatch_no IN <foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach></script>")
java.util.List<com.baiyue.lastleg.dispatch.entity.DispatchAggregate> findMany(@Param("numbers") java.util.List<String> numbers);
String BASE_FILTER = """
<if test='q.dispatchNo != null'> AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%')</if>
<if test='q.vehiclePlate != null'> AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%')</if>
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
<if test='q.customerId != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId})</if>
<if test='q.fbaAppointmentNo != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo})</if>
""";
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END";
String CURRENT_FILTER = """
<if test='q.status != "ALL"'> AND d.status=#{q.status}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.reviewStatus != null and q.section == "DISPATCH_REVIEW"'> AND (
""" + REVIEW_DISPATCH + ")=#{q.reviewStatus}</if>"
+ "<if test='q.reviewStatus != null and q.section == \"LOADING_REVIEW\"'> AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}</if>";
record Search(String dispatchNo, String vehiclePlate, String warehouseId, String customerId,
String fbaAppointmentNo, String status, String stage, String section, String reviewStatus,
long offset, int limit) { }
record Summary(String status, String stage, String reviewState, long quantity) { }
/** 首次创建整车所有权及初始版本,不写customer_id。 */
@Insert("""
INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code,
overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,
dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at,
estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,
remark,created_by,created_at,updated_at)
VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
#{r.packageCount},#{r.pickedPackageCount},#{r.shippedPackageCount},#{r.remark},#{r.createdBy},#{r.createdAt},#{r.updatedAt})
""")
int insert(@Param("r") DispatchAggregate row);
/** 平台按业务号读取真实整车,不借客户ID作为头部归属。 */
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{number}")
DispatchAggregate find(@Param("number") String number);
/** 已取得Fleet父锁及资源锁后再锁主单。 */
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{number} FOR UPDATE")
DispatchAggregate lock(@Param("number") String number);
/** SQL完成当前条件下的整车分页,每车只返回一行。 */
@Select("<script>SELECT d.* FROM ll_dispatch_order d WHERE 1=1 " + BASE_FILTER + CURRENT_FILTER
+ " ORDER BY d.created_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
List<DispatchAggregate> page(@Param("q") Search query);
/** 分页总数复用完全相同筛选,不能用当前页长度替代。 */
@Select("<script>SELECT COUNT(*) FROM ll_dispatch_order d WHERE 1=1 " + BASE_FILTER + CURRENT_FILTER + "</script>")
long count(@Param("q") Search query);
/** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */
@Select("<script>SELECT d.status,d.stage,d.review_state AS reviewState,COUNT(*) AS quantity FROM ll_dispatch_order d WHERE 1=1 "
+ BASE_FILTER + " GROUP BY d.status,d.stage,d.review_state</script>")
List<Summary> summaries(@Param("q") Search query);
/** 同一主单版本原子刷新货物摘要、计数并使原审核失效。 */
@Update("""
UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items},
pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT',
review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending},
record_version=record_version+1,updated_at=#{at}
WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING'
AND picked_package_count=0 AND shipped_package_count=0
""")
int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash,
@Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages,
@Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at);
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
@Update("""
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount},
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
WHERE id=#{r.id} AND record_version=#{version}
""")
int saveState(@Param("r") DispatchAggregate row,@Param("version") long originalVersion);
/** 动作及完整当时主单快照追加保存,不覆盖旧审核轮次或原请求历史。 */
@Insert("INSERT INTO ll_dispatch_history(id,dispatch_id,record_version,action,operator_id,reason,snapshot_json,occurred_at) "
+ "VALUES(#{id},#{dispatch},#{version},#{action},#{operator},#{reason},#{snapshot},#{at})")
int history(@Param("id") long id, @Param("dispatch") long dispatchId, @Param("version") long version,
@Param("action") String action, @Param("operator") String operator, @Param("reason") String reason,
@Param("snapshot") String snapshot, @Param("at") java.time.LocalDateTime at);
}
@@ -1,15 +1,11 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */
@Mapper
public interface DispatchBolMapper {
@Select("SELECT " +
@Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," +
"COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
"COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," +
@@ -24,12 +20,11 @@ public interface DispatchBolMapper {
"FROM ll_dispatch_item di " +
"LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " +
"LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " +
"LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " +
"WHERE di.customer_id=#{customerId} AND di.dispatch_order_id=#{dispatchOrderId} " +
"WHERE di.dispatch_order_id=#{dispatchOrderId} " +
"GROUP BY di.id,l.id ORDER BY di.id,l.id")
List<DispatchResponse.BolLine> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
List<DispatchBolData.Line> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -40,7 +40,7 @@ public interface DispatchCandidateMapper {
"</script>")
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
@Select("<script>" +
String AVAILABLE_PLAN_BODY =
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
@@ -134,8 +134,8 @@ public interface DispatchCandidateMapper {
"AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" +
"</script>")
"ORDER BY p.plan_id,p.sequence_no";
@Select("<script>" + AVAILABLE_PLAN_BODY + "</script>")
List<DispatchPalletCandidate> available(@Param("customerId") String customerId,
@Param("planIds") List<Long> planIds,
@Param("orderNo") String orderNo,
@@ -143,7 +143,13 @@ public interface DispatchCandidateMapper {
@Param("sourceStage") String sourceStage,
@Param("draftToken") String draftToken);
@Select("<script>" +
/** 公开整车候选先在数据库限制起运仓和数量,不按单客户缩小整车。 */
@Select("<script>SELECT * FROM (" + AVAILABLE_PLAN_BODY + ") selected WHERE selected.overseasWarehouseId=#{warehouseId} LIMIT 1001</script>")
List<DispatchPalletCandidate> wholeAvailable(@Param("warehouseId") String warehouseId,
@Param("customerId") String customerId,@Param("planIds") List<Long> planIds,@Param("orderNo") String orderNo,
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
String SELECTED_PLAN_SQL = "<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
@@ -222,12 +228,19 @@ public interface DispatchCandidateMapper {
"AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.id FOR UPDATE" +
"</script>")
"ORDER BY p.id";
/** 先普通读确定真实来源,再按来源订单→库存顺序取得写锁。 */
@Select(SELECTED_PLAN_SQL + "</script>")
List<DispatchPalletCandidate> readSelected(@Param("customerId") String customerId,
@Param("planPalletIds") List<Long> planPalletIds);
/** 已有调用需要的明确锁定读取,投影与普通读保持同一事实。 */
@Select(SELECTED_PLAN_SQL + " FOR UPDATE</script>")
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId,
@Param("planPalletIds") List<Long> planPalletIds);
@Select("<script>" +
String RELABEL_BODY =
"SELECT NULL AS planId,NULL AS planPalletId,pos.pallet_id AS warehousePalletId,a.lastleg_order_id AS orderId," +
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,MAX(wtp.gross_weight_kg) AS grossWeightKg,NULL AS containerNo," +
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_code) AS overseasWarehouseCode,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
@@ -250,16 +263,21 @@ public interface DispatchCandidateMapper {
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
"AND pos.hold_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " +
"ON existing_dispatch.id=di.dispatch_order_id " +
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
"AND existing_dispatch.status!='CANCELLED') " +
"GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no" +
"</script>")
"GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no";
@Select("<script>" + RELABEL_BODY + "</script>")
List<DispatchPalletCandidate> relabelAvailable(@Param("customerId") String customerId,
@Param("orderNo") String orderNo,
@Param("relabelNo") String relabelNo);
@Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," +
/** 换标来源也限定真实起运仓;超过上限必须明确缩小筛选。 */
@Select("<script>SELECT * FROM ("+RELABEL_BODY+") selected WHERE selected.overseasWarehouseId=#{warehouseId} LIMIT 1001</script>")
List<DispatchPalletCandidate> wholeRelabelAvailable(@Param("warehouseId") String warehouseId,
@Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("relabelNo") String relabelNo);
String NATIVE_RELABEL_BODY = "SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," +
"MIN(ri.source_warehouse_name) AS overseasWarehouseName,'RETURN_RELABEL' AS orderType,a.relabel_no AS orderNo,a.relabel_no AS orderRefs," +
"GROUP_CONCAT(DISTINCT c.source_return_no ORDER BY c.source_return_no SEPARATOR ',') AS inboundCode," +
"c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," +
@@ -277,11 +295,17 @@ public interface DispatchCandidateMapper {
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') </if>" +
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " +
"AND d.customer_id=di.customer_id WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " +
"GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code</script>")
"WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " +
"GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code";
@Select("<script>"+NATIVE_RELABEL_BODY+"</script>")
List<DispatchPalletCandidate> nativeReturnRelabelAvailable(@Param("customerId") String customerId,
@Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo);
/** 原生退货换标保持原账本,仓过滤与行数限制在数据库执行。 */
@Select("<script>SELECT * FROM ("+NATIVE_RELABEL_BODY+") selected WHERE selected.overseasWarehouseId=#{warehouseId} LIMIT 1001</script>")
List<DispatchPalletCandidate> wholeNativeRelabelAvailable(@Param("warehouseId") String warehouseId,
@Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("relabelNo") String relabelNo);
@org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " +
"WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'")
int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** 持久命令账本只新增和读取,不更新或覆盖原结果。 */
@Mapper
public interface DispatchCommandMapper {
/** 全局读取请求键,操作者在服务层判断,不能按客户过滤后重复创建。 */
@Select("SELECT id,dispatch_id AS dispatchId,dispatch_no AS dispatchNo,action,operator_id AS operatorId,"
+ "idempotency_key AS idempotencyKey,record_version AS recordVersion,command_hash AS commandHash,"
+ "result_json AS resultJson,created_at AS createdAt FROM ll_dispatch_command WHERE idempotency_key=#{key}")
DispatchCommandRecord find(@Param("key") String key);
/** 唯一索引仲裁全局请求键并发,与主单变化在调用方同一事务。 */
@Insert("INSERT INTO ll_dispatch_command(id,dispatch_id,dispatch_no,action,operator_id,idempotency_key,"
+ "record_version,command_hash,result_json,created_at) VALUES(#{row.id},#{row.dispatchId},#{row.dispatchNo},"
+ "#{row.action},#{row.operatorId},#{row.idempotencyKey},#{row.recordVersion},#{row.commandHash},"
+ "#{row.resultJson},#{row.createdAt})")
int insert(@Param("row") DispatchCommandRecord row);
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
/** 复用正式来源/实托唯一占用,仅写本拥有者草稿记录,不修改正式排车占用。 */
@Mapper
public interface DispatchDraftClaimMapper {
record Claim(Long id,String customerId,String sourceKey,Long palletId,Long containerId,LocalDateTime expiresAt) { }
/** 来源已加锁后才读取本草稿活动占用;不跨token领取他人记录。 */
@Select("SELECT id,customer_id AS customerId,source_pallet_key AS sourceKey,warehouse_pallet_id AS palletId,draft_container_id AS containerId,expires_at AS expiresAt "
+ "FROM ll_dispatch_pallet_lock WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL "
+ "AND status='ACTIVE' AND expires_at>#{at} ORDER BY id FOR UPDATE")
List<Claim> owned(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at);
/** 与正式claim共用两项唯一约束,跨客户同实托也不得重复占用。 */
@Insert("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id,warehouse_pallet_id,active_warehouse_pallet_id,"
+ "return_receipt_id,dispatch_order_id,draft_container_id,draft_token,operator_id,status,locked_at,expires_at,created_at,updated_at) "
+ "VALUES(#{id},#{customer},#{key},#{key},#{plan},#{pallet},#{pallet},#{receipt},NULL,#{container},#{token},#{owner},'ACTIVE',#{at},#{expires},#{at},#{at})")
int insert(@Param("id")long id,@Param("customer")String customer,@Param("key")String key,@Param("plan")Long plan,
@Param("pallet")Long pallet,@Param("receipt")Long receipt,@Param("container")long container,@Param("token")String token,
@Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires);
/** 布局移动仅匹配同草稿原容器,不能迁移他人新取得的占用。 */
@Update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token} "
+ "AND operator_id=#{owner} AND draft_container_id=#{previous} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}")
int move(@Param("id")long id,@Param("token")String token,@Param("owner")String owner,@Param("previous")long previous,
@Param("next")long next,@Param("at")LocalDateTime at);
/** 按精确原身份释放,保留历史行并同时清空来源键和全局实托键。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} "
+ "WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL AND status='ACTIVE' "
+ "AND (#{key} IS NULL OR source_pallet_key=#{key}) AND (#{container} IS NULL OR draft_container_id=#{container})")
int release(@Param("token")String token,@Param("owner")String owner,@Param("key")String key,@Param("container")Long container,@Param("at")LocalDateTime at);
/** 草稿写入续期只延长原草稿活动占用,不复活已被释放或转换的记录。 */
@Update("UPDATE ll_dispatch_pallet_lock SET expires_at=#{expires},updated_at=#{at} WHERE draft_token=#{token} AND operator_id=#{owner} "
+ "AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}")
int renew(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires);
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.entity.DispatchDraftCommand;
import org.apache.ibatis.annotations.*;
/** 唯一命令键串行化并保存全载荷结果;未完成记录绝不单独提交。 */
@Mapper
public interface DispatchDraftCommandMapper {
/** 同键等待原事务,保留首次主体/动作/请求摘要。 */
@Insert("INSERT INTO ll_dispatch_draft_command(id,idempotency_key,operator_id,action,target_token,request_hash,created_at) "
+ "VALUES(#{id},#{idempotencyKey},#{operatorId},#{action},#{targetToken},#{requestHash},#{createdAt}) ON DUPLICATE KEY UPDATE id=id")
int reserve(DispatchDraftCommand command);
/** 唯一键竞争后采用当前读恢复提交结果,避免旧快照漏见原命令。 */
@Select("SELECT * FROM ll_dispatch_draft_command WHERE idempotency_key=#{key} FOR UPDATE")
DispatchDraftCommand lock(@Param("key")String key);
/** 仅完成本事务首次预占的命令,失败必须使所有业务写入回滚。 */
@Update("UPDATE ll_dispatch_draft_command SET result_json=#{json} WHERE id=#{id} AND result_json IS NULL")
int complete(@Param("id")long id,@Param("json")String json);
}
@@ -5,10 +5,27 @@ import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Delete;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface DispatchDraftContainerMapper extends BaseMapper<DispatchDraftContainer> {
/** 容器只在其草稿根锁内修改,普通读保持服务端顺序。 */
@Select("SELECT * FROM ll_dispatch_draft_container WHERE draft_token=#{draftToken} ORDER BY sequence_no,id")
List<DispatchDraftContainer> findByToken(@Param("draftToken") String draftToken);
/** 保存已由服务校验的布局,不从任意客户端JSON新增来源。 */
@Update("UPDATE ll_dispatch_draft_container SET sequence_no=#{sequence},capacity=#{capacity},source_keys_json=#{keys},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token}")
int layout(@Param("id")long id,@Param("token")String token,@Param("sequence")int sequence,@Param("capacity")int capacity,
@Param("keys")String keys,@Param("at")LocalDateTime at);
/** 在同一草稿根锁中暂存原序号,交换两容器时不短暂冲撞唯一序号。 */
@Update("UPDATE ll_dispatch_draft_container SET sequence_no=-sequence_no WHERE draft_token=#{token} AND sequence_no>0")
int stageSequences(@Param("token")String token);
/** 服务已校验为空的容器才可删除,不释放其他容器的来源。 */
@Delete("DELETE FROM ll_dispatch_draft_container WHERE id=#{id} AND draft_token=#{token}")
int remove(@Param("id")long id,@Param("token")String token);
}
@@ -5,20 +5,34 @@ import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface DispatchDraftMapper extends BaseMapper<DispatchDraft> {
@Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " +
"AND (d.expires_at IS NULL OR d.expires_at>CURRENT_TIMESTAMP(3)) AND NOT EXISTS (" +
"SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_outbound_order o ON o.customer_id=l.customer_id " +
"AND o.dispatch_order_id=l.dispatch_order_id WHERE l.draft_token=d.draft_token) AND NOT EXISTS (" +
"SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order x ON x.id=l.dispatch_order_id " +
"AND x.customer_id=l.customer_id WHERE l.draft_token=d.draft_token AND x.status!='PENDING_DEPARTURE') " +
"ORDER BY d.updated_at DESC")
List<DispatchDraft> findActiveByOperator(@Param("operatorId") String operatorId);
/** 草稿头只按拥有者隔离,不再依赖客户子排车或旧主单状态。 */
default List<DispatchDraft> findActiveByOperator(String operatorId) {
return findActiveAt(operatorId,LocalDateTime.now(java.time.ZoneOffset.UTC));
}
@Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE")
/** 显式传入UTC时间,数据库会话时区不能使有效草稿被提前视为过期。 */
@Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " +
"AND d.expires_at>#{at} ORDER BY d.updated_at DESC,d.id")
List<DispatchDraft> findActiveAt(@Param("operatorId")String operatorId,@Param("at")LocalDateTime at);
/** 保留现有提供者的精确拥有者锁查询,过期资格由新服务统一核对。 */
@Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' FOR UPDATE")
DispatchDraft lockActive(@Param("draftToken") String draftToken, @Param("operatorId") String operatorId);
/** 锁定任何生命周期的原草稿,重放在服务层先于有效期校验。 */
@Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{token} AND operator_id=#{owner} FOR UPDATE")
DispatchDraft lockOwned(@Param("token") String token,@Param("owner") String owner);
/** 唯一公共版本CAS;所有布局、状态和命令结果随同事务提交。 */
@Update("UPDATE ll_dispatch_draft SET record_version=#{r.recordVersion},sources_json=#{r.sourcesJson},"
+ "plan_ids_json=#{r.planIdsJson},status=#{r.status},expires_at=#{r.expiresAt},released_at=#{r.releasedAt},updated_at=#{r.updatedAt} "
+ "WHERE id=#{r.id} AND operator_id=#{r.operatorId} AND record_version=#{version}")
int save(@Param("r") DispatchDraft row,@Param("version") long version);
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 本车真实扫码与不可变归属的有界批量读取,不用计划箱数补造执行。 */
@Mapper
public interface DispatchExecutionReadMapper {
record RescanCandidate(String dispatchNo,String deliveryNo,long deliveryVersion) { }
record PickSource(Long pickId,Long attributionId,Long itemId,String customerId,Long orderId,String orderNo,
String sourceRegion,String sourceInboundId,String inboundCode,Long positionId,Long receiptId,Long returnReceiptId,
Long returnCartonId,String cartonMark,long packages,long productQuantity) { }
record ItemPicked(Long itemId,long packages) { }
record ActualMeasurement(Long palletId,String sourceNo,LocalDateTime observedAt,BigDecimal weightKg,BigDecimal volumeCbm) { }
/** 只取实际存在的订单与归属;缺失源头会使完整性核对失败。 */
@Select("""
SELECT p.id AS pickId,a.id AS attributionId,i.id AS itemId,a.customer_id AS customerId,a.order_id AS orderId,
o.order_no AS orderNo,o.source_region AS sourceRegion,o.source_inbound_id AS sourceInboundId,
COALESCE(r.inbound_code,rb.inbound_code,i.inbound_code) AS inboundCode,p.inventory_position_id AS positionId,
a.receipt_id AS receiptId,a.return_receipt_id AS returnReceiptId,a.return_carton_id AS returnCartonId,
c.carton_mark AS cartonMark,a.packages,COALESCE(a.product_quantity,0) AS productQuantity
FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id
JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=p.customer_id
JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id
LEFT JOIN ll_inbound_stock_receipt r ON r.id=a.receipt_id AND r.customer_id=a.customer_id
LEFT JOIN ll_return_stock_receipt rr ON rr.id=a.return_receipt_id AND rr.customer_id=a.customer_id
LEFT JOIN ll_inventory_balance rb ON rb.id=rr.inventory_balance_id AND rb.customer_id=rr.customer_id
LEFT JOIN ll_return_inventory_carton c ON c.id=a.return_carton_id AND c.customer_id=a.customer_id
WHERE i.dispatch_order_id=#{id} ORDER BY i.id,p.id,a.id
""")
List<PickSource> sources(@Param("id")long dispatchId);
/** 每个明细都必须独立核平,不能以整车合计抵消超拣与漏拣。 */
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{id} GROUP BY i.id ORDER BY i.id")
List<ItemPicked> itemPicked(@Param("id")long dispatchId);
/** 每条扫码的不可变归属箱数与商品数必须完整,缺归属不能因总数相等而放行。 */
@Select("""
SELECT COUNT(*) FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id
WHERE i.dispatch_order_id=#{id} AND (p.packages<=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>p.packages
OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>COALESCE(p.product_quantity,0))
""")
long inconsistentPicks(@Param("id")long dispatchId);
/** 已完成实测只有当前整托组成未变时才可自动带入,计费重与订单分摊重不在此表。 */
@Select("""
SELECT DISTINCT p.warehouse_pallet_id AS palletId,t.transfer_no AS sourceNo,p.completed_at AS observedAt,
p.gross_weight_kg AS weightKg,p.volume_cbm AS volumeCbm
FROM ll_dispatch_item i JOIN ll_warehouse_transfer_pallet p ON p.warehouse_pallet_id=i.warehouse_pallet_id AND p.customer_id=i.customer_id
JOIN ll_warehouse_transfer_order t ON t.id=p.transfer_order_id AND t.customer_id=p.customer_id
WHERE i.dispatch_order_id=#{id} AND p.status='COMPLETED' AND p.carton_count=i.package_count AND p.completed_at IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM ll_inventory_ledger l WHERE l.customer_id=i.customer_id AND l.pallet_id=p.warehouse_pallet_id
AND l.created_at>=p.completed_at AND l.business_type IN('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND'))
ORDER BY p.warehouse_pallet_id,p.completed_at
""")
List<ActualMeasurement> actualMeasurements(@Param("id")long dispatchId);
/** 有界修复性重扫只找当前真实签收且缺本版本信号的在途车,不锁任何子单。 */
@Select("""
SELECT d.dispatch_no AS dispatchNo,l.delivery_no AS deliveryNo,l.version AS deliveryVersion
FROM ll_dispatch_order d JOIN ll_delivery_order l ON l.dispatch_order_id=d.id
WHERE d.status='ACTIVE' AND d.stage='DELIVERING' AND l.signed_packages>0 AND l.signed_at IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM ll_delivery_facts_changed s WHERE s.dispatch_id=d.id AND s.delivery_id=l.id AND s.delivery_version=l.version)
ORDER BY d.updated_at,d.id,l.id LIMIT #{limit}
""")
List<RescanCandidate> rescan(@Param("limit")int limit);
}
@@ -11,6 +11,9 @@ import java.util.List;
@Mapper
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
/** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */
@Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id")
List<DispatchItem> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id")
List<DispatchItem> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.OutboundReference;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
/** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */
@Mapper
public interface DispatchReadMapper {
/** 即使计数漂移也不得把已有真实扫码当成零扫码。 */
@Select("SELECT COALESCE(SUM(p.packages),0) FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{id}")
long realPicked(@Param("id") long dispatchId);
record DeliveryRow(String groupKey, String customerId, String customerName, String outboundNo,
String deliveryNo, String type, String warehouseId, String warehouseCode, String warehouseName,
String address, String postalCode, String contactName, String contactPhone, String status,
long packageCount, long palletCount) { }
record BatchRow(Long dispatchItemId,Long planPalletId,String batchKey, String batchNo, String customerId, String sourceKey, String destinationKey,
int packageCount, String confirmationNo, String status, String warehouseCode, LocalDateTime startUtc, Long recordVersion) { }
/** 读取所有客户出库,不因客户筛选把整车单据缩减成一张。 */
@Select("""
SELECT o.customer_id AS customerId,c.customerName,o.outbound_no AS outboundNo,o.status,
o.package_count AS packageCount,o.pallet_count AS palletCount
FROM ll_outbound_order o LEFT JOIN(SELECT dispatch_order_id,customer_id,MAX(customer_name) AS customerName
FROM ll_dispatch_item GROUP BY dispatch_order_id,customer_id)c
ON c.dispatch_order_id=o.dispatch_order_id AND c.customer_id=o.customer_id
WHERE o.dispatch_order_id=#{id} ORDER BY o.customer_id,o.id
""")
List<OutboundReference> outbounds(@Param("id") long dispatchId);
/** 所有目的地单据一次读取,地址字段只来自真实派送记录。 */
@Select("""
SELECT delivery_group_key AS groupKey,customer_id AS customerId,customer_name AS customerName,
outbound_no AS outboundNo,delivery_no AS deliveryNo,destination_type AS type,
destination_warehouse_id AS warehouseId,destination_warehouse_code AS warehouseCode,
destination_warehouse_name AS warehouseName,delivery_address AS address,postal_code AS postalCode,
contact_name AS contactName,contact_phone AS contactPhone,status,package_count AS packageCount,pallet_count AS palletCount
FROM ll_delivery_order WHERE dispatch_order_id=#{id} ORDER BY customer_id,id
""")
List<DeliveryRow> deliveries(@Param("id") long dispatchId);
/** 关联按真实明细客户读取,UTC时间不从当地时间猜测。 */
@Select("""
SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,CASE WHEN p.id IS NULL THEN d.dispatch_no ELSE p.appointment_no END AS batchNo,
a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey,
a.package_count AS packageCount,p.confirmation_no AS confirmationNo,p.appointment_status AS status,
p.fba_warehouse_code AS warehouseCode,p.appointment_start_utc AS startUtc,
CASE WHEN a.batch_key LIKE 'FBA_APPOINTMENT:%' THEN p.version
WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.record_version ELSE NULL END AS recordVersion
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id
JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
WHERE d.id=#{id} ORDER BY a.batch_key,a.customer_id,a.source_key,a.destination_key,a.dispatch_item_id
""")
List<BatchRow> batches(@Param("id") long dispatchId);
/** 未接通跨客户批次写提供者前必须阻断静默丢批次,不能写无依据运输。 */
@Select("""
SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id
JOIN ll_inbound_fba_appointment_item f ON f.order_id=l.order_id AND f.customer_id=l.customer_id AND f.active=1
WHERE a.pallet_id=#{pallet} AND l.customer_id=#{customer}
""")
long activeAppointments(@Param("customer") String customer, @Param("pallet") Long planPalletId);
}
@@ -0,0 +1,97 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
/** 排车选货的来源身份、真实库存版本及排他资源锁。 */
@Mapper
public interface DispatchSourceMapper {
record Stock(long id, long balanceId, Long palletId, String warehouseId, long availablePackages, long holdPackages,
long allocatedPackages, long pickedPackages, long version) { }
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
record Claim(Long id, Long dispatchId, String draftToken) { }
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
/** 换标单客户由服务端业务单号查得,不接受客户端指定。 */
@Select("SELECT customer_id FROM ll_relabel_application WHERE relabel_no=#{number}")
String relabelOwner(@Param("number") String number);
/** 无独立版本字段的申请以真实更新时间和客户确认轮次参加来源摘要。 */
@Select("SELECT status,fulfillment_status AS fulfillmentStatus,updated_at AS updatedAt,customer_confirmed_at AS customerConfirmedAt "
+ "FROM ll_relabel_application WHERE relabel_no=#{number}")
RelabelBasis relabelBasis(@Param("number") String number);
/** 同托真实收货和计划组成共同定位来源订单,禁止按展示MIN订单猜归属。 */
@Select("""
SELECT DISTINCT x.order_id FROM (
SELECT r.order_id FROM ll_inbound_stock_receipt r WHERE r.customer_id=#{customer} AND r.warehouse_pallet_id=#{pallet}
UNION SELECT r.order_id FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet}
UNION SELECT l.order_id FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=#{customer}
WHERE a.pallet_id=#{planPallet}
) x ORDER BY x.order_id
""")
List<Long> sourceOrders(@Param("customer") String customer, @Param("pallet") Long pallet,
@Param("planPallet") Long planPallet);
/** 锁来源订单先于库存,并在同一锁内拒绝暂扣。 */
@Select("SELECT id FROM ll_order WHERE customer_id=#{customer} AND id=#{id} AND detained=0 FOR UPDATE")
Long lockOrder(@Param("customer") String customer, @Param("id") Long id);
/** 计划托盘尚无实物时也有可串行占用的真实来源根。 */
@Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE")
Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id);
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
@Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE")
Long lockReturn(@Param("customer") String customer, @Param("id") Long id);
/** sourceVersion包含真实库存数量和版本;写时按仓位ID稳定加锁。 */
@Select("<script>SELECT id,balance_id AS balanceId,pallet_id AS palletId,overseas_warehouse_id AS warehouseId,available_packages AS availablePackages,"
+ "hold_packages AS holdPackages,allocated_packages AS allocatedPackages,picked_packages AS pickedPackages,version "
+ "FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id=#{pallet} ORDER BY id"
+ "<if test='lock'> FOR UPDATE</if></script>")
List<Stock> stock(@Param("customer") String customer, @Param("pallet") Long pallet, @Param("lock") boolean lock);
/** 版本还包含逐入仓真实组成,后到货不得静默加入原选货。 */
@Select("SELECT id,order_id AS orderId,inventory_position_id AS inventoryPositionId,received_packages AS packages,inbound_code AS inboundCode "
+ "FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id=#{pallet} ORDER BY id")
List<Receipt> receipts(@Param("customer") String customer, @Param("pallet") Long pallet);
/** 同托真实退回实收也参加来源摘要,不能被原计划展示订单覆盖。 */
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
/** 仅失效的草稿占用可让出唯一键,绝不释放活动正式单。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} "
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} "
+ "AND ((customer_id=#{customer} AND source_pallet_key=#{key}) OR active_warehouse_pallet_id=#{pallet})")
int expire(@Param("customer") String customer, @Param("key") String key, @Param("pallet") Long pallet,
@Param("at") LocalDateTime at);
/** 不同来源别名指向同一实托时只能归同一整车,客户不作为实托锁分片。 */
@Select("SELECT id,dispatch_order_id AS dispatchId,draft_token AS draftToken FROM ll_dispatch_pallet_lock "
+ "WHERE status='ACTIVE' AND ((customer_id=#{customer} AND active_key=#{key}) OR active_warehouse_pallet_id=#{pallet}) "
+ "AND (expires_at IS NULL OR expires_at>#{at}) ORDER BY id FOR UPDATE")
List<Claim> claims(@Param("customer") String customer, @Param("key") String key,
@Param("pallet") Long pallet, @Param("at") LocalDateTime at);
/** 只释放本车本来源原占用,清空实托唯一键,不触碰后续他车占用。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND status='ACTIVE' AND ((#{plan} IS NOT NULL AND plan_pallet_id=#{plan}) OR (#{receipt} IS NOT NULL AND return_receipt_id=#{receipt}) OR (#{pallet} IS NOT NULL AND warehouse_pallet_id=#{pallet}))")
int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("receipt")Long receipt,@Param("pallet")Long pallet,@Param("at")LocalDateTime at);
/** 原计划自然收货后取得同一实托唯一键,其他车/来源的占用不能被覆盖。 */
@Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},active_warehouse_pallet_id=#{pallet},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL")
int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("at")LocalDateTime at);
/** 唯一活动实托键与来源键共同阻止并发跨车重复排货。 */
@Insert("""
INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id,
warehouse_pallet_id,active_warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at)
VALUES(#{id},#{customer},#{key},#{key},#{planPallet},#{pallet},#{pallet},#{receipt},#{dispatch},'ACTIVE',#{operator},#{at},#{at},#{at})
""")
int claim(@Param("id") long id, @Param("customer") String customer, @Param("key") String key,
@Param("planPallet") Long planPallet, @Param("pallet") Long pallet, @Param("receipt") Long receipt,
@Param("dispatch") long dispatch, @Param("operator") String operator, @Param("at") LocalDateTime at);
}
@@ -23,17 +23,28 @@ public interface ReturnDispatchCandidateMapper {
"JOIN ll_delivery_order d ON d.customer_id=r.customer_id AND d.id=e.delivery_order_id ";
String ELIGIBLE = " AND o.detained=0 AND p.available_packages>0 AND r.received_packages>(SELECT COALESCE(SUM(a.packages),0) " +
"FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) ";
@Select("<script>" + SELECT + "WHERE 1=1 " + ELIGIBLE +
String AVAILABLE_BODY = SELECT + "WHERE 1=1 " + ELIGIBLE +
"<if test='customerId != null'>AND r.customer_id=#{customerId} </if>" +
"<if test='orderNo != null'>AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR b.inbound_code LIKE CONCAT('%',#{orderNo},'%') OR r.receipt_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
"<if test='fba != null'>AND d.destination_warehouse_code=#{fba} </if>" +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.customer_id=r.customer_id " +
"AND k.source_pallet_key=CONCAT('RECEIVED:',p.pallet_id) AND k.status='ACTIVE' " +
"AND (k.expires_at IS NULL OR k.expires_at>CURRENT_TIMESTAMP) AND (#{draftToken} IS NULL OR k.draft_token IS NULL OR k.draft_token != #{draftToken})) " +
"ORDER BY r.received_at,r.id</script>")
"ORDER BY r.received_at,r.id";
@Select("<script>" + AVAILABLE_BODY + "</script>")
List<DispatchPalletCandidate> available(@Param("customerId") String customerId, @Param("orderNo") String orderNo,
@Param("fba") String fba, @Param("draftToken") String draftToken);
/** 退回候选按真实库存仓过滤,保持多客户读取。 */
@Select("<script>SELECT * FROM ("+AVAILABLE_BODY+") selected WHERE selected.overseasWarehouseId=#{warehouseId} LIMIT 1001</script>")
List<DispatchPalletCandidate> wholeAvailable(@Param("warehouseId") String warehouseId,
@Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("fba") String fba,@Param("draftToken") String draftToken);
/** 只读解析选定退回来源,避免在来源订单前抢占库存锁。 */
@Select("<script>" + SELECT + "WHERE r.customer_id=#{customerId} AND r.id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>" + ELIGIBLE + " ORDER BY r.id</script>")
List<DispatchPalletCandidate> readSelected(@Param("customerId") String customerId, @Param("ids") List<Long> ids);
@Select("<script>" + SELECT + "WHERE r.customer_id=#{customerId} AND r.id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>" + ELIGIBLE + " ORDER BY r.id FOR UPDATE</script>")
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId, @Param("ids") List<Long> ids);
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.dispatch.model;
import java.time.LocalDateTime;
import java.util.*;
/** 打印服务内部一致快照,不恢复旧排车JSON协议。 */
public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName,
String vehiclePlate,LocalDateTime estimatedArrivalAt,List<Line> bolLines,Map<String,String> appointmentNumbers) {
public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo,
String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId,
String poReferenceNo,int packageCount) {
public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);}
public String groupKey(){return customerId+"\u0000"+destinationKey();}
}
public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());}
}
@@ -2,12 +2,9 @@ package com.baiyue.lastleg.dispatch.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.service.*;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -20,52 +17,56 @@ import java.util.List;
@RequestMapping("/api/pda/v1/last-leg/dispatches")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class DispatchPdaController {
@Resource private DispatchPdaWorkflowService service;
@Resource private FleetTripExecutionService trips;
@Resource private DispatchPdaQueryService queries;
@Resource private DispatchPickingService picking;
@Resource private DispatchAggregateService workflow;
@Resource private DispatchDepartureService departure;
/** 批量读取当前绑定仓的整车作业。 */
@GetMapping
public ApiResponse<List<DispatchPdaResponses.Task>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String status) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
String warehouse = String.valueOf(identity.overseasWarehouseId());
var tasks = service.list(warehouse, status);
var gates = trips.gates(warehouse, tasks.stream().map(DispatchPdaResponses.Task::dispatchNo).toList());
return ApiResponse.success(tasks.stream().map(task -> task.withGate(gates.get(task.dispatchNo()))).toList());
public ApiResponse<List<DispatchViews.PdaTask>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false)DispatchTypes.Stage stage) {
return ApiResponse.success(queries.list(DispatchActorFactory.pda(user),stage));
}
/** 使用排车业务号读取当前仓真实货物和扫码进度。 */
@GetMapping("/{dispatchNo}")
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
public ApiResponse<DispatchViews.PdaTask> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
String warehouse = String.valueOf(identity.overseasWarehouseId());
return ApiResponse.success(service.task(warehouse, dispatchNo).withGate(trips.gate(warehouse, dispatchNo)));
return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo));
}
/** 共用公共版本和全局请求键执行一次真实库存扫码。 */
@PostMapping("/{dispatchNo}/items/{itemId}/pick")
public ApiResponse<DispatchPdaResponses.Picked> pick(@AuthenticationPrincipal AuthenticatedUser user,
public ApiResponse<DispatchContracts.CommandResult> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo, @PathVariable long itemId,
@Valid @RequestBody DispatchPdaRequests.Pick request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pick(String.valueOf(identity.overseasWarehouseId()), dispatchNo, itemId,
identity.operatorKey(), identity.operatorName(), request));
@Valid @RequestBody DispatchCommands.Pick request) {
return ApiResponse.success(picking.pick(DispatchActorFactory.pda(user),dispatchNo,itemId,request));
}
@PostMapping("/{dispatchNo}/complete")
public ApiResponse<DispatchPdaResponses.PickingCompleted> completePicking(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.completePicking(String.valueOf(identity.overseasWarehouseId()),
dispatchNo, request));
/** 与平台共用同一实载证据读取。 */
@GetMapping("/{dispatchNo}/loading")
public ApiResponse<DispatchPlanningModels.LoadingPreview> loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo));
}
/** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */
@PostMapping("/{dispatchNo}/loading-complete")
public ApiResponse<DispatchContracts.CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request));
}
/** 与平台共用实际发车服务,不执行第二次库存扣减。 */
@PostMapping("/{dispatchNo}/depart")
public ApiResponse<FleetExecutionModels.Departed> depart(@AuthenticationPrincipal AuthenticatedUser user,
public ApiResponse<DispatchContracts.CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@Valid @RequestBody FleetExecutionModels.Depart request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(trips.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo,
identity.operatorKey(), request));
@Valid @RequestBody DispatchCommands.Depart request) {
return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request));
}
/** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
public ApiResponse<DispatchContracts.CommandResult> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(workflow.command(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey));
}
}
@@ -11,7 +11,6 @@ import java.time.LocalDateTime;
@TableName("ll_dispatch_pda_task")
public class DispatchPdaTask {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long dispatchOrderId;
private String taskNo;
private String status;
@@ -7,6 +7,9 @@ import java.util.List;
@Mapper
public interface DispatchPdaPickMapper extends BaseMapper<DispatchPdaPick> {
/** 按真实明细客户读取全车归属,不要求整车主单存在客户列。 */
@Select("SELECT p.* FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{dispatch} AND p.customer_id=#{customer} ORDER BY p.id")
List<DispatchPdaPick> wholeDispatch(@Param("customer")String customer,@Param("dispatch")long dispatch);
@Select("SELECT * FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} ORDER BY id")
List<DispatchPdaPick> listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId);
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}")
@@ -18,12 +18,14 @@ public interface DispatchPdaPickOrderMapper extends BaseMapper<DispatchPdaPickOr
CASE WHEN a.receipt_id IS NOT NULL THEN i.product_name_zh ELSE b.product_name_zh END AS productName,
COALESCE(a.packages,p.packages) AS packages
FROM ll_dispatch_pda_task t
JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND BINARY p.customer_id=BINARY t.customer_id
JOIN ll_dispatch_pda_pick p ON p.task_id=t.id
JOIN ll_dispatch_item di ON di.id=p.dispatch_item_id AND di.dispatch_order_id=t.dispatch_order_id
AND BINARY di.customer_id=BINARY p.customer_id
LEFT JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND BINARY a.customer_id=BINARY p.customer_id
LEFT JOIN ll_inbound_stock_receipt i ON i.id=a.receipt_id AND BINARY i.customer_id=BINARY a.customer_id
LEFT JOIN ll_inventory_position ip ON ip.id=p.inventory_position_id AND BINARY ip.customer_id=BINARY p.customer_id
LEFT JOIN ll_inventory_balance b ON b.id=ip.balance_id AND BINARY b.customer_id=BINARY ip.customer_id
WHERE BINARY t.customer_id=BINARY #{customerId} AND t.dispatch_order_id=#{dispatchId}
WHERE BINARY p.customer_id=BINARY #{customerId} AND t.dispatch_order_id=#{dispatchId}
ORDER BY p.id,a.id
""")
List<DeliveryPickedSku> deliveryPickedSkus(@Param("customerId") String customerId,
@@ -8,11 +8,11 @@ import java.util.List;
@Mapper
public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
DispatchPdaTask lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_pda_task WHERE dispatch_order_id=#{dispatchOrderId} FOR UPDATE")
DispatchPdaTask lockByDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_pda_task WHERE dispatch_order_id=#{dispatchOrderId}")
DispatchPdaTask findByDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_pda_task WHERE warehouse_id=#{warehouseId} " +
"AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200")
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 绑定仓整车PDA批量读取,不读取单客户主单头或逐车查询详情。 */
@Mapper
public interface DispatchWholePdaReadMapper {
record Picked(Long itemId,long packages) { }
record Invalid(Long dispatchId,long quantity) { }
record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { }
/** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' "
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
+"<otherwise>AND stage IN('LOADING','AFTER_LOADING','DELIVERING') </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
String ROOTS="<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
/** 一次读取选中整车所有真实客户货物。 */
@Select("<script>SELECT * FROM ll_dispatch_item WHERE dispatch_order_id IN "+ROOTS+" ORDER BY dispatch_order_id,customer_id,id</script>")
List<DispatchItem> items(@Param("ids")List<Long> ids);
/** 实际扫码按真实明细客户合计,不依赖客户任务头的假归属。 */
@Select("<script>SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id IN "+ROOTS+" GROUP BY i.id</script>")
List<Picked> picked(@Param("ids")List<Long> ids);
/** 每条扫码的来源订单、箱数与商品数均须真实完整。 */
@Select("<script>SELECT i.dispatch_order_id AS dispatchId,COUNT(*) AS quantity FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id IN "+ROOTS
+" AND (p.packages&lt;=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)&lt;&gt;p.packages"
+" OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)&lt;&gt;COALESCE(p.product_quantity,0)) GROUP BY i.dispatch_order_id</script>")
List<Invalid> invalid(@Param("ids")List<Long> ids);
/** 仓位先按本批明细客户、实托及绑定仓一次读取。 */
@Select("<script>SELECT DISTINCT p.* FROM ll_dispatch_item i JOIN ll_inventory_position p ON p.customer_id=i.customer_id AND p.pallet_id=i.warehouse_pallet_id WHERE i.dispatch_order_id IN "+ROOTS+" AND p.overseas_warehouse_id=#{warehouse} ORDER BY p.id</script>")
List<InventoryPosition> positions(@Param("ids")List<Long> ids,@Param("warehouse")String warehouse);
/** 原生退货箱仍从原账本取真实箱号,精确选中集合由业务层再比对。 */
@Select("<script>SELECT i.id AS itemId,c.id AS cartonId,c.carton_mark AS cartonMark,c.status,COALESCE(c.total_quantity,0) AS productQuantity FROM ll_dispatch_item i JOIN ll_return_inventory_carton c ON c.customer_id=i.customer_id AND c.pallet_code=i.pallet_no AND c.relabel_no=i.source_business_no WHERE i.dispatch_order_id IN "+ROOTS+" AND i.source_stage='RETURN_RELABEL' AND c.overseas_warehouse_id=#{warehouse} ORDER BY i.id,c.id</script>")
List<Carton> cartons(@Param("ids")List<Long> ids,@Param("warehouse")String warehouse);
}
@@ -1,405 +1,42 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.entity.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
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.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.*;
import java.util.*;
import java.util.stream.Collectors;
/** 实际发车推进既有来源状态;扫码与装车只使用公共整车命令。 */
@Service
public class DispatchPdaWorkflowService {
@Resource private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution;
@Resource private com.baiyue.lastleg.dispatch.repository.DispatchPickingRepository wholePicks;
@Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceDepartureRepository wholeDeparture;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private DispatchOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPalletItems;
@Resource private DispatchItemMapper items;
@Resource private DispatchPdaTaskMapper tasks;
@Resource private DispatchPdaPickMapper picks;
@Resource private DispatchPdaOperationMapper operations;
@Resource private DispatchPalletLockMapper palletLocks;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryCommandService inventory;
@Resource private RelabelApplicationService relabelApplications;
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Resource private DispatchPickOrderAttributionService pickAttribution;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnRelabelStock;
@Transactional(readOnly = true)
public List<DispatchPdaResponses.Task> list(String warehouseId, String status) {
String boundWarehouseId = warehouse(warehouseId);
List<DispatchPdaResponses.Task> result = new ArrayList<>();
for (DispatchPdaTask pdaTask : tasks.listByWarehouse(boundWarehouseId, trim(status))) {
DispatchOrder order = orders.selectById(pdaTask.getDispatchOrderId());
if (order == null || !pdaTask.getCustomerId().equals(order.getCustomerId())
|| !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) {
throw conflict("Dispatch PDA task scope is inconsistent");
@Resource private RelabelApplicationService relabelApplications;
/** 全部客户子单锁及来源订单锁已由调用方收齐,此处只推进原来源计数与原业务状态。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void departWholeSources(com.baiyue.lastleg.dispatch.port.DispatchContracts.Context context,
com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor actor,
com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.DepartureResult result,Instant departedAt) {
var at=LocalDateTime.ofInstant(departedAt,java.time.ZoneOffset.UTC);
var byCustomer=context.cargo().stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::customerId,TreeMap::new,Collectors.toList()));
for(var group:byCustomer.entrySet()) {
String customer=group.getKey();var outbound=result.outbounds().stream().filter(o->customer.equals(o.customerId())).findFirst().orElseThrow(()->conflict("本客户实际出库单缺失"));
var actual=wholePicks.picks(customer,context.dispatchId());
if(actual.isEmpty())throw conflict("本客户缺少真实扫码来源");
pickAttribution.depart(customer,outbound.outboundNo(),context.dispatchNo(),actual,at);
for(var order:group.getValue().stream().filter(c->c.relabelNo()==null).map(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::orderId).distinct().sorted().toList())wholeDeparture.transferOrder(customer,order,at);
for(var no:group.getValue().stream().map(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::relabelNo).filter(Objects::nonNull).distinct().sorted().toList()) {
wholeDeparture.relabel(customer,no,at);relabelApplications.changeStatus(customer,no,actor.operatorId(),new RelabelRequests.ChangeStatus("COMPLETED","整车实际发车完成"));
}
result.add(task(order.getCustomerId(), order, pdaTask,
items.findByDispatchOrder(order.getCustomerId(), order.getId())));
}
return result;
}
@Transactional
public DispatchPdaResponses.Task task(String warehouseId, String dispatchNo) {
DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = order.getCustomerId();
items.bindReceivedPallets(customerId, order.getId());
DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId());
if (task == null) throw notFound("Dispatch PDA task not found");
requireTaskWarehouse(task, warehouseId);
return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId()));
}
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId,
String operatorId, String operatorName,
DispatchPdaRequests.Pick request) {
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = found.getCustomerId();
// Transfer and picking serialize on the same dispatch before taking task/inventory locks.
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null) throw conflict("Dispatch PDA task is not available for picking");
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse");
DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) {
DispatchPdaResponses.Picked result = read(replay, DispatchPdaResponses.Picked.class);
if (!task.getId().equals(replay.getTaskId()) || result.dispatchItemId() != itemId) {
throw conflict("Idempotency key was reused with a different dispatch pick request");
}
return result;
}
requirePdaStatus(order);
requireVehicle(order);
fleetBindings.requirePickingApproved(order.getId());
items.bindReceivedPallets(customerId, order.getId());
if (!("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for picking");
}
bindOperator(task, operatorId, operatorName);
DispatchItem item = items.selectById(itemId);
if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) {
throw notFound("Dispatch pallet item not found");
}
if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse");
boolean nativeReturn = "RETURN_RELABEL".equals(item.getSourceStage());
if (!nativeReturn && request.positionId() == null) throw conflict("普通入仓拣货必须选择库存仓位");
if (nativeReturn && request.positionId() != null) throw conflict("退货箱拣货请扫描来源箱唛,不使用普通库存仓位");
InventoryPosition position = nativeReturn ? null : positions.selectById(request.positionId());
if (!nativeReturn && (position == null || !customerId.equals(position.getCustomerId())
|| item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId()))) {
throw conflict("Inventory position does not belong to this dispatch pallet");
}
long picked = picks.totalItem(customerId, task.getId(), itemId);
if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages");
long pickId = IdWorker.getId();
Long returnCartonId = null;
if (nativeReturn) {
pickAttribution.prepareNativeReturn(customerId, item.getOrderId());
returnCartonId = returnRelabelStock.pick(item, request.cartonMark(), request.packages(), request.productQuantity(),
pickId, order.getDispatchNo()).getId();
} else {
pickAttribution.preparePicking(customerId, request.positionId(),
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null);
String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey();
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark());
if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) {
inventory.release(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), item.getSourceBusinessNo(), order.getDispatchNo(), null,
keyBase + ":RELABEL_RELEASE", "换标排车拣货前释放冻结库存"));
}
inventory.allocate(customerId, operatorId, move);
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":PICK", request.remark()));
// A successful PDA scan is the inventory deduction point. Departure only
// changes outbound/dispatch facts and must not deduct the same quantity again.
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":SHIP", request.remark()));
}
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(pickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setReturnCartonId(returnCartonId);
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
if (nativeReturn) pickAttribution.attributeNativeReturn(customerId, pick, item.getOrderId());
else pickAttribution.attribute(customerId, pick,
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId());
long total = picked + request.packages();
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now());
if (allPicked(customerId, task.getId(), items.findByDispatchOrder(customerId, order.getId()))) {
task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART");
}
order.setUpdatedAt(LocalDateTime.now());
orders.updateById(order);
tasks.updateById(task);
DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false);
save(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public DispatchPdaResponses.PickingCompleted completePicking(String warehouseId, String dispatchNo,
DispatchPdaRequests.CompletePicking request) {
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = found.getCustomerId();
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
fleetBindings.requirePickingApproved(order.getId());
if (task != null && "COMPLETED".equals(task.getStatus())) {
List<DispatchItem> completedItems = items.findByDispatchOrder(customerId, order.getId());
long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(
order.getDispatchNo(), order.getStatus(), planned, picked, Math.max(0, planned - picked), false, false);
DispatchPdaOperation completed = operations.findCompleted(customerId, task.getId(), "COMPLETE_PICKING");
if (completed != null) throw conflict("PDA picking was already completed with another idempotency key");
save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
return result;
}
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for completion");
}
List<DispatchItem> source = items.findByDispatchOrder(customerId, order.getId());
long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
if (planned <= 0 || picked != planned || !allPicked(customerId, task.getId(), source)) {
throw conflict("Every dispatch pallet and package must be fully picked before completion");
}
LocalDateTime now = LocalDateTime.now();
task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task);
order.setStatus("READY_TO_DEPART");
order.setPartialCompletionReason(null);
order.setPickedPackageCount((int) picked);
order.setUpdatedAt(now); orders.updateById(order);
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(),
order.getStatus(), planned, picked, 0, false, false);
save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
return result;
}
/** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId,
DispatchPdaRequests.Depart request, LocalDateTime departedAt) {
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = found.getCustomerId();
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay();
fleetBindings.requireCoordinatedDeparture(order.getId());
requireVehicle(order);
OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId());
if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) {
throw conflict("Dispatch must be transferred to a pending outbound order before departure");
}
deliveryExecution.requireDeparture(outbound);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null || !"COMPLETED".equals(task.getStatus())
|| !"READY_TO_DEPART".equals(order.getStatus())) {
throw conflict("Dispatch must complete PDA picking before departure");
}
requireTaskWarehouse(task, warehouseId);
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
if (!allPicked(customerId, task.getId(), dispatchItems)) {
throw conflict("Every dispatch pallet must be fully picked before departure");
}
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship");
long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum();
LocalDateTime now = Objects.requireNonNull(departedAt, "departedAt");
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
if (outboundPalletItems != null) {
for (var outboundItem : outboundPalletItems.findByOutbound(customerId, outbound.getId())) {
boolean selected = dispatchItems.stream().anyMatch(item -> Objects.equals(item.getWarehousePalletId(), outboundItem.getWarehousePalletId())
&& Objects.equals(item.getInboundCode(), outboundItem.getInboundCode()));
if (selected) {
outboundItem.setPickedPackageCount(outboundItem.getPackageCount());
outboundItem.setStatus("PICKED");
outboundPalletItems.updateById(outboundItem);
}
}
}
for (DispatchItem item : dispatchItems) {
// A relabel dispatch may consume only part of a warehouse-transfer
// pallet. Do not close the whole transfer by its parent order; the
// remaining cartons still need destination binding/outbound.
if (item.getOrderId() != null && !"RELABEL_ORDER".equals(item.getSourceBusinessType())) {
warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now);
}
if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) {
warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now);
}
}
dispatchItems.stream().filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(Objects::nonNull).distinct().forEach(relabelNo ->
relabelApplications.changeStatus(customerId, relabelNo, operatorId,
new RelabelRequests.ChangeStatus("COMPLETED", "换标排车PDA发车完成")));
outbound.setStatus("OUTBOUNDED");
outbound.setUpdatedAt(now);
outboundOrders.updateById(outbound);
order.setShippedPackageCount((int) shipped);
order.setStatus("DEPARTED");
order.setDepartAt(now);
order.setUpdatedAt(now);
orders.updateById(order);
releasePalletLocks(customerId, order.getId(), now);
Map<Long,Integer> sourcePackages = new TreeMap<>();
for (var item : dispatchItems) if (item.getOrderId() != null)
sourcePackages.merge(item.getOrderId(), item.getPackageCount(), Math::addExact);
fleetPool.departed(customerId, order.getDispatchNo(), sourcePackages, operatorId, now);
DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(),
order.getStatus(), shipped, false);
save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result);
return result;
}
private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source) {
List<DispatchPdaResponses.Item> values = new ArrayList<>();
for (DispatchItem item : source) {
if ("RETURN_RELABEL".equals(item.getSourceStage())) {
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), List.of(), returnRelabelStock.detail(item)));
continue;
}
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages())
+ (relabel ? n(p.getHoldPackages()) : 0),
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
}
OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId());
String departBlockReason = departBlockReason(order, task, source, outbound);
return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(),
task.getWarehouseId(), "DEPARTED".equals(order.getStatus()) ? "DEPARTED" : task.getStatus(),
departBlockReason == null, departBlockReason, values, null, null, 0, null, false, "请先关联车次并通过调度审核");
}
private String departBlockReason(DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source,
OutboundOrder outbound) {
if ("DEPARTED".equals(order.getStatus())) return "该排车已发车,不可重复提交。";
if (trim(order.getVehiclePlate()) == null) return "请先在平台维护车辆信息。";
if (!"COMPLETED".equals(task.getStatus()) || !"READY_TO_DEPART".equals(order.getStatus())
|| !allPicked(order.getCustomerId(), task.getId(), source)) {
return "请先完成排车拣货并核平全部箱数。";
}
if (outbound == null) return "请先在平台完成“转派送”,生成待出库单后才能发车。";
if (!"WAIT_OUTBOUND".equals(outbound.getStatus())) return "关联出库单不是待出库状态,暂不可发车。";
return null;
}
private boolean allPicked(String customerId, Long taskId, List<DispatchItem> source) {
return source.stream().allMatch(item -> picks.totalItem(customerId, taskId, item.getId()) == item.getPackageCount());
}
private void releasePalletLocks(String customerId, Long dispatchOrderId, LocalDateTime now) {
List<DispatchPalletLock> activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId)
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
palletLocks.updateById(lock);
Map<Long,Integer> packages=new TreeMap<>();for(var cargo:group.getValue())packages.merge(cargo.orderId(),Math.toIntExact(cargo.packages()),Math::addExact);
fleetPool.departed(customer,context.dispatchNo(),packages,actor.operatorId(),at);
}
}
private DispatchOrder requireWarehouseOrder(String warehouseId, String dispatchNo) {
String boundWarehouseId = warehouse(warehouseId);
DispatchOrder order = orders.findGlobalByDispatchNo(required(dispatchNo, "Dispatch number is required"));
if (order == null || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) {
throw notFound("Dispatch order not found in the bound warehouse");
}
return order;
}
private void requirePdaStatus(DispatchOrder order) {
if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus())
|| "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA");
}
private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); }
private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); }
private static void requireTaskWarehouse(DispatchPdaTask task, String warehouseId) {
if (!warehouse(warehouseId).equals(trim(task.getWarehouseId()))) {
throw notFound("Dispatch PDA task not found in the bound warehouse");
}
}
private static void bindOperator(DispatchPdaTask task, String operatorId, String operatorName) {
String normalizedOperator = required(operatorId, "PDA operator is required");
if (trim(task.getOperatorId()) != null && !normalizedOperator.equals(trim(task.getOperatorId()))) {
throw conflict("Dispatch PDA task is already assigned to another operator");
}
task.setOperatorId(normalizedOperator);
task.setOperatorName(trim(operatorName));
}
private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; }
private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); }
private <T> T read(DispatchPdaOperation op, Class<T> type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } }
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
private static long n(Long value) { return value == null ? 0 : value; }
private static long n(Integer value) { return value == null ? 0 : value; }
static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) {
String original = trim(originalFbaWarehouseCode);
String destination = trim(destinationWarehouseCode);
return original != null && destination != null && !original.equalsIgnoreCase(destination)
? original + " -> " + destination : null;
}
private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static RuntimeException conflict(String message){return new com.baiyue.common.core.exception.BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,message);}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.dispatch.port;
/** 各端唯一端点常量;骨架本身不表示Controller已经上线。 */
public final class DispatchApi {
public static final String PLATFORM = "/api/platform/v1/last-leg/dispatches";
public static final String PDA = "/api/pda/v1/last-leg/dispatches";
public static final String CANDIDATES = "/candidates";
public static final String ITEMS = "/{dispatchNo}/items";
public static final String REMOVE_ITEM = "/{dispatchNo}/items/{itemId}/remove";
public static final String PICK_ITEM = "/{dispatchNo}/items/{itemId}/pick";
public static final String CANCEL = "/{dispatchNo}/cancel";
public static final String LOADING = "/{dispatchNo}/loading";
public static final String DRAFTS = "/drafts";
public static final String DRAFT_SNAPSHOT = "/drafts/snapshot";
public static final String DRAFT_CONTAINERS = "/drafts/containers";
public static final String DELETE_DRAFT_CONTAINER = "/drafts/{draftToken}/containers/{containerId}";
public static final String DRAFT_LOCK = "/draft-locks/lock";
public static final String DRAFT_UNLOCK = "/draft-locks/unlock";
public static final String DRAFT_RELEASE = "/draft-locks/release";
public static final String CONVERT_DRAFT = "/drafts/pre-arrange";
public static final String COMMAND = "/{dispatchNo}/commands/{idempotencyKey}";
public static final String SUBMIT_REVIEW = "/{dispatchNo}/submit-review";
public static final String DISPATCH_REVIEW = "/{dispatchNo}/dispatch-review";
public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review";
public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review";
public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete";
public static final String DEPART = "/{dispatchNo}/depart";
public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement";
public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery";
/** 端点容器不允许实例化。 */
private DispatchApi() { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.dispatch.port;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 既有预约改量专用内部端口,不注册HTTP,不恢复旧客户排车或手工Fleet绑定。 */
public interface DispatchAppointmentAmendmentPort {
/** 必须由已锁定原运输scope读取;主流程仍逐项核对DB真实归属及完整实物量。 */
record PhysicalItem(Long itemId,String customerId,Long planPalletId,String sourceStage,Integer packages) { }
/** MANDATORY:一次锁全范围trip→全部车辆→司机/资源→全部dispatch,禁止逐车交错。 */
void lockExistingAppointmentDispatches(Actor actor,List<String> dispatchNos);
/**
* MANDATORY:调用者先核对完整scope并锁齐原权威批次、来源及业务行。
* identity.recordVersion是本车当前公共版本;idempotencyKey是原父预约命令键。
* 实现按父键、可信操作者及dispatchNo派生稳定子键,共用原整车命令账本,重试恢复原快照。
* expected/next必须来自原权威事实;removePhysicalItems只能由服务端完整实物守卫决定。
* 只允许ACTIVE、BEFORE_LOADING、零真实扫码、无下游执行;任何失败跟随父事务整体回滚。
*/
CommandResult applyExistingAppointmentChange(Actor actor,String dispatchNo,DispatchCommands.ReasonCommand identity,
List<PhysicalItem> touchedItems,List<BatchAllocation> expectedAllocations,
List<BatchAllocation> nextAllocations,boolean removePhysicalItems);
}
@@ -0,0 +1,95 @@
package com.baiyue.lastleg.dispatch.port;
import java.time.Instant;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 公开整车写请求;所有已有单写入只使用recordVersion。 */
public final class DispatchCommands {
/** 请求容器不允许实例化。 */
private DispatchCommands() { }
public record Vehicle(ResourceMode mode, Long vehicleId, String plate, String carrierName,
Capacity capacity, List<Long> evidenceFileIds) { }
public record Driver(ResourceMode mode, Long driverId, String name, String phone,
DriverEvidence evidence) { }
public record CargoSelection(SourceType sourceType, Long planPalletId, Long returnReceiptId,
String relabelNo, Long warehousePalletId, String sourceVersion) { }
/** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations) { }
/** 只关联权威既有预约,确认状态/时间必须解析提供者结果。 */
public record AppointmentReference(String referenceNo, Long recordVersion) {
/** 拒绝缺引用或缺版本,不提供手工确认状态字段。 */
public AppointmentReference {
if (referenceNo == null || referenceNo.isBlank()) throw new IllegalArgumentException("预约引用必填");
recordVersion = requireVersion(recordVersion);
}
}
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt,
AppointmentReference vehicleAppointment, String remark) {
/** 缺版本与有效版本零必须区分。 */
public Update { recordVersion = requireVersion(recordVersion); }
}
public record AddCargo(Long recordVersion, String idempotencyKey, List<CargoSelection> cargo,
List<BatchAllocation> batchAllocations) {
/** 缺版本不得退化成零版本。 */
public AddCargo { recordVersion = requireVersion(recordVersion); }
}
public record ReasonCommand(Long recordVersion, String idempotencyKey, String reason) {
/** 移货和取消同样必须提交当前版本。 */
public ReasonCommand { recordVersion = requireVersion(recordVersion); }
}
public record SubmitReview(Long recordVersion, String idempotencyKey, String remark) {
/** 审核提交不接受隐式零版本。 */
public SubmitReview { recordVersion = requireVersion(recordVersion); }
}
public record ReviewCommand(Long recordVersion, String idempotencyKey, Decision decision,
String reason) {
/** 两级审核都执行相同的必填版本约束。 */
public ReviewCommand { recordVersion = requireVersion(recordVersion); }
}
/** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */
public record ReturnToReview(Long recordVersion, String idempotencyKey, String reason) {
/** 零扫码退回也不能覆盖并发新版本。 */
public ReturnToReview { recordVersion = requireVersion(recordVersion); }
}
public record Pick(Long recordVersion, String idempotencyKey, Long positionId, long packages,
long productQuantity, String cartonMark, String remark) {
/** 扫码缺版本必须失败,避免将旧请求解释为首版。 */
public Pick { recordVersion = requireVersion(recordVersion); }
}
/** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */
public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash,
List<LoadingMeasurement> measurements, String remark) {
/** 后台与PDA共用必填版本约束。 */
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
}
public record Depart(Long recordVersion, String idempotencyKey, String remark) {
/** 实际发车不得接受缺省版本。 */
public Depart { recordVersion = requireVersion(recordVersion); }
}
/** 装箱类型保留JSON缺字段事实,同时允许显式提交零。 */
static Long requireVersion(Long recordVersion) {
if (recordVersion == null || recordVersion < 0) throw new IllegalArgumentException("recordVersion必填且不得小于零");
return recordVersion;
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.dispatch.port;
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
/** 主流程独占实现;阶段CAS与本信号确认在同一新事务提交。 */
public interface DispatchCompletionPort {
/** 在签收提交后取得trip/dispatch锁,真实全签才推进,失败不确认信号。 */
CompletionResult reconcile(Signal signal);
}
@@ -0,0 +1,95 @@
package com.baiyue.lastleg.dispatch.port;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 主流程与派送域之间的事实快照;不允许传递持久化实体。 */
public final class DispatchContracts {
/** 契约容器不允许实例化。 */
private DispatchContracts() { }
/** 由可信认证上下文构造,不允许从请求体绑定操作身份或PDA仓库。 */
public record Actor(String operatorId, ActorMode mode, String boundWarehouseId) { }
/** 未允许时必须携带稳定错误码和可展示原因。 */
public record Gate(boolean allowed, String blockCode, String blockReason) { }
public record Counts(long itemCount, long palletCount, long packageCount,
long pickedPackageCount, long shippedPackageCount) { }
public record Capacity(Integer palletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { }
/** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */
public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn,
List<Long> fileIds, Instant expectedTaskEndAt) { }
public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate,
String carrierName, String vehicleStatus, Capacity capacity,
List<Long> evidenceFileIds) { }
public record DriverSnapshot(ResourceMode mode, Long driverId, String name, String phone,
DriverEvidence evidence) { }
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver) { }
public record Destination(String key, String type, String warehouseId, String warehouseCode,
String warehouseName, String address, String postalCode,
String contactName, String contactPhone) { }
public record Review(ReviewStatus status, String reason, String reviewerId, Instant reviewedAt,
long reviewRound, String basisHash, String invalidatedReason) { }
public record Reviews(Review dispatchReview, Review loadingReview) { }
public record BatchAllocation(Long planPalletId, String batchKey, String customerId,
String sourceKey, String destinationKey, int packageCount, Long recordVersion) {
/** 本字段属于batchKey的权威记录,不是整车版本;必须显式提交,零合法。 */
public BatchAllocation { recordVersion=DispatchCommands.requireVersion(recordVersion); }
}
public record BatchSource(String sourceKey, String destinationKey, int packageCount) { }
public record BatchReference(String batchKey, String batchNo, String customerId,
List<BatchSource> sources, Long recordVersion) {
/** FBA使用原预约版本,自动DISPATCH引用使用该真实主单版本。 */
public BatchReference { recordVersion=DispatchCommands.requireVersion(recordVersion); }
}
/** 一行表示本次不可变真实拣货来源,展示入仓码不能替代此归属。 */
public record CargoLine(Long dispatchItemId, String customerId, String customerName,
Long orderId, String orderNo, String sourceKey, String sourceStage,
String sourceRegion, String sourceInboundId, String inboundCode,
Long planPalletId, Long warehousePalletId, String palletNo,
Long pickId, Long attributionId, Long inventoryPositionId,
Long receiptId, Long returnReceiptId, Long returnCartonId,
String cartonMark, String relabelNo, Destination destination,
long packages, long productQuantity, BigDecimal grossWeightKg,
BigDecimal volumeCbm, List<BatchAllocation> batchAllocations) { }
/** 仅用于服务内协作;锁定上下文并不等于通过当前版本或阶段资格。 */
public record Context(Long dispatchId, String dispatchNo, Long tripId, String tripNo,
String warehouseId, String warehouseCode, String warehouseName,
Status status, Stage stage, long recordVersion, long cargoRevision,
String cargoHash, String loadingCompletionHash, Instant loadingCompletedAt,
Instant departedAt, Resources resources, Counts counts, Reviews reviews,
boolean pickingReconciled, List<CargoLine> cargo) { }
/** 内部命令身份由服务端摘要器产生,公开请求不接受commandHash。 */
public record CommandIdentity(Action action, String idempotencyKey, long recordVersion,
String commandHash) { }
public record CommandResult(String dispatchNo, Action action, String idempotencyKey,
long recordVersion, Status status, Stage stage, Instant occurredAt,
Effect effect, Counts counts, DispatchResults.Payload result,
boolean replayed, boolean alreadyArranged) { }
public record LoadingMeasurement(Long warehousePalletId, int palletSpaces, BigDecimal weightKg,
BigDecimal volumeCbm, int deliveryIndex) { }
public record ConfirmedLoad(String manifestHash, List<LoadingMeasurement> pallets,
String operatorId, Instant confirmedAt) { }
}
@@ -0,0 +1,91 @@
package com.baiyue.lastleg.dispatch.port;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 派送安排、费用证据与正常签收的共享边界,均保留真实客户。 */
public final class DispatchDeliveryModels {
/** 派送契约容器不允许实例化。 */
private DispatchDeliveryModels() { }
public record CoverageSelection(Long financeFeeId, int sourceVersion, String sourceHash,
List<String> services) { }
public record FeeItem(String feeType, String feeName, String direction, String currency,
BigDecimal amount, Long supplierId, String supplierName, String supplierCategory,
String settlementMode, Instant paidAt, String paymentMethod, String paymentReferenceNo,
List<String> paymentVoucherFileIds, BigDecimal chargeableQuantity, String quantityUnit,
BigDecimal unitPrice, LocalDate periodStart, LocalDate periodEnd, String remark) { }
public record ArrangeGroup(String groupKey, String groupCargoHash, Instant plannedDeliveryAt,
String deliveryMethod, String waybillNo, String carrierName, String pickupMethod,
String loadingType, String vehicleType, String vehicleSize, String appointmentAccount,
String sellerName, String remark, String externalFreightHash, String feeCoverageCargoHash,
List<CoverageSelection> feeCoverage, List<FeeItem> fees) { }
public record ArrangeDelivery(Long recordVersion, String idempotencyKey, String cargoHash,
String loadingCompletionHash, List<ArrangeGroup> groups) {
/** 安排派送缺版本不得被反序列化为有效零。 */
public ArrangeDelivery { recordVersion = DispatchCommands.requireVersion(recordVersion); }
}
public record OutboundReference(String customerId, String customerName, String outboundNo,
String status, long packageCount, long palletCount) { }
public record DeliveryReference(String groupKey, String customerId, String customerName,
String outboundNo, String deliveryNo, Destination destination,
String status, long packageCount, long palletCount) { }
public record ArrangementResult(String dispatchNo, long recordVersion, String arrangementKey,
String cargoHash, Instant arrangedAt, List<OutboundReference> outbounds,
List<DeliveryReference> deliveries) implements DispatchResults.Payload { }
public record ExternalQuote(String customerId, String groupKey, String bookingNo, String inboundCode,
String currency, BigDecimal amount, String sourceHash) { }
public record FeeCandidate(Long financeFeeId, String customerId, String currency, BigDecimal amount,
int sourceVersion, String sourceHash, String status, List<String> services,
List<String> sourceKeys) { }
public record PreviewGroup(String groupKey, String customerId, String customerName, Destination destination,
String groupCargoHash, List<CargoLine> cargo, String externalFreightHash,
List<ExternalQuote> externalQuotes, String feeCoverageCargoHash,
List<FeeCandidate> feeSources, List<String> requiredServices,
List<String> blockReasons) { }
public record ArrangementPreview(String dispatchNo, long recordVersion, Status status, Stage stage,
String cargoHash, String loadingCompletionHash, Gate arrange,
List<PreviewGroup> groups, ArrangementResult arrangement) { }
/** 只认本车全部客户单据完整覆盖,lock模式由调用方父事务提供。 */
public record DepartureFacts(String dispatchNo, String arrangementKey, String cargoHash,
String loadingCompletionHash, String arrangementBusinessHash,
List<OutboundReference> outbounds, List<DeliveryReference> deliveries,
List<CargoLine> cargo, Gate depart) { }
public record DepartureResult(List<OutboundReference> outbounds, List<DeliveryReference> deliveries,
long shippedPackages) { }
public record NormalDeliveryFact(String customerId, String groupKey, String outboundNo,
String deliveryNo, long deliveryVersion, long originalPackages,
long signedPackages, long returnedPackages, long activeReturnCount,
String status, boolean outboundCompleted, Instant signedAt) { }
public record NormalDeliveryFacts(String dispatchNo, String factsHash, boolean allNormallySigned,
Instant completedAt, List<String> expectedDeliveryNos,
List<NormalDeliveryFact> deliveries, List<String> blockReasons) { }
public record ArrangementFact(String arrangementKey, String arrangementBusinessHash,
String cargoHash, String loadingCompletionHash, ArrangementResult result) { }
public record Signal(Long signalId, String dispatchNo, String deliveryNo, long deliveryVersion,
int attempts, Instant nextRetryAt) { }
public record CompletionResult(String dispatchNo, Stage stage, long recordVersion,
boolean changed, boolean allNormallySigned, Instant completedAt) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.dispatch.port;
import java.time.Instant;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
/** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */
public interface DispatchDeliveryPort {
/** 一次预览返回同快照下全部客户目的地及费用证据。 */
ArrangementPreview preview(Context context);
/** 平台整车安排;使用主流程端口获得父锁和共同命令结果。 */
CommandResult arrange(Actor actor, String dispatchNo, ArrangeDelivery request);
/** lock为真时先锁齐本车全部outbound/delivery,再允许来源/费用锁。 */
DepartureFacts departureFacts(Context context, boolean lock);
/** 加入发车父事务,仅更新真实客户单据/计数,不执行库存SHIP。 */
DepartureResult markDeparted(Context context, Actor actor, Instant departedAt, String commandKey);
/** 普通读取全部本趟签收事实,不锁历史来源,不调用跨趟来源证明。 */
NormalDeliveryFacts normalDeliveryFacts(Context context);
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.dispatch.port;
import java.util.Optional;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
/** 主流程实现的整车权威端口;写方法必须加入调用者已有事务。 */
public interface DispatchExecutionPort {
/** 只读返回当前真实货物,不授予写资格或取得子单锁。 */
Context preview(Actor actor, String dispatchNo);
/** 双端按当前权限/绑定仓读取真实实载及缺项,不补造PDA数量或预约。 */
DispatchPlanningModels.LoadingPreview loading(Actor actor, String dispatchNo);
/** 取得trip→必要资源→dispatch;原命令恢复优先,不提前拒绝旧版本。 */
Context lockForArrangement(Actor actor, String dispatchNo);
/** 当前权限/仓核验后查全局请求键;异主体或异摘要必须拒绝。 */
Optional<CommandResult> replay(Context context, Actor actor, CommandIdentity identity);
/** 新命令复验当前版本、有效状态、装车后和完整真实拣货。 */
void requireFreshArrangement(Context context, long recordVersion);
/** 与客户单据/费用同事务记录安排引用及公共命令;既有安排不得再执行。 */
CommandResult recordArrangement(Context context, Actor actor, CommandIdentity identity,
ArrangementFact fact, boolean alreadyArranged);
}
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.dispatch.port;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 候选、草稿和实载读取种子,不生成日历预约或虚构实测。 */
public final class DispatchPlanningModels {
/** 规划契约容器不允许实例化。 */
private DispatchPlanningModels() { }
public record CandidateQuery(String warehouseId, List<Long> planIds, String orderNo,
String destinationWarehouseCode, String sourceStage, String draftToken) { }
/** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */
public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion,
String customerId, String customerName, String warehouseId, String warehouseCode,
String warehouseName, String orderNo, String inboundCode, String planNo,
String palletNo, String containerNo, Destination destination, long availablePackages,
BigDecimal suggestedWeightKg, BigDecimal suggestedVolumeCbm, String measurementSource,
List<BatchReference> appointmentBatches, List<String> blockReasons) { }
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete) { }
public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity,
List<String> sourceKeys) { }
public record Draft(String draftToken, long recordVersion, String status, String warehouseId,
List<DraftContainer> containers, List<Candidate> sources, Instant expiresAt) { }
public record CreateDraft(String idempotencyKey, String warehouseId) { }
public record CreateContainer(Long recordVersion, String idempotencyKey, String draftToken, int capacity) {
/** 已建草稿也必须区分缺版本和首版零。 */
public CreateContainer { recordVersion = DispatchCommands.requireVersion(recordVersion); }
}
public record DraftSelection(Long recordVersion, String idempotencyKey, String draftToken,
Long draftContainerId, DispatchCommands.CargoSelection source) {
/** 锁定或移除来源时不能覆盖并发草稿修订。 */
public DraftSelection { recordVersion = DispatchCommands.requireVersion(recordVersion); }
}
public record DraftSnapshot(Long recordVersion, String idempotencyKey, String draftToken,
List<Long> planIds, List<DraftContainer> containers) {
/** 草稿布局同样要求显式版本。 */
public DraftSnapshot { recordVersion = DispatchCommands.requireVersion(recordVersion); }
}
public record DraftRelease(Long recordVersion, String idempotencyKey, String draftToken) {
/** 释放仅作用于原版本/原拥有者,不能覆盖新选择。 */
public DraftRelease { recordVersion = DispatchCommands.requireVersion(recordVersion); }
}
/** 一个服务器草稿容器原子生成一张整车,不按客户再拆子排车。 */
public record DraftVehicle(Long draftContainerId, DispatchCommands.Create dispatch) { }
public record ConvertDraft(Long recordVersion, String idempotencyKey, String draftToken,
List<DraftVehicle> groups) {
/** 正式转换按已核对草稿版本执行,不重复生成车单。 */
public ConvertDraft { recordVersion = DispatchCommands.requireVersion(recordVersion); }
}
public record ConvertedVehicle(Long draftContainerId, CommandResult dispatch) { }
public record ConvertedDraft(String draftToken, List<ConvertedVehicle> vehicles, boolean replayed) { }
/** 非实测来源只能作为建议展示,不能填入实际测量值。 */
public record MeasurementEvidence(String field, String sourceType, String sourceBusinessNo,
Instant observedAt, BigDecimal value, boolean actual) { }
public record LoadingPallet(Long warehousePalletId, String palletNo, long scheduledPackages,
long pickedPackages, Integer palletSpaces, BigDecimal weightKg,
BigDecimal volumeCbm, Integer deliveryIndex, List<String> missingFields,
List<MeasurementEvidence> evidence) { }
public record LoadingPreview(String dispatchNo, long recordVersion, DispatchTypes.Status status,
DispatchTypes.Stage stage, String manifestHash, boolean pickingReconciled,
Resources resources, List<LoadingPallet> pallets, ConfirmedLoad confirmedLoad,
Gate loadingComplete, List<String> missingFields) { }
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.dispatch.port;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
/** 主流程独占实现候选与草稿,不按客户拆成多个排车头。 */
public interface DispatchPlanningPort {
/** 有界批量解析真实来源、客户和事实摘要,不能跨仓或静默截断。 */
CandidateList candidates(Actor actor, CandidateQuery query);
/** 只查询当前操作者拥有的未失效草稿。 */
List<Draft> drafts(Actor actor);
/** 持久幂等创建草稿,返回服务端token及合法零版本。 */
Draft createDraft(Actor actor, CreateDraft request);
/** 在草稿版本和拥有者锁内新增装车容器。 */
Draft createContainer(Actor actor, CreateContainer request);
/** 保存布局并按服务端来源占用重核,不信任布局JSON赋予货物资格。 */
Draft saveSnapshot(Actor actor, DraftSnapshot request);
/** 比对sourceVersion后取得真实来源/实托排他占用。 */
Draft select(Actor actor, DraftSelection request);
/** 仅解除本草稿本容器的原来源,不释放他人新占用。 */
Draft unselect(Actor actor, DraftSelection request);
/** 以当前草稿版本移除容器并归还其未执行来源。 */
Draft removeContainer(Actor actor, String draftToken, Long containerId, DispatchCommands.ReasonCommand request);
/** 幂等释放当前操作者的原草稿。 */
Draft release(Actor actor, DraftRelease request);
/** 一个事务转换全部组,一组一整车,任意客户失败全批回滚。 */
ConvertedDraft convert(Actor actor, ConvertDraft request);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.dispatch.port;
import java.time.Instant;
import java.util.List;
/** 不可变命令结果的数据类型,恢复不得改成当前详情。 */
public final class DispatchResults {
/** 结果容器不允许实例化。 */
private DispatchResults() { }
public sealed interface Payload permits Saved, Picked, Loaded, Departed,
DispatchDeliveryModels.ArrangementResult { }
public record Saved(String dispatchNo, List<Long> changedItemIds) implements Payload { }
public record Picked(Long dispatchItemId, Long positionId, Long returnCartonId, long packages,
long pickedPackages, boolean pickingReconciled) implements Payload { }
public record Loaded(String loadingCompletionHash, Instant loadingCompletedAt,
DispatchContracts.ConfirmedLoad confirmedLoad) implements Payload { }
public record Departed(Instant departedAt, long shippedPackages,
List<DispatchDeliveryModels.OutboundReference> outbounds,
List<DispatchDeliveryModels.DeliveryReference> deliveries) implements Payload { }
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.dispatch.port;
import java.time.Instant;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
/** 派送域实现持久提醒数据访问;调度、退避和重扫由主流程负责。 */
public interface DispatchSignalPort {
/** 有界普通读到期信号,不持信号或派送锁进入父事务。 */
List<Signal> due(Instant now, int limit);
/** MANDATORY:与主阶段CAS同一汇总事务确认,失败全部回滚。 */
void acknowledge(Signal signal, Instant acknowledgedAt);
/** REQUIRES_NEW:仅在父汇总回滚后独立更新失败次数及退避时间。 */
void failed(Signal signal, Instant nextRetryAt, String errorCode);
/** 在原签收事务中持久去重写入,不回调父流程或自动推进阶段。 */
void enqueue(String dispatchNo, String deliveryNo, long deliveryVersion, Instant occurredAt);
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dispatch.port;
/** 整车作业各端共用的唯一枚举,不复用客户子单或车次状态。 */
public final class DispatchTypes {
/** 枚举容器不允许实例化。 */
private DispatchTypes() { }
public enum Status { ACTIVE, CANCELLED }
public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED }
public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED }
public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED }
public enum Decision { APPROVED, REJECTED }
public enum ResourceMode { MASTER, EXTERNAL }
public enum ActorMode { PLATFORM, PDA }
public enum SourceType { INBOUND_PLAN, RETURN_RECEIPT, RELABEL }
public enum BeforeLoadingSection { VEHICLE_INFO, DISPATCH_REVIEW, LOADING_REVIEW }
public enum StatusFilter { ACTIVE, CANCELLED, ALL }
public enum Effect { APPLIED, EXISTING }
public enum Action {
CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW,
LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART
}
}
@@ -0,0 +1,80 @@
package com.baiyue.lastleg.dispatch.port;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 列表及四页签展示对象,不将当前页统计冒充全量统计。 */
public final class DispatchViews {
/** 展示容器不允许实例化。 */
private DispatchViews() { }
public record Query(long page, long size, String dispatchNo, String vehiclePlate,
String warehouseId, String customerId, String fbaAppointmentNo,
StatusFilter status, Stage stage, BeforeLoadingSection beforeLoadingSection,
ReviewStatus reviewStatus) { }
public record ReviewCounts(long notSubmitted, long pending, long approved, long rejected) { }
public record BeforeLoadingCounts(long vehicleInfoTotal, ReviewCounts dispatchReview,
ReviewCounts loadingReview) { }
public record Statistics(long matchedTotal, long activeTotal, long cancelledTotal,
Map<Stage, Long> stageCounts, BeforeLoadingCounts beforeLoadingReviewCounts) { }
public record Page(List<ListRow> records, long total, long page, long size, long pages,
Statistics statistics, Instant snapshotAt) { }
public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion,
ReviewState reviewState, Reviews reviews, Resources resources,
String warehouseId, String warehouseCode, String warehouseName,
Instant plannedDepartureAt, Instant departedAt, Counts counts,
boolean pickingReconciled, boolean arranged,
Map<Action, Gate> actions) { }
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver) { }
public record FbaAppointment(String batchKey, String batchNo, String appointmentNo,
String destinationKey, String destinationWarehouseCode,
String status, Instant confirmedAt, String blockReason) { }
/** 仅引用已存在的车辆预约;本批不新增预约权威模型或创建接口。 */
public record VehicleAppointment(String referenceNo, String status, Instant appointmentAt) { }
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
VehicleAppointment vehicleAppointment, List<BatchReference> appointmentBatches) { }
public record Documents(String dispatchNo, List<DispatchDeliveryModels.OutboundReference> outbounds,
List<DispatchDeliveryModels.DeliveryReference> deliveries,
Instant createdAt, Instant updatedAt, String remark) { }
public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode,
BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse,
BigDecimal volumeCbm, String containerNo, String sourceKey) { }
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion,
ReviewState reviewState, Reviews reviews, Counts counts,
boolean pickingReconciled, Map<Action, Gate> actions,
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
List<OrderDetail> orderDetails) { }
public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status,
Stage stage, long recordVersion, Reviews reviews, Counts counts,
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions) { }
public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo,
Destination destination, long scheduledPackages, long pickedPackages,
List<Position> positions, List<ReturnCarton> returnCartons) { }
public record Position(Long positionId, Long balanceId, long availablePackages,
long allocatedPackages, long pickedPackages) { }
public record ReturnCarton(Long returnCartonId, String cartonMark, String status,
long productQuantity) { }
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.port;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 四详情对象之外的作业弹框读取,不以展示字段代替真实明细或卸货顺序。 */
public final class DispatchWorkModels {
/** DTO容器不实例化。 */
private DispatchWorkModels(){ }
public record CargoItem(Long dispatchItemId,String customerId,String customerName,String sourceKey,String sourceVersion,
SourceType sourceType,Long planPalletId,Long returnReceiptId,String relabelNo,Long warehousePalletId,String palletNo,
String orderNo,String inboundCode,Destination destination,long scheduledPackages,long pickedPackages,Gate remove) { }
public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List<CargoItem> items) { }
public record Stop(int index,String name,String address,String destinationKey) { }
public record Route(String dispatchNo,long recordVersion,List<Stop> stops,Gate edit) { }
/** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */
public record RouteCommand(Long recordVersion,String idempotencyKey,List<String> destinationKeys,String reason) {
/** 显式零版本合法,缺版本不能覆盖并发变更。 */
public RouteCommand { recordVersion=DispatchCommands.requireVersion(recordVersion); }
}
}
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/** 整车读写持久化入口;业务流转与事务由服务负责。 */
@Repository
public class DispatchAggregateRepository {
private final DispatchAggregateMapper mapper;
private final DispatchItemMapper items;
private final DispatchReadMapper reads;
/** 只注入本域Mapper,服务不直接执行SQL。 */
public DispatchAggregateRepository(DispatchAggregateMapper mapper, DispatchItemMapper items, DispatchReadMapper reads) {
this.mapper = mapper; this.items = items; this.reads=reads;
}
/** 新建真整车。 */
public int insert(DispatchAggregate row) { return mapper.insert(row); }
/** 状态、审核、资源和实际执行事实以同一公共版本CAS保存。 */
public int saveState(DispatchAggregate row,long originalVersion) { return mapper.saveState(row,originalVersion); }
/** 真实扫码流水优先于主单冗余计数。 */
public long realPicked(long id) { return reads.realPicked(id); }
/** 读取业务号对应整车。 */
public DispatchAggregate find(String number) { return mapper.find(number); }
/** 同一快照批量读整车,空集合不生成无效IN SQL。 */
public List<DispatchAggregate> findMany(List<String> numbers) { return numbers.isEmpty()?List.of():mapper.findMany(numbers); }
/** 在调用方已取得资源父锁后锁整车。 */
public DispatchAggregate lock(String number) { return mapper.lock(number); }
/** 整车分页。 */
public List<DispatchAggregate> page(DispatchAggregateMapper.Search query) { return mapper.page(query); }
/** 整车分页总数。 */
public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); }
/** 全搜索基集的阶段和审核分布。 */
public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); }
/** 按整车读取全部真实客户明细。 */
public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); }
/** 保存已核对真实客户与仓库的来源快照。 */
public int insertItem(DispatchItem item) { return items.insert(item); }
/** 未执行明细才可移除,资格和根版本由应用层保证。 */
public int removeItem(long itemId) {return items.deleteById(itemId);}
/** 原子更新货量并作废旧审核依据。 */
public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages,
String route, String pending, LocalDateTime at) {
return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at);
}
/** 追加不可变业务快照。 */
public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) {
return mapper.history(id,row.getId(),row.getRecordVersion(),action,operator,reason,snapshot,at);
}
/** 同车全部客户出库摘要。 */
public List<com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.OutboundReference> outbounds(long id) { return reads.outbounds(id); }
/** 同车全部客户目的地派送摘要。 */
public List<DispatchReadMapper.DeliveryRow> deliveries(long id) { return reads.deliveries(id); }
/** 已有关联预约批次只读事实。 */
public List<DispatchReadMapper.BatchRow> batches(long id) { return reads.batches(id); }
/** 是否必须走受保护提供者核验批次分配。 */
public long activeAppointments(String customer, Long pallet) { return reads.activeAppointments(customer,pallet); }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord;
import com.baiyue.lastleg.dispatch.mapper.DispatchCommandMapper;
import org.springframework.stereotype.Repository;
/** 命令账本的数据入口,不承担权限、状态或幂等业务决定。 */
@Repository
public class DispatchCommandRepository {
private final DispatchCommandMapper mapper;
/** 注入本服务显式注册的命令Mapper。 */
public DispatchCommandRepository(DispatchCommandMapper mapper) { this.mapper = mapper; }
/** 按全局请求键查询原不可变结果。 */
public DispatchCommandRecord find(String key) { return mapper.find(key); }
/** 原子新增成功命令,不能更新覆盖既有行。 */
public int insert(DispatchCommandRecord row) { return mapper.insert(row); }
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.dispatch.mapper.*;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/** 草稿命令、根版本、布局和自身占用的数据访问;事务及资格由专用服务编排。 */
@Repository
public class DispatchDraftRepository {
private final DispatchDraftMapper drafts;
private final DispatchDraftContainerMapper containers;
private final DispatchDraftCommandMapper commands;
private final DispatchDraftClaimMapper claims;
/** 仅装配本有界包Mapper,来源资格仍取既有提供者。 */
public DispatchDraftRepository(DispatchDraftMapper drafts,DispatchDraftContainerMapper containers,
DispatchDraftCommandMapper commands,DispatchDraftClaimMapper claims) {
this.drafts=drafts;this.containers=containers;this.commands=commands;this.claims=claims;
}
/** 读取当前拥有者的未失效草稿。 */
public List<DispatchDraft> active(String owner){return drafts.findActiveByOperator(owner);}
/** 在唯一命令之后取得拥有者根锁。 */
public DispatchDraft lock(String token,String owner){return drafts.lockOwned(token,owner);}
/** 新建服务器生成身份的草稿。 */
public void insert(DispatchDraft row){if(drafts.insert(row)!=1)throw new IllegalStateException("草稿未保存");}
/** 公共版本CAS失败必须回滚本次全部写入。 */
public void save(DispatchDraft row,long version){if(drafts.save(row,version)!=1)throw new IllegalStateException("草稿版本竞争失败");}
/** 布局普通读始终在草稿根事务中使用。 */
public List<DispatchDraftContainer> containers(String token){return containers.findByToken(token);}
/** 保存真实新容器。 */
public void insert(DispatchDraftContainer row){if(containers.insert(row)!=1)throw new IllegalStateException("草稿容器未保存");}
/** 布局交换先腾出本草稿原序号,异常由外层事务整体回滚。 */
public void stageSequences(String token){containers.stageSequences(token);}
/** 写回服务端校验后的完整布局。 */
public void layout(DispatchDraftContainer row){if(containers.layout(row.getId(),row.getDraftToken(),row.getSequenceNo(),row.getCapacity(),row.getSourceKeysJson(),row.getUpdatedAt())!=1)throw new IllegalStateException("草稿容器归属已变化");}
/** 只删除已核对为空的本容器。 */
public void remove(String token,long id){if(containers.remove(id,token)!=1)throw new IllegalStateException("草稿容器不存在");}
/** 全局草稿命令键竞争采用数据库唯一约束。 */
public DispatchDraftCommand reserve(DispatchDraftCommand row){commands.reserve(row);return commands.lock(row.getIdempotencyKey());}
/** 原始结果随业务事务共同完成。 */
public void complete(long id,String json){if(commands.complete(id,json)!=1)throw new IllegalStateException("草稿命令结果未保存");}
/** 来源提供者已取根锁后,锁本草稿活动占用。 */
public List<DispatchDraftClaimMapper.Claim> claims(DispatchDraft row,LocalDateTime now){return claims.owned(row.getDraftToken(),row.getOperatorId(),now);}
/** 占用来源及实托,两项唯一键共同保证排他。 */
public void claim(long id,DispatchDraft row,long container,DispatchServiceSource source,LocalDateTime now){
if(claims.insert(id,source.customer(),source.key(),source.plan(),source.pallet(),source.receipt(),container,row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt())!=1)throw new IllegalStateException("来源占用未保存");
}
/** 数据访问输入只包含提供者已经解析的真实标识。 */
public record DispatchServiceSource(String customer,String key,Long plan,Long pallet,Long receipt) { }
/** 移动只针对同草稿原容器记录,零行不能被当作成功。 */
public void move(DispatchDraft row,DispatchDraftClaimMapper.Claim claim,long next,LocalDateTime now){if(claims.move(claim.id(),row.getDraftToken(),row.getOperatorId(),claim.containerId(),next,now)!=1)throw new IllegalStateException("原草稿占用已变化");}
/** 释放限定原token、owner及尚未转换的草稿记录。 */
public int release(DispatchDraft row,String key,Long container,LocalDateTime now){return claims.release(row.getDraftToken(),row.getOperatorId(),key,container,now);}
/** 活动草稿修改时同步续期原占用。 */
public int renew(DispatchDraft row,LocalDateTime now){return claims.renew(row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt());}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 真实执行读取只经本域Mapper,流程判断留给应用服务。 */
@Repository
public class DispatchExecutionRepository {
private final DispatchExecutionReadMapper reads;
/** 装配批量查询。 */
public DispatchExecutionRepository(DispatchExecutionReadMapper reads){this.reads=reads;}
/** 全车所有客户的真实扫码来源。 */
public List<DispatchExecutionReadMapper.PickSource> sources(long id){return reads.sources(id);}
/** 每项实际已拣数量。 */
public List<DispatchExecutionReadMapper.ItemPicked> itemPicked(long id){return reads.itemPicked(id);}
/** 不完整归属不能核平。 */
public long inconsistentPicks(long id){return reads.inconsistentPicks(id);}
/** 可自动带入的实测证据。 */
public List<DispatchExecutionReadMapper.ActualMeasurement> actualMeasurements(long id){return reads.actualMeasurements(id);}
/** 没有持久信号的已签收版本仍可通过真实单据重建通知。 */
public List<DispatchExecutionReadMapper.RescanCandidate> rescan(int limit){return reads.rescan(limit);}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper;
import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 整车PDA批量读投影,空集合由应用层短路,避免无范围查询。 */
@Repository
public class DispatchPdaReadRepository {
private final DispatchWholePdaReadMapper mapper;
/** 装配本域读Mapper。 */
public DispatchPdaReadRepository(DispatchWholePdaReadMapper mapper){this.mapper=mapper;}
/** 绑定仓真实主单。 */
public List<DispatchAggregate> headers(String warehouse,String number,String stage){return mapper.headers(warehouse,number,stage);}
/** 一次取齐当前根集合的所有客户货物。 */
public List<DispatchItem> items(List<Long> ids){return mapper.items(ids);}
/** 实际扫码计数。 */
public List<DispatchWholePdaReadMapper.Picked> picked(List<Long> ids){return mapper.picked(ids);}
/** 归属不完整不能核平。 */
public List<DispatchWholePdaReadMapper.Invalid> invalid(List<Long> ids){return mapper.invalid(ids);}
/** 本批真实客户库存仓位。 */
public List<InventoryPosition> positions(List<Long> ids,String warehouse){return mapper.positions(ids,warehouse);}
/** 本批原生退货箱。 */
public List<DispatchWholePdaReadMapper.Carton> cartons(List<Long> ids,String warehouse){return mapper.cartons(ids,warehouse);}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.pda.entity.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 每张排车唯一作业,扫码明细保留真实客户与来源归属。 */
@Repository
public class DispatchPickingRepository {
private final DispatchPdaTaskMapper tasks;private final DispatchPdaPickMapper picks;
private final InventoryPositionMapper positions;private final DispatchItemMapper items;
/** 封装原作业Mapper,库存状态变化仍经库存命令服务。 */
public DispatchPickingRepository(DispatchPdaTaskMapper tasks,DispatchPdaPickMapper picks,InventoryPositionMapper positions,DispatchItemMapper items){this.tasks=tasks;this.picks=picks;this.positions=positions;this.items=items;}
/** 根锁后锁本车唯一作业。 */
public DispatchPdaTask task(long dispatch){return tasks.lockByDispatch(dispatch);}
/** 审核后首次实际扫码创建本车作业。 */
public int createTask(DispatchPdaTask task){return tasks.insert(task);}
/** 保存本车作业进度。 */
public int saveTask(DispatchPdaTask task){return tasks.updateById(task);}
/** 普通读确定实际仓位身份,来源准备后再锁库存复验。 */
public InventoryPosition position(long id){return positions.selectById(id);}
/** 真实扫码明细在同一事务保存。 */
public int pick(DispatchPdaPick pick){return picks.insert(pick);}
/** 本明细实际已扫码数量。 */
public long itemPicked(String customer,long task,long item){return picks.totalItem(customer,task,item);}
/** 自然收货后保存已经核实的同一计划来源实托身份。 */
public int materialize(DispatchItem item){return items.updateById(item);}
/** 按真实客户取本车扫码,不借主单客户头。 */
public List<DispatchPdaPick> picks(String customer,long dispatch){return picks.wholeDispatch(customer,dispatch);}
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
/** 原来源业务状态同步的数据封装,不能把物理库存SHIP再执行一次。 */
@Repository
public class DispatchSourceDepartureRepository {
private final WarehouseTransferOrderMapper transfers;
/** 保留原转仓完成条件,主流程不重写该提供者SQL。 */
public DispatchSourceDepartureRepository(WarehouseTransferOrderMapper transfers){this.transfers=transfers;}
/** 普通实际来源同步其转仓履约。 */
public void transferOrder(String customer,long order,LocalDateTime at){transfers.markOutboundByOrder(customer,order,at);}
/** 换标只在该原转仓剩余可用箱数为零时完成。 */
public void relabel(String customer,String number,LocalDateTime at){transfers.markOutboundByRelabel(customer,number,at);}
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
import com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */
@Repository
public class DispatchSourceRepository {
private final DispatchCandidateMapper plans;
private final ReturnDispatchCandidateMapper returns;
private final DispatchSourceMapper source;
/** 复用已有来源查询和本域锁Mapper。 */
public DispatchSourceRepository(DispatchCandidateMapper plans, ReturnDispatchCandidateMapper returns, DispatchSourceMapper source) {
this.plans=plans; this.returns=returns; this.source=source;
}
/** 普通来源按仓和筛选在SQL取有界集合。 */
public List<DispatchPalletCandidate> availablePlans(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) {
return plans.wholeAvailable(q.warehouseId(),null,q.planIds(),q.orderNo(),q.destinationWarehouseCode(),q.sourceStage(),q.draftToken());
}
/** 退回来源保持其真实客户和库存仓。 */
public List<DispatchPalletCandidate> availableReturns(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) {
return returns.wholeAvailable(q.warehouseId(),null,q.orderNo(),q.destinationWarehouseCode(),q.draftToken());
}
/** 普通换标候选先限定真实仓,之后由实际测量提供者补齐证据。 */
public List<DispatchPalletCandidate> availableRelabel(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) {
return plans.wholeRelabelAvailable(q.warehouseId(),null,q.orderNo(),null);
}
/** 来源托盘的真实客户。 */
public List<String> planOwners(Long id) { return plans.findCustomerScopesByPlanPalletIds(List.of(id)); }
/** 退回收据的真实客户。 */
public List<String> returnOwners(Long id) { return returns.customers(List.of(id)); }
/** 换标单业务客户。 */
public String relabelOwner(String no) { return source.relabelOwner(no); }
/** 真实申请轮次依据。 */
public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); }
/** 取得已有普通计划候选,保留原来源资格SQL。 */
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
/** 取得已有真实退回候选。 */
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
/** 同托真实来源订单组成。 */
public List<Long> sourceOrders(String customer, Long pallet, Long planPallet) { return source.sourceOrders(customer,pallet,planPallet); }
/** 来源订单加锁并校验未暂扣。 */
public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); }
/** 锁定真实计划托盘根。 */
public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); }
/** 锁定实际退回收据根。 */
public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); }
/** 真实库存数量及版本。 */
public List<DispatchSourceMapper.Stock> stock(String customer, Long pallet, boolean lock) { return source.stock(customer,pallet,lock); }
/** 不可变收货来源组成。 */
public List<DispatchSourceMapper.Receipt> receipts(String customer, Long pallet) { return source.receipts(customer,pallet); }
/** 同一实托的退回实收不得从来源证明丢失。 */
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
/** 释放已到期草稿键。 */
public int expire(String customer, String key, Long pallet, LocalDateTime at) { return source.expire(customer,key,pallet,at); }
/** 锁定当前活动资源占用。 */
public List<DispatchSourceMapper.Claim> claims(String customer, String key, Long pallet, LocalDateTime at) { return source.claims(customer,key,pallet,at); }
/** 精确释放本车本来源,不释放他车重新取得的键。 */
public int release(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item,LocalDateTime at) {return source.release(dispatch,item.getCustomerId(),item.getPlanPalletId(),item.getReturnReceiptId(),item.getWarehousePalletId(),at);}
/** 原计划收货的物理身份与其活动键同事务补齐,不复制来源占用。 */
public int materialize(long dispatch,String customer,long plan,long pallet,LocalDateTime at){return source.materialize(dispatch,customer,plan,pallet,at);}
/** 保存正式源/实托占用。 */
public int claim(long id, String customer, String key, Long plan, Long pallet, Long receipt, long dispatch, String operator, LocalDateTime at) {
return source.claim(id,customer,key,plan,pallet,receipt,dispatch,operator,at);
}
}
@@ -0,0 +1,824 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
/** 仅用于显示的不可变来源字段,数量/归属仍取关系明细和真实拣货。 */
public record SourceDisplay(String containerNo,String planNo) { }
private static final String PREFIX = "platform:overseas-warehouse:dispatch:";
private final DispatchAggregateRepository repository;
private final DispatchSourceRepository sources;
private final DispatchService sourceRules;
private final FleetTripExecutionService fleet;
private final DispatchCommandLedger commands;
private final DispatchCommandHasher hashes;
private final ObjectMapper json;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
/** 领域编排只访问Repository和提供者应用入口。 */
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
DispatchService sourceRules, FleetTripExecutionService fleet, DispatchCommandLedger commands,
DispatchCommandHasher hashes, ObjectMapper json) {
this.repository=repository; this.sources=sources; this.sourceRules=sourceRules; this.fleet=fleet;
this.commands=commands; this.hashes=hashes; this.json=json;
}
/** 草稿转换统一预锁全批资源;正式create只重入已持身份锁,再逐组持久新根。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockDraftCreationResources(Actor actor,List<DispatchCommands.Create> creates) {
requirePlatform(actor);if(creates==null || creates.isEmpty() || creates.size()>1000 || creates.stream().anyMatch(Objects::isNull))throw bad("请选择完整草稿建车分组");
fleet.lockDispatchCreationResources(creates.stream().map(c->fleetVehicle(c.vehicle())).toList(),creates.stream().map(c->fleetDriver(c.driver())).toList());
appointments.lockCreates(creates);
}
/** 全范围先锁父级/资源,再按真实主单ID锁全部dispatch,不交错取下一车父锁。 */
@Override @Transactional(propagation=Propagation.MANDATORY)
public void lockExistingAppointmentDispatches(Actor actor,List<String> numbers) {
requirePlatform(actor);fleet.lockExistingDispatchShells(numbers);
var observed=repository.findMany(numbers);
if(observed.size()!=numbers.size())throw conflict("原运输排车集合已变化,请重新读取完整范围");
for(var row:observed.stream().sorted(Comparator.comparing(DispatchAggregate::getId)).toList()) {
var locked=required(actor,row.getDispatchNo(),true);var identity=fleet.dispatchIdentity(row.getDispatchNo());
if(!Objects.equals(locked.getTripId(),row.getTripId()) || identity.tripId()!=locked.getTripId())throw conflict("原运输资源归属已变化");
}
}
/** 原预约父事务的唯一改量桥接,不调用旧Fleet解绑,不接收用户可选的移货开关。 */
@Override @Transactional(propagation=Propagation.MANDATORY)
public CommandResult applyExistingAppointmentChange(Actor actor,String number,DispatchCommands.ReasonCommand request,
List<DispatchAppointmentAmendmentPort.PhysicalItem> physical,List<BatchAllocation> expected,List<BatchAllocation> next,boolean remove) {
requirePlatform(actor);var row=required(actor,number,true);
if(request==null || trim(request.idempotencyKey())==null || trim(request.reason())==null)throw bad("原预约命令身份及变更原因必填");
String key="APPOINTMENT:"+hashes.source(Arrays.asList(actor.operatorId(),request.idempotencyKey(),number));
var identity=identity(actor,number,Action.EDIT,request.recordVersion(),key,Arrays.asList("EXISTING_APPOINTMENT",request,physical,expected,next,remove));
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
requireEditing(row,request.recordVersion());
if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有实际扫码或有效下游单据,不能普通调整原预约");
if(physical==null || physical.isEmpty() || physical.size()>500 || physical.stream().anyMatch(Objects::isNull))throw bad("原预约缺少完整真实实物范围");
var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i));
var ids=new HashSet<Long>();var touched=new ArrayList<DispatchItem>();
for(var input:physical) {
var item=byId.get(input.itemId());
if(item==null || !ids.add(input.itemId()) || !Objects.equals(input.customerId(),item.getCustomerId())
|| !Objects.equals(input.planPalletId(),item.getPlanPalletId()) || !Objects.equals(input.sourceStage(),item.getSourceStage())
|| !Objects.equals(input.packages(),item.getPackageCount()) || input.packages()==null || input.packages()<=0
|| !Objects.equals(item.getOverseasWarehouseId(),row.getOverseasWarehouseId()))throw conflict("原预约只能调整已核齐真实客户与完整数量的原实物");
touched.add(item);
}
var current=repository.batches(row.getId()).stream().filter(b->ids.contains(b.dispatchItemId())).toList();
var actual=current.stream().map(b->new BatchAllocation(b.planPalletId(),b.batchKey(),b.customerId(),b.sourceKey(),b.destinationKey(),b.packageCount(),b.recordVersion())).toList();
validateAppointmentVersions(expected);validateAppointmentVersions(next);
if(!allocationTotals(actual).equals(allocationTotals(expected)))throw conflict("原实物预约分配集合或数量已变化,请重新读取完整范围");
if(remove) {
if(next!=null && !next.isEmpty())throw bad("移出完整实物时不能保留其新分配");
var relabels=touched.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet());
if(items.stream().anyMatch(i->relabels.contains(i.getSourceBusinessNo()) && !ids.contains(i.getId())))throw conflict("换标必须按原完整申请调整,不能仅移出部分来源");
appointments.remove(row.getId(),touched);sourceRules.releaseWholeRelabel(touched);
for(var item:touched) {
if(sources.release(row.getId(),item,now())<1 || repository.removeItem(item.getId())!=1)throw conflict("原实物占用或货物明细已变化");
}
refreshCargo(row,items.stream().filter(i->!ids.contains(i.getId())).toList());row=repository.find(number);
history(row,actor,Action.EDIT,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList()));
commands.append(row.getId(),actor,identity,result);return result;
}
appointments.bind(row.getId(),touched,next);invalidate(row,"原预约分配已调整,请重新两级审核");
return saveCommand(row,actor,identity,request.reason(),new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList()));
}
/** 原分配比较业务归属和数量;父事务已锁批次并可能正推进权威版本。 */
private record AllocationIdentity(Long planPalletId,String batchKey,String customerId,String sourceKey,String destinationKey) { }
private static Map<AllocationIdentity,Long> allocationTotals(List<BatchAllocation> allocations) {
var result=new HashMap<AllocationIdentity,Long>();
if(allocations!=null)for(var a:allocations) {
if(a==null || trim(a.batchKey())==null || trim(a.customerId())==null || trim(a.sourceKey())==null || trim(a.destinationKey())==null || a.packageCount()<=0)throw bad("原预约分配缺少真实来源或数量");
result.merge(new AllocationIdentity(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey()),(long)a.packageCount(),Math::addExact);
}
return result;
}
private static void validateAppointmentVersions(List<BatchAllocation> allocations) {
var versions=new HashMap<String,Long>();
if(allocations!=null)for(var a:allocations) {
if(a==null || a.recordVersion()==null || a.recordVersion()<0)throw bad("原预约批次版本必填且不能为负");
var old=versions.putIfAbsent(a.batchKey(),a.recordVersion());if(old!=null && !old.equals(a.recordVersion()))throw conflict("同一预约批次包含不同原版本");
}
}
/** 候选及来源摘要来自同一只读快照;公开选择可直接用于同仓多客户建单。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchPlanningModels.CandidateList candidates(Actor actor,DispatchPlanningModels.CandidateQuery query) {
requirePlatform(actor);
if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓");
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
var batchFacts=appointments.candidates(rows);
var result=new ArrayList<DispatchPlanningModels.Candidate>();
for(var row:rows) {
SourceType type="RELABEL".equals(row.sourceStage())?SourceType.RELABEL:
row.returnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN;
String relabel=type==SourceType.RELABEL?row.orderRefs():null;
var preliminary=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),null);
String key=DispatchService.wholeSourceKey(preliminary),version=sourceRules.wholeSourceVersion(row,key,false);
var selection=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),version);
var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(),
row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(),
row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone());
var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
var references=new ArrayList<BatchReference>();var reasons=new LinkedHashSet<String>();
if(fact!=null) {
reasons.addAll(fact.blockReasons());
// 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。
if(fact.occupied() && normalized.draftToken()==null)reasons.add("实物已被其他有效运输安排占用");
for(var ref:fact.appointmentBatches()) {
if(!Objects.equals(ref.customerId(),row.customerId()))throw conflict("原预约候选客户与实物归属不一致");
references.add(new BatchReference(ref.batchKey(),ref.batchNo(),ref.customerId(),ref.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),ref.recordVersion()));
}
}
result.add(new DispatchPlanningModels.Candidate(selection,key,version,row.customerId(),row.customerName(),row.overseasWarehouseId(),
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons)));
}
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
}
/** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchWorkModels.Items items(Actor actor,String number) {
requirePlatform(actor);var row=required(actor,number,false);
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
var gate=actions(actor,row).get(Action.REMOVE_CARGO);var result=new ArrayList<DispatchWorkModels.CargoItem>();
for(var item:repository.cargo(row.getId())) {
boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType());
var remove=relabel?new Gate(false,"RELABEL_WHOLE_SOURCE","换标按整单排车,请取消整单后重排"):gate;
if(picked.getOrDefault(item.getId(),0L)>0)remove=new Gate(false,"ALREADY_PICKED","本来源已经真实扫码");
result.add(new DispatchWorkModels.CargoItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getSourceVersion(),
relabel?SourceType.RELABEL:item.getReturnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN,item.getPlanPalletId(),item.getReturnReceiptId(),
relabel?item.getSourceBusinessNo():null,item.getWarehousePalletId(),item.getPalletNo(),item.getOrderNo(),item.getInboundCode(),destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),remove));
}
return new DispatchWorkModels.Items(number,row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),List.copyOf(result));
}
/** 路线返回真实资源记录中的顺序,索引可直接对应装载卸货站。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchWorkModels.Route route(Actor actor,String number) {
requirePlatform(actor);var row=required(actor,number,false);var actual=fleet.dispatchStops(number);var stops=new ArrayList<DispatchWorkModels.Stop>();
for(int index=0;index<actual.size();index++){var stop=actual.get(index);stops.add(new DispatchWorkModels.Stop(index,stop.name(),stop.address(),stop.destinationKey()));}
return new DispatchWorkModels.Route(number,row.getRecordVersion(),List.copyOf(stops),actions(actor,row).get(Action.EDIT));
}
/** 路线是关键审核依据,沿同一公共版本和EDIT幂等动作作废两级审核。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult reorderRoute(Actor actor,String number,DispatchWorkModels.RouteCommand request) {
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request);
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion());
if(repository.realPicked(row.getId())!=0)throw conflict("已真实扫码,不能调整卸货路线");
if(trim(request.reason())==null)throw bad("路线调整原因不能为空");
fleet.reorderDispatchStops(number,actor.operatorId(),request.destinationKeys(),request.reason());invalidate(row,"卸货顺序已调整");
return saveCommand(row,actor,identity,request.reason());
}
/** 上传不修改主单引用;返回本车文件ID后由公共版本编辑命令保存选择。 */
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number,
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,org.springframework.web.multipart.MultipartFile file) {
requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion());
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
}
/** 列表权限含详情只读,但文件仍限当前整车复合归属。 */
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File evidenceMetadata(Actor actor,String number,
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.metadata(row.getId(),kind,fileId);
}
/** 内容仅经服务端鉴权代理,不返回匿名存储URL。 */
public byte[] evidenceContent(Actor actor,String number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.download(row.getId(),kind,fileId);
}
/** 空单或同仓多客户选货一次建车,与资源关系、来源锁和账本同事务。 */
@Transactional(isolation = Isolation.READ_COMMITTED)
public CommandResult create(Actor actor, DispatchCommands.Create request) {
requirePlatform(actor);
if (request == null || request.warehouseId() == null || !request.warehouseId().matches("[1-9][0-9]*"))
throw bad("请选择真实海外起运仓");
var identity = new CommandIdentity(Action.CREATE,request.idempotencyKey(),0,
hashes.command(actor,Action.CREATE,null,null,request));
var replay = commands.replay(actor,null,identity);
if (replay.isPresent()) return replay.get();
long id=IdWorker.getId();
String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT);
var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark()));
var row=new DispatchAggregate();
row.setId(id); row.setDispatchNo(number); row.setTripId(shell.tripId());
row.setOverseasWarehouseId(shell.warehouseId()); row.setOverseasWarehouseCode(shell.warehouseCode()); row.setOverseasWarehouseName(shell.warehouseName());
row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L);
row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L);
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
Resources resources=resources(shell); row.setResourcesJson(write(resources));
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
row.setRemark(request.remark()); row.setCreatedBy(actor.operatorId()); row.setCreatedAt(now()); row.setUpdatedAt(row.getCreatedAt());
if (repository.insert(row)!=1) throw conflict("排车单未保存");
fleet.linkDispatchShell(actor.operatorId(),id,number,shell.tripNo());
appointments.lock(List.of(),request.batchAllocations());
List<Long> changed=addResolved(row,actor,request.cargo(),request.batchAllocations());
row=repository.find(number);
history(row,actor,Action.CREATE,request.remark());
var result=result(row,identity,new DispatchResults.Saved(number,changed));
commands.append(id,actor,identity,result);
return result;
}
/** 先trip→资源→dispatch,原命令恢复后才判断当前版本。 */
@Transactional(isolation = Isolation.READ_COMMITTED)
public CommandResult addCargo(Actor actor, String number, DispatchCommands.AddCargo request) {
requirePlatform(actor);
var shell=fleet.lockDispatchShell(number);
var row=required(actor,number,true);
if (!Objects.equals(row.getTripId(),shell.tripId())) throw conflict("整车资源关系已变化");
var identity=new CommandIdentity(Action.ADD_CARGO,request.idempotencyKey(),request.recordVersion(),
hashes.command(actor,Action.ADD_CARGO,number,request.recordVersion(),request));
var replay=commands.replay(actor,number,identity);
if (replay.isPresent()) return replay.get();
requireEditing(row,request.recordVersion());
appointments.lock(repository.batches(row.getId()),request.batchAllocations());
if (request.cargo()==null || request.cargo().isEmpty()) throw bad("请选择需要加入的真实货物");
var changed=addResolved(row,actor,request.cargo(),request.batchAllocations());
row=repository.find(number); history(row,actor,Action.ADD_CARGO,null);
var result=result(row,identity,new DispatchResults.Saved(number,changed));
commands.append(row.getId(),actor,identity,result);
return result;
}
/** 移除真实未执行明细,来源占用与货量更新一起提交,保留既有整单换标边界。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult removeCargo(Actor actor,String number,long itemId,DispatchCommands.ReasonCommand request) {
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.REMOVE_CARGO,request.recordVersion(),request.idempotencyKey(),Arrays.asList(itemId,request));
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion());
if(repository.realPicked(row.getId())!=0)throw conflict("本车已经真实扫码,不能移除货物");
var all=repository.cargo(row.getId());var selected=all.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"排车货物明细不存在"));
if("RELABEL_ORDER".equals(selected.getSourceBusinessType()))throw conflict("换标按整单排车,请取消整单后重排");
appointments.lock(repository.batches(row.getId()),List.of());
appointments.remove(row.getId(),List.of(selected));
if(sources.release(row.getId(),selected,now())<1)throw conflict("原来源占用已变化,未移除货物");
if(repository.removeItem(itemId)!=1)throw conflict("货物明细已变化");
refreshCargo(row,all.stream().filter(i->!Objects.equals(i.getId(),itemId)).toList());row=repository.find(number);
history(row,actor,Action.REMOVE_CARGO,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,List.of(itemId)));
commands.append(row.getId(),actor,identity,result);return result;
}
/** 零扫码取消协调主单、原来源与资源,取消保留在正交status而不增加第六阶段。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult cancel(Actor actor,String number,DispatchCommands.ReasonCommand request) {
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.CANCEL,request.recordVersion(),request.idempotencyKey(),request);
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion());
if(trim(request.reason())==null)throw bad("取消原因不能为空");
if(repository.realPicked(row.getId())!=0 || !repository.outbounds(row.getId()).isEmpty())throw conflict("本车已有真实执行或关联出库,不能取消");
var items=repository.cargo(row.getId());appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
for(var item:items)if(sources.release(row.getId(),item,now())<1)throw conflict("原来源占用已变化,未取消排车");
fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消");
return saveCommand(row,actor,identity,request.reason());
}
/** 旧新资源身份一次锁齐,编辑关键依据后两级审核均失效,普通备注不篡改旧审核。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult update(Actor actor,String number,DispatchCommands.Update request) {
requirePlatform(actor);
fleet.lockDispatchShell(number,request.vehicle()==null?null:request.vehicle().vehicleId(),request.driver()==null?null:request.driver().driverId());
var row=required(actor,number,true);var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion());
if(request.vehicleAppointment()!=null)throw conflict("车辆预约关联需按权威预约重新核验");
String before=basis(row);
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
return saveCommand(row,actor,identity,request.remark());
}
/** 提交新轮次审核,不把已通过调度审核复用到更改后的货物或资源。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult submitReview(Actor actor,String number,DispatchCommands.SubmitReview request) {
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion());
if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
verifyReviewResources(row);
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
long round=row.getReviewRound()+1;String basis=basis(row);
row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name());
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null)));
row.setLoadingReviewJson(write(pending(round)));
return saveCommand(row,actor,identity,request.remark());
}
/** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) {
var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW;
var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion());
if(request.decision()==null)throw bad("请选择审核结论");
String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name();
if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化");
if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空");
if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交");
if(loading) {
var dispatch=reviews(row).dispatchReview();
if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效");
}
if(request.decision()==Decision.APPROVED)verifyReviewResources(row);
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null);
if(loading) {
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name());
} else {
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound())));
}
return saveCommand(row,actor,identity,request.reason());
}
/** 只允许零真实扫码退回原单,保留编号并明确作废两级审核。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult returnToReview(Actor actor,String number,DispatchCommands.ReturnToReview request) {
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.RETURN_TO_REVIEW,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
if(row.getRecordVersion().longValue()!=request.recordVersion() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()))throw conflict("仅当前版本装车中的有效单可退回重审");
if(trim(request.reason())==null)throw bad("退回原因不能为空");
if(n(row.getPickedPackageCount())!=0 || n(row.getShippedPackageCount())!=0 || repository.realPicked(row.getId())!=0)throw conflict("已经真实扫码,不能退回装车前");
row.setStage(Stage.BEFORE_LOADING.name());invalidate(row,request.reason());
return saveCommand(row,actor,identity,request.reason());
}
/** 派送域只读取本车真实已拣归属;缺失归属不会由计划补齐。 */
@Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Context preview(Actor actor,String number) { return context(actor,required(actor,number,false)); }
/** 原命令恢复前仅取根身份锁,不提前拒绝旧版本或已经发车的阶段。 */
@Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public Context lockForArrangement(Actor actor,String number) {
required(actor,number,false);fleet.lockDispatchShell(number);return context(actor,required(actor,number,true));
}
/** 当前范围复验后只恢复原主体与严格摘要相同的命令。 */
@Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public Optional<CommandResult> replay(Context context,Actor actor,CommandIdentity identity) {
required(actor,context.dispatchNo(),false);return commands.replay(actor,context.dispatchNo(),identity);
}
/** 新安排只能使用装车后的完整真实来源。 */
@Override public void requireFreshArrangement(Context context,long version) {
if(context.recordVersion()!=version || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING
|| !context.pickingReconciled() || context.loadingCompletionHash()==null || context.loadingCompletedAt()==null)
throw conflict("本车尚未完成真实拣货及装车,或当前版本已变化");
}
/** 子单及费用保存后,同一事务以主单CAS和全局账本确认原子结果。 */
@Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public CommandResult recordArrangement(Context context,Actor actor,CommandIdentity identity,DispatchDeliveryModels.ArrangementFact fact,boolean existing) {
var row=required(actor,context.dispatchNo(),true);
if(row.getRecordVersion()!=identity.recordVersion() || !Objects.equals(row.getCargoHash(),fact.cargoHash())
|| !Objects.equals(row.getLoadingCompletionHash(),fact.loadingCompletionHash()) || fact.result()==null)throw conflict("本车安排依据已变化");
if(existing) {
if(!Objects.equals(row.getArrangementKey(),fact.arrangementKey()) || !Objects.equals(row.getArrangementBusinessHash(),fact.arrangementBusinessHash()))throw conflict("原派送安排依据不一致");
} else {
requireFreshArrangement(context,identity.recordVersion());if(row.getArrangementKey()!=null)throw conflict("本车已经安排派送");
row.setArrangementKey(fact.arrangementKey());row.setArrangementBusinessHash(fact.arrangementBusinessHash());
}
long version=row.getRecordVersion();row.setRecordVersion(version+1);row.setUpdatedAt(now());
if(repository.saveState(row,version)!=1)throw conflict("排车版本已变化");history(row,actor,Action.ARRANGE_DELIVERY,null);
var result=new CommandResult(row.getDispatchNo(),Action.ARRANGE_DELIVERY,identity.idempotencyKey(),row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),
Instant.now().truncatedTo(ChronoUnit.MILLIS),existing?Effect.EXISTING:Effect.APPLIED,counts(row),fact.result(),false,existing);
commands.append(row.getId(),actor,identity,result);return result;
}
/** 两端读取同一实载清单;已知实测自动带出,建议值不能填入实际字段。 */
@Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchPlanningModels.LoadingPreview loading(Actor actor,String number) {
var row=required(actor,number,false);var context=context(actor,row);var items=repository.cargo(row.getId());
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
var measured=executionReads.actualMeasurements(row.getId()).stream().collect(Collectors.groupingBy(p->p.palletId()));
var stops=fleet.dispatchStops(number);var pallets=new ArrayList<DispatchPlanningModels.LoadingPallet>();var missing=new ArrayList<String>();
var groups=items.stream().filter(i->i.getWarehousePalletId()!=null).collect(Collectors.groupingBy(DispatchItem::getWarehousePalletId,TreeMap::new,Collectors.toList()));
for(var entry:groups.entrySet()) {
var group=entry.getValue();var first=group.get(0);String destination=destination(first).key();
if(group.stream().anyMatch(i->!Objects.equals(destination,destination(i).key())))throw conflict("同一实托的目的地不一致,请先核对来源");
Integer index=null;for(int n=1;n<stops.size();n++)if(Objects.equals(destination,stops.get(n).destinationKey())){if(index!=null)throw conflict("卸货站重复,请核对路线");index=n;}
java.math.BigDecimal weight=null,volume=null;var proof=new ArrayList<DispatchPlanningModels.MeasurementEvidence>();
var facts=measured.getOrDefault(entry.getKey(),List.of());
if(facts.size()==1) {
var fact=facts.get(0);weight=fact.weightKg();volume=fact.volumeCbm();
if(weight!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("weightKg","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),weight,true));
if(volume!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("volumeCbm","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),volume,true));
}
var absent=new ArrayList<String>();absent.add("palletSpaces");if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex");
missing.addAll(absent.stream().map(field->Objects.toString(first.getPalletNo(),"未关联托盘")+":"+field).toList());
pallets.add(new DispatchPlanningModels.LoadingPallet(entry.getKey(),first.getPalletNo(),group.stream().mapToLong(i->n(i.getPackageCount())).sum(),
group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),null,weight,volume,index,List.copyOf(absent),List.copyOf(proof)));
}
if(items.stream().anyMatch(i->i.getWarehousePalletId()==null))missing.add("存在未关联实托来源");
String hash=hashes.source(Arrays.asList(context.cargo(),context.resources(),stops,pallets));
var gate=actions(actor,row).get(Action.LOADING_COMPLETE);
if(!context.pickingReconciled() || groups.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))gate=new Gate(false,"PICKING_INCOMPLETE","本车真实拣货和逐项来源尚未核平");
return new DispatchPlanningModels.LoadingPreview(number,row.getRecordVersion(),context.status(),context.stage(),hash,context.pickingReconciled(),context.resources(),
List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing));
}
/** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true);
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
var supplied=new HashMap<Long,LoadingMeasurement>();
for(var m:request.measurements())if(m==null || m.warehousePalletId()==null || supplied.putIfAbsent(m.warehousePalletId(),m)!=null || m.palletSpaces()<=0 || m.weightKg()==null || m.weightKg().signum()<=0 || m.volumeCbm()==null || m.volumeCbm().signum()<=0)throw bad("实托测量必须完整且不得重复");
for(var p:preview.pallets()) {
var m=supplied.get(p.warehousePalletId());if(m==null || p.deliveryIndex()!=null && p.deliveryIndex()!=m.deliveryIndex()
|| p.weightKg()!=null && p.weightKg().compareTo(m.weightKg())!=0 || p.volumeCbm()!=null && p.volumeCbm().compareTo(m.volumeCbm())!=0)throw conflict("已知实测或卸货站发生变化,请重新核对");
}
verifyReviewResources(row);var context=context(actor,row);
var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList();
var measurements=request.measurements().stream().sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed));
}
/** 当前明细、实际扫码、真实归属及批次组合为唯一整车上下文。 */
/** Fleet附加能力只读执行事实;调用方已按trip→资源→主单顺序持父锁。 */
@Transactional(propagation=Propagation.MANDATORY)
public Context fleetContext(String number,boolean lock) {
var row=lock?repository.lock(number):repository.find(number);
if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
return context(null,row);
}
private Context context(Actor actor,DispatchAggregate row) {
var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化");
var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i));
var batches=repository.batches(row.getId());var cargo=new ArrayList<CargoLine>();
var remaining=new LinkedHashMap<com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow,Long>();batches.forEach(b->remaining.put(b,(long)b.packageCount()));
for(var pick:executionReads.sources(row.getId())) {
var item=byId.get(pick.itemId());String source=pick.returnCartonId()!=null?"RETURN_CARTON:"+pick.returnCartonId():pick.returnReceiptId()!=null?"RETURN:"+pick.returnReceiptId():"ORDER:"+pick.orderId();
var allocations=new ArrayList<BatchAllocation>();long unassigned=pick.packages();
for(var batch:batches)if(Objects.equals(batch.dispatchItemId(),item.getId()) && Objects.equals(batch.customerId(),item.getCustomerId()) && Objects.equals(batch.sourceKey(),source) && unassigned>0) {
long allocated=Math.min(unassigned,remaining.get(batch));if(allocated<=0)continue;
allocations.add(new BatchAllocation(batch.planPalletId(),batch.batchKey(),batch.customerId(),batch.sourceKey(),batch.destinationKey(),Math.toIntExact(allocated),batch.recordVersion()));
unassigned-=allocated;remaining.put(batch,remaining.get(batch)-allocated);
}
cargo.add(new CargoLine(item.getId(),pick.customerId(),item.getCustomerName(),pick.orderId(),pick.orderNo(),source,item.getSourceStage(),pick.sourceRegion(),pick.sourceInboundId(),pick.inboundCode(),
item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),pick.cartonMark(),
"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations));
}
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
boolean complete=reconciled(row) && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount()))
&& executionReads.inconsistentPicks(row.getId())==0 && cargo.stream().mapToLong(CargoLine::packages).sum()==n(row.getPackageCount());
return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),identity.tripNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),
Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),row.getLoadingCompletionHash(),instant(row.getLoadingCompletedAt()),instant(row.getDepartedAt()),
read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),complete,List.copyOf(cargo));
}
/** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);fleet.lockDispatchShell(number);return required(actor,number,true); }
/** 严格请求身份不接收前端摘要或操作者。 */
private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));}
/** 每次状态变更、历史及不可变命令结果同事务保存。 */
private CommandResult saveCommand(DispatchAggregate row,Actor actor,CommandIdentity identity,String reason) {
return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(row.getDispatchNo(),List.of()));
}
/** 公开结果类型按动作保存,不把Loaded等恢复成普通保存结果。 */
private CommandResult saveCommand(DispatchAggregate row,Actor actor,CommandIdentity identity,String reason,DispatchResults.Payload payload) {
long version=row.getRecordVersion();row.setRecordVersion(version+1);row.setUpdatedAt(now());
if(repository.saveState(row,version)!=1)throw conflict("排车版本已变化,请重新核对");
history(row,actor,identity.action(),reason);var result=result(row,identity,payload);
commands.append(row.getId(),actor,identity,result);return result;
}
/** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */
private String basis(DispatchAggregate row) {
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));return hashes.source(facts);
}
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
private void invalidate(DispatchAggregate row,String reason) {
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason);
row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid));
}
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
void verifyReviewResources(DispatchAggregate row) {
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话");
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间");
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
}
/** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */
private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids) {
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请补齐本车有效的车辆及司机证据");
for(var id:ids)evidence.metadata(dispatch,kind,id);
}
/** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.Page page(Actor actor, DispatchViews.Query query) {
requirePlatform(actor);
long page=query.page()==0 ? 1 : query.page(),size=query.size()==0 ? 20 : query.size();
if(page<1 || size<1 || size>200 || page>1_000_000) throw bad("分页范围不合法");
StatusFilter status=query.status()==null ? StatusFilter.ACTIVE : query.status();
if(query.beforeLoadingSection()!=null && (status!=StatusFilter.ACTIVE || query.stage()!=Stage.BEFORE_LOADING)) throw bad("审核分区仅适用于装车前有效单据");
if(query.reviewStatus()!=null && (query.beforeLoadingSection()==null || query.beforeLoadingSection()==BeforeLoadingSection.VEHICLE_INFO)) throw bad("请先选择对应审核分区");
var filter=new DispatchAggregateMapper.Search(trim(query.dispatchNo()),trim(query.vehiclePlate()),trim(query.warehouseId()),
trim(query.customerId()),trim(query.fbaAppointmentNo()),status.name(),query.stage()==null?null:query.stage().name(),
query.beforeLoadingSection()==null?null:query.beforeLoadingSection().name(),query.reviewStatus()==null?null:query.reviewStatus().name(),(page-1)*size,(int)size);
var rows=repository.page(filter); long count=repository.count(filter);
return new DispatchViews.Page(rows.stream().map(r -> listRow(actor,r)).toList(),count,page,size,(count+size-1)/size,
statistics(repository.summaries(filter)),Instant.now());
}
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.Detail detail(Actor actor, String number) {
var row=required(actor,number,false); var cargo=repository.cargo(row.getId());
var resources=read(row.getResourcesJson(),Resources.class);
var batches=repository.batches(row.getId());
var references=new LinkedHashMap<String,List<com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow>>();
batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b));
var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(),
group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList();
var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream()
.map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList();
var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(),
d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()),
d.status(),d.packageCount(),d.palletCount())).toList();
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,null,batchRefs),
documents,orderDetails(cargo));
}
/** 恢复动作结果前重新核验当前单据范围和原动作权限。 */
@Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED)
public CommandResult command(Actor actor,String number,String key) {
required(actor,number,false);
var allowed=EnumSet.noneOf(Action.class);
for(var action:Action.values()) if(hasPermission(actor,action)) allowed.add(action);
return commands.readOriginal(actor,number,key,allowed);
}
/** 全部客户来源和占用同事务,任一失败不留下部分整车。 */
private List<Long> addResolved(DispatchAggregate row,Actor actor,List<DispatchCommands.CargoSelection> selections,List<BatchAllocation> batchAllocations) {
if(selections==null || selections.isEmpty()) {
if(batchAllocations!=null && !batchAllocations.isEmpty())throw bad("没有真实货物时不能提交预约分配");
return List.of();
}
var current=repository.cargo(row.getId()); var keys=current.stream().map(DispatchItem::getSourceKey).collect(Collectors.toSet());
var physical=current.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toSet());
var resolved=sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections);
var saved=new ArrayList<DispatchItem>();
for(var source:resolved) {
if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入");
var c=source.candidate();
if(c.warehousePalletId()!=null && !physical.add(c.warehousePalletId()))throw conflict("同一实托不能作为不同来源重复计入本车,请核对真实来源组成");
var at=now(); var claims=sources.claims(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at);
if(claims.stream().anyMatch(lock -> !Objects.equals(lock.dispatchId(),row.getId()))) throw conflict("所选托盘已被其他有效运输安排占用");
if(claims.isEmpty()) {
sources.expire(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at);
try { if(sources.claim(IdWorker.getId(),c.customerId(),c.sourcePalletKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),row.getId(),actor.operatorId(),at)!=1) throw conflict("来源占用未保存"); }
catch(DuplicateKeyException error) { throw conflict("所选实托已被并发排车占用,请重新核对"); }
}
var item=item(row,source); if(repository.insertItem(item)!=1) throw conflict("排车明细未保存");
saved.add(item);
}
sourceRules.bindWholeRelabel(resolved,saved);
appointments.bind(row.getId(),saved,batchAllocations);
var all=new ArrayList<>(current); all.addAll(saved);
refreshCargo(row,all);return saved.stream().map(DispatchItem::getId).toList();
}
/** 加货和移货共享同一货量、摘要、审核失效与Fleet容量刷新。 */
private void refreshCargo(DispatchAggregate row,List<DispatchItem> all) {
int packages=0;
try { for(var item:all) packages=Math.addExact(packages,item.getPackageCount()); }
catch(ArithmeticException error) { throw bad("本车箱数超过可支持范围"); }
int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count();
String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(","));
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物");
var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops);
}
/** 冻结真实来源和客户,候选订单号只用于展示,不替代后续逐来源拣货归属。 */
private DispatchItem item(DispatchAggregate row,DispatchService.WholeSource source) {
var c=source.candidate(); var i=new DispatchItem(); i.setId(IdWorker.getId()); i.setDispatchOrderId(row.getId());
i.setCustomerId(c.customerId()); i.setCustomerName(c.customerName()); i.setSourceKey(source.sourceKey()); i.setSourceVersion(source.sourceVersion()); i.setSourceSnapshotJson(write(new SourceDisplay(c.containerNo(),c.planNo())));
i.setPlanId(c.planId()); i.setPlanPalletId(c.planPalletId()); i.setReturnReceiptId(c.returnReceiptId()); i.setWarehousePalletId(c.warehousePalletId());
i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setOrderRefs(c.orderRefs()); i.setInboundCode(c.inboundCode()); i.setPalletNo(c.palletNo());
i.setOverseasWarehouseId(c.overseasWarehouseId()); i.setOverseasWarehouseName(c.overseasWarehouseName());
i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode()); i.setDestinationWarehouseName(c.destinationWarehouseName());
i.setFbaWarehouseCode(c.fbaWarehouseCode()); i.setDestinationType(c.destinationType()); i.setDeliveryAddress(c.deliveryAddress()); i.setPostalCode(c.postalCode()); i.setContactName(c.contactName()); i.setContactPhone(c.contactPhone());
i.setPackageCount(c.packageCount()); i.setGrossWeightKg(c.grossWeightKg()); i.setVolumeWeightKg(c.volumeWeightKg()); i.setVolumeCbm(c.volumeCbm());
i.setSourceStage(source.selection().sourceType()==SourceType.RELABEL ? c.returnCartonSource()?"RETURN_RELABEL":"RELABEL" : c.sourceStage());
i.setCartonMarks(c.relabelCartonMarks()==null?c.cartonMarks():c.relabelCartonMarks());
if(source.selection().relabelNo()!=null) { i.setSourceBusinessType("RELABEL_ORDER"); i.setSourceBusinessNo(source.selection().relabelNo()); }
i.setCreatedAt(now()); return i;
}
/** 读取后验证PDA绑定仓;平台整车读取不按客户头部裁切。 */
private DispatchAggregate required(Actor actor,String number,boolean lock) {
if(actor==null || actor.operatorId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"请先登录");
var row=lock?repository.lock(number):repository.find(number);
if(row==null) throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),row.getOverseasWarehouseId())) throw new BizException(ResultCode.FORBIDDEN,"排车单不属于当前绑定仓");
return row;
}
/** 普通变更不允许覆盖已执行的真实库存或旧版本。 */
private static void requireEditing(DispatchAggregate row,long version) {
if(row.getRecordVersion()!=version) throw conflict("排车版本已变化,请重新核对");
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物");
}
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
for(var action:Action.values()) {
boolean state=switch(action) {
case CREATE -> actor.mode()==ActorMode.PLATFORM;
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING;
case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING;
case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0;
case PICK -> active && stage==Stage.LOADING && review==ReviewState.APPROVED;
case LOADING_COMPLETE -> active && stage==Stage.LOADING && reconciled(row);
case ARRANGE_DELIVERY -> active && stage==Stage.AFTER_LOADING;
case DEPART -> active && stage==Stage.AFTER_LOADING && row.getArrangementKey()!=null;
};
boolean permitted=hasPermission(actor,action);
gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件"));
}
return Collections.unmodifiableMap(gates);
}
/** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */
private static boolean hasPermission(Actor actor,Action action) {
var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false;
if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action)
&& auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority()));
if(action==Action.PICK) return false;
String permission=switch(action) {
case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel";
case DISPATCH_REVIEW -> "platform:fleet:trip:dispatch-review"; case LOADING_REVIEW -> "platform:fleet:trip:loading-review";
case LOADING_COMPLETE -> PREFIX+"loading-complete"; case DEPART -> PREFIX+"depart";
case ARRANGE_DELIVERY -> PREFIX+"transfer-delivery"; default -> PREFIX+"edit";
};
return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));
}
/** 统计按真整车去重并补齐全部零项。 */
private static DispatchViews.Statistics statistics(List<DispatchAggregateMapper.Summary> groups) {
var stages=new EnumMap<Stage,Long>(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L);
long active=0,cancelled=0,before=0; long[] dispatch=new long[4],loading=new long[4];
for(var group:groups) {
if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; }
active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum);
if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState());
dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity();
loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); }
}
return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before,
new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3])));
}
/** 列表不逐行读取明细、附件或派送。 */
private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) {
return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row));
}
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
private List<DispatchViews.OrderDetail> orderDetails(List<DispatchItem> cargo) {
var result=new ArrayList<DispatchViews.OrderDetail>();
for(var item:cargo) {
var source=read(item.getSourceSnapshotJson(),SourceDisplay.class);
var orders=Arrays.stream(Objects.requireNonNullElse(item.getOrderRefs(),Objects.requireNonNullElse(item.getOrderNo(),"")).split(",")).map(String::trim).filter(s->!s.isBlank()).distinct().toList();
if(orders.isEmpty()) orders=Collections.singletonList(null);
for(var order:orders) result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),order,item.getInboundCode(),orders.size()==1?item.getGrossWeightKg():null,
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey()));
}
return List.copyOf(result);
}
/** 把Fleet权威主档或真实临时输入转换成整车审阅快照。 */
private static Resources resources(FleetExecutionModels.DispatchShell shell) {
var v=shell.vehicle(); var d=shell.driver();
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt())));
}
/** 主档选择不接受请求覆盖其车牌和容量。 */
private static FleetExecutionModels.DispatchVehicle fleetVehicle(DispatchCommands.Vehicle v) {
if(v==null) return null; if(v.mode()==null) throw bad("请选择车辆来源方式");
return new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),v.capacity()==null?null:v.capacity().palletSpaces(),
v.capacity()==null?null:v.capacity().maxLoadKg(),v.capacity()==null?null:v.capacity().maxVolumeCbm(),v.evidenceFileIds()==null?List.of():List.copyOf(v.evidenceFileIds()));
}
/** 司机证据是本趟输入,主档事实仍由Fleet提供者核验。 */
private static FleetExecutionModels.DispatchDriver fleetDriver(DispatchCommands.Driver d) {
if(d==null) return null; if(d.mode()==null) throw bad("请选择司机来源方式"); var e=d.evidence();
return new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e==null?null:e.licenseNo(),e==null?null:e.licenseExpiresOn(),
e==null||e.fileIds()==null?List.of():List.copyOf(e.fileIds()),e==null?null:e.expectedTaskEndAt());
}
/** 只允许平台发起整车写命令。 */
private static void requirePlatform(Actor actor) { if(actor==null || actor.mode()!=ActorMode.PLATFORM || actor.operatorId()==null) throw new BizException(ResultCode.FORBIDDEN,"仅平台可安排整车作业"); }
/** 保存动作时不可变结果。 */
private static CommandResult result(DispatchAggregate row,CommandIdentity identity,DispatchResults.Payload payload) {
return new CommandResult(row.getDispatchNo(),identity.action(),identity.idempotencyKey(),row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),Instant.now().truncatedTo(ChronoUnit.MILLIS),Effect.APPLIED,counts(row),payload,false,false);
}
/** 追加原时刻主单快照和操作证据。 */
private void history(DispatchAggregate row,Actor actor,Action action,String reason) {
if(repository.history(IdWorker.getId(),row,action.name(),actor.operatorId(),reason,write(row),now())!=1) throw conflict("排车操作历史未保存");
}
/** 当前审核记录,不以版本相等代替轮次有效性。 */
Reviews reviews(DispatchAggregate row) { return new Reviews(read(row.getDispatchReviewJson(),Review.class),read(row.getLoadingReviewJson(),Review.class)); }
/** 未提交的审核不伪造待审核。 */
private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null); }
/** 整车真实统计。 */
private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); }
/** 空单永远不能核平为装车完成。 */
private static boolean reconciled(DispatchAggregate row) { return n(row.getPackageCount())>0 && Objects.equals(row.getPackageCount(),row.getPickedPackageCount()); }
/** 目的地业务标签仅来源于真实目的仓或实际地址。 */
private String destinationLabel(DispatchItem i) { return "PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():trim(i.getDestinationWarehouseCode())==null?i.getFbaWarehouseCode():i.getDestinationWarehouseCode(); }
/** 目的地稳定业务键复用既有地址规范,不信任用户指定路线。 */
Destination destination(DispatchItem i) { return new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),i.getDestinationType(),i.getDestinationWarehouseId(),i.getDestinationWarehouseCode(),i.getDestinationWarehouseName(),i.getDeliveryAddress(),i.getPostalCode(),i.getContactName(),i.getContactPhone()); }
/** 严格写入领域快照,失败回滚而不吞字段。 */
private String write(Object value) { try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("排车快照保存失败",error);} }
/** 严格读取本版快照,不提供旧字段降级解析。 */
private <T> T read(String value,Class<T> type) { try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("排车快照读取失败",error);} }
/** 数据库实际时间统一保存UTC毫秒。 */
private static LocalDateTime now() { return utc(Instant.now().truncatedTo(ChronoUnit.MILLIS)); }
/** 可空计划不填假时间。 */
private static LocalDateTime utc(Instant time) { return time==null?null:LocalDateTime.ofInstant(time,ZoneOffset.UTC); }
/** 读取UTC字段不使用操作者本地时区。 */
private static Instant instant(LocalDateTime time) { return time==null?null:time.toInstant(ZoneOffset.UTC); }
/** 空统计只作零处理,不用于业务编号回退。 */
private static int n(Integer value) { return value==null?0:value; }
/** 查询空字符串收敛为无筛选。 */
private static String trim(String value) { return value==null||value.isBlank()?null:value.trim(); }
/** 明确参数错误。 */
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST,message); }
/** 明确版本/状态/事实竞争失败。 */
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); }
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.BatchAllocation;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.util.*;
/** 预约提供者薄合同适配,客户归属只从已持久化真实明细取得。 */
@Service
@Transactional(propagation=Propagation.MANDATORY)
public class DispatchAppointmentAllocations {
private final AppointmentWorkDispatchPort provider;
public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;}
/** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */
public Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList();
if(identities.isEmpty())return Map.of();
var result=provider.candidateBatches(identities);
if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"原预约候选批读范围不完整,请重新核对");
return result;
}
/** 当前保留分配与全部请求合并后一次预锁;同批混版本或缺版本不能绕过。 */
public void lock(List<BatchRow> retained,List<BatchAllocation> requested) {
var versions=new TreeMap<String,Long>();
if(retained!=null)for(var row:retained)if(row.batchKey()!=null && row.batchKey().startsWith("FBA_APPOINTMENT:"))add(versions,row.batchKey(),row.recordVersion());
if(requested!=null)for(var row:requested) {
if(row==null || row.batchKey()==null || !row.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*"))throw bad("只能选择原权威FBA预约批次,客户仓引用由服务器生成");
add(versions,row.batchKey(),row.recordVersion());
}
provider.lockBatches(Collections.unmodifiableMap(versions));
}
/** 草稿在来源锁之前锁齐所有车辆分组的全部预约,不能在第二组才新增批次锁。 */
public void lockCreates(List<com.baiyue.lastleg.dispatch.port.DispatchCommands.Create> requests) {
var allocations=new ArrayList<BatchAllocation>();
for(var request:requests)if(request.batchAllocations()!=null)allocations.addAll(request.batchAllocations());
lock(List.of(),allocations);
}
/** Provider只增量替换touched;不将同车其他客户原分配删掉。 */
public void bind(long dispatch,List<DispatchItem> touched,List<BatchAllocation> requested) {
var items=touched.stream().map(i->new AppointmentWorkDispatchPort.PhysicalItem(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getSourceStage(),i.getPackageCount())).toList();
var allocations=requested==null?List.<AppointmentWorkDispatchPort.Allocation>of():requested.stream().map(a->
new AppointmentWorkDispatchPort.Allocation(a.planPalletId(),a.batchKey(),a.recordVersion(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount())).toList();
provider.bindItems(dispatch,items,allocations);
}
/** 调用方已锁齐全车原批次且核完整实物,删除只作用于本车指定明细。 */
public void remove(long dispatch,List<DispatchItem> items){for(var item:items)provider.removeItem(dispatch,item.getId());}
private static void add(Map<String,Long> versions,String key,Long version) {
if(version==null || version<0)throw bad("预约批次recordVersion必填且不能小于零");
var existing=versions.putIfAbsent(key,version);
if(existing!=null && !existing.equals(version))throw new BizException(ResultCode.CONFLICT,"同一预约批次版本不一致,请重新核对原批次");
}
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
}
@@ -4,7 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.client.PrintPdfClient;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import feign.FeignException;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
@@ -27,13 +27,13 @@ public class DispatchBolPrintService {
private static final int CONTINUATION_PAGE_ROWS = 26;
private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
@Resource private DispatchService dispatches;
@Resource private DispatchBolReadService dispatches;
@Resource private PrintPdfClient print;
@Resource private DomesticWarehouseDirectoryClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public GeneratedPdf generate(String dispatchNo, String requestId) {
DispatchResponse detail = dispatches.platformDetail(dispatchNo);
DispatchBolData detail = dispatches.read(dispatchNo);
if (detail.bolLines() == null || detail.bolLines().isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "排车单没有可打印的BOL明细");
}
@@ -54,16 +54,16 @@ public class DispatchBolPrintService {
}
}
private List<PrintPdfClient.Document> documents(DispatchResponse detail) {
private List<PrintPdfClient.Document> documents(DispatchBolData detail) {
String shipFromAddress = warehouseAddress(detail.overseasWarehouseId());
Map<String, List<DispatchResponse.BolLine>> groups = new LinkedHashMap<>();
for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line);
Map<String, List<DispatchBolData.Line>> groups = new LinkedHashMap<>();
for (DispatchBolData.Line line : detail.bolLines()) groups.computeIfAbsent(line.groupKey(), ignored -> new ArrayList<>()).add(line);
List<PrintPdfClient.Document> documents = new ArrayList<>();
for (Map.Entry<String, List<DispatchResponse.BolLine>> group : groups.entrySet()) {
List<DispatchResponse.BolLine> lines = group.getValue();
for (Map.Entry<String, List<DispatchBolData.Line>> group : groups.entrySet()) {
List<DispatchBolData.Line> lines = group.getValue();
int pages = pageCount(lines.size());
int total = lines.stream().mapToInt(DispatchResponse.BolLine::packageCount).sum();
String destination = lines.stream().map(DispatchResponse.BolLine::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—");
int total = lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum();
String destination = lines.stream().map(DispatchBolData.Line::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—");
int offset = 0;
for (int page = 0; page < pages; page++) {
int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS;
@@ -72,8 +72,8 @@ public class DispatchBolPrintService {
.map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()),
text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList();
documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()),
text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false),
text(detail.appointmentFor(lines.get(0))), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false),
total, page + 1, pages, rows));
offset = end;
}
@@ -128,9 +128,9 @@ public class DispatchBolPrintService {
return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS;
}
private String unique(List<DispatchResponse.BolLine> lines, boolean shipment) {
private String unique(List<DispatchBolData.Line> lines, boolean shipment) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (DispatchResponse.BolLine line : lines) {
for (DispatchBolData.Line line : lines) {
String raw = shipment ? line.fbaShipmentId() : line.poReferenceNo();
if (raw != null) for (String value : raw.split(",")) if (!value.isBlank()) values.add(value.strip());
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
import java.util.*;
import java.util.stream.Collectors;
/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */
@Service
public class DispatchBolReadService {
private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json;
public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=json;}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchBolData read(String number) {
var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
Resources resources;try{resources=json.readValue(row.getResourcesJson(),Resources.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("本车资源快照损坏",e);}
var cargo=lines.findWholeDispatch(row.getId());
if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实");
var confirmations=new LinkedHashMap<String,LinkedHashSet<String>>();
for(var batch:roots.batches(row.getId()))if(batch.confirmationNo()!=null && !batch.confirmationNo().isBlank())confirmations.computeIfAbsent(batch.customerId()+"\u0000"+batch.destinationKey(),k->new LinkedHashSet<>()).add(batch.confirmationNo());
var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue())));
return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(),
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers));
}
}
@@ -0,0 +1,102 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels;
import com.baiyue.lastleg.dispatch.port.DispatchResults;
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import org.springframework.stereotype.Component;
import java.time.Instant;
/** 严格按已持久化Action恢复结果,不依赖进程内类型缓存或默认多态。 */
@Component
public final class DispatchCommandCodec {
private final ObjectMapper json;
/** 保留正式时间/Long配置,并防止中间树将资金或实测小数转为double丢精度。 */
public DispatchCommandCodec(ObjectMapper json) {
this.json = json.copy().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
.setNodeFactory(JsonNodeFactory.withExactBigDecimals(true));
}
/** 原动作与结果类型必须匹配后才能写入不可变账本。 */
public String encode(DispatchContracts.CommandResult result) {
if (result == null || result.action() == null || result.result() == null
|| !payloadType(result.action()).isInstance(result.result())) {
throw new IllegalArgumentException("排车动作与命令结果类型不匹配");
}
try { return json.writeValueAsString(result); }
catch (JsonProcessingException error) { throw new IllegalStateException("排车命令结果保存失败", error); }
}
/** 从数据库result_json解码,禁止猜测历史类型或回退为空成功结果。 */
public DispatchContracts.CommandResult decode(String stored) {
try {
JsonNode root = json.readTree(stored);
DispatchTypes.Action action = read(root, "action", DispatchTypes.Action.class);
Long version = read(root, "recordVersion", Long.class);
if (version < 0) throw new IllegalArgumentException("命令结果版本不得为负");
JsonNode payload = required(root, "result");
for (String field : mandatoryPayloadFields(action)) required(payload, field);
var result = new DispatchContracts.CommandResult(
read(root, "dispatchNo", String.class), action,
read(root, "idempotencyKey", String.class), version,
read(root, "status", DispatchTypes.Status.class), read(root, "stage", DispatchTypes.Stage.class),
read(root, "occurredAt", Instant.class), read(root, "effect", DispatchTypes.Effect.class),
read(root, "counts", DispatchContracts.Counts.class),
json.treeToValue(payload, payloadType(action)),
read(root, "replayed", Boolean.class), read(root, "alreadyArranged", Boolean.class));
return result;
} catch (JsonProcessingException | IllegalArgumentException error) {
throw new IllegalStateException("排车命令账本结果损坏,不能恢复为成功", error);
}
}
/** 原结果仅改变重放标记,原版本、阶段、单据与事实时间均保持。 */
public DispatchContracts.CommandResult replay(String stored) {
var original = decode(stored);
return new DispatchContracts.CommandResult(original.dispatchNo(), original.action(), original.idempotencyKey(),
original.recordVersion(), original.status(), original.stage(), original.occurredAt(), original.effect(),
original.counts(), original.result(), true, original.alreadyArranged());
}
/** 封闭动作集合决定具体类型,不启用任意类名反序列化。 */
private static Class<? extends DispatchResults.Payload> payloadType(DispatchTypes.Action action) {
return switch (action) {
case PICK -> DispatchResults.Picked.class;
case LOADING_COMPLETE -> DispatchResults.Loaded.class;
case DEPART -> DispatchResults.Departed.class;
case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class;
case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW,
DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class;
};
}
/** 防止将其他动作JSON缺字段默认为零或null后冒充有效结果。 */
private static String[] mandatoryPayloadFields(DispatchTypes.Action action) {
return switch (action) {
case PICK -> new String[] { "dispatchItemId", "packages", "pickedPackages", "pickingReconciled" };
case LOADING_COMPLETE -> new String[] { "loadingCompletionHash", "loadingCompletedAt", "confirmedLoad" };
case DEPART -> new String[] { "departedAt", "shippedPackages", "outbounds", "deliveries" };
case ARRANGE_DELIVERY -> new String[] { "dispatchNo", "recordVersion", "arrangementKey", "cargoHash", "arrangedAt", "outbounds", "deliveries" };
default -> new String[] { "dispatchNo" };
};
}
/** 账本缺少字段即失败,不将缺少布尔或数值解释为默认值。 */
private static JsonNode required(JsonNode root, String field) {
if (root == null || !root.isObject() || !root.hasNonNull(field))
throw new IllegalArgumentException("排车命令结果缺少字段:" + field);
return root.get(field);
}
/** 使用正式ObjectMapper解析对应显式类型。 */
private <T> T read(JsonNode root, String field, Class<T> type) throws JsonProcessingException {
return json.treeToValue(required(root, field), type);
}
}
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels;
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeMap;
/** 命令身份与派送业务分别取摘要,禁止换主体或换版本冒充原请求。 */
@Component
public final class DispatchCommandHasher {
private static final Set<String> UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services",
"batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds");
private final ObjectMapper json;
/** 复用已注册的时间、数值和公开字段序列化配置。 */
public DispatchCommandHasher(ObjectMapper json) { this.json = json; }
/** 严格摘要绑定可信主体、动作、业务号及原版本,渠道不改变命令身份。 */
public String command(DispatchContracts.Actor actor, DispatchTypes.Action action, String dispatchNo,
Long recordVersion, Object request) {
var root = json.createObjectNode();
root.put("operatorId", actor.operatorId());
root.put("action", action.name());
root.put("dispatchNo", dispatchNo);
if (recordVersion == null) root.putNull("recordVersion");
else root.put("recordVersion", recordVersion);
JsonNode payload = json.valueToTree(request);
if (payload.isObject()) ((com.fasterxml.jackson.databind.node.ObjectNode) payload).remove("idempotencyKey");
root.set("payload", payload);
return sha256(canonical(root, ""));
}
/** 同车安排只按业务证据去重,不受操作者、请求键或纯资格版本影响。 */
public String arrangement(String dispatchNo, DispatchDeliveryModels.ArrangeDelivery request,
DispatchContracts.Resources resources) {
var root = json.createObjectNode();
root.put("dispatchNo", dispatchNo);
var payload = (com.fasterxml.jackson.databind.node.ObjectNode) json.valueToTree(request);
payload.remove(List.of("idempotencyKey", "recordVersion"));
root.set("payload", payload);
root.set("resources", json.valueToTree(resources));
return sha256(canonical(root, ""));
}
/** 来源版本复用同一规范化算法,调用方必须提供完整权威事实。 */
public String source(Object authoritativeFacts) { return sha256(canonical(json.valueToTree(authoritativeFacts), "")); }
/** 无序集合按规范内容排序;路线/站点等有序数组必须保留次序。 */
private String canonical(JsonNode node, String field) {
if (node == null || node.isNull()) return "null";
if (node.isObject()) {
var values = new TreeMap<String, String>();
node.fields().forEachRemaining(entry -> values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey())));
var result = new ArrayList<String>();
values.forEach((key, value) -> result.add(quoted(key) + ":" + value));
return "{" + String.join(",", result) + "}";
}
if (node.isArray()) {
var values = new ArrayList<String>();
node.forEach(value -> values.add(canonical(value, "")));
if (UNORDERED.contains(field)) values.sort(String::compareTo);
return "[" + String.join(",", values) + "]";
}
if (node.isNumber()) return node.decimalValue().stripTrailingZeros().toPlainString();
return node.toString();
}
/** 使用Jackson转义字段名,避免自制引号破坏规范JSON。 */
private String quoted(String value) { return json.getNodeFactory().textNode(value).toString(); }
/** 标准SHA256输出固定小写十六进制,不使用进程相关hashCode。 */
private static String sha256(String value) {
try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException("运行环境缺少SHA256", error); }
}
}
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.repository.DispatchCommandRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.Optional;
/** 严格命令身份和不可变结果;当前权限/绑定仓必须由领域入口先核验。 */
@Service
public class DispatchCommandLedger {
private final DispatchCommandRepository repository;
private final DispatchCommandCodec codec;
/** 数据访问经过Repository,编解码不依赖进程内结果对象。 */
public DispatchCommandLedger(DispatchCommandRepository repository, DispatchCommandCodec codec) {
this.repository = repository;
this.codec = codec;
}
/** 原键恢复优先于当前版本判断,但绝不向另一主体披露原结果。 */
@Transactional(propagation = Propagation.MANDATORY)
public Optional<DispatchContracts.CommandResult> replay(DispatchContracts.Actor actor, String dispatchNo,
DispatchContracts.CommandIdentity identity) {
requireIdentity(actor, identity);
DispatchCommandRecord record = repository.find(identity.idempotencyKey());
if (record == null) return Optional.empty();
if (!Objects.equals(record.operatorId(), actor.operatorId()))
throw new BizException(ResultCode.FORBIDDEN, "该请求标识不属于当前操作员");
boolean creatingUnknownNumber = dispatchNo == null && identity.action() == com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.CREATE;
if ((!creatingUnknownNumber && !Objects.equals(record.dispatchNo(), dispatchNo)) || !Objects.equals(record.action(), identity.action().name())
|| record.recordVersion() != identity.recordVersion() || !Objects.equals(record.commandHash(), identity.commandHash()))
throw new BizException(ResultCode.CONFLICT, "原请求标识不能更换排车、动作、版本或内容");
var result = codec.replay(record.resultJson());
if (!Objects.equals(result.dispatchNo(), record.dispatchNo()) || result.action() != identity.action()
|| !Objects.equals(result.idempotencyKey(), record.idempotencyKey()))
throw new IllegalStateException("排车命令账本身份与持久结果不一致");
return Optional.of(result);
}
/** GET恢复原命令:先检查当前主体/目标/原动作权限,再从持久JSON读取。 */
@Transactional(propagation = Propagation.MANDATORY)
public DispatchContracts.CommandResult readOriginal(DispatchContracts.Actor actor, String dispatchNo, String key,
java.util.Set<com.baiyue.lastleg.dispatch.port.DispatchTypes.Action> allowedActions) {
var record = repository.find(key);
if (record == null) throw new BizException(ResultCode.NOT_FOUND,"未找到该请求的已提交结果,请保持原请求核对");
if (actor == null || !Objects.equals(record.operatorId(),actor.operatorId()) || !Objects.equals(dispatchNo,record.dispatchNo()))
throw new BizException(ResultCode.FORBIDDEN,"无权读取该请求结果");
var action = com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.valueOf(record.action());
if (!allowedActions.contains(action)) throw new BizException(ResultCode.FORBIDDEN,"当前没有原动作权限");
return replay(actor,dispatchNo,new DispatchContracts.CommandIdentity(action,key,record.recordVersion(),record.commandHash())).orElseThrow();
}
/** 调用方已完成状态CAS后在同一事务保存结果,任何唯一键冲突必须整体回滚。 */
@Transactional(propagation = Propagation.MANDATORY)
public void append(Long dispatchId, DispatchContracts.Actor actor, DispatchContracts.CommandIdentity identity,
DispatchContracts.CommandResult result) {
requireIdentity(actor, identity);
if (result.replayed() || result.action() != identity.action()
|| !Objects.equals(result.idempotencyKey(), identity.idempotencyKey()))
throw new IllegalArgumentException("只能保存本次命令的原始结果");
var row = new DispatchCommandRecord(IdWorker.getId(), dispatchId, result.dispatchNo(), identity.action().name(),
actor.operatorId(), identity.idempotencyKey(), identity.recordVersion(), identity.commandHash(), codec.encode(result),
LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS), ZoneOffset.UTC));
try {
if (repository.insert(row) != 1) throw new IllegalStateException("排车命令结果未保存");
} catch (DuplicateKeyException error) {
// 不在已经写过主单/费用的事务中吞掉冲突返回成功,外层必须回滚后原键恢复。
throw new BizException(ResultCode.CONFLICT, "请求标识已使用,请保持原内容核对原结果");
}
}
/** 只接受可信主体和服务端产生的完整摘要,不允许空键或伪摘要。 */
private static void requireIdentity(DispatchContracts.Actor actor, DispatchContracts.CommandIdentity identity) {
if (actor == null || actor.operatorId() == null || actor.operatorId().isBlank())
throw new BizException(ResultCode.FORBIDDEN, "缺少可信操作身份");
if (identity == null || identity.action() == null || identity.idempotencyKey() == null
|| identity.idempotencyKey().isBlank() || identity.idempotencyKey().length() > 255
|| identity.recordVersion() < 0 || identity.commandHash() == null || !identity.commandHash().matches("[0-9a-f]{64}"))
throw new BizException(ResultCode.BAD_REQUEST, "命令标识、版本或摘要无效");
}
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository;
import com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper.RescanCandidate;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 提交后的正常签收汇总;父CAS、唯一完成历史和指定信号确认必须共同提交。 */
@Service
public class DispatchCompletionService implements DispatchCompletionPort {
private static final Actor SYSTEM=new Actor("SYSTEM_DELIVERY_RECONCILE",ActorMode.PLATFORM,null);
private final DispatchAggregateRepository repository;
private final DispatchExecutionRepository reads;
private final FleetTripExecutionService fleet;
private final DispatchExecutionPort execution;
private final DispatchDeliveryPort delivery;
private final DispatchSignalPort signals;
private final ObjectMapper json;
/** 仅依赖共同端口,禁止持签收子锁回调父流程。 */
public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet,
DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json) {
this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json;
}
/** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */
@Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
public CompletionResult reconcile(Signal signal) {
if(signal==null || signal.signalId()==null || signal.signalId()<=0 || signal.dispatchNo()==null || signal.deliveryNo()==null)throw new IllegalArgumentException("签收信号身份不完整");
var identity=fleet.lockDispatchCompletion(signal.dispatchNo());var row=repository.lock(signal.dispatchNo());
if(row==null || !Objects.equals(row.getTripId(),identity.tripId()))throw new IllegalStateException("整车身份已变化");
var context=execution.preview(SYSTEM,signal.dispatchNo());var facts=delivery.normalDeliveryFacts(context);
if(facts==null || !Objects.equals(facts.dispatchNo(),row.getDispatchNo()) || facts.expectedDeliveryNos()==null || !facts.expectedDeliveryNos().contains(signal.deliveryNo()))throw new IllegalStateException("签收信号不属于本车已安排派送");
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Set.of(Stage.DELIVERING.name(),Stage.DELIVERED.name()).contains(row.getStage()))throw new IllegalStateException("本车尚无合法在途运输事实");
boolean changed=false;var now=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
if(Stage.DELIVERING.name().equals(row.getStage()) && facts.allNormallySigned()) {
if(facts.completedAt()==null || facts.factsHash()==null || facts.factsHash().isBlank() || row.getDepartedAt()==null)throw new IllegalStateException("缺少正常签收完成依据");
long version=row.getRecordVersion();row.setStage(Stage.DELIVERED.name());row.setDeliveryCompletedAt(LocalDateTime.ofInstant(facts.completedAt(),ZoneOffset.UTC));
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(now);
if(repository.saveState(row,version)!=1)throw new IllegalStateException("整车完成版本竞争失败");
if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存");
changed=true;
}
// 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。
signals.acknowledge(signal,Instant.now());
return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(),
row.getDeliveryCompletedAt()==null?null:row.getDeliveryCompletedAt().toInstant(ZoneOffset.UTC));
}
/** 普通读候选不持子锁跨入汇总或重建事务。 */
@Transactional(readOnly=true)
public List<RescanCandidate> rescanCandidates(int limit) {
if(limit<1 || limit>200)throw new IllegalArgumentException("重扫范围不合法");return reads.rescan(limit);
}
/** 每个缺失信号独立短事务重核真实派送版本,竞争失败不阻塞下一张车。 */
@Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
public void rescan(RescanCandidate candidate) {signals.enqueue(candidate.dispatchNo(),candidate.deliveryNo(),candidate.deliveryVersion(),Instant.now());}
/** 证据序列化失败必须随主阶段一起回滚。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("正常签收证据保存失败",error);}}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.DispatchSignalPort;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Instant;
/** 有界、可重复的持久信号消费者,不在子事务持锁期间调用汇总。 */
@Component
public class DispatchDeliveryCompletionWorker {
private static final Logger LOG=LoggerFactory.getLogger(DispatchDeliveryCompletionWorker.class);
private final DispatchCompletionService completion;
private final DispatchSignalPort signals;
/** 调用独立代理服务,确保失败事务已回滚再持久记录退避。 */
public DispatchDeliveryCompletionWorker(DispatchCompletionService completion,DispatchSignalPort signals){this.completion=completion;this.signals=signals;}
/** 每批最多100条;单车失败不终止其他车次,已确认信号不会被失败记录重新激活。 */
@Scheduled(fixedDelayString="${lastleg.dispatch.delivery-completion-poll-ms:5000}")
public void consume() {
for(var signal:signals.due(Instant.now(),100))try{completion.reconcile(signal);}catch(RuntimeException error){
long delay=Math.min(900L,5L<<Math.min(8,Math.max(0,signal.attempts())));
try{signals.failed(signal,Instant.now().plusSeconds(delay),"DISPATCH_COMPLETION_FAILED");}
catch(RuntimeException recordingFailure){LOG.warn("正常签收汇总退避记录失败,后续扫描重试");}
}
}
/** 信号缺失也可按当前真实签收版本恢复;不造签收、退库或费用。 */
@Scheduled(fixedDelayString="${lastleg.dispatch.delivery-completion-rescan-ms:60000}",initialDelayString="${lastleg.dispatch.delivery-completion-rescan-ms:60000}")
public void rescan() {
for(var candidate:completion.rescanCandidates(100))try{completion.rescan(candidate);}catch(RuntimeException error){LOG.debug("正常签收重扫遇到并发版本变化,后续批次重核");}
}
}
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** Admin/PDA共用实际发车,扫码已执行的库存SHIP在此绝不重做。 */
@Service
public class DispatchDepartureService {
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;
private final DispatchSourceRepository sources;private final DispatchDeliveryPort delivery;
private final DispatchPdaWorkflowService sourceWorkflow;private final FleetTripExecutionService fleet;
private final DispatchCommandLedger commands;private final DispatchCommandHasher hashes;private final ObjectMapper json;
/** 注入共同派送端口,保留原来源/车队附加能力的真实生产者。 */
public DispatchDepartureService(DispatchAggregateService workflow,DispatchAggregateRepository repository,DispatchSourceRepository sources,
DispatchDeliveryPort delivery,DispatchPdaWorkflowService sourceWorkflow,FleetTripExecutionService fleet,DispatchCommandLedger commands,DispatchCommandHasher hashes,ObjectMapper json) {
this.workflow=workflow;this.repository=repository;this.sources=sources;this.delivery=delivery;this.sourceWorkflow=sourceWorkflow;this.fleet=fleet;this.commands=commands;this.hashes=hashes;this.json=json;
}
/** 原命令恢复先于当前阶段;新命令先锁齐所有出库派送,再锁齐真实来源订单。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult depart(Actor actor,String number,DispatchCommands.Depart request) {
var context=workflow.lockForArrangement(actor,number);var identity=new CommandIdentity(Action.DEPART,request.idempotencyKey(),request.recordVersion(),hashes.command(actor,Action.DEPART,number,request.recordVersion(),request));
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
workflow.requireFreshArrangement(context,request.recordVersion());var row=repository.lock(number);workflow.verifyReviewResources(row);
if(row.getArrangementKey()==null || row.getLoadingConfirmationJson()==null)throw conflict("请先完成装车并一次安排全部客户派送");
var confirmed=read(row.getLoadingConfirmationJson(),ConfirmedLoad.class);
var facts=delivery.departureFacts(context,true);
if(facts==null || facts.depart()==null || !facts.depart().allowed())throw conflict(facts==null || facts.depart()==null?"本车派送安排尚未核齐":facts.depart().blockReason());
if(!Objects.equals(facts.arrangementKey(),row.getArrangementKey()) || !Objects.equals(facts.arrangementBusinessHash(),row.getArrangementBusinessHash())
|| !Objects.equals(facts.cargoHash(),context.cargoHash()) || !Objects.equals(facts.loadingCompletionHash(),context.loadingCompletionHash()))throw conflict("本车实际货物或派送安排已变化");
fleet.requireDispatchDeparture(number,confirmed.manifestHash());
var orderOwners=new TreeMap<Long,String>();for(var cargo:context.cargo())orderOwners.put(cargo.orderId(),cargo.customerId());
for(var source:orderOwners.entrySet())if(sources.lockOrder(source.getValue(),source.getKey())==null)throw conflict("来源订单缺失或已暂扣,不能发车");
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var departed=delivery.markDeparted(context,actor,at,request.idempotencyKey());
if(departed.shippedPackages()!=context.counts().packageCount())throw conflict("实际发运单据与本车箱数未核平");
sourceWorkflow.departWholeSources(context,actor,departed,at);
for(var item:repository.cargo(row.getId()))if(sources.release(row.getId(),item,LocalDateTime.ofInstant(at,ZoneOffset.UTC))<1)throw conflict("原发运来源占用已变化");
fleet.departDispatchShell(number,actor.operatorId(),at,request.remark());long version=row.getRecordVersion();
row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(departed.shippedPackages()));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
if(repository.saveState(row,version)!=1)throw conflict("本车发车版本竞争失败");
if(repository.history(IdWorker.getId(),row,Action.DEPART.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"departure",departed)),row.getUpdatedAt())!=1)throw conflict("发车历史未保存");
var result=new CommandResult(number,Action.DEPART,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.DELIVERING,at,Effect.APPLIED,
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Departed(at,departed.shippedPackages(),departed.outbounds(),departed.deliveries()),false,false);
commands.append(row.getId(),actor,identity,result);return result;
}
/** 原装车快照不能缺项回退成当前建议值。 */
private <T>T read(String value,Class<T> type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("装车快照读取失败",error);}}
/** 发车证据失败与单据更新一起回滚。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("发车证据保存失败",error);}}
/** 并发、来源或阶段不满足时整条命令失败。 */
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
}
@@ -0,0 +1,355 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
import com.baiyue.lastleg.dispatch.entity.DispatchDraftCommand;
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftClaimMapper;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchDraftRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
import java.util.function.Consumer;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
/** 平台单仓多客户草稿;持久命令、服务器来源、排他占用和整批创建共同提交。 */
@Service
public class DispatchDraftService implements DispatchPlanningPort {
private static final TypeReference<List<Candidate>> CANDIDATES = new TypeReference<>() { };
private static final TypeReference<List<String>> KEYS = new TypeReference<>() { };
private final DispatchDraftRepository repository;
private final DispatchSourceRepository sources;
private final DispatchService sourceRules;
private final DispatchAggregateService aggregate;
private final DispatchCommandHasher hashes;
private final DispatchCommandCodec commandCodec;
private final ObjectMapper json;
private final Clock clock;
private final AppointmentWorkDispatchPort appointmentProvider;
/** 只装配草稿仓储和实际来源/主单提供者,不持久化客户端伪造候选。 */
@org.springframework.beans.factory.annotation.Autowired
public DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules,
DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider) {
this(repository,sources,sourceRules,aggregate,hashes,json,commandCodec,appointmentProvider,Clock.systemUTC());
}
/** 测试可控制锁等待跨越的时间边界,生产始终使用UTC系统时钟。 */
DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules,
DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,Clock clock) {
this.repository=repository;this.sources=sources;this.sourceRules=sourceRules;
this.aggregate=aggregate;this.hashes=hashes;this.json=json;this.commandCodec=commandCodec;this.appointmentProvider=appointmentProvider;this.clock=Objects.requireNonNull(clock);
}
/** 候选继续委托主流程已有真实查询和权限范围。 */
@Override public CandidateList candidates(Actor actor,CandidateQuery query){platform(actor);return aggregate.candidates(actor,query);}
/** 只读已保存来源快照,不把历史版本推断成最新可转换资格。 */
@Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public List<Draft> drafts(Actor actor){platform(actor);var rows=repository.active(actor.operatorId());var batches=readCandidateBatches(rows.stream().flatMap(r->selected(r).stream()).toList());return rows.stream().map(r->view(r,batches)).toList();}
/** 服务器生成token,零版本创建结果可在进程重启后按原命令恢复。 */
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
public Draft createDraft(Actor actor,CreateDraft request) {
platform(actor);if(request==null || request.warehouseId()==null || !request.warehouseId().matches("[1-9][0-9]*"))throw bad("请选择海外起运仓");
var command=command(actor,"CREATE_DRAFT","",request.idempotencyKey(),request);
if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class);
var now=now();var row=new DispatchDraft();row.setId(IdWorker.getId());
row.setDraftToken("DD-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT));
row.setOperatorId(actor.operatorId());row.setWarehouseId(request.warehouseId());row.setRecordVersion(0L);
row.setPlanIdsJson("[]");row.setContainersJson("[]");row.setSourcesJson("[]");row.setStatus("ACTIVE");
row.setCreatedAt(now);row.setUpdatedAt(now);row.setExpiresAt(now.plusMinutes(30));repository.insert(row);
return complete(command,view(row));
}
/** 新容器只归本拥有者草稿,容量沿既有15/30托选项。 */
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
public Draft createContainer(Actor actor,CreateContainer request) {
Objects.requireNonNull(request);capacity(request.capacity());
return change(actor,"CREATE_CONTAINER",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{
var all=repository.containers(row.getDraftToken());if(all.size()>=100)throw bad("每个草稿最多100个容器");
var container=new DispatchDraftContainer();container.setId(IdWorker.getId());container.setDraftId(row.getId());container.setDraftToken(row.getDraftToken());
container.setSequenceNo(all.stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0)+1);
container.setCapacity(request.capacity());container.setSourceKeysJson("[]");container.setCreatedAt(now());container.setUpdatedAt(now());repository.insert(container);
});
}
/** 来源资格和版本取真实提供者,锁定后才取得共享来源/实托占用。 */
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
public Draft select(Actor actor,DraftSelection request) {
Objects.requireNonNull(request);
return change(actor,"SELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{
var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source());
if(selected.stream().anyMatch(c->c.sourceKey().equals(key)))throw conflict("来源已在本草稿中,请勿重复选择");
var containers=repository.containers(row.getDraftToken());var container=container(containers,request.draftContainerId());
var selections=new ArrayList<>(selected.stream().map(Candidate::selection).toList());selections.add(request.source());
var locked=sourceRules.lockDraftSources(row.getWarehouseId(),selections);
requireLease(row.getExpiresAt());
verifyClaims(row,selected,containers);
var resolved=locked.stream().filter(s->s.sourceKey().equals(key)).findFirst().orElseThrow(()->conflict("所选来源不存在"));
var candidate=candidate(resolved);var next=new ArrayList<>(selected);next.add(candidate);
var keys=new ArrayList<>(keys(container));keys.add(key);requireCapacity(container.getCapacity(),keys,index(next));
var at=now();sources.expire(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at);
if(!sources.claims(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at).isEmpty())throw conflict("所选来源或实托已被其他作业占用");
requireLease(row.getExpiresAt());
try {repository.claim(IdWorker.getId(),row,container.getId(),new DispatchDraftRepository.DispatchServiceSource(candidate.customerId(),key,
resolved.candidate().planPalletId(),resolved.candidate().warehousePalletId(),resolved.candidate().returnReceiptId()),at);}
catch(DuplicateKeyException error){throw conflict("所选来源或实托已被其他作业占用");}
container.setSourceKeysJson(write(keys));container.setUpdatedAt(at);repository.layout(container);row.setSourcesJson(write(next));
});
}
/** 解除只认原容器与原来源完整身份;来源变旧不阻止拥有者释放自己的占用。 */
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
public Draft unselect(Actor actor,DraftSelection request) {
Objects.requireNonNull(request);
return change(actor,"UNSELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{
var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source());
var current=index(selected).get(key);if(current==null || !Objects.equals(current.selection(),request.source()))throw conflict("原来源身份或版本不一致");
var container=container(repository.containers(row.getDraftToken()),request.draftContainerId());var keys=new ArrayList<>(keys(container));
if(!keys.remove(key))throw conflict("来源不属于本容器");
if(repository.release(row,key,container.getId(),now())!=1)throw conflict("原草稿占用已变化");
container.setSourceKeysJson(write(keys));container.setUpdatedAt(now());repository.layout(container);
row.setSourcesJson(write(selected.stream().filter(c->!c.sourceKey().equals(key)).toList()));
});
}
/** 完整布局只能重排当前服务器来源和容器,不能用JSON新增货物或绕过容量。 */
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
public Draft saveSnapshot(Actor actor,DraftSnapshot request) {
Objects.requireNonNull(request);
return change(actor,"SNAPSHOT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{
var selected=selected(row);var old=repository.containers(row.getDraftToken());
if(request.containers()==null || request.containers().size()!=old.size())throw bad("布局必须包含全部原容器");
var remaining=new HashSet<>(old.stream().map(DispatchDraftContainer::getId).toList());var seen=new HashSet<String>();var sequence=new HashSet<Integer>();
var mapping=new HashMap<String,Long>();var byKey=index(selected);
for(var c:request.containers()) {
if(c==null || !remaining.remove(c.draftContainerId()) || c.sequenceNo()<1 || !sequence.add(c.sequenceNo()) || c.sourceKeys()==null)throw bad("容器身份或顺序不完整");
capacity(c.capacity());requireCapacity(c.capacity(),c.sourceKeys(),byKey);
for(var key:c.sourceKeys()){if(!seen.add(key))throw bad("同一来源不能重复放入容器");mapping.put(key,c.draftContainerId());}
}
if(!remaining.isEmpty() || !seen.equals(byKey.keySet()))throw conflict("布局只能完整移动当前已锁来源");
if(request.planIds()==null || request.planIds().stream().anyMatch(id->id==null || id<=0) || request.planIds().size()>500)throw bad("计划筛选不合法");
sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList());
var claims=verifyClaims(row,selected,old);var at=now();
for(var claim:claims)if(!Objects.equals(claim.containerId(),mapping.get(claim.sourceKey())))repository.move(row,claim,mapping.get(claim.sourceKey()),at);
repository.stageSequences(row.getDraftToken());
for(var c:request.containers()) {var existing=container(old,c.draftContainerId());existing.setSequenceNo(c.sequenceNo());existing.setCapacity(c.capacity());
existing.setSourceKeysJson(write(c.sourceKeys()));existing.setUpdatedAt(at);repository.layout(existing);}
row.setPlanIdsJson(write(request.planIds()));
});
}
/** 仅移除空容器;来源必须通过显式解除动作处理。 */
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
public Draft removeContainer(Actor actor,String token,Long id,DispatchCommands.ReasonCommand request) {
Objects.requireNonNull(request);if(request.reason()==null || request.reason().isBlank())throw bad("移除容器原因必填");
return change(actor,"REMOVE_CONTAINER",token,request.recordVersion(),request.idempotencyKey(),Arrays.asList(token,id,request),row->{
var container=container(repository.containers(token),id);
if(!keys(container).isEmpty() || repository.claims(row,now()).stream().anyMatch(c->Objects.equals(c.containerId(),id)))throw conflict("仅空容器可以删除");
repository.remove(token,container.getId());
});
}
/** 释放原拥有者草稿,绝不按过期token释放后来归属正式单或其他人的记录。 */
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
public Draft release(Actor actor,DraftRelease request) {
Objects.requireNonNull(request);
return change(actor,"RELEASE",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{
repository.release(row,null,null,now());row.setStatus("RELEASED");row.setReleasedAt(now());row.setSourcesJson("[]");
for(var c:repository.containers(row.getDraftToken())){c.setSourceKeysJson("[]");c.setUpdatedAt(now());repository.layout(c);}
});
}
/** 全组先资源后来源,随后释放原草稿占用并调用真实整车创建;任一失败全批回滚。 */
@Override @Transactional(isolation=Isolation.READ_COMMITTED)
public ConvertedDraft convert(Actor actor,ConvertDraft request) {
Objects.requireNonNull(request);platform(actor);
var command=command(actor,"CONVERT",request.draftToken(),request.idempotencyKey(),request);
if(command.getResultJson()!=null)return readConverted(command.getResultJson(),request.draftToken());
var row=owned(actor,request.draftToken(),request.recordVersion());var deadline=row.getExpiresAt();var selected=selected(row);var containers=repository.containers(row.getDraftToken());
var nonempty=containers.stream().filter(c->!keys(c).isEmpty()).toList();
if(request.groups()==null || request.groups().isEmpty() || request.groups().size()!=nonempty.size())throw bad("须一次转换全部非空容器");
var byId=new TreeMap<Long,DraftVehicle>();var childKeys=new HashSet<String>();
for(var group:request.groups()) {
if(group==null || group.dispatch()==null || group.draftContainerId()==null || byId.putIfAbsent(group.draftContainerId(),group)!=null)throw bad("转换分组缺失或重复");
var c=container(nonempty,group.draftContainerId());var create=group.dispatch();
if(!Objects.equals(create.warehouseId(),row.getWarehouseId()))throw conflict("整批转换必须使用草稿原起运仓");
if(create.idempotencyKey()==null || create.idempotencyKey().isBlank() || !childKeys.add(create.idempotencyKey()))throw bad("每辆车须有独立创建命令键");
var original=selected.stream().filter(s->keys(c).contains(s.sourceKey())).map(Candidate::selection).toList();
if(!selectionIndex(original).equals(selectionIndex(create.cargo())))throw conflict("创建货物必须精确等于原容器来源及版本");
requireCapacity(c.getCapacity(),keys(c),index(selected));
}
if(!byId.keySet().equals(new TreeSet<>(nonempty.stream().map(DispatchDraftContainer::getId).toList())))throw bad("转换必须覆盖全部原容器");
// 该提供者只锁全批资源,不写主单;绝不能先锁来源再锁下一辆车的资源。
aggregate.lockDraftCreationResources(actor,byId.values().stream().map(DraftVehicle::dispatch).toList());
sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList());
requireLease(deadline);
verifyClaims(row,selected,containers);
if(repository.release(row,null,null,now())!=selected.size())throw conflict("草稿来源占用未完整核齐");
var results=new ArrayList<ConvertedVehicle>();
for(var group:byId.values()) {var result=aggregate.create(actor,group.dispatch());
if(result==null || result.replayed() || result.action()!=DispatchTypes.Action.CREATE)throw conflict("子创建命令已被其他批次使用");
results.add(new ConvertedVehicle(group.draftContainerId(),result));}
long version=row.getRecordVersion();row.setRecordVersion(Math.addExact(version,1));row.setStatus("CONVERTED");row.setReleasedAt(now());row.setUpdatedAt(now());repository.save(row,version);
var result=complete(command,new ConvertedDraft(row.getDraftToken(),List.copyOf(results),false));
requireLease(deadline);return result;
}
/** 草稿写操作统一先恢复原命令,再校验当前拥有者、生命周期与公共版本。 */
private Draft change(Actor actor,String action,String token,long version,String key,Object request,Consumer<DispatchDraft> mutation) {
platform(actor);var command=command(actor,action,token,key,request);if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class);
var row=owned(actor,token,version);var deadline=row.getExpiresAt();mutation.accept(row);long before=row.getRecordVersion();
requireLease(deadline);
var at=now();row.setRecordVersion(Math.addExact(before,1));row.setUpdatedAt(at);
if("ACTIVE".equals(row.getStatus())){
row.setExpiresAt(at.plusMinutes(30));int renewed=repository.renew(row,at);
requireLease(deadline);
var claims=verifyClaims(row,selected(row),repository.containers(token));
// 部分驱动对未改变值返回0,最终以锁内有效行、精确归属和完整到期时间证明续约;不能只延长根。
if(renewed<0 || renewed>claims.size())throw conflict("草稿来源续约集合不完整");
}
repository.save(row,before);var result=complete(command,view(row));requireLease(deadline);return result;
}
/** 同键完整身份不一致拒绝,结果未知时只读取数据库原结果,不推断未执行。 */
private DispatchDraftCommand command(Actor actor,String action,String token,String key,Object payload) {
if(key==null || key.isBlank() || key.length()>128)throw bad("命令键必填且最多128字符");
if(token==null || token.length()>128)throw bad("草稿标识无效");
var command=new DispatchDraftCommand();command.setId(IdWorker.getId());command.setIdempotencyKey(key);command.setOperatorId(actor.operatorId());
command.setAction(action);command.setTargetToken(token);command.setRequestHash(hashes.source(Arrays.asList(actor.operatorId(),actor.mode(),action,token,payload)));command.setCreatedAt(now());
var saved=repository.reserve(command);
if(saved==null || !Objects.equals(saved.getOperatorId(),actor.operatorId()) || !Objects.equals(saved.getAction(),action)
|| !Objects.equals(saved.getTargetToken(),token) || !Objects.equals(saved.getRequestHash(),command.getRequestHash()))throw conflict("相同命令键的主体、动作、版本或载荷不同");
if(!Objects.equals(saved.getId(),command.getId()) && saved.getResultJson()==null)throw new IllegalStateException("已提交草稿命令缺少原结果");
return saved;
}
/** 精确拥有者查询避免暴露他人草稿;正常变更不复活过期或已转换状态。 */
private DispatchDraft owned(Actor actor,String token,long version) {
var row=repository.lock(token,actor.operatorId());if(row==null)throw new BizException(ResultCode.NOT_FOUND,"草稿不存在或不属于当前操作者");
if(!"ACTIVE".equals(row.getStatus()))throw conflict("草稿已释放或转换");requireLease(row.getExpiresAt());
if(row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("草稿版本已变化,请刷新");return row;
}
/** 来源、容器与实际草稿占用逐项相等,不以JSON或总数相等替代归属。 */
private List<DispatchDraftClaimMapper.Claim> verifyClaims(DispatchDraft row,List<Candidate> selected,List<DispatchDraftContainer> containers) {
var claims=repository.claims(row,now());var mapping=new HashMap<String,Long>();
for(var c:containers)for(var key:keys(c))if(mapping.putIfAbsent(key,c.getId())!=null)throw conflict("草稿来源被重复放入容器");
var expected=index(selected);if(!mapping.keySet().equals(expected.keySet()) || claims.size()!=expected.size())throw conflict("草稿来源及占用未完整对应");
var seen=new HashSet<String>();for(var claim:claims){var c=expected.get(claim.sourceKey());
if(c==null || !seen.add(claim.sourceKey()) || !Objects.equals(c.customerId(),claim.customerId())
|| !Objects.equals(c.selection().warehousePalletId(),claim.palletId()) || !Objects.equals(mapping.get(claim.sourceKey()),claim.containerId())
|| !Objects.equals(row.getExpiresAt(),claim.expiresAt()))throw conflict("草稿原占用身份或有效期已变化");}
return claims;
}
/** 公开候选快照沿主流程同一字段映射,所有身份和版本来自锁内真实来源。 */
private Candidate candidate(DispatchService.WholeSource source) {
var r=source.candidate();var d=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(r.destinationType(),r.destinationWarehouseCode(),r.fbaWarehouseCode(),r.deliveryAddress(),r.postalCode()),
r.destinationType(),r.destinationWarehouseId(),r.destinationWarehouseCode(),r.destinationWarehouseName(),r.deliveryAddress(),r.postalCode(),r.contactName(),r.contactPhone());
var selection=new DispatchCommands.CargoSelection(source.selection().sourceType(),source.selection().planPalletId(),source.selection().returnReceiptId(),source.selection().relabelNo(),r.warehousePalletId(),source.sourceVersion());
var candidate=new Candidate(selection,source.sourceKey(),source.sourceVersion(),r.customerId(),r.customerName(),r.overseasWarehouseId(),r.overseasWarehouseCode(),r.overseasWarehouseName(),
r.orderNo(),r.inboundCode(),r.planNo(),r.palletNo(),r.containerNo(),d,r.packageCount(),r.grossWeightKg(),r.volumeCbm(),"SOURCE_SUGGESTION",List.of(),List.of());
return projectCandidate(candidate,readCandidateBatches(List.of(candidate)));
}
/** 单容器容量按实际托盘去重;未收货计划托盘按真实来源键计位。 */
private static void requireCapacity(int capacity,List<String> keys,Map<String,Candidate> sources) {
var physical=new HashSet<String>();var seen=new HashSet<String>();
for(var key:keys){var source=sources.get(key);if(source==null || !seen.add(key))throw bad("布局包含未知或重复来源");
physical.add(source.selection().warehousePalletId()==null?key:"PALLET:"+source.selection().warehousePalletId());}
if(physical.size()>capacity)throw conflict("本容器实托数量超过容量");
}
/** 只接受既有15/30托容器,不将计划容量当成装载实测。 */
private static void capacity(int capacity){if(capacity!=15 && capacity!=30)throw bad("容器容量仅支持15托或30托");}
/** 按来源键核对完整原始选择,缺失或重复不能退化为部分转换。 */
private static Map<String,DispatchCommands.CargoSelection> selectionIndex(List<DispatchCommands.CargoSelection> selections){
if(selections==null)throw bad("创建货物来源必填");var result=new TreeMap<String,DispatchCommands.CargoSelection>();
for(var s:selections)if(result.putIfAbsent(DispatchService.wholeSourceKey(s),s)!=null)throw bad("来源重复");return result;
}
/** 草稿内来源键唯一,不能以覆盖Map掩盖重复持久数据。 */
private static Map<String,Candidate> index(List<Candidate> selected){var result=new TreeMap<String,Candidate>();for(var c:selected)if(result.putIfAbsent(c.sourceKey(),c)!=null)throw conflict("草稿来源重复");return result;}
/** 容器必须是本草稿中实际已创建的服务器身份。 */
private static DispatchDraftContainer container(List<DispatchDraftContainer> containers,Long id){if(id==null)throw bad("容器标识必填");return containers.stream().filter(c->Objects.equals(c.getId(),id)).findFirst().orElseThrow(()->conflict("容器不属于本草稿"));}
/** 只读服务器持久来源,不解析客户端自定义候选。 */
private List<Candidate> selected(DispatchDraft row){return read(row.getSourcesJson(),CANDIDATES);}
/** 容器布局只保存来源键,实际资格取草稿服务器来源。 */
private List<String> keys(DispatchDraftContainer row){return read(row.getSourceKeysJson(),KEYS);}
/** 返回公开单一版本及来源快照,不泄露内部命令或存储字段。 */
private Draft view(DispatchDraft row){return view(row,readCandidateBatches(selected(row)));}
/** 整页复用一份权威批读;不改来源摘要、草稿版本、占用或命令结果的重放语义。 */
private Draft view(DispatchDraft row,Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> batches){return new Draft(row.getDraftToken(),row.getRecordVersion(),row.getStatus(),row.getWarehouseId(),repository.containers(row.getDraftToken()).stream()
.map(c->new DraftContainer(c.getId(),c.getSequenceNo(),c.getCapacity(),keys(c))).toList(),selected(row).stream().map(c->projectCandidate(c,batches)).toList(),row.getExpiresAt().toInstant(ZoneOffset.UTC));}
/** 真实客户及计划托盘来自服务器来源,整页一次调用原Provider。 */
private Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> readCandidateBatches(List<Candidate> candidates) {
var identities=candidates.stream().filter(c->c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList();
var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches>();
for(int offset=0;offset<identities.size();offset+=1000)result.putAll(appointmentProvider.candidateBatches(identities.subList(offset,Math.min(offset+1000,identities.size()))));
return result;
}
/** 显式映射原批次版本;本草稿有效占用不作为所选来源自身的阻断理由。 */
private Candidate projectCandidate(Candidate c,Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> batches) {
if(c.selection().planPalletId()==null)return c;
var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId()));
var reasons=new LinkedHashSet<String>();
if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons());
var refs=result==null?List.<BatchReference>of():result.appointmentBatches().stream().map(b->new BatchReference(b.batchKey(),b.batchNo(),b.customerId(),
b.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),b.recordVersion())).toList();
return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),refs,List.copyOf(reasons));
}
/** 原结果完整保存,异常时随所有业务写入一起回滚。 */
private <T>T complete(DispatchDraftCommand command,T result){repository.complete(command.getId(),write(result));return result;}
/** 原批结果的每张车使用公共Action编解码器恢复,不能让抽象Payload触发当前快照或重新创建回退。 */
private ConvertedDraft readConverted(String stored,String expectedToken) {
try {
var root=json.readTree(stored);
if(root==null || !root.isObject() || !root.hasNonNull("draftToken") || !root.hasNonNull("vehicles")
|| !root.get("vehicles").isArray() || root.get("vehicles").isEmpty() || !root.hasNonNull("replayed")
|| !root.get("replayed").isBoolean())throw new IllegalArgumentException("原转换结果字段不完整");
String token=json.treeToValue(root.get("draftToken"),String.class);
if(!Objects.equals(token,expectedToken))throw new IllegalArgumentException("原转换结果不属于本草稿");
var vehicles=new ArrayList<ConvertedVehicle>();var ids=new HashSet<Long>();
for(var vehicle:root.get("vehicles")) {
if(!vehicle.isObject() || !vehicle.hasNonNull("draftContainerId") || !vehicle.hasNonNull("dispatch"))throw new IllegalArgumentException("原转换车辆字段不完整");
Long id=json.treeToValue(vehicle.get("draftContainerId"),Long.class);
if(id==null || id<=0 || !ids.add(id))throw new IllegalArgumentException("原转换容器身份不完整");
var dispatch=commandCodec.decode(vehicle.get("dispatch").toString());
if(dispatch.action()!=DispatchTypes.Action.CREATE)throw new IllegalArgumentException("原转换车辆动作不是创建");
vehicles.add(new ConvertedVehicle(id,dispatch));
}
// 仅批次返回标记为重放,子车仍是不可变原结果,原版本/时间/载荷逐项保留。
return new ConvertedDraft(token,List.copyOf(vehicles),true);
}catch(JsonProcessingException | IllegalArgumentException error){throw new IllegalStateException("草稿转换原结果损坏",error);}
}
/** 复用配置好的时间和Long序列化,持久结果不得保留内存对象引用。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("草稿结果保存失败",error);}}
/** 进程重启后从不可变JSON恢复原公开结果。 */
private <T>T read(String value,Class<T> type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("草稿结果损坏",error);}}
/** 来源和布局使用封闭类型读取,非法存储不能按空草稿降级。 */
private <T>T read(String value,TypeReference<T> type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("草稿来源损坏",error);}}
/** 草稿只属于平台操作者,客户和PDA不能复用整车写入口。 */
private static void platform(Actor actor){if(actor==null || actor.operatorId()==null || actor.operatorId().isBlank() || actor.mode()!=DispatchTypes.ActorMode.PLATFORM)throw new BizException(ResultCode.FORBIDDEN,"仅平台操作者可操作草稿");}
/** 存储时间统一为UTC,本地进程时区不改变过期或幂等身份。 */
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS),ZoneOffset.UTC);}
/** 等待锁、续约或写入后仍须在原有效期内,不能在过期后复活占用再返回成功。 */
private void requireLease(LocalDateTime deadline){if(deadline==null || !deadline.isAfter(now()))throw conflict("草稿已过期,请重新创建并核对来源");}
/** 参数错误采用统一业务结果码。 */
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
/** 状态或依据变化不能被解释为新的成功命令。 */
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
}
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchPdaReadRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** PDA按绑定仓读取真正整车,列表批量装配全部明细而不逐行请求详情。 */
@Service
public class DispatchPdaQueryService {
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
/** 列表只展示绑定仓实际作业,阶段使用共同枚举。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public List<DispatchViews.PdaTask> list(Actor actor,Stage stage){require(actor);return assemble(actor,repository.headers(actor.boundWarehouseId(),null,stage==null?null:stage.name()));}
/** 单车也先在SQL约束绑定仓,其他仓业务编号不可用于读取。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.PdaTask task(Actor actor,String number) {
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
}
/** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
var picked=repository.picked(ids).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
var invalid=repository.invalid(ids).stream().collect(Collectors.toMap(p->p.dispatchId(),p->p.quantity()));
var positions=repository.positions(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(p->p.getCustomerId()+":"+p.getPalletId()));
var cartons=repository.cartons(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(c->c.itemId()));var result=new ArrayList<DispatchViews.PdaTask>();
for(var root:roots) {
var items=cargo.getOrDefault(root.getId(),List.of());var detail=new ArrayList<DispatchViews.PdaItem>();
for(var item:items) {
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType());
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages())+(relabel?n(p.getHoldPackages()):0),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
var sourceCartons=cartons.getOrDefault(item.getId(),List.of()).stream().filter(c->marks.contains(c.cartonMark()))
.map(c->new DispatchViews.ReturnCarton(c.cartonId(),c.cartonMark(),c.status(),c.productQuantity())).toList();
detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons));
}
boolean complete=n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0
&& !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount()));
var actions=new EnumMap<Action,Gate>(workflow.actions(actor,root));
if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平"));
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
}
return List.copyOf(result);
}
/** 仓和操作者均来自受信身份,不接收请求自报。 */
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
/** 缺失计数不作为业务标识使用。 */
private static long n(Number value){return value==null?0:value.longValue();}
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.pda.entity.*;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
@Service
public class DispatchPickingService {
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
private final DispatchCommandLedger commands;private final DispatchCommandHasher hashes;private final ObjectMapper json;
/** 库存命令与来源归属继续使用既有真实生产者,不用请求数量伪造库存。 */
public DispatchPickingService(DispatchAggregateService workflow,DispatchAggregateRepository repository,DispatchSourceRepository sources,
DispatchPickingRepository picking,DispatchPickOrderAttributionService attribution,InventoryCommandService inventory,
ReturnRelabelDispatchStock returns,DispatchCommandLedger commands,DispatchCommandHasher hashes,ObjectMapper json) {
this.workflow=workflow;this.repository=repository;this.sources=sources;this.picking=picking;this.attribution=attribution;this.inventory=inventory;this.returns=returns;this.commands=commands;this.hashes=hashes;this.json=json;
}
/** 原键恢复先于当前版本/阶段,全部写入跟随同一根事务回滚。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult pick(Actor actor,String number,long itemId,DispatchCommands.Pick request) {
if(actor==null || actor.mode()!=ActorMode.PDA || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可扫码");
var context=workflow.lockForArrangement(actor,number);var row=repository.lock(number);
var identity=new CommandIdentity(Action.PICK,request.idempotencyKey(),request.recordVersion(),hashes.command(actor,Action.PICK,number,request.recordVersion(),Arrays.asList(itemId,request)));
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING
|| context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码");
if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法");
try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");}
var item=repository.cargo(row.getId()).stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在"));
if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓");
materialize(row.getId(),item);
var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC);
if(task==null) {
task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId());
task.setTaskNo(number);
task.setStatus("PICKING");task.setWarehouseId(actor.boundWarehouseId());task.setPushedAt(at);task.setCreatedAt(at);task.setUpdatedAt(at);
if(picking.createTask(task)!=1)throw conflict("本车作业未保存");
}
if(!Objects.equals(task.getWarehouseId(),actor.boundWarehouseId()))throw conflict("本车作业仓已变化");
long before=picking.itemPicked(item.getCustomerId(),task.getId(),itemId);
if(request.packages()>item.getPackageCount()-before)throw conflict("实际扫码超过本来源应装箱数");
boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage());
if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位");
long pickId=IdWorker.getId();Long returnCartonId=null;
if(nativeReturn) {
attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId());
returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId();
} else {
var position=picking.position(request.positionId());
if(position==null || !Objects.equals(item.getCustomerId(),position.getCustomerId()) || !Objects.equals(item.getWarehousePalletId(),position.getPalletId()) || !Objects.equals(actor.boundWarehouseId(),position.getOverseasWarehouseId()))throw conflict("实际库存仓位不属于本车本客户实托");
Long fallback="RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null;
attribution.preparePicking(item.getCustomerId(),request.positionId(),fallback);
var actual=sources.stock(item.getCustomerId(),item.getWarehousePalletId(),true).stream().filter(s->s.id()==request.positionId()).findFirst().orElseThrow(()->conflict("实际库存仓位已移动"));
if(!Objects.equals(actual.warehouseId(),actor.boundWarehouseId()))throw conflict("实际库存起运仓已变化");
String base="DISPATCH:"+number+":"+itemId+":"+request.idempotencyKey();
if(fallback!=null)inventory.release(item.getCustomerId(),actor.operatorId(),move(request,number,item.getSourceBusinessNo(),base+":RELABEL_RELEASE"));
inventory.allocate(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":ALLOCATE"));
inventory.pick(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":PICK"));
inventory.ship(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":SHIP"));
}
var pick=new DispatchPdaPick();pick.setId(pickId);pick.setCustomerId(item.getCustomerId());pick.setTaskId(task.getId());pick.setDispatchItemId(itemId);
pick.setInventoryPositionId(request.positionId());pick.setReturnCartonId(returnCartonId);pick.setPackages(request.packages());pick.setProductQuantity(request.productQuantity());pick.setIdempotencyKey(request.idempotencyKey());pick.setPickedAt(at);pick.setCreatedAt(at);
if(picking.pick(pick)!=1)throw conflict("真实扫码明细未保存");
if(nativeReturn)attribution.attributeNativeReturn(item.getCustomerId(),pick,item.getOrderId());
else attribution.attribute(item.getCustomerId(),pick,"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null,item.getReturnReceiptId());
task.setOperatorId(actor.operatorId());task.setStatus("PICKING");task.setUpdatedAt(at);if(picking.saveTask(task)!=1)throw conflict("本车作业进度未保存");
long version=row.getRecordVersion();row.setPickedPackageCount(Math.toIntExact(repository.realPicked(row.getId())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败");
boolean complete=workflow.preview(actor,number).pickingReconciled();
if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存");
var result=new CommandResult(number,Action.PICK,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.LOADING,at.toInstant(ZoneOffset.UTC),Effect.APPLIED,
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
commands.append(row.getId(),actor,identity,result);return result;
}
/** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */
private void materialize(long dispatch,DispatchItem item) {
if(item.getWarehousePalletId()!=null)return;
if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托");
var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
if(orders.isEmpty())throw conflict("原计划缺少真实订单组成");
for(var id:orders)if(sources.lockOrder(item.getCustomerId(),id)==null)throw conflict("原计划订单已变化或暂扣");
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0);
if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|| !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType())
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对");
if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单");
try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC))!=1)throw conflict("原计划来源占用已变化");}
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");}
item.setWarehousePalletId(actual.warehousePalletId());item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存");
}
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());}
/** 不可变扫码证据序列化失败随根命令回滚。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("扫码证据保存失败",error);}}
/** 实际来源或版本冲突不能降级为成功。 */
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
/** 明确请求值错误。 */
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
}

Some files were not shown because too many files have changed in this diff Show More