fix(lastleg): confirm inbound shortages and reconcile empty preplanned pallets
This commit is contained in:
+1
-1
@@ -122,7 +122,7 @@ public interface DispatchAggregateMapper {
|
||||
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
|
||||
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
||||
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},item_count=#{r.itemCount},route=#{r.route},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
||||
WHERE id=#{r.id} AND record_version=#{version}
|
||||
""")
|
||||
|
||||
+19
-2
@@ -33,7 +33,7 @@ public interface DispatchSourceMapper {
|
||||
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
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') 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
|
||||
@@ -41,6 +41,23 @@ public interface DispatchSourceMapper {
|
||||
@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 COUNT(*) FROM ll_inbound_plan_pallet p
|
||||
JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND p.plan_id=#{plan}
|
||||
AND CAST(ip.overseas_warehouse_id AS CHAR(128))=#{warehouse}
|
||||
AND ip.status IN ('COMPLETED','PARTIAL_INBOUND')
|
||||
AND COALESCE(p.actual_package_count,0)=0
|
||||
AND EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
AND (a.counted_package_count IS NULL OR a.counted_package_count<>0 OR a.picked_package_count IS NULL OR a.picked_package_count<>0))
|
||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_stock_receipt r JOIN ll_inbound_plan_pallet_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id
|
||||
WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
||||
""")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
int confirmedEmptyPlan(@Param("customer") String customer,@Param("id") long id,@Param("plan") long plan,@Param("warehouse") String warehouse);
|
||||
|
||||
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
||||
@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")
|
||||
@@ -115,7 +132,7 @@ public interface DispatchSourceMapper {
|
||||
|
||||
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')")
|
||||
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
|
||||
|
||||
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
|
||||
|
||||
+2
@@ -68,6 +68,8 @@ public class DispatchSourceRepository {
|
||||
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 boolean confirmedEmptyPlan(String customer,long id,long plan,String warehouse) { return source.confirmedEmptyPlan(customer,id,plan,warehouse)==1; }
|
||||
/** 取得已有真实退回候选。 */
|
||||
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
|
||||
/** 同托真实来源订单组成。 */
|
||||
|
||||
+19
@@ -733,6 +733,25 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
|
||||
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
|
||||
long version=row.getRecordVersion();
|
||||
// 空预排明细已移除后按剩余真实货物重算,避免列表、路线和车次继续保留幽灵托盘。
|
||||
var all=repository.cargo(row.getId());
|
||||
boolean removedItems=!Objects.equals(row.getItemCount(),all.size());
|
||||
int previousPackages=row.getPackageCount();
|
||||
row.setPackageCount(Math.toIntExact(all.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
||||
row.setItemCount(all.size());
|
||||
row.setPalletCount(Math.toIntExact(all.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||
if(removedItems)row.setRoute(all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")));
|
||||
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i->List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
|
||||
row.setCargoHash(hashes.source(digest));row.setCargoRevision(Math.addExact(row.getCargoRevision(),1));
|
||||
if(removedItems || previousPackages!=row.getPackageCount()) {
|
||||
var stops=all.stream().map(i->new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
|
||||
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
||||
}
|
||||
if(all.isEmpty()) {
|
||||
invalidate(row,"实收核对后本车无货物,请重新安排货物并提交审核");
|
||||
row.setStage(Stage.BEFORE_LOADING.name());preserveReview=false;
|
||||
}
|
||||
if(preserveReview)row.setReviewBasisHash(basis(row));
|
||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
|
||||
|
||||
+22
-13
@@ -67,7 +67,13 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
||||
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 empty=new ArrayList<DispatchItem>();
|
||||
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
if(sources.confirmedEmptyPlan(item.getCustomerId(),item.getPlanPalletId(),item.getPlanId(),row.getOverseasWarehouseId())) {
|
||||
empty.add(item);
|
||||
continue;
|
||||
}
|
||||
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
|
||||
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
|
||||
received.put(item,actual);
|
||||
@@ -79,14 +85,25 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
||||
entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg());
|
||||
entry.getKey().setVolumeCbm(entry.getValue().volumeCbm());
|
||||
}
|
||||
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
for(var item:received.keySet().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();
|
||||
// 先释放空预排的逻辑占用;共享实托仍有成员时保留其物理排他。
|
||||
if(!empty.isEmpty()) {
|
||||
var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null);
|
||||
var original=physicalClaims.lockOwner(owner);
|
||||
for(var item:empty) {
|
||||
if(picking.picks(item.getCustomerId(),row.getId()).stream().anyMatch(p->Objects.equals(p.getDispatchItemId(),item.getId())))
|
||||
throw conflict("空托盘已有真实拣货,不能按短少移除");
|
||||
if(sources.release(row.getId(),item,at)!=1)throw conflict("空托盘原逻辑占用已变化");
|
||||
}
|
||||
physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at);
|
||||
}
|
||||
var pallets=received.keySet().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();
|
||||
@@ -95,16 +112,8 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
||||
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()));
|
||||
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),empty.contains(i)?0:i.getPackageCount())).toList());
|
||||
for(var item:empty)if(repository.removeItem(item.getId())!=1)throw conflict("空托盘原排车明细已变化");
|
||||
workflow.saveReceivedCargo(row,operator,at,preserveReview);
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -38,6 +38,23 @@ public class InboundPlanController {
|
||||
@Resource private InboundPlanService service;
|
||||
@Resource private ContainerInboundPlanConversionCoordinator containerConversion;
|
||||
@Resource private InboundFbaAppointmentService appointmentService;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
|
||||
|
||||
/** 平台核对现场短少后按实收入库,不修改原应收数量。 */
|
||||
@PostMapping("/{planNo}/confirm-shortage")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
|
||||
@Operation(summary = "确认入库短少", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
|
||||
public ApiResponse<com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses.Task> confirmShortage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
||||
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可确认入库短少");
|
||||
}
|
||||
String customerId = convertScope(user, request.customerId());
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(inboundWorkflow.confirmShortage(customerId, planNo, operatorId, idempotencyKey, request));
|
||||
}
|
||||
|
||||
/** 按入仓计划明细分页查询可预约的入库订单行。 */
|
||||
@GetMapping
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** 平台确认现场短少,数量仅用于核对当前收货事实,不能补写实收。 */
|
||||
public record ConfirmInboundShortageRequest(
|
||||
@NotBlank String customerId,
|
||||
@NotNull @Min(0) Integer version,
|
||||
@NotNull @Min(1) Integer expectedPackageCount,
|
||||
@NotNull @Min(1) Integer receivedPackageCount,
|
||||
@NotBlank @Size(max = 200) String reason
|
||||
) { }
|
||||
+1
-1
@@ -11,7 +11,7 @@ public interface InboundDispatchReceiptGateway {
|
||||
/** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */
|
||||
void complete(List<Long> planIds, String operatorId, LocalDateTime at);
|
||||
|
||||
/** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */
|
||||
/** 只传原计划真实数量;零表示已确认无实收,须释放该托的原预约分配。 */
|
||||
record Item(long itemId, String customerId, long planPalletId, int packages) { }
|
||||
|
||||
/** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */
|
||||
|
||||
+26
@@ -10,6 +10,32 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ReceivingSkuReceiptMapper extends BaseMapper<ReceivingSkuReceipt> {
|
||||
record FrozenSku(String skuCode, long packages) { }
|
||||
|
||||
/** 查询冻结申报的全部 SKU,不能从已自动分摊的实收反推缺失商品。 */
|
||||
@Select("SELECT UPPER(TRIM(d.sku_code)) AS skuCode,SUM(d.package_count) AS packages "
|
||||
+ "FROM ll_inbound_plan_line l JOIN ll_order_detail d ON d.order_id=l.order_id AND d.customer_id=l.customer_id "
|
||||
+ "WHERE l.customer_id=#{customerId} AND l.plan_id=#{planId} AND l.id=#{lineId} GROUP BY UPPER(TRIM(d.sku_code))")
|
||||
List<FrozenSku> frozenSkus(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId);
|
||||
|
||||
/** 同柜次同 FBA 仓按总箱数收货,跨客户只返回成员数用于拒绝有歧义的短少确认。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_plan_line selected
|
||||
JOIN ll_inbound_plan sp ON sp.id=selected.plan_id AND sp.customer_id=selected.customer_id
|
||||
JOIN ll_order so ON so.id=selected.order_id AND so.customer_id=selected.customer_id
|
||||
JOIN ll_inbound_plan p ON p.container_no=sp.container_no
|
||||
JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.customer_id=p.customer_id
|
||||
JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id
|
||||
WHERE selected.customer_id=#{customerId} AND selected.plan_id=#{planId} AND selected.id=#{lineId}
|
||||
AND NULLIF(TRIM(so.source_container_booking_id),'') IS NOT NULL
|
||||
AND TRIM(o.source_container_booking_id)=TRIM(so.source_container_booking_id)
|
||||
AND TRIM(o.container_no)=sp.container_no
|
||||
AND COALESCE(o.order_type,'')<>'ONE_CLICK_DROPSHIP'
|
||||
AND UPPER(TRIM(l.fba_warehouse_code))=UPPER(TRIM(selected.fba_warehouse_code))
|
||||
AND UPPER(COALESCE(NULLIF(TRIM(l.destination_type),''),NULLIF(TRIM(o.destination_type),''),'FBA'))='FBA'
|
||||
""")
|
||||
long fbaGroupLineCount(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId);
|
||||
|
||||
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
|
||||
"AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE")
|
||||
List<ReceivingSkuReceipt> lockLineSku(@Param("customerId") String customerId,
|
||||
|
||||
+16
@@ -36,6 +36,22 @@ public class PdaWorkflowRepository {
|
||||
@Resource private InboundPalletMergeMapper merges;
|
||||
@Resource private InboundPlanCartonMapper cartons;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper skuReceipts;
|
||||
|
||||
/** 短少确认只能采用本客户本计划已登记的逐 SKU 实收。 */
|
||||
public List<com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt> skuReceipts(String customerId, Long planId) {
|
||||
return skuReceipts.listPlan(customerId, planId);
|
||||
}
|
||||
|
||||
/** 冻结申报用于判断总箱数能否唯一确定实际 SKU。 */
|
||||
public List<com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku> frozenSkus(String customerId, Long planId, Long lineId) {
|
||||
return skuReceipts.frozenSkus(customerId, planId, lineId);
|
||||
}
|
||||
|
||||
/** 返回整组来源数,不把其他客户的货物明细暴露给操作端。 */
|
||||
public long fbaGroupLineCount(String customerId, Long planId, Long lineId) {
|
||||
return skuReceipts.fbaGroupLineCount(customerId, planId, lineId);
|
||||
}
|
||||
|
||||
/** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */
|
||||
public InboundPlan findPlan(String customerId, String planNo) {
|
||||
|
||||
+81
-1
@@ -716,7 +716,87 @@ public class InboundPdaWorkflowService {
|
||||
repository.update(plan);
|
||||
}
|
||||
|
||||
/** Explicit operator decision to receive only the cartons actually picked. */
|
||||
/** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */
|
||||
@Transactional
|
||||
public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId,
|
||||
String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
||||
if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128
|
||||
|| operatorId == null || operatorId.isBlank() || request.reason() == null || request.reason().isBlank()) {
|
||||
throw conflict("短少确认必须提供操作人、请求键和确认原因");
|
||||
}
|
||||
InboundPlan original = repository.findPlan(customerId, planNo);
|
||||
if (original == null) throw notFound("入库单不存在或不属于当前客户");
|
||||
dispatchReceipts.lock(List.of(original.getId()));
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
// 将单号和操作人纳入幂等摘要,避免跨单或跨操作人复用请求键。
|
||||
Map<String, Object> command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request);
|
||||
InboundPdaOperation replay = replay(customerId, "CONFIRM_SHORTAGE", idempotencyKey, command);
|
||||
if (replay != null) return read(replay, PdaResponses.Task.class);
|
||||
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
||||
if (!"PENDING_RECONCILIATION".equals(plan.getStatus()) || task == null
|
||||
|| !"PENDING_RECONCILIATION".equals(task.getStatus())) {
|
||||
throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试");
|
||||
}
|
||||
if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少");
|
||||
if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单");
|
||||
if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) {
|
||||
throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少");
|
||||
}
|
||||
List<InboundPlanLine> lines = repository.lines(plan.getId());
|
||||
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
|
||||
if (lines.isEmpty() || lines.stream().anyMatch(line -> !"FBA".equals(destinationType(line)))) {
|
||||
throw conflict("短少确认要求每条明细均为 FBA 实收货物");
|
||||
}
|
||||
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
|
||||
int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
|
||||
if (!Objects.equals(expected, request.expectedPackageCount()) || !Objects.equals(received, request.receivedPackageCount())) {
|
||||
throw conflict("应收或实收数量已变化,请刷新后重新确认");
|
||||
}
|
||||
if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少");
|
||||
var skuReceipts = repository.skuReceipts(customerId, plan.getId());
|
||||
// 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。
|
||||
if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量");
|
||||
var onlyLine = lines.get(0);
|
||||
var frozenSkus = repository.frozenSkus(customerId, plan.getId(), onlyLine.getId());
|
||||
if (repository.fbaGroupLineCount(customerId, plan.getId(), onlyLine.getId()) != 1
|
||||
|| frozenSkus.size() != 1 || frozenSkus.get(0).skuCode() == null || frozenSkus.get(0).skuCode().isBlank()
|
||||
|| frozenSkus.get(0).packages() != expected) {
|
||||
throw conflict("当前仅支持同柜同目的仓单票、单 SKU 短少确认;多票或多 SKU 请先核实实际货物归属");
|
||||
}
|
||||
if (skuReceipts.stream().anyMatch(r -> r.getSkuCode() == null
|
||||
|| !frozenSkus.get(0).skuCode().equalsIgnoreCase(r.getSkuCode().trim()))) {
|
||||
throw conflict("实收 SKU 与冻结申报不一致,不能确认短少");
|
||||
}
|
||||
for (InboundPlanLine line : lines) {
|
||||
int picked = allocations.stream().filter(a -> Objects.equals(a.getLineId(), line.getId()))
|
||||
.mapToInt(a -> value(a.getPickedPackageCount())).sum();
|
||||
int skuPackages = skuReceipts.stream().filter(r -> Objects.equals(r.getLineId(), line.getId()))
|
||||
.mapToInt(r -> value(r.getReceivedPackages())).sum();
|
||||
if (picked < 0 || picked > value(line.getRequiredPackageCount()) || picked != skuPackages) {
|
||||
throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认");
|
||||
}
|
||||
}
|
||||
if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0
|
||||
|| value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) {
|
||||
throw conflict("清点与收货数量不一致,不能确认短少");
|
||||
}
|
||||
List<InboundPlanPallet> pallets = new ArrayList<>(repository.pallets(plan.getId()));
|
||||
Set<Long> palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet());
|
||||
pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId)
|
||||
.filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet())));
|
||||
// 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true);
|
||||
task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1);
|
||||
repository.update(task);
|
||||
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
|
||||
dispatchReceipts.complete(List.of(plan.getId()), operatorId, now);
|
||||
PdaResponses.Task result = taskResponse(task, plan, planNo);
|
||||
saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 操作员明确选择仅将当前已收箱数入库。 */
|
||||
@Transactional
|
||||
public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) {
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
|
||||
+13
-4
@@ -48,6 +48,7 @@ public class InboundStockReceiptService {
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
|
||||
|
||||
/** 按完整实收生成库存凭证。 */
|
||||
@Transactional
|
||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
@@ -55,6 +56,7 @@ public class InboundStockReceiptService {
|
||||
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
|
||||
}
|
||||
|
||||
/** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */
|
||||
@Transactional
|
||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
@@ -103,7 +105,7 @@ public class InboundStockReceiptService {
|
||||
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
|
||||
if (!exactSkuReceipts.isEmpty()) {
|
||||
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
||||
palletById, order, exactSkuReceipts, pickedPackages, receivedAt));
|
||||
palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial));
|
||||
continue;
|
||||
}
|
||||
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
||||
@@ -143,14 +145,17 @@ public class InboundStockReceiptService {
|
||||
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
|
||||
}
|
||||
|
||||
/** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */
|
||||
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
|
||||
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
|
||||
Map<Long, InboundPlanPallet> palletById, LastlegOrder order,
|
||||
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt) {
|
||||
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt, boolean allowPartial) {
|
||||
if (exactReceipts.stream().anyMatch(r -> r.getSkuCode() == null || r.getSkuCode().isBlank()
|
||||
|| value(r.getReceivedPackages()) <= 0)) throw conflict("SKU 实收必须包含有效 SKU 和正数箱数");
|
||||
long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum();
|
||||
if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total");
|
||||
|
||||
Map<String, Long> expectedBySku = skuRemainders(order.getId(), pickedPackages).stream()
|
||||
Map<String, Long> expectedBySku = skuRemainders(order.getId(), value(line.getRequiredPackageCount())).stream()
|
||||
.collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum,
|
||||
java.util.LinkedHashMap::new));
|
||||
Map<String, Long> actualBySku = exactReceipts.stream().collect(Collectors.groupingBy(
|
||||
@@ -159,7 +164,11 @@ public class InboundStockReceiptService {
|
||||
Map<String, Long> normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap(
|
||||
value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum,
|
||||
java.util.LinkedHashMap::new));
|
||||
if (!actualBySku.equals(normalizedExpected)) {
|
||||
boolean invalid = allowPartial
|
||||
? actualBySku.entrySet().stream().anyMatch(entry -> !normalizedExpected.containsKey(entry.getKey())
|
||||
|| entry.getValue() > normalizedExpected.get(entry.getKey()))
|
||||
: !actualBySku.equals(normalizedExpected);
|
||||
if (invalid) {
|
||||
throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -738,7 +738,7 @@ public class ReceivingContainerService {
|
||||
boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders
|
||||
&& plannedOrders == totalOrders && planCount == totalOrders;
|
||||
boolean allPlansCompleted = !matchedPlans.isEmpty()
|
||||
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION")
|
||||
.contains(plan.getStatus()));
|
||||
boolean shippingReady = facts == null
|
||||
? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1
|
||||
@@ -812,7 +812,7 @@ public class ReceivingContainerService {
|
||||
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
||||
throw conflict("Every inbound plan in the receiving container must have an owning customer");
|
||||
}
|
||||
if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||
if (values.stream().allMatch(value -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION")
|
||||
.contains(value.getStatus()))) {
|
||||
throw conflict("Receiving container is already completed");
|
||||
}
|
||||
|
||||
+8
@@ -37,6 +37,14 @@ public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceipt
|
||||
for(var item:items) {
|
||||
var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList();
|
||||
var physical=repository.physical(item.planPalletId(),item.customerId());
|
||||
// 零实收不再占用任何批次额度;明确核对数量和归属后仅移除本车本托分配。
|
||||
if(item.packages()==0) {
|
||||
if(physical.isEmpty() || physical.stream().anyMatch(p->p.countedPackages()==null || p.countedPackages()!=0)
|
||||
|| original.stream().anyMatch(r->!r.customerId().equals(item.customerId()) || r.palletId()!=item.planPalletId()))
|
||||
throw conflict("空托盘实收或原预约归属不一致,不能释放预约分配");
|
||||
binding.removeItem(dispatch,item.itemId());
|
||||
continue;
|
||||
}
|
||||
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
|
||||
var quantities=new LinkedHashMap<List<String>,Integer>();
|
||||
for(var part:physical) {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ public interface OrderWorkflowFactMapper {
|
||||
WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id
|
||||
AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address)
|
||||
AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering,
|
||||
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded
|
||||
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')) unloaded
|
||||
FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER')
|
||||
AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>
|
||||
(o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey})
|
||||
|
||||
Reference in New Issue
Block a user