Unify dispatch operations with matched published transfer fixture

This commit is contained in:
2026-10-20 00:29:25 +08:00
parent a563f63857
commit d7616899d8
346 changed files with 29244 additions and 12579 deletions
@@ -19,11 +19,11 @@ public interface AppointmentImpactRelationMapper {
""")
List<Appointment> appointments(String id);
@Select("""
SELECT DISTINCT d.customer_id,d.dispatch_no FROM ll_order o
SELECT DISTINCT i.customer_id,d.dispatch_no FROM ll_order o
JOIN ll_dispatch_item i ON i.order_id=o.id AND i.customer_id=o.customer_id
JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.customer_id=i.customer_id
JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id
WHERE o.source_container_booking_id=#{id} AND o.order_type='FIRST_LEG' AND o.current_status NOT IN ('CANCELLED','COMPLETED')
AND d.status NOT IN ('CANCELLED','COMPLETED') ORDER BY d.dispatch_no LIMIT 1001
AND d.status='ACTIVE' AND d.stage<>'DELIVERED' ORDER BY d.dispatch_no LIMIT 1001
""")
List<AppointmentImpactTransportGateway.Dispatch> dispatches(String id);
}
@@ -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);
}
@@ -12,7 +12,7 @@ import java.util.List;
@Mapper
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
/** 普通库存流水与原箱回仓凭据共同证明实际退回,每个原箱只计一次箱数。 */
/** 普通流水与原箱凭据证明退回,共享任务按真实扫码及逐票客户归属核验。 */
@Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " +
"l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " +
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " +
@@ -25,7 +25,7 @@ public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
"AND di.warehouse_pallet_id=oi.warehouse_pallet_id " +
"AND EXISTS (SELECT 1 FROM ll_dispatch_pda_pick p JOIN ll_dispatch_pda_pick_order a " +
"ON a.customer_id=p.customer_id AND a.pick_id=p.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 p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " +
"AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " +
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " +
@@ -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);
}
@@ -17,7 +17,7 @@ public interface DispatchFeeCargoMapper {
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=di.customer_id
LEFT JOIN ll_order o ON o.id=b.inbound_order_id AND o.customer_id=di.customer_id
LEFT JOIN (SELECT pk.dispatch_item_id,pk.inventory_position_id,SUM(pk.packages) AS packages
FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id AND task.customer_id=pk.customer_id
FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id
WHERE task.dispatch_order_id=#{dispatchId} AND pk.customer_id=#{customerId}
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
@@ -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,103 @@
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 boolean restoreRelabelArrangement(RelabelApplication application,String outboundNo,String fulfillment,LocalDateTime now) {
return relabels.update(null,new LambdaUpdateWrapper<RelabelApplication>()
.eq(RelabelApplication::getId,application.getId()).eq(RelabelApplication::getCustomerId,application.getCustomerId())
.eq(RelabelApplication::getOutboundOrderNo,outboundNo).eq(RelabelApplication::getStatus,"READY_FOR_OUTBOUND")
.set(RelabelApplication::getOutboundOrderNo,null).set(RelabelApplication::getFulfillmentStatus,fulfillment)
.set(RelabelApplication::getUpdatedAt,now))==1;
}
/** 子明细始终按出库客户读取。 */
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, "原费用来源暂不可用,请稍后重试"); }
}
@@ -1,8 +1,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;
@@ -23,9 +21,9 @@ import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
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.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
@@ -94,7 +92,7 @@ import java.util.stream.Collectors;
@Service
public class DeliveryTransferService {
@Resource private DispatchOrderMapper dispatchOrders;
@Resource private DispatchAggregateRepository dispatches;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPallets;
@@ -129,283 +127,9 @@ public class DeliveryTransferService {
@Resource private RelabelApplicationService relabelService;
@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) {
@@ -1164,65 +888,95 @@ 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("排车已开始执行,不能直接取消出库");
// 唯一Fleet资源记录随整车自动创建,不能把本车正常关系当作额外取消阻断。
}
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());
boolean relabelArrangement = "RELABEL_OUTBOUND".equals(outbound.getOutboundType());
String pendingRelabelFulfillment = null;
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())
|| !outbound.getOutboundNo().equals(application.getOutboundOrderNo())
|| !Objects.equals(outbound.getOverseasWarehouseId(),application.getOverseasWarehouseId())
|| !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus()))
throw conflict("换标出库关联状态已变化,不能取消");
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(outbound.getOverseasWarehouseId(),carton.getOverseasWarehouseId())
|| !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,
new RelabelRequests.ChangeStatus("CANCELLED", request.reason().trim()));
// 原生箱和普通仓托均只撤销本次安排;整车分支仍须先通过父锁、阶段和零扫码门禁。
pendingRelabelFulfillment = "TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION";
if (!cancellations.restoreRelabelArrangement(application,outbound.getOutboundNo(),pendingRelabelFulfillment,now))
throw conflict("原换标出库关联已变化,未取消本次安排");
}
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);
}
if (relabelArrangement) {
// 仅清本次出库/派送关联,来源主状态和换标完成事实保持原值,不能退回未换标库存。
for (var order : cancelSources.values()) {
if (!cancellations.restore(order,outboundNo,order.getCurrentStatus(),"RELABEL:"+pendingRelabelFulfillment,now))
throw conflict("原换标来源出库关联已变化,请刷新核对");
}
}
for (DeliveryOrder delivery : existingDeliveries) {
@@ -1236,61 +990,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()
@@ -1326,7 +1077,7 @@ public class DeliveryTransferService {
OutboundOrder outbound = outboundOrders.findGlobalByNo(code);
if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) {
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo());
DispatchAggregate dispatch = dispatches.find(outbound.getDispatchNo());
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(),
dispatch.getDispatchNo());
@@ -1336,7 +1087,7 @@ public class DeliveryTransferService {
if (task != null) return outboundResolution(outbound, task);
}
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code);
DispatchAggregate dispatch = dispatches.find(code);
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo());
}
@@ -1590,47 +1341,6 @@ 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(), 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(),
@@ -1653,7 +1363,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();
@@ -1663,16 +1372,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()))
@@ -1,57 +0,0 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/** 外接绑定和约定运费共同消费同一份逐来源货物。 */
@Service
public class DispatchCargoSourceQuery {
private final DispatchCargoSourceRepository repository;
/** 装配当前库存与本次拣货的真实来源入口。 */
public DispatchCargoSourceQuery(DispatchCargoSourceRepository repository) { this.repository = repository; }
/** 新增普通派送应收只绑定真实未约定来源;混合多票时保留派送归属。 */
public Set<Long> ordinaryOrderIds(DispatchOrder dispatch, Set<String> externalInboundCodes) {
return repository.sources(dispatch.getCustomerId(), dispatch.getId()).stream()
.filter(row -> !externalInboundCodes.contains(row.inboundCode()))
.map(row -> row.orderId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet());
}
/** 每条普通排车明细核平实际组成;箱级换标和退货保留其专用来源快照。 */
public List<FleetDispatchGateway.SourceCargo> sources(DispatchOrder dispatch, List<DispatchItem> items) {
if (items.isEmpty()) return List.of();
var rows = repository.sources(dispatch.getCustomerId(), dispatch.getId());
var result = new ArrayList<FleetDispatchGateway.SourceCargo>();
for (var item : items) {
String destination = FleetDispatchGateway.destinationKey(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 FleetDispatchGateway.SourceCargo(item.getInboundCode(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(),
item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), destination));
continue;
}
var selected = rows.stream().filter(row -> Objects.equals(row.dispatchItemId(), item.getId())).toList();
if (selected.isEmpty() || item.getPackageCount() == null || selected.stream().mapToLong(row -> row.packages()).sum() != item.getPackageCount())
throw conflict("实际货物组成与排车箱数不一致,请核对收货、托盘及本次拣货来源");
for (var row : selected) {
if (row.inboundCode() == null || row.inboundCode().isBlank() || row.packages() <= 0
|| !Objects.equals(row.warehousePalletId(), item.getWarehousePalletId()))
throw conflict("货物缺少真实入仓来源或实物托盘,不能识别为普通货跳过外接核对");
result.add(new FleetDispatchGateway.SourceCargo(row.inboundCode(), row.warehousePalletId().toString(),
row.palletNo(), Math.toIntExact(row.packages()), destination));
}
}
return List.copyOf(result);
}
/** 来源不足直接阻断,不回退展示字段。 */
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); }
}
@@ -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");
}
}
}
@@ -3,10 +3,10 @@ package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.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 +34,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));}
}
@@ -1,4 +0,0 @@
package com.baiyue.lastleg.dispatch.dto;
/** One independent dispatch for one server-owned loading container. */
public record DispatchDraftGroupResult(Long draftContainerId, DispatchResponse dispatch) { }
@@ -1,109 +0,0 @@
package com.baiyue.lastleg.dispatch.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation;
public final class DispatchRequests {
private DispatchRequests() {
}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo,
String vehiclePlate, String status, String fbaAppointmentNo, Stage stage) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public enum Stage {
BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED;
public List<String> statuses() {
return switch (this) {
case BEFORE_LOADING -> List.of("PENDING_DEPARTURE");
case LOADING -> List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART");
case AFTER_LOADING -> List.of("DEPARTED", "PARTIAL_DEPARTED");
case CANCELLED -> List.of("CANCELLED");
};
}
}
public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
@Size(max = 64) String draftToken,
@Size(max = 64) String relabelNo,
@Size(max = 500) List<@NotNull Long> returnReceiptIds,
@Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) {
public PreArrange {
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations);
}
public PreArrange(List<Long> planPalletIds,String fbaAppointmentNo,String fbaAppointmentStatus,String vehicleAppointmentStatus,String draftToken,String relabelNo,List<Long> returnReceiptIds) {
this(planPalletIds,fbaAppointmentNo,fbaAppointmentStatus,vehicleAppointmentStatus,draftToken,relabelNo,returnReceiptIds,List.of());
}
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus,
String vehicleAppointmentStatus, String draftToken, String relabelNo) {
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, relabelNo, List.of());
}
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus) {
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null);
}
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) {
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null);
}
}
public record DraftPallet(Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId,
Long returnReceiptId, String sourceType) {
public DraftPallet(Long planPalletId, String draftToken, Long draftContainerId) { this(planPalletId, draftToken, draftContainerId, null, "INBOUND_PLAN"); }
public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); }
@jakarta.validation.constraints.AssertTrue(message = "请选择唯一且匹配类型的实物来源")
public boolean isValidSource() {
return returnReceiptId != null ? planPalletId == null && "RETURN_RECEIPT".equals(sourceType)
: planPalletId != null && (sourceType == null || "INBOUND_PLAN".equals(sourceType));
}
}
public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {}
public record PreArrangeDraft(@NotBlank @Size(max = 64) String draftToken,
@NotEmpty @Size(max = 100) List<@NotNull @jakarta.validation.Valid DraftGroup> groups) {}
public record DraftGroup(@NotNull Long draftContainerId,
@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 500) List<@NotNull Long> returnReceiptIds,
@Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) {
public DraftGroup {
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations);
}
public DraftGroup(Long id,List<Long> pallets,List<Long> receipts) { this(id,pallets,receipts,List.of()); }
}
public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) {
}
public record DraftSnapshot(@NotBlank @Size(max = 64) String draftToken,
@NotEmpty @Size(max = 500) List<@NotNull Long> planIds,
@NotBlank @Size(max = 20000) String containersJson) {
}
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
LocalDateTime departAt,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
LocalDateTime estimatedArrivalAt) {
}
}
@@ -1,35 +0,0 @@
package com.baiyue.lastleg.dispatch.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName,
String driverPhone, String carrierName, String orderNos, String route,
String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt,
String fbaAppointmentNo, String fbaAppointmentStatus,
String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items, List<BolLine> bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches,
Actions actions) {
public record Actions(boolean canEdit, String editBlockReason,
boolean canCancel, String cancelBlockReason,
boolean canRemovePallet, String removePalletBlockReason,
boolean canTransfer, String transferBlockReason,
String outboundNo, String outboundStatus) { }
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
BigDecimal grossWeightKg, BigDecimal volumeWeightKg,
BigDecimal volumeCbm, String sourceStage) {
}
public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode,
String destinationWarehouseName, String warehouseNo, String fbaShipmentId,
String poReferenceNo, int packageCount) {
}
}
@@ -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;
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/** 普通换标逐item/实际仓位的冻结贡献,原货源与换标履约单分开保留。 */
@Data
@TableName("ll_dispatch_item_source")
public class DispatchItemSource {
@TableId(type=IdType.INPUT) private Long id;
private Long dispatchOrderId;
private Long dispatchItemId;
private String customerId;
private Long relabelApplicationId;
private Long relabelItemId;
private String relabelRoundKey;
private Long warehousePalletId;
private Long inventoryPositionId;
private Long inventoryBalanceId;
private Long sourceInventoryBalanceId;
private Long sourceInboundOrderId;
private String sourceInboundOrderNo;
private String sourceInboundCode;
private Long notifiedPackageCount;
private Long packageCount;
private Long productQuantity;
private String bindingBasisHash;
private String unitFactsJson;
private LocalDateTime createdAt;
}
@@ -1,40 +0,0 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */
@Data
@TableName("ll_dispatch_order")
public class DispatchOrder {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String dispatchNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String vehiclePlate;
private String driverName;
private String driverPhone;
private String carrierName;
private String route;
private LocalDateTime departAt;
private String fbaAppointmentNo;
private String fbaAppointmentStatus;
private String vehicleAppointmentStatus;
private LocalDateTime estimatedArrivalAt;
private String status;
private Integer itemCount;
private Integer palletCount;
private Integer packageCount;
private Integer pickedPackageCount;
private Integer shippedPackageCount;
private String partialCompletionReason;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -9,15 +9,16 @@ import lombok.Data;
import java.time.LocalDateTime;
/** Prevents a source pallet from being added to more than one active dispatch order. */
/** 真实客户逐逻辑来源的活动占用,实际实托排他归独立物理占用行。 */
@Data
@TableName("ll_dispatch_pallet_lock")
public class DispatchPalletLock {
@TableId(type = IdType.INPUT)
private Long id;
private Long physicalLockId;
private String customerId;
private String sourcePalletKey;
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
/** 活动时为完整逻辑来源键,释放后为空;不同换标来源不再共用一个来源键。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activeKey;
private Long planId;
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/** 实托排他占用归主单或草稿容器,不以任何逻辑来源或客户代表整托。 */
@Data
@TableName("ll_dispatch_physical_pallet_lock")
public class DispatchPhysicalPalletLock {
@TableId(type=IdType.INPUT) private Long id;
private String warehouseId;
private Long warehousePalletId;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private Long activeWarehousePalletId;
private Long dispatchOrderId;
private String draftToken;
private Long draftContainerId;
private String operatorId;
private String status;
private LocalDateTime expiresAt;
private LocalDateTime lockedAt;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -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} " +
"GROUP BY di.id,l.id ORDER BY di.id,l.id")
List<DispatchResponse.BolLine> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
"WHERE di.dispatch_order_id=#{dispatchOrderId} " +
"GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id")
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," +
@@ -116,9 +116,8 @@ public interface DispatchCandidateMapper {
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
"AND (d.expires_at IS NULL OR d.expires_at &gt; CURRENT_TIMESTAMP(3)) AND (" +
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " +
"AND (d.expires_at IS NULL OR d.expires_at &gt; CURRENT_TIMESTAMP(3)) AND " +
"d.source_pallet_key=CONCAT('PLAN:',p.id) " +
"AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " +
// Incoming creation mode never grants ownership of a later transfer reservation.
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
@@ -134,8 +133,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 +142,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 +227,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 +262,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 +294,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,Long physicalLockId) { }
/** 来源已加锁后才读取本草稿活动占用;不跨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,physical_lock_id AS physicalLockId "
+ "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);
/** 每个逻辑来源独立保留客户和来源键,同容器同实托共享已取得的物理占用。 */
@Insert("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id,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},#{physical},#{customer},#{key},#{key},#{plan},#{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,@Param("physical")Long physical);
/** 布局移动仅匹配同草稿原容器,不能迁移他人新取得的占用。 */
@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,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,81 @@
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,Long dispatchItemSourceId) { }
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(s.source_inbound_code,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,a.dispatch_item_source_id AS dispatchItemSourceId
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
LEFT JOIN ll_dispatch_item_source s ON s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id AND s.dispatch_item_id=i.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);
/** 两端共同校验四种来源和逐贡献额度,避免同托来源互相补数量而放行。 */
String INVALID_PICK="""
(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)
OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id
AND NOT EXISTS(SELECT 1 FROM ll_order o WHERE o.id=a.order_id AND o.customer_id=a.customer_id))
OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item_source s WHERE s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id
AND s.dispatch_order_id=i.dispatch_order_id AND s.dispatch_item_id=i.id AND s.inventory_position_id=p.inventory_position_id
AND s.warehouse_pallet_id=i.warehouse_pallet_id AND i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER'
AND (SELECT COALESCE(SUM(x.packages),0) FROM ll_dispatch_pda_pick_order x WHERE x.dispatch_item_source_id=s.id AND x.customer_id=s.customer_id)<=s.package_count))
OR (i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER' AND EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a
WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NULL)))
""";
/** 每条扫码的不可变归属箱数与商品数必须完整,缺归属不能因总数相等而放行。 */
@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
"""+INVALID_PICK)
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=(SELECT SUM(x.package_count) FROM ll_dispatch_item x
WHERE x.dispatch_order_id=i.dispatch_order_id AND x.warehouse_pallet_id=i.warehouse_pallet_id) 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,40 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItemSource;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 不可变来源快照只追加或随零扫码移货删除,不用聚合库存回推历史贡献。 */
@Mapper
public interface DispatchItemSourceMapper extends BaseMapper<DispatchItemSource> {
record PickedSource(Long sourceId,long packages,long productQuantity) { }
/** 读取整车完整贡献,客户和逻辑明细身份始终随行保留。 */
@Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} ORDER BY customer_id,dispatch_item_id,inventory_position_id,relabel_item_id,id")
List<DispatchItemSource> all(@Param("dispatch")long dispatch);
/** PDA列表一次读取所有本批主单贡献,不逐车逐来源查询。 */
@Select("<script>SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,dispatch_item_id,inventory_position_id,relabel_item_id,id</script>")
List<DispatchItemSource> batchAll(@Param("ids")List<Long> ids);
/** 一次核对冻结贡献的实际已消费箱件,不用同position其他申请的拣货抵消额度。 */
@Select("<script>SELECT s.id AS sourceId,COALESCE(SUM(a.packages),0) AS packages,COALESCE(SUM(a.product_quantity),0) AS productQuantity "
+ "FROM ll_dispatch_item_source s LEFT JOIN ll_dispatch_pda_pick_order a ON a.dispatch_item_source_id=s.id AND a.customer_id=s.customer_id "
+ "WHERE s.dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> GROUP BY s.id ORDER BY s.id</script>")
List<PickedSource> pickedAll(@Param("ids")List<Long> ids);
/** 扫码位置只能消费所选真实客户和逻辑明细的贡献。 */
@Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} AND customer_id=#{customer} "
+ "AND inventory_position_id=#{position} ORDER BY relabel_item_id,id")
List<DispatchItemSource> position(@Param("dispatch")long dispatch,@Param("item")long item,
@Param("customer")String customer,@Param("position")long position);
/** 额度按不可变真实拣货归属累计,不能以整仓位已拣数代替本来源已拣数。 */
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customer} AND dispatch_item_source_id=#{source}")
long picked(@Param("customer")String customer,@Param("source")long source);
/** 零扫码移货才允许删除;已有实际归属时由SQL再次阻断。 */
@Delete("DELETE FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} "
+ "AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.dispatch_item_source_id=ll_dispatch_item_source.id)")
int removeUnpicked(@Param("dispatch")long dispatch,@Param("item")long item);
}
@@ -1,22 +0,0 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1 FOR UPDATE")
DispatchOrder lockGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -23,13 +23,6 @@ public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock>
List<DispatchPalletLock> lockActiveByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " +
"ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id " +
"WHERE l.draft_token=#{draftToken} AND l.draft_container_id=#{draftContainerId} AND l.status='ACTIVE' " +
"ORDER BY l.updated_at DESC,l.id DESC LIMIT 1")
Long findDispatchOrderIdByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchPalletLock lockActive(@Param("customerId") String customerId,
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
/** 物理层只保存实托与真正拥有者,逻辑来源份额仍由各自客户成员保存。 */
@Mapper
public interface DispatchPhysicalClaimMapper extends BaseMapper<DispatchPhysicalPalletLock> {
/** 到期但尚未清键的原行也必须先锁定,不能越过原拥有者领取。 */
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=#{pallet} FOR UPDATE")
DispatchPhysicalPalletLock lockActive(@Param("pallet")long pallet);
/** 释放按原物理占用主键取得,不能按实托号误释放新拥有者的后续占用。 */
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE id=#{id} FOR UPDATE")
DispatchPhysicalPalletLock lockOriginal(@Param("id")long id);
/** 只读原实托身份供服务排序,再逐主键取锁,避免filesort之前按主键先锁的执行计划。 */
@Select("<script>SELECT * FROM ll_dispatch_physical_pallet_lock WHERE id IN "
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY warehouse_pallet_id,id</script>")
List<DispatchPhysicalPalletLock> originals(@Param("ids")List<Long> ids);
/** 批量释放、转换与续约前按物理托盘顺序收齐本主单的原行。 */
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE dispatch_order_id=#{dispatch} AND status='ACTIVE' ORDER BY warehouse_pallet_id,id")
List<DispatchPhysicalPalletLock> lockDispatch(@Param("dispatch")long dispatch);
/** 草稿归属同时包含token与操作人,容器身份由每条原行保留。 */
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE draft_token=#{token} AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' ORDER BY warehouse_pallet_id,id")
List<DispatchPhysicalPalletLock> lockDraft(@Param("token")String token,@Param("operator")String operator);
/** 仅使该原物理占用下已到期的草稿成员失效,不释放正式来源或已经续期的成员。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,released_at=#{at},updated_at=#{at} "
+ "WHERE physical_lock_id=#{id} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} "
+ "AND EXISTS(SELECT 1 FROM ll_dispatch_physical_pallet_lock p WHERE p.id=#{id} AND p.status='ACTIVE' AND p.dispatch_order_id IS NULL "
+ "AND p.draft_token=ll_dispatch_pallet_lock.draft_token AND p.draft_container_id=ll_dispatch_pallet_lock.draft_container_id "
+ "AND p.operator_id=ll_dispatch_pallet_lock.operator_id AND p.expires_at<=#{at})")
int expireMembers(@Param("id")long id,@Param("at")LocalDateTime at);
/** 最后活动成员结束后才能清物理键;原id保证不会触碰同托后续的新占用。 */
@Update("UPDATE ll_dispatch_physical_pallet_lock SET status=#{status},active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} "
+ "WHERE id=#{id} AND status='ACTIVE' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} AND l.status='ACTIVE')")
int releaseUnused(@Param("id")long id,@Param("status")String status,@Param("at")LocalDateTime at);
/** 草稿成员已经在同事务全部移入目标容器后,才允许迁移物理拥有者。 */
@Update("UPDATE ll_dispatch_physical_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} "
+ "AND draft_token=#{token} AND operator_id=#{operator} AND draft_container_id=#{previous} AND dispatch_order_id IS NULL "
+ "AND status='ACTIVE' AND expires_at>#{at} AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} "
+ "AND l.status='ACTIVE' AND (l.dispatch_order_id IS NOT NULL OR l.draft_token IS NULL OR l.draft_token<>#{token} "
+ "OR l.draft_container_id IS NULL OR l.draft_container_id<>#{next} OR l.operator_id IS NULL OR l.operator_id<>#{operator}))")
int moveDraft(@Param("id")long id,@Param("token")String token,@Param("operator")String operator,
@Param("previous")long previous,@Param("next")long next,@Param("at")LocalDateTime at);
/** 活动续约不复活已到期记录,期限与逻辑来源及草稿根一起提交。 */
@Update("UPDATE ll_dispatch_physical_pallet_lock SET expires_at=#{expires},updated_at=#{at} WHERE draft_token=#{token} "
+ "AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}")
int renewDraft(@Param("token")String token,@Param("operator")String operator,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires);
}
@@ -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,143 @@
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) { }
record NativeApplication(String customerId,String relabelNo) { }
record PlanReference(Long planPalletId,String customerId) { }
record ReturnReference(Long receiptId,String customerId) { }
record OrderReference(Long orderId,String customerId) { }
record ItemPicked(Long itemId,long packages) { }
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
@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=#{dispatch} GROUP BY i.id")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<ItemPicked> pickedItems(@Param("dispatch")long dispatch);
/** 组成查询不按占用可选性筛掉本草稿或隐藏客户的真实成员。 */
@Select("<script>SELECT DISTINCT p.id AS planPalletId,ip.customer_id AS customerId FROM ll_inbound_plan_pallet p "
+ "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
+ "JOIN ll_inventory_position s ON s.pallet_id=p.warehouse_pallet_id AND s.customer_id=ip.customer_id "
+ "AND s.overseas_warehouse_id=CAST(ip.overseas_warehouse_id AS CHAR) "
+ "WHERE s.overseas_warehouse_id=#{warehouse} AND p.warehouse_pallet_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY ip.customer_id,p.id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<PlanReference> physicalPlans(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
/** 退回实收的事实身份不因当前草稿占用而消失。 */
@Select("<script>SELECT r.id AS receiptId,r.customer_id AS customerId 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 p.overseas_warehouse_id=#{warehouse} AND p.pallet_id IN "
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY r.customer_id,r.id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<ReturnReference> physicalReturns(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
/** 当前余额对应的真实实收订单参加共同锁序,不拿展示MIN或原货源快照ID猜订单表身份。 */
@Select("<script>SELECT DISTINCT x.order_id AS orderId,x.customer_id AS customerId FROM ("
+ "SELECT r.order_id,r.customer_id FROM ll_inventory_position c JOIN ll_inventory_position p ON p.balance_id=c.balance_id AND p.customer_id=c.customer_id "
+ "JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=p.id AND r.customer_id=p.customer_id WHERE c.overseas_warehouse_id=#{warehouse} AND c.pallet_id IN "
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> UNION "
+ "SELECT r.order_id,r.customer_id FROM ll_inventory_position c JOIN ll_return_stock_receipt r ON r.inventory_balance_id=c.balance_id AND r.customer_id=c.customer_id "
+ "WHERE c.overseas_warehouse_id=#{warehouse} AND c.pallet_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>) x ORDER BY x.order_id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<OrderReference> physicalOrders(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
/** 共同串行只补齐真实身份锁,不给额外原货源或仓内操作新增暂扣资格。 */
@Select("SELECT id FROM ll_order WHERE customer_id=#{customer} AND id=#{id} FOR UPDATE")
Long lockOrderIdentity(@Param("customer")String customer,@Param("id")long id);
/** 原生退货实托仍由真实箱唛与申请号关联,不借普通库存仓位推断。 */
@Select("<script>SELECT DISTINCT customer_id AS customerId,relabel_no AS relabelNo FROM ll_return_inventory_carton "
+ "WHERE overseas_warehouse_id=#{warehouse} AND status='RELABEL_PENDING' AND relabel_no IS NOT NULL "
+ "AND pallet_code IN <foreach collection='codes' item='code' open='(' separator=',' close=')'>#{code}</foreach> ORDER BY relabel_no</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<NativeApplication> nativeApplications(@Param("warehouse")String warehouse,@Param("codes")List<String> codes);
/** 换标单客户由服务端业务单号查得,不接受客户端指定。 */
@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>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
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,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}")
int expire(@Param("customer") String customer,@Param("key") String key,@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} "
+ "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("at") LocalDateTime at);
/** 精确释放本车本客户完整来源键,不能按共享实托一起释放另一申请。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND source_pallet_key=#{key} AND status='ACTIVE'")
int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("key")String key,@Param("at")LocalDateTime at);
/** 原计划收货只补齐本逻辑成员的实际实托和已取得的物理占用引用。 */
@Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},physical_lock_id=#{physical},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 AND physical_lock_id IS NULL")
int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("physical")long physical,@Param("at")LocalDateTime at);
/** 本来源活动唯一键独立保存,实际实托必须关联本拥有者的物理占用。 */
@Insert("""
INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id,
warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at)
VALUES(#{id},#{physical},#{customer},#{key},#{key},#{planPallet},#{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("physical")Long physical,
@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.source_pallet_key=CONCAT('RETURN:',r.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,15 @@
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 destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;}
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));
}
}
@@ -1,19 +0,0 @@
package com.baiyue.lastleg.dispatch.pda.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class DispatchPdaRequests {
private DispatchPdaRequests() {}
public record Pick(Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark, @Size(max = 255) String cartonMark) {
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
}
}
public record Depart(@NotBlank String idempotencyKey, String remark) {}
public record CompletePicking(@NotBlank String idempotencyKey) {}
}
@@ -1,43 +1,6 @@
package com.baiyue.lastleg.dispatch.pda.dto;
import java.util.List;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
/** 原生退货换标箱的内部读取投影。 */
public final class DispatchPdaResponses {
private DispatchPdaResponses() {}
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
boolean canDepart, String departBlockReason, List<Item> items, String tripNo, Long tripRecordVersion, int tripDispatchCount, String driverWarning,
boolean canPick, String pickBlockReason) {
public Task withGate(FleetExecutionModels.Gate gate) {
return new Task(id, taskNo, dispatchNo, vehiclePlate, warehouseId, status, gate.canDepart(), gate.blockReason(), items,
gate.tripNo(), gate.recordVersion(), gate.dispatchCount(), gate.driverWarning(), gate.canPick(), gate.pickBlockReason());
}
}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages,
long pickedPackages, List<Position> positions, List<ReturnCarton> returnCartons) {
public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages, long pickedPackages, List<Position> positions) {
this(dispatchItemId, palletNo, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode,
warehouseRouteLabel, scheduledPackages, pickedPackages, positions, List.of());
}
}
public record ReturnCarton(Long id, String cartonMark, String status, long productQuantity) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
boolean palletScrapped, boolean replayed) {}
public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) {
public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); }
}
public record PickingCompleted(String dispatchNo, String status, long plannedPackages,
long pickedPackages, long remainingPackages, boolean partial,
boolean replayed) {
public PickingCompleted withReplay() {
return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages,
remainingPackages, partial, true);
}
}
private DispatchPdaResponses() { }
public record ReturnCarton(Long id,String cartonMark,String status,long productQuantity) { }
}
@@ -17,6 +17,7 @@ public class DispatchPdaPickOrder {
private Long receiptId;
private Long returnReceiptId;
private Long returnCartonId;
private Long dispatchItemSourceId;
private Long orderId;
private Long packages;
private Long productQuantity;
@@ -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,36 @@
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 <![CDATA["+com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper.INVALID_PICK+"]]> 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);}
}
@@ -18,6 +18,9 @@ import java.time.LocalDateTime;
import java.util.TreeMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository;
import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService;
@Service
public class DispatchPickOrderAttributionService {
@@ -25,14 +28,41 @@ public class DispatchPickOrderAttributionService {
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderStatusHistoryMapper history;
@Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
@Resource private DispatchItemSourceRepository relabelSources;
/** Acquire source order locks before any inventory lock, matching order hold/release. */
/** 普通换标只锁本申请真实履约单,不读取其他来源receipt做FIFO猜测。 */
@org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void prepareRelabel(String customerId,Long orderId) {
var order=orderId==null?null:orders.lockById(customerId,orderId);
if(order==null || Boolean.TRUE.equals(order.getDetained()))throw conflict("换标履约订单缺失或暂扣");
}
/** 第四来源分支链接真实冻结贡献,全部归属、履约计数和库存跟随原扫码事务提交。 */
@org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List<DispatchItemSourceService.Allocation> allocations) {
long packages=0,quantity=0;var seen=new java.util.HashSet<Long>();
for(var allocation:allocations) {
var source=allocation.dispatchItemSourceId()==null?null:relabelSources.find(allocation.dispatchItemSourceId());
if(source==null || !seen.add(source.getId()) || !Objects.equals(source.getCustomerId(),customerId)
|| !Objects.equals(source.getDispatchItemId(),pick.getDispatchItemId()) || !Objects.equals(source.getInventoryPositionId(),pick.getInventoryPositionId())
|| allocation.packages()<=0 || allocation.productQuantity()!=null && allocation.productQuantity()<0)throw conflict("换标扫码冻结贡献身份不一致");
packages=Math.addExact(packages,allocation.packages());quantity=Math.addExact(quantity,allocation.productQuantity()==null?0:allocation.productQuantity());
}
if(allocations.isEmpty() || packages!=value(pick.getPackages()) || quantity!=value(pick.getProductQuantity()))throw conflict("换标扫码箱件与真实贡献未核平");
for(var allocation:allocations) {
var row=new DispatchPdaPickOrder();row.setId(IdWorker.getId());row.setCustomerId(customerId);row.setPickId(pick.getId());row.setOrderId(orderId);
row.setDispatchItemSourceId(allocation.dispatchItemSourceId());row.setPackages(allocation.packages());row.setProductQuantity(allocation.productQuantity()==null?0:allocation.productQuantity());row.setCreatedAt(pick.getPickedAt());
if(attributions.insert(row)!=1)throw conflict("换标实际来源归属未保存");
}
if(orderId==null || orders.increasePicked(customerId,orderId,packages)!=1)throw conflict("换标履约实际拣货箱数不一致");
}
/** 来源订单先于库存锁,普通实收不补造换标履约归属。 */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void preparePicking(String customerId, long positionId, Long fallbackOrderId) {
public void preparePicking(String customerId, long positionId) {
var sourceIds = new java.util.TreeSet<Long>();
attributions.availableReceipts(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId()));
returnReceipts.available(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId()));
if (fallbackOrderId != null && fallbackOrderId > 0) sourceIds.add(fallbackOrderId);
if (sourceIds.isEmpty()) throw conflict("PDA pick has no attributable source order");
for (Long orderId : sourceIds) {
LastlegOrder order = orders.lockById(customerId, orderId);
@@ -50,20 +80,8 @@ public class DispatchPickOrderAttributionService {
}
}
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick) {
return attribute(customerId, pick, null);
}
/**
* Relabel inventory created before receipt-detail backfill may have no
* ll_inbound_stock_receipt row. Its dispatch item still carries the
* authoritative tail-leg order, so use that order only for relabel picks.
*/
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) {
return attribute(customerId, pick, fallbackOrderId, null);
}
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId, Long selectedReturnReceiptId) {
/** 普通实收与退回实收只按真实receipt归属;换标另走冻结贡献分支。 */
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long selectedReturnReceiptId) {
long remainingPackages = value(pick.getPackages());
long totalPackages = remainingPackages;
long totalQuantity = value(pick.getProductQuantity());
@@ -114,10 +132,6 @@ public class DispatchPickOrderAttributionService {
pendingAttributions.add(row); pickedByOrder.merge(locked.getOrderId(), packages, Long::sum);
remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity;
}
if (remainingPackages != 0 && selectedReturnReceiptId == null && fallbackOrderId != null && fallbackOrderId > 0) {
pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum);
remainingPackages = 0;
}
if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders");
pendingAttributions.forEach(attributions::insert);
for (Map.Entry<Long, Long> entry : pickedByOrder.entrySet()) {
@@ -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,86 @@
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 Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt,
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,101 @@
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 SourceOrigin(Long dispatchItemSourceId, Long relabelApplicationId, Long relabelItemId,
Long sourceInventoryBalanceId, Long sourceInboundOrderId,
String sourceInboundOrderNo, String sourceInboundCode, Long productQuantity) { }
/** 一行表示本次不可变真实拣货来源,展示入仓码不能替代此归属。 */
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,
SourceOrigin sourceOrigin) { }
/** 仅用于服务内协作;锁定上下文并不等于通过当前版本或阶段资格。 */
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,97 @@
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,
SelectionGroup selectionGroup) { }
/** 服务端核齐实托与整申请闭包;缺组、缺版本或complete=false均不能作为完整选货证明。 */
public record SelectionGroup(String groupKey, String groupVersion, List<SelectionGroupSource> sources,
List<SelectionGroupPallet> pallets, boolean complete, List<String> blockReasons) { }
/** 每个逻辑来源保留真实客户及业务号,selection携带该来源的权威版本。 */
public record SelectionGroupSource(DispatchCommands.CargoSelection selection, String sourceKey,
String customerId, String customerName, String sourceBusinessType,
String sourceBusinessNo, long packages) { }
/** 实托只列一次;sourceKeys完整列出其逻辑贡献,packageCount为这些贡献的真实合计。 */
public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List<String> sourceKeys,
long packageCount) { }
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,79 @@
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 DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
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,
String sourceBusinessType, String sourceBusinessNo) { }
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
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,
String sourceKey, String sourceBusinessType, String sourceBusinessNo) { }
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); }
}

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