This commit is contained in:
2026-10-20 10:35:52 +08:00
parent ce189f062f
commit c13175bf77
202 changed files with 38397 additions and 7138 deletions
@@ -7,11 +7,105 @@ import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Options;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import java.util.List;
@Mapper
public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation> {
String MUTATION_IDS = "<foreach collection='palletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
String MUTATION_CODES = "<foreach collection='palletCodes' item='code' open='(' separator=',' close=')'>#{code}</foreach>";
String MUTATION_APPS = "<choose><when test='applicationNos != null and applicationNos.size() > 0'>"
+ "<foreach collection='applicationNos' item='no' open='(' separator=',' close=')'>#{no}</foreach>"
+ "</when><otherwise>(NULL)</otherwise></choose>";
/** 真实订单身份不携带暂扣资格,仓内作业只与运输取得相同的身份锁。 */
record MutationOrder(String customerId, Long orderId) { }
/** 原生退货只有托盘码时保持ID为空,调用方必须通过权威资源提供者解析。 */
record MutationPallet(Long palletId, String palletCode) { }
/** 组成重读比较逐位置、箱记录与换标明细,不以客户或首行代表整托。 */
record MutationFact(String kind, Long id, String customerId, Long applicationId, Long referenceId,
Long palletId, String palletCode, Long quantity, Long version) { }
/** 从全客户库存与原生退货扩展相关申请,不按排车资格过滤。 */
@Select("<script>SELECT a.* FROM ll_relabel_application a WHERE a.overseas_warehouse_id=#{warehouseId} AND (a.relabel_no IN " + MUTATION_APPS
+ " OR EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_inventory_position p ON p.id=i.source_inventory_position_id AND p.customer_id=i.customer_id "
+ "WHERE i.application_id=a.id AND i.customer_id=a.customer_id AND p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS + ")"
+ " OR EXISTS(SELECT 1 FROM ll_return_inventory_carton c WHERE c.relabel_application_id=a.id AND c.customer_id=a.customer_id "
+ "AND c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 AND c.pallet_code IN " + MUTATION_CODES + ")"
+ " OR EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_return_inventory_carton c ON c.id=i.return_carton_id AND c.customer_id=i.customer_id "
+ "WHERE i.application_id=a.id AND i.customer_id=a.customer_id AND c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 AND c.pallet_code IN " + MUTATION_CODES + ")) ORDER BY a.relabel_no</script>")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
List<RelabelApplication> mutationApplications(@Param("warehouseId") String warehouseId, @Param("palletIds") List<Long> palletIds,
@Param("palletCodes") List<String> palletCodes, @Param("applicationNos") List<String> applicationNos);
/** 原申请全部绑定实托参与共同串行集合;无托退货不伪造资源ID。 */
@Select("<script>SELECT DISTINCT p.pallet_id AS palletId,p.pallet_no AS palletCode FROM ll_relabel_item i "
+ "JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "JOIN ll_inventory_position p ON p.id=i.source_inventory_position_id AND p.customer_id=i.customer_id "
+ "WHERE a.relabel_no IN " + MUTATION_APPS + " AND p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id>0 "
+ "UNION SELECT DISTINCT NULL AS palletId,c.pallet_code AS palletCode FROM ll_return_inventory_carton c "
+ "LEFT JOIN ll_relabel_item i ON i.return_carton_id=c.id AND i.customer_id=c.customer_id "
+ "JOIN ll_relabel_application a ON (a.id=i.application_id OR a.id=c.relabel_application_id) AND a.customer_id=c.customer_id "
+ "WHERE a.relabel_no IN " + MUTATION_APPS + " AND c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 "
+ "AND c.pallet_code IS NOT NULL AND c.pallet_code&lt;&gt;'' AND UPPER(c.pallet_code)&lt;&gt;'UNBOUND'</script>")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
List<MutationPallet> mutationPallets(@Param("warehouseId") String warehouseId, @Param("applicationNos") List<String> applicationNos);
/** 收齐原入仓、退回实收、计划及原生退货业务订单,不使用MIN或其他客户代表行。 */
@Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId FROM ll_relabel_application a WHERE a.relabel_no IN " + MUTATION_APPS
+ " UNION SELECT i.customer_id,i.source_inbound_order_id FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE a.relabel_no IN " + MUTATION_APPS + " AND i.return_carton_id IS NULL"
+ " UNION SELECT p.customer_id,b.inbound_order_id FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "
+ "WHERE p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS
+ " UNION SELECT r.customer_id,r.order_id FROM ll_inbound_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=#{warehouseId} AND r.warehouse_pallet_id IN " + MUTATION_IDS
+ " UNION SELECT r.customer_id,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 p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS
+ " UNION SELECT l.customer_id,l.order_id 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_inbound_plan_pallet_allocation x ON x.pallet_id=p.id AND x.plan_id=p.plan_id AND x.customer_id=p.customer_id "
+ "JOIN ll_inbound_plan_line l ON l.id=x.line_id AND l.plan_id=x.plan_id AND l.customer_id=x.customer_id "
+ "WHERE ip.overseas_warehouse_id=#{warehouseId} AND p.warehouse_pallet_id IN " + MUTATION_IDS
+ " UNION SELECT c.customer_id,o.lastleg_order_id FROM ll_return_inventory_carton c LEFT JOIN ll_return_order o ON o.customer_id=c.customer_id "
+ "AND LOCATE(CONCAT(',',o.return_no,','),CONCAT(',',c.source_return_no,','))>0 WHERE c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 "
+ "AND (c.pallet_code IN " + MUTATION_CODES + " OR EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.return_carton_id=c.id AND i.customer_id=c.customer_id AND a.relabel_no IN " + MUTATION_APPS + "))</script>")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
List<MutationOrder> mutationOrders(@Param("warehouseId") String warehouseId, @Param("palletIds") List<Long> palletIds,
@Param("palletCodes") List<String> palletCodes, @Param("applicationNos") List<String> applicationNos);
/** 逐申请业务号取锁,完整顺序由调用方在首次锁前确定。 */
@Select("SELECT id FROM ll_relabel_application WHERE relabel_no=#{number} FOR UPDATE")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
List<Long> lockMutationApplication(@Param("number") String number);
/** 只锁真实订单身份,不为仓内作业附加暂扣或运输状态资格。 */
@Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} FOR UPDATE")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
Long lockMutationOrder(@Param("customerId") String customerId, @Param("orderId") long orderId);
/** 重读全部位置和逐申请绑定,发现同锁集合内的新成员也必须重试。 */
@Select("<script>SELECT 'POSITION' AS kind,p.id,p.customer_id AS customerId,NULL AS applicationId,p.balance_id AS referenceId,p.pallet_id AS palletId,p.pallet_no AS palletCode,"
+ "(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) AS quantity,p.version FROM ll_inventory_position p "
+ "WHERE p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS
+ " UNION ALL SELECT 'ITEM',i.id,i.customer_id,i.application_id,COALESCE(i.source_inventory_position_id,i.return_carton_id),NULL,NULL,i.notified_package_count,NULL "
+ "FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id WHERE a.relabel_no IN " + MUTATION_APPS
+ " UNION ALL SELECT 'RETURN',c.id,c.customer_id,c.relabel_application_id,c.source_receipt_id,NULL,c.pallet_code,c.total_quantity,NULL FROM ll_return_inventory_carton c "
+ "WHERE c.overseas_warehouse_id=#{warehouseId} AND c.total_quantity>0 AND (c.pallet_code IN " + MUTATION_CODES
+ " OR EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.return_carton_id=c.id AND i.customer_id=c.customer_id AND a.relabel_no IN " + MUTATION_APPS + "))</script>")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
List<MutationFact> mutationFacts(@Param("warehouseId") String warehouseId, @Param("palletIds") List<Long> palletIds,
@Param("palletCodes") List<String> palletCodes, @Param("applicationNos") List<String> applicationNos);
/** 共同实托锁持有期间检查正式和未过期草稿,过期判断使用调用方统一UTC业务时钟。 */
@Select("<script>SELECT (SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE' AND active_warehouse_pallet_id IN " + MUTATION_IDS
+ " AND (dispatch_order_id IS NOT NULL OR expires_at IS NULL OR expires_at>#{at})) + "
+ "(SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND warehouse_pallet_id IN " + MUTATION_IDS
+ " AND (dispatch_order_id IS NOT NULL OR expires_at IS NULL OR expires_at>#{at}))</script>")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
long mutationTransportClaims(@Param("palletIds") List<Long> palletIds, @Param("at") java.time.LocalDateTime at);
String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
"b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," +
"p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," +
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.palletops.repository;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationFact;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationOrder;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationPallet;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.*;
/** 仓内实托组成及身份锁的数据访问,不承担换标或运输资格判断。 */
@Repository
public class PdaPalletOperationRepository {
private final PdaPalletOperationMapper mapper;
/** 仅复用本域Mapper,不依赖排车内部服务或跨域Mapper。 */
public PdaPalletOperationRepository(PdaPalletOperationMapper mapper) { this.mapper = mapper; }
/** 未解析的原生退货托盘码由Service交给真实资源提供者解析,不伪造ID。 */
public record Composition(List<RelabelApplication> applications, List<MutationOrder> orders,
List<MutationPallet> pallets, List<MutationFact> facts) { }
/** 从全部客户的源目标实托扩展申请及其其他实托,保留完整原始组成。 */
public Composition composition(String warehouseId, Map<Long,String> seedPallets, RelabelApplication source) {
var ids = new TreeSet<>(seedPallets.keySet());
var codes = new TreeSet<>(seedPallets.values());
var numbers = new TreeSet<String>();
if (source != null) numbers.add(source.getRelabelNo());
List<RelabelApplication> applications;
List<MutationPallet> related;
boolean changed;
do {
applications = mapper.mutationApplications(warehouseId, List.copyOf(ids), List.copyOf(codes), List.copyOf(numbers));
changed = applications.stream().map(RelabelApplication::getRelabelNo).map(numbers::add).reduce(false, Boolean::logicalOr);
related = mapper.mutationPallets(warehouseId, List.copyOf(numbers));
for (var pallet : related) {
if (pallet.palletId() != null && pallet.palletId() > 0) changed |= ids.add(pallet.palletId());
if (pallet.palletCode() != null && !pallet.palletCode().isBlank()) changed |= codes.add(pallet.palletCode());
}
} while (changed);
var pallets = new ArrayList<MutationPallet>();
seedPallets.forEach((id,code) -> pallets.add(new MutationPallet(id,code)));
pallets.addAll(related);
return new Composition(List.copyOf(applications),
List.copyOf(mapper.mutationOrders(warehouseId,List.copyOf(ids),List.copyOf(codes),List.copyOf(numbers))),
List.copyOf(pallets), List.copyOf(mapper.mutationFacts(warehouseId,List.copyOf(ids),List.copyOf(codes),List.copyOf(numbers))));
}
/** 申请按调用方已收齐的业务号顺序逐行加锁。 */
public boolean lockApplication(String number) { return mapper.lockMutationApplication(number).size() == 1; }
/** 订单只锁身份,不添加暂扣或履约状态筛选。 */
public boolean lockOrder(MutationOrder order) { return mapper.lockMutationOrder(order.customerId(),order.orderId()) != null; }
/** 已持有共同串行点后读取正式及当前业务时钟下尚未过期的草稿占用。 */
public long transportClaims(List<Long> pallets, LocalDateTime at) { return mapper.mutationTransportClaims(pallets,at); }
}
@@ -12,6 +12,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository;
import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
@@ -42,6 +44,8 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.Clock;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -58,6 +62,9 @@ import java.util.stream.Collectors;
@Service
public class PdaPalletOperationService {
@Resource private PdaPalletOperationMapper mapper;
@Resource private PdaPalletOperationRepository mutationRepository;
@Resource private PhysicalPalletSerialPort physicalPalletSerial;
private Clock mutationClock = Clock.systemUTC();
@Resource private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@@ -75,11 +82,15 @@ public class PdaPalletOperationService {
private TransactionTemplate transaction;
private TransactionTemplate newTransaction;
/** 仓内组成预读和串行点后的复读使用RC,意图记录仍保留独立事务。 */
@Resource
void configureTransactions(PlatformTransactionManager manager) {
transaction = new TransactionTemplate(manager);
// 共同串行点之后必须看到其他事务的新成员,不能沿用MySQL默认RR的预读快照。
transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
newTransaction = new TransactionTemplate(manager, definition);
}
@@ -218,6 +229,7 @@ public class PdaPalletOperationService {
false));
}
/** 换标组托沿真实申请及箱贡献改绑,三个分支均先冻结完整实托组成。 */
public PalletOperationResponses.Operation relabelDisassemble(PdaWarehouseIdentity identity,
PalletOperationRequests.RelabelDisassemble request) {
PalletOperationResponses.Operation replay = replayIfPresent(identity, "RELABEL_PALLET_DISASSEMBLE",
@@ -288,6 +300,8 @@ public class PdaPalletOperationService {
}
if (source.palletId() == target.palletId()) {
return transaction.execute(status -> {
lockMutationComposition(identity, application, pallets);
requireCurrentRelabelItems(application,selected);
var lockedApp = relabelApplications.lockAnyByRelabelNo(application.getRelabelNo());
requireRelabelWork(identity, lockedApp);
long total = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), source.palletId());
@@ -359,6 +373,7 @@ public class PdaPalletOperationService {
reason, relabelPlan);
}
/** 原生退货及无来源托盘的箱绑定共用冻结门禁,远端失败在原事务持锁期间补偿。 */
private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity,
RelabelApplication application, List<RelabelItem> selected,
PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target,
@@ -371,7 +386,7 @@ public class PdaPalletOperationService {
long quantity = 0;
List<ReturnInventoryCarton> cartons = new ArrayList<>();
for (RelabelItem item : selected) {
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|| (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode()))
: !Objects.equals(carton.getPalletCode(), source.palletCode()))
@@ -388,9 +403,19 @@ public class PdaPalletOperationService {
String requestHash = hash(request);
PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash);
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
var transition = physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托");
PhysicalJournal journal = new PhysicalJournal();
Boolean[] compensationResult = { null };
try {
return transaction.execute(status -> {
Map<String,PalletPhysicalGateway.PalletReference> pallets = new LinkedHashMap<>();
if (source != null) pallets.put(source.palletCode(), source);
pallets.put(target.palletCode(), target);
lockMutationComposition(identity, application, pallets);
requireCurrentRelabelItems(application,selected);
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
// 先取得共同串行锁再触碰远端;本地失败补偿也必须在该锁释放前结束。
try {
applyPhysical(operation,identity.overseasWarehouseId(),target,"OCCUPIED",target.locationId(),key,"退货换标组托",journal);
for (int index = 0; index < cartons.size(); index++) {
RelabelItem item = selected.get(index);
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
@@ -423,10 +448,16 @@ public class PdaPalletOperationService {
operation.setStatus("COMPLETED"); operation.setResultJson(write(result));
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
return result;
} catch (RuntimeException failure) {
boolean restored = compensate(identity.overseasWarehouseId(),journal.confirmed);
compensationResult[0] = restored && !journal.awaitingReceipt;
if (journal.awaitingReceipt) failure.addSuppressed(new IllegalStateException("本次实托请求尚未取得有效回执,不能确认已补偿"));
throw failure;
}
});
} catch (RuntimeException failure) {
try { physical.compensate(identity.overseasWarehouseId(), transition); }
catch (RuntimeException compensationFailure) { failure.addSuppressed(compensationFailure); }
boolean compensated = compensationResult[0] != null ? compensationResult[0] : journal.attempts.isEmpty();
fail(operation.getId(),failure,compensated);
throw failure;
}
}
@@ -519,6 +550,7 @@ public class PdaPalletOperationService {
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true));
}
/** 所有托盘变更在库存锁之前冻结源目标完整组成,保留幂等与原事务持锁补偿。 */
private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key,
Object request, String reason, Plan plan) {
String normalizedKey = required(key, "幂等键不能为空");
@@ -526,9 +558,18 @@ public class PdaPalletOperationService {
PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash);
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
List<PalletPhysicalGateway.Transition> transitions = new ArrayList<>();
PhysicalJournal journal = new PhysicalJournal();
List<PalletPhysicalGateway.Transition> transitions = journal.confirmed;
Boolean[] compensationResult = { null };
try {
PalletOperationResponses.Operation result = transaction.execute(status -> {
lockMutationComposition(identity, plan.relabelMutation() == null ? null : plan.relabelMutation().application(), plan.pallets());
if (plan.relabelMutation() != null) {
var selected = plan.relabelMutation().itemsByPosition().values().stream().flatMap(List::stream).toList();
requireCurrentRelabelItems(plan.relabelMutation().application(),selected);
for (var move : plan.moves()) requireRelabelDestination(identity.overseasWarehouseId(), selected,
plan.pallets().get(move.targetPalletCode()));
}
if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId());
Long relabelOwner = plan.relabelMutation() == null ? null : inventoryCommands.relabelTransfer(
plan.relabelMutation().application().getCustomerId(), plan.relabelMutation().application().getId());
@@ -547,18 +588,27 @@ public class PdaPalletOperationService {
requirePalletReservation(anchor.getCustomerId(), anchorId, relabelOwner);
}
}
try {
for (PalletChange change : plan.changes()) {
transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
applyPhysical(operation,identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey,
change.pallet().palletId()), reason));
change.pallet().palletId()), reason,journal);
}
saveTransitions(operation.getId(), transitions);
return mutate(identity, operation, plan, normalizedKey, requestHash, reason);
} catch (RuntimeException failure) {
// 普通及换标操作都持有共同串行点,不能先解锁再补偿旧的物理组成。
boolean compensated = compensate(identity.overseasWarehouseId(), transitions);
compensationResult[0] = compensated && !journal.awaitingReceipt;
if (journal.awaitingReceipt) failure.addSuppressed(new IllegalStateException("本次实托请求尚未取得有效回执,局部补偿不代表整体恢复"));
if (!compensated) failure.addSuppressed(new IllegalStateException("实托流转补偿未完成"));
throw failure;
}
});
if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果");
return result;
} catch (RuntimeException failure) {
boolean compensated = compensate(identity.overseasWarehouseId(), transitions);
// 未进入物理阶段时没有远端变更;提交阶段异常不能解锁后自动回放旧补偿。
boolean compensated = compensationResult[0] != null ? compensationResult[0] : journal.attempts.isEmpty();
fail(operation.getId(), failure, compensated);
throw failure;
}
@@ -620,6 +670,97 @@ public class PdaPalletOperationService {
return result;
}
/** 首次申请锁前收齐闭包;申请、订单、实托共同串行点之后才允许读取库存并改绑。 */
private void lockMutationComposition(PdaWarehouseIdentity identity, RelabelApplication source,
Map<String,PalletPhysicalGateway.PalletReference> pallets) {
var seeds = new java.util.TreeMap<Long,String>();
pallets.values().forEach(pallet -> seeds.put(pallet.palletId(),pallet.palletCode()));
var before = readMutationComposition(identity,source,seeds);
String expected = mutationCompositionIdentity(before);
var numbers = new java.util.TreeSet<String>();
before.composition().applications().forEach(app -> numbers.add(app.getRelabelNo()));
if (source != null && !numbers.contains(source.getRelabelNo())) throw conflict("换标申请的真实组成未完整返回");
for (String number : numbers) if (!mutationRepository.lockApplication(number))
throw conflict("换标申请身份已变化,请重新读取完整组成");
var orders = new java.util.TreeMap<Long,PdaPalletOperationMapper.MutationOrder>();
for (var order : before.composition().orders()) {
if (order.orderId() == null || order.orderId() <= 0 || order.customerId() == null || order.customerId().isBlank())
throw conflict("实托来源缺少真实原始订单身份,不能改绑");
var previous = orders.putIfAbsent(order.orderId(),order);
if (previous != null && !previous.customerId().equals(order.customerId()))
throw conflict("实托来源订单客户归属不一致");
}
for (var order : orders.values()) if (!mutationRepository.lockOrder(order))
throw conflict("实托来源订单不存在或归属已变化");
physicalPalletSerial.lock(List.copyOf(before.pallets().keySet()));
var current = readMutationComposition(identity,source,seeds);
// 共同串行点之后发现新申请、订单、实托或贡献即整笔拒绝,不追加乱序锁。
if (!expected.equals(mutationCompositionIdentity(current))) throw conflict("实托组成已变化,请重新读取后组托");
for (var application : current.composition().applications()) requireUnallocatedRelabel(application);
if (source != null) {
var currentSource = current.composition().applications().stream()
.filter(app -> source.getRelabelNo().equals(app.getRelabelNo())).findFirst()
.orElseThrow(() -> conflict("换标申请身份已变化"));
requireRelabelWork(identity,currentSource);
}
var now = LocalDateTime.ofInstant(mutationClock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS),ZoneOffset.UTC);
if (mutationRepository.transportClaims(List.copyOf(current.pallets().keySet()),now) != 0)
throw conflict("实托已进入排车或有效草稿,请先解除运输占用后再组托");
}
/** 解析原生退货关联的真实托盘码,再重新展开全客户组成,绝不猜测资源ID。 */
private MutationComposition readMutationComposition(PdaWarehouseIdentity identity, RelabelApplication source,
Map<Long,String> seeds) {
var resolved = new java.util.TreeMap<>(seeds);
PdaPalletOperationRepository.Composition composition;
boolean changed;
do {
composition = mutationRepository.composition(String.valueOf(identity.overseasWarehouseId()),resolved,source);
changed = false;
for (var pallet : composition.pallets()) {
if (pallet.palletCode() == null || pallet.palletCode().isBlank()) throw conflict("关联实托缺少真实托盘码");
Long id = pallet.palletId();
if (id == null) {
var known = resolved.entrySet().stream().filter(entry -> entry.getValue().equals(pallet.palletCode())).findFirst();
id = known.isPresent() ? known.get().getKey() : requirePallet(identity.overseasWarehouseId(),pallet.palletCode()).palletId();
}
if (id <= 0) throw conflict("关联实托缺少真实资源身份");
var previous = resolved.putIfAbsent(id,pallet.palletCode());
if (previous != null && !previous.equals(pallet.palletCode())) throw conflict("关联实托身份与托盘码不一致");
changed |= previous == null;
}
} while (changed);
return new MutationComposition(composition,java.util.Collections.unmodifiableSortedMap(resolved));
}
/** 比较组成身份与贡献,不把预读对象或展示首行当作已锁定事实。 */
private String mutationCompositionIdentity(MutationComposition value) {
var applications = value.composition().applications().stream().map(app -> java.util.Arrays.asList(
app.getId(),app.getRelabelNo(),app.getCustomerId(),app.getOverseasWarehouseId(),app.getLastlegOrderId(),
app.getStatus(),app.getFulfillmentStatus(),app.getOutboundOrderNo())).map(Object::toString).sorted().toList();
return write(Map.of("applications",applications,"orders",value.composition().orders().stream().map(Object::toString).sorted().toList(),
"pallets",value.pallets(),"facts",value.composition().facts().stream().map(Object::toString).sorted().toList()));
}
/** 本轮完整组成和已解析的全局实托集合。 */
private record MutationComposition(PdaPalletOperationRepository.Composition composition, java.util.SortedMap<Long,String> pallets) { }
/** 申请锁内复验请求预读的逐箱指针,不能用改绑前的旧明细覆盖当前贡献。 */
private void requireCurrentRelabelItems(RelabelApplication application,List<RelabelItem> selected) {
for (var item : selected) {
var current = item.getId() == null ? null : relabelItems.selectById(item.getId());
if (current == null || !Objects.equals(current.getApplicationId(),application.getId())
|| !Objects.equals(current.getCustomerId(),application.getCustomerId())
|| !Objects.equals(current.getSourceInventoryPositionId(),item.getSourceInventoryPositionId())
|| !Objects.equals(current.getReturnCartonId(),item.getReturnCartonId())
|| !Objects.equals(current.getCartonMark(),item.getCartonMark())
|| !Objects.equals(current.getNotifiedPackageCount(),item.getNotifiedPackageCount())
|| !Objects.equals(current.getDestinationFbaWarehouseCode(),item.getDestinationFbaWarehouseCode()))
throw conflict("换标箱的真实贡献或绑定已变化,请重新读取");
}
}
/** 已收齐全部申请锁后重核本次申请,重复取得同一申请不改变全局锁序。 */
private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) {
RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo());
if (locked != null) requireUnallocatedRelabel(locked);
@@ -1016,10 +1157,49 @@ public class PdaPalletOperationService {
return read(existing).asReplay();
}
private void saveTransitions(long operationId, List<PalletPhysicalGateway.Transition> transitions) {
/** 每次远端调用先保存完整意图,只有匹配的APPLIED回执才能清除当前未决事实。 */
private void applyPhysical(PdaPalletOperation operation,long warehouseId,PalletPhysicalGateway.PalletReference pallet,
String targetStatus,Long targetLocationId,String key,String reason,PhysicalJournal journal) {
var attempt = new PhysicalAttempt(key,warehouseId,pallet.palletId(),pallet.palletCode(),pallet.version(),
pallet.status(),pallet.locationId(),targetStatus,targetLocationId,null);
journal.attempts.add(attempt);
savePhysicalAttempts(operation.getId(),journal.attempts);
// 从进入POST调用到有效回执之间均可能已在远端提交,不能按异常类型或HTTP码推定未写。
journal.awaitingReceipt = true;
var receipt = physical.apply(warehouseId,pallet,targetStatus,targetLocationId,key,reason);
Long effectiveLocation = targetLocationId == null ? pallet.locationId() : targetLocationId;
if (receipt == null || !key.equals(receipt.operationKey()) || receipt.warehouseId()!=warehouseId
|| receipt.palletId()!=pallet.palletId() || !Objects.equals(receipt.palletCode(),pallet.palletCode())
|| !Objects.equals(receipt.beforeStatus(),pallet.status()) || receipt.beforeVersion()!=pallet.version()
|| !Objects.equals(receipt.beforeLocationId(),pallet.locationId())
|| !Objects.equals(receipt.afterStatus(),targetStatus) || !Objects.equals(receipt.afterLocationId(),effectiveLocation)
|| (long)receipt.afterVersion()!=(long)pallet.version()+1 || !"APPLIED".equals(receipt.operationStatus()))
throw new IllegalStateException("实托流转未返回匹配本次意图的APPLIED回执,请保留原请求核对");
journal.confirmed.add(receipt);
journal.awaitingReceipt = false;
journal.attempts.set(journal.attempts.size()-1,new PhysicalAttempt(key,warehouseId,pallet.palletId(),pallet.palletCode(),pallet.version(),
pallet.status(),pallet.locationId(),targetStatus,targetLocationId,receipt));
savePhysicalAttempts(operation.getId(),journal.attempts);
}
/** 已知回执与尚未确认的原请求分别保留,不用占位Transition冒充成功证据。 */
private record PhysicalAttempt(String operationKey,long warehouseId,long palletId,String palletCode,int expectedVersion,
String beforeStatus,Long beforeLocationId,String targetStatus,Long targetLocationId,
PalletPhysicalGateway.Transition receipt) { }
/** 本次作业所有意图和已确认回执;未决当前调用不属于可补偿回执集合。 */
private static final class PhysicalJournal {
private final List<PhysicalAttempt> attempts = new ArrayList<>();
private final List<PalletPhysicalGateway.Transition> confirmed = new ArrayList<>();
private boolean awaitingReceipt;
}
/** 意图证据独立保存,业务事务回滚或进程中断不能抹去已发远端请求。 */
private void savePhysicalAttempts(long operationId, List<PhysicalAttempt> attempts) {
newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId);
operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now());
if (operation == null) throw new IllegalStateException("实托作业意图记录不存在,不能发出远端请求");
operation.setRemoteTransitionsJson(write(attempts)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
});
}