fix(dispatch): confirm pallet quantities at inbound receipt
This commit is contained in:
+10
@@ -11,6 +11,16 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
|
||||
/** 收货只定位原计划已关联的有效排车,按车辆父锁顺序返回。 */
|
||||
@Select("<script>SELECT DISTINCT d.dispatch_no,d.trip_id,d.id FROM ll_dispatch_order d "
|
||||
+ "JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id "
|
||||
+ "WHERE d.status='ACTIVE' AND i.plan_id IN "
|
||||
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> "
|
||||
+ "ORDER BY d.trip_id,d.id</script>")
|
||||
List<ReceiptDispatch> receiptDispatches(@Param("ids") List<Long> planIds);
|
||||
|
||||
record ReceiptDispatch(String dispatchNo, Long tripId, Long id) { }
|
||||
|
||||
/** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */
|
||||
@Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id")
|
||||
List<DispatchItem> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
|
||||
|
||||
+18
@@ -21,6 +21,24 @@ public interface DispatchSourceMapper {
|
||||
record OrderReference(Long orderId,String customerId) { }
|
||||
record ItemPicked(Long itemId,long packages) { }
|
||||
|
||||
/** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */
|
||||
@Select("""
|
||||
SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId,
|
||||
p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo,
|
||||
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
|
||||
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
|
||||
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage
|
||||
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 a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL
|
||||
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
|
||||
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
||||
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
||||
""")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
|
||||
|
||||
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
||||
@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")
|
||||
|
||||
+4
@@ -42,6 +42,10 @@ public class DispatchAggregateRepository {
|
||||
public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); }
|
||||
/** 按整车读取全部真实客户明细。 */
|
||||
public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); }
|
||||
/** 只读取本次确认入库的原计划关联排车。 */
|
||||
public List<String> receiptDispatches(List<Long> planIds) {
|
||||
return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList();
|
||||
}
|
||||
/** 保存已核对真实客户与仓库的来源快照。 */
|
||||
public int insertItem(DispatchItem item) { return items.insert(item); }
|
||||
/** 未执行明细才可移除,资格和根版本由应用层保证。 */
|
||||
|
||||
+2
@@ -66,6 +66,8 @@ public class DispatchSourceRepository {
|
||||
public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); }
|
||||
/** 取得已有普通计划候选,保留原来源资格SQL。 */
|
||||
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
|
||||
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
|
||||
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
|
||||
/** 取得已有真实退回候选。 */
|
||||
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
|
||||
/** 同托真实来源订单组成。 */
|
||||
|
||||
+22
-1
@@ -693,9 +693,30 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
|
||||
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
|
||||
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
|
||||
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));}
|
||||
else {
|
||||
var batches=json.valueToTree(repository.batches(row.getId()));
|
||||
// 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。
|
||||
for(var batch:batches)if(("DISPATCH:"+row.getId()).equals(batch.path("batchKey").asText()))
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)batch).remove("recordVersion");
|
||||
facts.put("batches",batches);facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));
|
||||
}
|
||||
return hashes.source(facts);
|
||||
}
|
||||
/** 交接前记住原审核依据是否仍有效,不借实收替资源或预约变化重新背书。 */
|
||||
boolean currentReceiptBasis(DispatchAggregate row) {
|
||||
return row.getReviewBasisHash()!=null && Objects.equals(row.getReviewBasisHash(),basis(row));
|
||||
}
|
||||
|
||||
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
|
||||
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
|
||||
long version=row.getRecordVersion();
|
||||
if(preserveReview)row.setReviewBasisHash(basis(row));
|
||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
|
||||
if(repository.history(IdWorker.getId(),row,"RECEIVING_COMPLETE",operator,"按确认入库的逐托实收交接原预排",write(row),at)!=1)
|
||||
throw conflict("排车实收交接历史未保存");
|
||||
}
|
||||
|
||||
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
|
||||
private void invalidate(DispatchAggregate row,String reason) {
|
||||
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.transaction.support.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/** 确认入库时完成原预排实收交接,出货只执行已确认的逐托数量。 */
|
||||
@Service
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public class DispatchInboundReceiptService implements InboundDispatchReceiptGateway {
|
||||
private final DispatchAggregateRepository repository;
|
||||
private final DispatchSourceRepository sources;
|
||||
private final DispatchPickingRepository picking;
|
||||
private final DispatchPhysicalClaimService physicalClaims;
|
||||
private final DispatchAggregateService workflow;
|
||||
private final FleetTripExecutionService fleet;
|
||||
private final InboundDispatchReceiptGateway.Appointments appointments;
|
||||
private final Object locksKey=new Object();
|
||||
|
||||
/** 使用原排车、实物占用及预约边界,不新增库存出库动作。 */
|
||||
public DispatchInboundReceiptService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
|
||||
DispatchPickingRepository picking,
|
||||
DispatchPhysicalClaimService physicalClaims, DispatchAggregateService workflow, FleetTripExecutionService fleet,
|
||||
InboundDispatchReceiptGateway.Appointments appointments) {
|
||||
this.repository=repository;this.sources=sources;this.picking=picking;
|
||||
this.physicalClaims=physicalClaims;this.workflow=workflow;this.fleet=fleet;this.appointments=appointments;
|
||||
}
|
||||
|
||||
/** 先车辆父锁、排车主锁和预约锁,再由收货链路锁来源与库存。 */
|
||||
@Override public void lock(List<Long> planIds) {
|
||||
var numbers=repository.receiptDispatches(planIds);
|
||||
@SuppressWarnings("unchecked") var previous=(Set<String>)TransactionSynchronizationManager.getResource(locksKey);
|
||||
if(previous!=null) {
|
||||
if(!previous.containsAll(numbers))throw conflict("收货期间关联排车已变化,请刷新后重新确认入库");
|
||||
return;
|
||||
}
|
||||
if(!numbers.isEmpty())fleet.lockExistingDispatchShells(numbers);
|
||||
for(var row:repository.findMany(numbers).stream().sorted(Comparator.comparing(com.baiyue.lastleg.dispatch.entity.DispatchAggregate::getId)).toList())repository.lock(row.getDispatchNo());
|
||||
appointments.lock(numbers);
|
||||
TransactionSynchronizationManager.bindResource(locksKey,Set.copyOf(numbers));
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
/** 事务完成后清除本次收货持锁范围。 */
|
||||
@Override public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(locksKey); }
|
||||
});
|
||||
}
|
||||
|
||||
/** 实收确认、原排车数量及预约交接随同一收货事务提交或回滚。 */
|
||||
@Override public void complete(List<Long> planIds, String operator, LocalDateTime at) {
|
||||
var numbers=repository.receiptDispatches(planIds);
|
||||
@SuppressWarnings("unchecked") var locked=(Set<String>)TransactionSynchronizationManager.getResource(locksKey);
|
||||
if(locked==null || !locked.containsAll(numbers))throw conflict("收货前未锁齐原排车,请刷新后重新确认入库");
|
||||
for(var number:numbers) {
|
||||
var row=repository.lock(number);
|
||||
if(row==null || !"ACTIVE".equals(row.getStatus()))continue;
|
||||
var items=repository.cargo(row.getId());
|
||||
var pending=items.stream().filter(i->planIds.contains(i.getPlanId()) && i.getWarehousePalletId()==null).toList();
|
||||
if(pending.isEmpty())continue;
|
||||
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
|
||||
boolean preserveReview=workflow.currentReceiptBasis(row);
|
||||
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
|
||||
for(var item:pending) {
|
||||
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
|
||||
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
|
||||
received.put(item,actual);
|
||||
}
|
||||
for(var entry:received.entrySet()) {
|
||||
entry.getKey().setWarehousePalletId(entry.getValue().warehousePalletId());
|
||||
entry.getKey().setPackageCount(entry.getValue().packageCount());
|
||||
}
|
||||
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|
||||
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
|
||||
}
|
||||
var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
|
||||
for(var entry:received.entrySet()) {
|
||||
var item=entry.getKey();var actual=entry.getValue();
|
||||
if(sources.materialize(row.getId(),item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)
|
||||
throw conflict("原排车托盘占用已变化,入库交接未保存");
|
||||
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
|
||||
}
|
||||
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList());
|
||||
int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum());
|
||||
if(total!=row.getPackageCount()) {
|
||||
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
|
||||
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
|
||||
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(number,total,stops);
|
||||
}
|
||||
row.setPackageCount(total);
|
||||
row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||
workflow.saveReceivedCargo(row,operator,at,preserveReview);
|
||||
}
|
||||
}
|
||||
|
||||
/** 来源变化必须使收货交接整体失败,不留下半份运输事实。 */
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); }
|
||||
}
|
||||
+1
-13
@@ -16,8 +16,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
@Service
|
||||
public class DispatchPdaQueryService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
|
||||
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
|
||||
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
|
||||
@@ -30,7 +28,7 @@ public class DispatchPdaQueryService {
|
||||
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
|
||||
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
|
||||
}
|
||||
/** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */
|
||||
/** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */
|
||||
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
|
||||
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
|
||||
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
|
||||
@@ -61,8 +59,6 @@ public class DispatchPdaQueryService {
|
||||
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
|
||||
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
|
||||
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
|
||||
if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null)
|
||||
sourcePositions=receivedPlanPositions(actor,item,items);
|
||||
if(normalRelabel(item)) {
|
||||
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
|
||||
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
|
||||
@@ -95,14 +91,6 @@ public class DispatchPdaQueryService {
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
|
||||
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
|
||||
var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules);
|
||||
if(received.isEmpty())return List.of();
|
||||
// 只修改本次投影对象,不在GET更新排车、版本或库存。
|
||||
item.setPackageCount(received.get().source().packageCount());
|
||||
return received.get().positions();
|
||||
}
|
||||
/** 仓和操作者均来自受信身份,不接收请求自报。 */
|
||||
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
||||
/** 缺失计数不作为业务标识使用。 */
|
||||
|
||||
+2
-43
@@ -12,7 +12,6 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.time.*;
|
||||
@@ -24,10 +23,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
|
||||
@Service
|
||||
public class DispatchPickingService {
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet;
|
||||
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
|
||||
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
|
||||
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
|
||||
@@ -53,7 +50,8 @@ public class DispatchPickingService {
|
||||
var currentItems=repository.cargo(row.getId());
|
||||
var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在"));
|
||||
if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓");
|
||||
materialize(row,item,actor,currentItems);
|
||||
if(item.getWarehousePalletId()==null)throw conflict("该托盘尚未完成收货交接,请先确认入库后再拣货");
|
||||
relabelSources.validateFrozen(row,currentItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),currentItems));
|
||||
var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC);
|
||||
if(task==null) {
|
||||
task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId());
|
||||
@@ -103,45 +101,6 @@ public class DispatchPickingService {
|
||||
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
|
||||
commands.append(row.getId(),actor,identity,result);return result;
|
||||
}
|
||||
/** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */
|
||||
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
|
||||
long dispatch=row.getId();
|
||||
if(item.getWarehousePalletId()!=null) {
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
|
||||
}
|
||||
var received=new LinkedHashMap<DispatchItem,DispatchReceivedPlan.Fact>();
|
||||
for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules)
|
||||
.ifPresent(fact->received.put(candidate,fact));
|
||||
if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对");
|
||||
// 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。
|
||||
for(var entry:received.entrySet()) {
|
||||
entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId());
|
||||
entry.getKey().setPackageCount(entry.getValue().source().packageCount());
|
||||
}
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
|
||||
for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0))
|
||||
|| !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||
|| !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码");
|
||||
}
|
||||
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at);
|
||||
for(var entry:received.entrySet()) {
|
||||
var candidate=entry.getKey();var actual=entry.getValue().source();
|
||||
try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");}
|
||||
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");}
|
||||
candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存");
|
||||
}
|
||||
row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
||||
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
|
||||
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
|
||||
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
||||
}
|
||||
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
|
||||
private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());}
|
||||
/** 不可变扫码证据序列化失败随根命令回滚。 */
|
||||
|
||||
+5
-27
@@ -2,45 +2,23 @@ package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType;
|
||||
|
||||
/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */
|
||||
/** 预排来源只冻结货物身份;自然实收的逐托箱数在确认入库时由真实库存提供。 */
|
||||
final class DispatchReceivedPlan {
|
||||
record Fact(DispatchPalletCandidate source, List<DispatchViews.Position> positions) { }
|
||||
|
||||
/** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */
|
||||
static Optional<Fact> resolve(String warehouse, DispatchItem item, List<DispatchItem> items,
|
||||
DispatchSourceRepository sources, DispatchService rules) {
|
||||
/** 原客户、计划及目的地保持一致,数量直接取确认入库的逐票实收。 */
|
||||
static Optional<DispatchPalletCandidate> resolve(String warehouse, DispatchItem item, DispatchSourceRepository sources) {
|
||||
if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null
|
||||
|| !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty();
|
||||
if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty();
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
var rows=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)return Optional.empty();
|
||||
var actual=rows.get(0);
|
||||
if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty();
|
||||
var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(original.isEmpty() || !new HashSet<>(original).containsAll(
|
||||
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty();
|
||||
var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection(
|
||||
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty();
|
||||
for(var member:group.sources()) {
|
||||
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty();
|
||||
// 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。
|
||||
if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null)
|
||||
&& (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty();
|
||||
}
|
||||
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse)
|
||||
|| !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0
|
||||
|| p.allocatedPackages()!=0 || p.pickedPackages()!=0)
|
||||
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty();
|
||||
return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0)
|
||||
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList()));
|
||||
return Optional.of(actual);
|
||||
}
|
||||
|
||||
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.gateway;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 收货确认在原事务内交接真实托盘,预排估算不进入出货执行。 */
|
||||
public interface InboundDispatchReceiptGateway {
|
||||
/** 收货锁来源前,按固定顺序锁齐已有车辆、排车及预约。 */
|
||||
void lock(List<Long> planIds);
|
||||
|
||||
/** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */
|
||||
void complete(List<Long> planIds, String operatorId, LocalDateTime at);
|
||||
|
||||
/** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */
|
||||
record Item(long itemId, String customerId, long planPalletId, int packages) { }
|
||||
|
||||
/** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */
|
||||
interface Appointments {
|
||||
/** 在库存来源锁之前锁齐全部原预约。 */
|
||||
void lock(List<String> dispatchNumbers);
|
||||
|
||||
/** 逐票采用已确认实收,禁止对多批次来源猜测分配。 */
|
||||
void complete(long dispatchId, List<Item> items);
|
||||
}
|
||||
}
|
||||
+7
@@ -37,6 +37,13 @@ public class PdaWorkflowRepository {
|
||||
@Resource private InboundPlanCartonMapper cartons;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
|
||||
/** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */
|
||||
public InboundPlan findPlan(String customerId, String planNo) {
|
||||
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
|
||||
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo));
|
||||
}
|
||||
|
||||
/** 父运输锁完成后锁定原客户入仓计划。 */
|
||||
public InboundPlan lockPlan(String customerId, String planNo) {
|
||||
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
|
||||
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
|
||||
|
||||
+12
-1
@@ -55,6 +55,7 @@ public class InboundPdaWorkflowService {
|
||||
@Resource private PdaWorkflowRepository repository;
|
||||
@Resource private WarehouseResourceGateway warehouse;
|
||||
@Resource private InboundStockReceiptService stockReceipts;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts;
|
||||
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
|
||||
@@ -544,16 +545,21 @@ public class InboundPdaWorkflowService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 单票确认入库也先锁运输父记录,再复用整柜实收交接事务。 */
|
||||
@Transactional
|
||||
public PdaResponses.Task complete(String customerId, String planNo) {
|
||||
InboundPlan original = repository.findPlan(customerId, planNo);
|
||||
if (original == null) throw conflict("入仓计划不存在");
|
||||
dispatchReceipts.lock(List.of(original.getId()));
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
return completeContainer(List.of(plan)).get(0);
|
||||
}
|
||||
|
||||
/** Completes every plan of one physical container and emits exactly one container-level outbox event. */
|
||||
/** 整柜确认实收、库存及原预排交接,并且仅发布一次柜级卸货事件。 */
|
||||
@Transactional
|
||||
public List<PdaResponses.Task> completeContainer(List<InboundPlan> containerPlans) {
|
||||
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
|
||||
dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList());
|
||||
List<InboundPlan> orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList();
|
||||
String containerNo = orderedPlans.get(0).getContainerNo();
|
||||
if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan ->
|
||||
@@ -569,6 +575,8 @@ public class InboundPdaWorkflowService {
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (CompletionContext context : contexts) completePlan(context.plan().getCustomerId(), context, now);
|
||||
// 实收库存和原预排一起交接,出货时不再补写数量。
|
||||
dispatchReceipts.complete(orderedPlans.stream().map(InboundPlan::getId).toList(), contexts.get(0).task().getOperatorId(), now);
|
||||
String sourceContainerBookingId = sourceContainerBookingId(contexts);
|
||||
LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt())
|
||||
.max(LocalDateTime::compareTo).orElseThrow();
|
||||
@@ -587,6 +595,7 @@ public class InboundPdaWorkflowService {
|
||||
public void completeContainerWithDifferences(List<InboundPlan> containerPlans,
|
||||
List<InboundPlan> reconciledPlans) {
|
||||
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
|
||||
dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList());
|
||||
Set<Long> reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<InboundPdaTask> allTasks = new ArrayList<>();
|
||||
@@ -606,6 +615,7 @@ public class InboundPdaWorkflowService {
|
||||
allTasks.add(task);
|
||||
}
|
||||
}
|
||||
if (!reconciledPlans.isEmpty()) dispatchReceipts.complete(reconciledPlans.stream().map(InboundPlan::getId).toList(), allTasks.get(0).getOperatorId(), now);
|
||||
String containerNo = containerPlans.get(0).getContainerNo();
|
||||
if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) {
|
||||
throw conflict("Inbound plans must belong to one physical container");
|
||||
@@ -678,6 +688,7 @@ public class InboundPdaWorkflowService {
|
||||
return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons);
|
||||
}
|
||||
|
||||
/** 按确认的逐托实收入库,完成状态与运输交接属于同一事务。 */
|
||||
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
|
||||
InboundPlan plan = context.plan();
|
||||
InboundPdaTask task = context.task();
|
||||
|
||||
+5
@@ -84,6 +84,7 @@ public class ReceivingContainerService {
|
||||
"一个托盘最多只能收66箱,请拆分到其他托盘";
|
||||
|
||||
@Resource private ReceivingCandidateRepository candidateRepository;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts;
|
||||
@Resource private InboundPlanMapper plans;
|
||||
@Resource private InboundPlanLineMapper lines;
|
||||
@Resource private InboundPlanPalletMapper pallets;
|
||||
@@ -594,11 +595,15 @@ public class ReceivingContainerService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 确认拆柜入库前先锁原运输安排;实际数量在本次收货事务完成交接。 */
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public ReceivingContainerResponses.Session complete(String operatorKey,
|
||||
ReceivingContainerRequests.Complete request) {
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||
// 在收货会话、订单及库存来源加锁之前,先锁齐原运输父记录。
|
||||
ReceivingContainerSession original = requireCurrent(operatorKey, false);
|
||||
dispatchReceipts.lock(requirePlans(original.getSourceContainerBookingId(), original.getContainerNo()).stream().map(InboundPlan::getId).toList());
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
List<InboundPlan> containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo());
|
||||
Totals totals = totals(session, containerPlans);
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import com.baiyue.lastleg.appointmentplanning.AppointmentSourceService;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.conflict;
|
||||
|
||||
/** 实收沿原逐票预约归属更新数量,绝不按托数均分货物或新选批次。 */
|
||||
@Component
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceiptGateway.Appointments {
|
||||
private final AppointmentReceiptRepository repository;
|
||||
private final AppointmentWorkDispatchBinding binding;
|
||||
private final InboundFbaAppointmentService appointments;
|
||||
private final AppointmentSourceService sources;
|
||||
|
||||
/** 复用原批次锁和真实来源;收货记录不依赖预约是否已满足出货资格。 */
|
||||
public AppointmentReceiptDispatchAdapter(AppointmentReceiptRepository repository, AppointmentWorkDispatchBinding binding,
|
||||
InboundFbaAppointmentService appointments, AppointmentSourceService sources) {
|
||||
this.repository=repository;this.binding=binding;this.appointments=appointments;this.sources=sources;
|
||||
}
|
||||
|
||||
/** 收货前按业务批次顺序锁齐原有效预约,避免库存与预约反向取锁。 */
|
||||
@Override public void lock(List<String> numbers) {
|
||||
var versions=new TreeMap<String,Long>();
|
||||
for(var number:numbers) for(var row:repository.bindings(number))
|
||||
if(!row.batchKey().startsWith("DISPATCH:"))versions.computeIfAbsent(row.batchKey(),this::version);
|
||||
binding.lockBatches(versions);
|
||||
}
|
||||
|
||||
/** 实收来源必须与原绑定唯一对应,多预约分摊不明确时拒绝猜测。 */
|
||||
@Override public void complete(long dispatch, List<InboundDispatchReceiptGateway.Item> items) {
|
||||
var previous=repository.bindings(dispatch);
|
||||
for(var item:items) {
|
||||
var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList();
|
||||
var physical=repository.physical(item.planPalletId(),item.customerId());
|
||||
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
|
||||
var quantities=new LinkedHashMap<List<String>,Integer>();
|
||||
for(var part:physical) {
|
||||
if(part.countedPackages()==null || part.countedPackages()<=0)throw conflict("收货逐票实物箱数尚未确认");
|
||||
var key=List.of(AppointmentWorkPhysicalSources.ref(canonical,part).sourceKey(),part.destinationKey());
|
||||
quantities.merge(key,part.countedPackages(),Math::addExact);
|
||||
}
|
||||
var originalKeys=new HashSet<List<String>>();
|
||||
for(var row:original)originalKeys.add(List.of(row.sourceKey(),row.destinationKey()));
|
||||
if(!quantities.keySet().equals(originalKeys))throw conflict("实收货物来源与原排车预约不一致,请核对原逐票安排");
|
||||
if(quantities.values().stream().mapToLong(Integer::longValue).sum()!=item.packages())throw conflict("逐票实收与托盘确认箱数不一致");
|
||||
for(var entry:quantities.entrySet()) {
|
||||
var matches=original.stream().filter(r->r.sourceKey().equals(entry.getKey().get(0)) && r.destinationKey().equals(entry.getKey().get(1))).toList();
|
||||
if(matches.size()!=1)throw conflict("同一实收来源关联多个预约批次,请明确逐票分配,不能自动均分");
|
||||
var row=matches.get(0);
|
||||
if(!row.customerId().equals(item.customerId()) || row.palletId()!=item.planPalletId())throw conflict("原预约客户或托盘归属已变化");
|
||||
if(row.packages()!=entry.getValue() && repository.receiveQuantity(row,entry.getValue())!=1)throw conflict("原预约逐托分配已变化,收货交接未保存");
|
||||
}
|
||||
}
|
||||
binding.receiptQuantitiesChanged();
|
||||
}
|
||||
|
||||
/** 使用同事务已锁定的原预约版本,不沿用客户端旧快照。 */
|
||||
private long version(String batch) {
|
||||
var value=appointments.batchContext(batch).appointment().version();
|
||||
if(value==null || value<0)throw conflict("原预约批次版本缺失,无法确认收货交接");
|
||||
return value.longValue();
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 收货交接只读取原排车预约绑定和已确认逐票来源。 */
|
||||
@Repository
|
||||
public class AppointmentReceiptRepository {
|
||||
private final AppointmentWorkDispatchMapper mapper;
|
||||
|
||||
/** 复用权威预约与托盘分配Mapper。 */
|
||||
public AppointmentReceiptRepository(AppointmentWorkDispatchMapper mapper) { this.mapper=mapper; }
|
||||
|
||||
/** 读取本车已选择的批次,不选择新预约。 */
|
||||
public List<AppointmentWorkDispatchMapper.Bound> bindings(String number) { return mapper.bindings(number); }
|
||||
|
||||
/** 读取原车逐托绑定,供实收来源逐项匹配。 */
|
||||
public List<AppointmentWorkDispatchMapper.Bound> bindings(long id) { return mapper.byDispatch(id); }
|
||||
|
||||
/** 真实逐票数量以收货确认的分配记录为准。 */
|
||||
public List<AppointmentWorkDispatchMapper.Physical> physical(long pallet, String customer) { return mapper.physical(pallet,customer); }
|
||||
|
||||
/** 原绑定身份及旧数量同时参与更新,防止覆盖并发改量。 */
|
||||
public int receiveQuantity(AppointmentWorkDispatchMapper.Bound original,int actual) { return mapper.receiveQuantity(original,actual); }
|
||||
}
|
||||
+3
@@ -270,6 +270,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车");
|
||||
if(!physical.isEmpty())throw conflict("FBA货物须先预约成功并明确送仓时间,再加入排车");
|
||||
}
|
||||
/** 实收只更新原逐票绑定后,仍在业务提交末尾刷新工作视图。 */
|
||||
void receiptQuantitiesChanged() { invalidateAtBusinessEnd(); }
|
||||
|
||||
/** 调用方已锁主单与原批次,删除只作用于指定实物。 */
|
||||
public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); }
|
||||
/** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */
|
||||
|
||||
+9
@@ -135,5 +135,14 @@ public interface AppointmentWorkDispatchMapper {
|
||||
@Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List<Long> trips(String batch);
|
||||
@Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}")
|
||||
List<Bound> bindings(String number);
|
||||
|
||||
/** 实收只改原绑定箱数,原客户、实物、来源及预约批次必须全部一致。 */
|
||||
@Update("""
|
||||
UPDATE ll_dispatch_appointment_allocation SET package_count=#{actual}
|
||||
WHERE dispatch_id=#{row.dispatchId} AND dispatch_item_id=#{row.itemId} AND plan_pallet_id=#{row.palletId}
|
||||
AND batch_key=#{row.batchKey} AND customer_id=#{row.customerId} AND source_key=#{row.sourceKey}
|
||||
AND destination_key=#{row.destinationKey} AND package_count=#{row.packages}
|
||||
""")
|
||||
int receiveQuantity(@Param("row") Bound original,@Param("actual") int actual);
|
||||
@Select("SELECT dispatch_id,dispatch_item_id item_id,plan_pallet_id pallet_id,batch_key,customer_id,source_key,destination_key,package_count packages FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") List<Bound> byDispatch(long id);
|
||||
}
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.appointmentplanning.*;
|
||||
import com.baiyue.lastleg.appointmentwork.*;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchPickingRepository;
|
||||
import com.baiyue.lastleg.inboundplan.dto.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
||||
import com.baiyue.lastleg.inboundplan.receiving.*;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baiyue.lastleg.inboundplan.work.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */
|
||||
class DispatchInboundReceiptPersistenceTest {
|
||||
/** 直接读取9+11实收,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */
|
||||
@Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception {
|
||||
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var db=new org.springframework.jdbc.core.JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,status VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,pallet_no VARCHAR,delivery_address VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'COMPLETED')");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8')");
|
||||
for(int n=1;n<=2;n++) {
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,1,'101',?,?,NULL)",n,500+n,"P-"+n);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?)",n,n==1?9:11);
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchSourceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class);
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9);
|
||||
assertThat(mapper.confirmedPlan("101",2).get(0).packageCount()).isEqualTo(11);
|
||||
assertThat(mapper.confirmedPlan("other",1)).isEmpty();
|
||||
db.update("UPDATE ll_inbound_plan SET status='RECEIVING'");
|
||||
assertThat(mapper.confirmedPlan("101",1)).isEmpty();
|
||||
}
|
||||
|
||||
/** 入库确认立即将10+10交接为9+11,尚未扫码也已核平预约;出货不再补改数量。 */
|
||||
@Test void receiptConfirmsNineAndElevenBeforeAnyPickingAndBothPalletsCanLeave() throws Exception {
|
||||
var f=new Fixture();f.confirm();
|
||||
assertThat(f.quantities()).containsExactly(9,11);
|
||||
assertThat(f.allocations()).containsExactly(9,11);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero();
|
||||
assertThat(f.p.f.db.queryForObject("SELECT picked_package_count FROM ll_dispatch_order",Integer.class)).isZero();
|
||||
verifyNoInteractions(f.p.inventory);
|
||||
var before=f.version();
|
||||
var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(before,"receipt-nine",11L,9,0,null,null)));
|
||||
assertThatCode(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).doesNotThrowAnyException();
|
||||
var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"receipt-eleven",12L,11,0,null,null)));
|
||||
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
|
||||
assertThat(f.binding.departureBlockReason(f.p.number)).isNull();
|
||||
assertThat(f.quantities()).containsExactly(9,11);
|
||||
assertThat(f.allocations()).containsExactly(9,11);
|
||||
verify(f.p.inventory,times(2)).ship(eq("101"),eq("picker"),any());
|
||||
}
|
||||
|
||||
/** 重复交接不修改版本、重复占用托盘或新增收货历史。 */
|
||||
@Test void confirmedReceiptIsIdempotent() throws Exception {
|
||||
var f=new Fixture();f.confirm();long version=f.version();
|
||||
f.p.f.tx.executeWithoutResult(s->{f.receipt.lock(List.of(1L,2L));f.receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now());});
|
||||
assertThat(f.version()).isEqualTo(version);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='RECEIVING_COMPLETE'",Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 已因其他事实变化失效的审核,不能在确认入库时被重新放行。 */
|
||||
@Test void receiptDoesNotApproveAnAlreadyStaleReview() throws Exception {
|
||||
var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash='stale-review'");
|
||||
f.confirm();
|
||||
assertThat(f.p.f.db.queryForObject("SELECT review_basis_hash FROM ll_dispatch_order",String.class)).isEqualTo("stale-review");
|
||||
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("审核依据失效");
|
||||
}
|
||||
|
||||
/** 仓库预约回复尚待处理不改变实收数量,收货成功后出货门禁仍明确阻止发运。 */
|
||||
@Test void receivingConfirmsFactsWhileAppointmentStillBlocksShipping() throws Exception {
|
||||
var f=new Fixture();when(f.receiving.hasPending("FBA_APPOINTMENT:1","101")).thenReturn(true);
|
||||
f.confirm();
|
||||
assertThat(f.quantities()).containsExactly(9,11);
|
||||
assertThat(f.allocations()).containsExactly(9,11);
|
||||
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("待仓库确认");
|
||||
}
|
||||
|
||||
/** 未关联有效排车的正常收货也可完成,不能要求存在车辆或预约。 */
|
||||
@Test void receiptWithoutActiveDispatchDoesNotRequireTransportResources() throws Exception {
|
||||
var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
|
||||
assertThatCode(f::confirm).doesNotThrowAnyException();
|
||||
assertThat(f.quantities()).containsExactly(10,10);
|
||||
}
|
||||
|
||||
/** 客户仓沿本车逐票绑定交接9+11,首托扫码递增公共版本不能使第二托审核失效。 */
|
||||
@Test void privateWarehouseReceiptAndBothPicksKeepTheSameReview() throws Exception {
|
||||
var f=new Fixture();long dispatch=f.p.f.repository.find(f.p.number).getId();
|
||||
f.p.f.db.update("UPDATE ll_dispatch_item SET destination_type='PRIVATE'");
|
||||
f.p.f.db.update("UPDATE ll_inbound_plan_line SET destination_type='PRIVATE'");
|
||||
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET batch_key=?,destination_key='CUSTOMER:9001'","DISPATCH:"+dispatch);
|
||||
f.p.f.candidates.values().forEach(c->c.destinationType("PRIVATE"));
|
||||
f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number)));
|
||||
f.confirm();
|
||||
var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(f.version(),"private-nine",11L,9,0,null,null)));
|
||||
var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"private-eleven",12L,11,0,null,null)));
|
||||
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
|
||||
assertThat(f.allocations()).containsExactly(9,11);
|
||||
}
|
||||
|
||||
/** 预约逐票交接失败时,收货数量、库存及排车实托绑定必须整体回滚。 */
|
||||
@Test void ambiguousBatchAllocationRollsBackTheWholeReceipt() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_id,dispatch_item_id,plan_pallet_id,'FBA_APPOINTMENT:2',customer_id,source_key,destination_key,1 FROM ll_dispatch_appointment_allocation WHERE plan_pallet_id=1");
|
||||
f.p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(2,0,'AP-2')");
|
||||
when(f.appointments.batchContext("FBA_APPOINTMENT:2")).thenReturn(new FbaAppointmentBatchContext(f.snapshot,List.of(),List.of(),List.of(),List.of(),List.of()));
|
||||
assertThatThrownBy(f::confirm).hasMessageContaining("不能自动均分");
|
||||
assertThat(f.quantities()).containsExactly(10,10);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Integer.class)).isZero();
|
||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(counted_package_count) FROM ll_inbound_plan_pallet_allocation",Integer.class)).isZero();
|
||||
}
|
||||
|
||||
/** 不能借收货确认接管其他排车已经占用的实托。 */
|
||||
@Test void occupiedPhysicalPalletRejectsReceiptWithoutPartialUpdates() throws Exception {
|
||||
var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501");
|
||||
assertThatThrownBy(f::confirm).hasMessageContaining("占用");
|
||||
assertThat(f.quantities()).containsExactly(10,10);
|
||||
assertThat(f.allocations()).containsExactly(10,10);
|
||||
}
|
||||
|
||||
/** 单客户同票两托的真实预约提供者,避免用恒定放行的预约Mock掩盖数量冲突。 */
|
||||
private static final class Fixture {
|
||||
final DispatchPickingPersistenceTest.Picking p=new DispatchPickingPersistenceTest.Picking(true);
|
||||
final InboundFbaAppointmentService appointments=mock(InboundFbaAppointmentService.class);
|
||||
final FbaAppointmentResponse snapshot=mock(FbaAppointmentResponse.class);
|
||||
final FbaReceivingStore receiving=mock(FbaReceivingStore.class);
|
||||
final AppointmentWorkDispatchBinding binding;
|
||||
final DispatchInboundReceiptService receipt;
|
||||
|
||||
/** 预排估算为10+10,收货输入固定9+11,来源及预约均保持同一票。 */
|
||||
Fixture() throws Exception {
|
||||
p.receivedPlans(9,11);
|
||||
p.f.db.update("UPDATE ll_dispatch_item SET customer_id='101',order_id=9001,destination_type='FBA',fba_warehouse_code='ONT8'");
|
||||
p.f.db.update("UPDATE ll_inventory_position SET customer_id='101',available_packages=0");
|
||||
p.f.db.update("UPDATE ll_inbound_stock_receipt SET customer_id='101'");
|
||||
p.f.db.update("UPDATE ll_dispatch_order SET estimated_arrival_at=DATEADD('HOUR',1,planned_departure_at)");
|
||||
p.f.candidates.get(2L).customerId("101");p.f.candidates.get(2L).orderId(9001L);p.f.candidates.get(2L).fbaWarehouseCode("ONT8");
|
||||
p.f.candidates.get(2L).destinationWarehouseCode("ONT8");
|
||||
doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L);
|
||||
doReturn(true).when(p.sources).receivedPlanCompleted("101",2L);
|
||||
for(long id=1;id<=2;id++) {
|
||||
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id);
|
||||
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id);
|
||||
doReturn(id).when(p.sources).lockPlan("101",id);
|
||||
doReturn(List.of(p.f.candidates.get(id))).when(p.sources).confirmedPlan("101",id);
|
||||
doReturn(1).when(p.sources).materialize(anyLong(),eq("101"),eq(id),eq(500+id),anyLong(),any());
|
||||
}
|
||||
p.f.db.execute("ALTER TABLE ll_inbound_plan_line ADD (destination_type VARCHAR(20),fba_warehouse_code VARCHAR(20))");
|
||||
p.f.db.execute("ALTER TABLE ll_inbound_plan_pallet_allocation ADD (customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)");
|
||||
p.f.db.update("INSERT INTO ll_inbound_plan_line(id,order_id,customer_id,destination_type,fba_warehouse_code) VALUES(1,9001,'101','FBA','ONT8')");
|
||||
for(long id=1;id<=2;id++) {
|
||||
p.f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,0)",id);
|
||||
p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_order_id,id,plan_pallet_id,'FBA_APPOINTMENT:1','101','ORDER:9001','FBA:ONT8',10 FROM ll_dispatch_item WHERE plan_pallet_id=?",id);
|
||||
}
|
||||
p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(1,0,'AP-1')");
|
||||
// 与生产一致,排车和预约Mapper共用同一SqlSession,更新后一起失效本地查询缓存。
|
||||
p.f.session.getConfiguration().addMapper(AppointmentWorkDispatchMapper.class);
|
||||
var mapper=p.f.session.getMapper(AppointmentWorkDispatchMapper.class);
|
||||
var sources=mock(AppointmentSourceService.class);
|
||||
when(sources.canonicalOrders(anyList())).thenAnswer(call->{List<AppointmentExternalSourceGateway.OrderRef> refs=call.getArgument(0);return refs.stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList();});
|
||||
when(snapshot.version()).thenReturn(0);when(snapshot.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(snapshot.replacementBatchKeys()).thenReturn(List.of());
|
||||
var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn("101");when(item.sourceKey()).thenReturn("ORDER:9001");when(item.destinationKey()).thenReturn("FBA:ONT8");when(item.packageCount()).thenReturn(20);when(snapshot.items()).thenReturn(List.of(item));
|
||||
when(appointments.batchContext("FBA_APPOINTMENT:1")).thenReturn(new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.of(),List.of(),List.of()));
|
||||
var details=new FbaAppointmentDetails(null,null,null,"CONF-1",LocalDateTime.now(ZoneOffset.UTC).plusDays(3),null,"SUCCESS",null,null,null);
|
||||
var member=new FbaReceivingContracts.Member("FBA_APPOINTMENT:1","AP-1",0,"101",List.of(new FbaReceivingContracts.Source("ORDER:9001","FBA:ONT8",20,"v1",1,"IN-1")),null);
|
||||
when(receiving.currentAgreement("FBA_APPOINTMENT:1","101")).thenReturn(new FbaReceivingContracts.Agreement("AG-1",0,"ACTIVE","FBA:ONT8",details,"UTC",List.of(member),"确认收货"));
|
||||
binding=new AppointmentWorkDispatchBinding(mapper,appointments,sources,mock(AppointmentWorkChanges.class),receiving,null);
|
||||
var bridge=new AppointmentReceiptDispatchAdapter(new AppointmentReceiptRepository(mapper),binding,appointments,sources);
|
||||
var picking=(DispatchPickingRepository)ReflectionTestUtils.getField(p.service,"picking");
|
||||
receipt=new DispatchInboundReceiptService(p.f.repository,p.sources,picking,(DispatchPhysicalClaimService)ReflectionTestUtils.getField(p.f.service,"physicalClaims"),p.f.service,p.f.fleet,bridge);
|
||||
ReflectionTestUtils.setField(p.f.service,"appointments",new DispatchAppointmentAllocations(binding));
|
||||
p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(p.f.service,"basis",p.f.repository.find(p.number)));
|
||||
doAnswer(call->{var pick=(com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick)call.getArgument(1);long receiptId=pick.getInventoryPositionId()==11?1:2;
|
||||
p.f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,'101',?,9001,?,?,0)",pick.getId(),pick.getId(),receiptId,pick.getPackages());return List.of(new com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService.OrderPackages(9001L,pick.getPackages()));
|
||||
}).when(p.attribution).attribute(eq("101"),any(),isNull());
|
||||
}
|
||||
|
||||
/** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */
|
||||
void confirm() {
|
||||
p.f.tx.executeWithoutResult(s->{
|
||||
receipt.lock(List.of(1L,2L));
|
||||
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END");
|
||||
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END");
|
||||
receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC));
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取持久化实收箱数,不能只检查响应投影。 */
|
||||
List<Integer> quantities() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY plan_pallet_id",Integer.class); }
|
||||
/** 读取实际预约分配,覆盖本次遗漏的第二套账本。 */
|
||||
List<Integer> allocations() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_appointment_allocation ORDER BY plan_pallet_id",Integer.class); }
|
||||
/** 后续扫码必须使用收货交接后的公共版本。 */
|
||||
long version() { return p.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class); }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -259,7 +259,7 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway.class));
|
||||
ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins);
|
||||
picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json);
|
||||
ReflectionTestUtils.setField(picking,"physicalClaims",ReflectionTestUtils.getField(f.service,"physicalClaims"));ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots);
|
||||
ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots);
|
||||
pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins);
|
||||
balance(601,"101",5);position(101,601,"101",crossCustomer?2:5);
|
||||
if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);}
|
||||
|
||||
+7
-36
@@ -34,44 +34,16 @@ class DispatchPickingPersistenceTest {
|
||||
verify(fixture.inventory,never()).ship(any(),any(),any());
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
|
||||
}
|
||||
/** 数量可自然同步不代表能接管其他车的实际托盘。 */
|
||||
@Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception {
|
||||
/** 未在收货事务完成交接的原计划不能在出货时临时修数量。 */
|
||||
@Test void pickingCannotMaterializeAnUnconfirmedReceipt() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(9,11);
|
||||
fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501");
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
|
||||
new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null))))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用");
|
||||
verify(fixture.inventory,never()).ship(any(),any(),any());
|
||||
new DispatchCommands.Pick(fixture.version,"receipt-not-confirmed",11L,9,0,null,null))))
|
||||
.hasMessageContaining("收货交接");
|
||||
verifyNoInteractions(fixture.inventory);
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
|
||||
}
|
||||
/** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */
|
||||
@Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(9,11);
|
||||
var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null);
|
||||
var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED");
|
||||
var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
|
||||
assertThat(replay.replayed()).isTrue();
|
||||
var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem,
|
||||
new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null)));
|
||||
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
|
||||
verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any());
|
||||
assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */
|
||||
@Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(8,11);
|
||||
doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any());
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
|
||||
new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero();
|
||||
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version);
|
||||
}
|
||||
/** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */
|
||||
@Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception {
|
||||
var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码");
|
||||
@@ -113,7 +85,7 @@ class DispatchPickingPersistenceTest {
|
||||
}
|
||||
|
||||
/** 真主单和实际MyBatis写入;仅模拟两个独立应用命令提供者,未连接真实环境。 */
|
||||
private static final class Picking {
|
||||
static final class Picking {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
|
||||
final DispatchPickingService service;final String number;final long item,version;final Long secondItem;
|
||||
@@ -147,8 +119,7 @@ class DispatchPickingPersistenceTest {
|
||||
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
|
||||
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
|
||||
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
|
||||
for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet);
|
||||
for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
|
||||
}
|
||||
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
|
||||
void receivedPlans(int first,int second) {
|
||||
|
||||
+19
-52
@@ -33,62 +33,30 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
assertThat(mapper.receivedPlanCompleted("101",3L)).isZero();
|
||||
}
|
||||
}
|
||||
/** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */
|
||||
@Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception {
|
||||
var f=new Fixture();var before=f.dbState();
|
||||
var task=f.read();
|
||||
assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L);
|
||||
assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3);
|
||||
assertThat(f.dbState()).isEqualTo(before);
|
||||
verify(f.sources).stock("101",501L,false);
|
||||
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
|
||||
verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
|
||||
}
|
||||
/** 在途和来源身份变化均不成为可拣库存。 */
|
||||
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
|
||||
var f=new Fixture();var row=f.f.candidates.get(1L);
|
||||
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
/** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */
|
||||
@Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception {
|
||||
/** PDA查询只读取确认入库时已经交接的事实,不在GET补写或投影修正计划数量。 */
|
||||
@Test void pendingReceiptDoesNotExposeStockOrReplaceEstimates() throws Exception {
|
||||
var f=new Fixture(true);var before=f.dbState();
|
||||
var task=f.read();
|
||||
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(10L,10L);
|
||||
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isEmpty());
|
||||
assertThat(f.dbState()).isEqualTo(before);
|
||||
verifyNoInteractions(f.sources,f.rules);
|
||||
}
|
||||
|
||||
/** 收货已保存9+11时,PDA直接显示9+11并提供实际托盘仓位。 */
|
||||
@Test void confirmedReceiptUsesPersistedNineAndEleven() throws Exception {
|
||||
var f=new Fixture(true);
|
||||
for(int n=1;n<=2;n++) {
|
||||
int count=n==1?9:11;
|
||||
f.f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=?,source_stage='RECEIVED',package_count=? WHERE plan_pallet_id=?",500+n,count,n);
|
||||
f.f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages) VALUES(?,'101',?,?,'9',?,0,0,0)",10+n,20+n,500+n,count);
|
||||
}
|
||||
var before=f.dbState();var task=f.read();
|
||||
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L);
|
||||
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty());
|
||||
assertThat(task.counts().packageCount()).isEqualTo(20);
|
||||
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).hasSize(1));
|
||||
assertThat(f.dbState()).isEqualTo(before);
|
||||
}
|
||||
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
|
||||
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
|
||||
var f=new Fixture();
|
||||
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
|
||||
when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
|
||||
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
|
||||
null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of()));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
/** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */
|
||||
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
|
||||
var f=new Fixture();
|
||||
for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1),
|
||||
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1),
|
||||
new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) {
|
||||
when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
}
|
||||
}
|
||||
/** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */
|
||||
@Test void receivingPlanDoesNotExposePartialPallet() throws Exception {
|
||||
var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false);
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
|
||||
/** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */
|
||||
private static final class Fixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
@@ -109,7 +77,6 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
|
||||
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
|
||||
when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L)));
|
||||
when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true);
|
||||
when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L));
|
||||
|
||||
+10
@@ -33,11 +33,13 @@ class InboundPdaContainerCompletionTest {
|
||||
@Mock InboundStockReceiptService stockReceipts;
|
||||
@Mock InboundUnloadCompletedOutboxService outbox;
|
||||
@Mock LastlegOrderMapper orders;
|
||||
@Mock com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts;
|
||||
private InboundPdaWorkflowService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InboundPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatchReceipts);
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
|
||||
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
|
||||
@@ -74,6 +76,14 @@ class InboundPdaContainerCompletionTest {
|
||||
any(Instant.class), any(Instant.class), anyLong());
|
||||
verify(repository, times(2)).update(any(InboundPlan.class));
|
||||
verify(repository, times(2)).update(any(InboundPdaTask.class));
|
||||
// 运输父锁必须早于收货来源锁;两票库存落账后才交接原排车,并且只交接一次。
|
||||
var sequence=org.mockito.Mockito.inOrder(dispatchReceipts,repository,stockReceipts);
|
||||
sequence.verify(dispatchReceipts).lock(List.of(2L,1L));
|
||||
sequence.verify(repository).lockTask("100",1L);
|
||||
sequence.verify(stockReceipts).stockIn(eq("100"),any(),any(),any(),any(),any(),any());
|
||||
sequence.verify(stockReceipts).stockIn(eq("200"),any(),any(),any(),any(),any(),any());
|
||||
sequence.verify(dispatchReceipts).complete(eq(List.of(1L,2L)),org.mockito.ArgumentMatchers.isNull(),any());
|
||||
verify(dispatchReceipts,times(1)).complete(any(),any(),any());
|
||||
}
|
||||
|
||||
private static InboundPlan plan(long id, String planNo) {
|
||||
|
||||
+2
@@ -71,6 +71,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InboundPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class));
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
|
||||
@@ -91,6 +92,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
|
||||
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
|
||||
lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
|
||||
lenient().when(repository.findPlan("100", "LIP1")).thenReturn(plan);
|
||||
LastlegOrder sourceOrder = new LastlegOrder();
|
||||
sourceOrder.setId(1001L); sourceOrder.setSourceContainerBookingId("BOOKING-1");
|
||||
lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder);
|
||||
|
||||
+2
@@ -83,6 +83,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ReceivingContainerService();
|
||||
ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class));
|
||||
ReflectionTestUtils.setField(service, "candidateRepository", candidateRepository);
|
||||
org.mockito.Mockito.lenient().when(candidateRepository.currentSession(any())).thenAnswer(invocation ->
|
||||
sessions.findActiveByOperator(invocation.getArgument(0)));
|
||||
@@ -1166,6 +1167,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
order.setContainerNo(containerNo); order.setSourceContainerBookingId("BOOKING-A");
|
||||
order.setInboundCode("IN-1");
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
|
||||
org.mockito.Mockito.lenient().when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
|
||||
when(plans.selectList(any())).thenReturn(List.of(plan));
|
||||
when(lines.selectList(any())).thenReturn(List.of(line));
|
||||
org.mockito.Mockito.lenient().when(orders.selectList(any())).thenReturn(List.of(order));
|
||||
|
||||
Reference in New Issue
Block a user