fix(iam): 收敛仓库角色权限并纳入统一迁移

This commit is contained in:
2026-08-10 12:22:29 +08:00
parent edd5108625
commit b294695169
29 changed files with 1009 additions and 424 deletions
@@ -3,7 +3,6 @@ package com.baiyue.firstleg.loading.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest;
@@ -21,7 +20,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** 平台端装柜证据、装柜完成和派送完成操作。 */
/** 平台端装柜证据和国内装柜完成操作。 */
@Tag(name = "订单装柜与派送")
@RestController
@RequestMapping("/api/platform/v1/inbounds")
@@ -39,6 +38,37 @@ public class PlatformLoadingDeliveryController {
return ApiResponse.success(service.upsertEvidence(tenantId, inboundId, user.userId(), user.username(), key, request));
}
/** PDA现场拍照专用入口。与后台人工上传使用不同权限,避免拣货员获得后台证据维护入口。 */
@Operation(summary = "PDA追加装柜证据")
@PostMapping("/{inboundId}/loading/evidence/pda")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<LoadingActionResponse> pdaEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpsertEvidenceRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.upsertEvidence(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "PDA查询装柜证据")
@GetMapping("/{inboundId}/loading/evidence/pda")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<EvidenceListResponse> pdaEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.evidence(tenantId, inboundId));
}
@Operation(summary = "PDA作废错误装柜证据")
@PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<LoadingActionResponse> pdaVoidEvidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@PathVariable long evidenceId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody VoidEvidenceRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.voidEvidence(tenantId, inboundId, evidenceId, user.userId(), user.username(), key, request));
}
@Operation(summary = "查询装柜证据")
@GetMapping("/{inboundId}/loading/evidence")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
@@ -71,17 +101,6 @@ public class PlatformLoadingDeliveryController {
return ApiResponse.success(service.completeLoading(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "确认派送完成")
@PostMapping("/{inboundId}/delivery/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:delivery:complete')")
@BusinessAudit(event = "INBOUND_COMPLETE_DELIVERY", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认派送完成")
public ApiResponse<LoadingActionResponse> completeDelivery(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteDeliveryRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.completeDelivery(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "PDA开始拣货")
@PostMapping("/{inboundId}/loading/picking/start")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')")
@@ -25,10 +25,6 @@ public final class LoadingRequests {
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String remark) {}
public record CompleteDeliveryRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String remark) {}
public record PickingRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@PositiveOrZero Integer expectedCount,
@PositiveOrZero Integer pickedCount,
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.loading.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** SQL-backed hard gate for every loading plan linked to an inbound. */
@Mapper
public interface LoadingCompletionGateMapper {
@Select("""
SELECT COUNT(*) linkCount,
COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM fl_ocean_booking_container_allocation a
JOIN fl_ocean_booking b ON b.id=a.ocean_booking_id
WHERE a.container_booking_id=p.container_booking_id AND b.status='RELEASED') THEN 1 ELSE 0 END),0) oceanReady,
COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM fl_truck_appointment t
WHERE t.container_booking_id=p.container_booking_id AND t.loading_plan_id=p.id
AND t.status='COMPLETED') THEN 1 ELSE 0 END),0) truckReady,
COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM fl_customs_case c
JOIN fl_customs_receipt r ON r.customs_case_id=c.id
WHERE c.tenant_id=l.tenant_id AND c.inbound_id=l.inbound_id
AND c.container_booking_id=p.container_booking_id
AND c.status='CLEARANCE_COMPLETED' AND r.receipt_type='RELEASED' AND r.file_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM fl_customs_receipt newer
WHERE newer.submission_batch_id=r.submission_batch_id
AND newer.receipt_type=r.receipt_type AND newer.receipt_version&gt;r.receipt_version))
THEN 1 ELSE 0 END),0) customsReady
FROM fl_preload_loading_plan_link l
JOIN fl_loading_plan p ON p.id=l.loading_plan_id
WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}
""")
GateSnapshot snapshot(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
record GateSnapshot(Integer linkCount, Integer oceanReady, Integer truckReady, Integer customsReady) {
public boolean allBookingReady() { return count(linkCount) > 0 && count(linkCount) == count(oceanReady); }
public boolean allTruckReady() { return count(linkCount) > 0 && count(linkCount) == count(truckReady); }
public boolean allCustomsReady() { return count(linkCount) > 0 && count(linkCount) == count(customsReady); }
private static int count(Integer value) { return value == null ? 0 : value; }
}
}
@@ -16,10 +16,5 @@ public interface LoadingInboundMapper {
int completeLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_inbound_order SET process_status='DELIVERED', updated_by=#{operatorId}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADED' AND version=#{expectedVersion}")
int completeDelivery(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
record Snapshot(Long id, Long tenantId, String processStatus, Integer version) {}
}
@@ -29,10 +29,6 @@ public class LoadingRepository {
return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion);
}
public int completeDelivery(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return inbounds.completeDelivery(tenantId, inboundId, operatorId, expectedVersion);
}
public List<LoadingEvidenceEntity> listEvidence(long tenantId, long inboundId) {
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.loading.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.loading.mapper.LoadingCompletionGateMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
/** Centralized, database-backed LOADED hard gate. */
@Service
public class LoadingCompletionGateService {
@Resource private LoadingCompletionGateMapper mapper;
public void requireBookingTransportAndCustoms(long tenantId, long inboundId) {
LoadingCompletionGateMapper.GateSnapshot gate = mapper.snapshot(tenantId, inboundId);
if (gate == null || gate.linkCount() == null || gate.linkCount() == 0) {
throw blocked("未找到有效柜次关联");
}
if (!gate.allBookingReady()) throw blocked("订舱尚未完成(必须为 RELEASED)");
if (!gate.allTruckReady()) throw blocked("拖车尚未完成(必须为 COMPLETED)");
if (!gate.allCustomsReady()) throw blocked("缺少有效报关 RELEASED 放行回执");
}
private static BizException blocked(String message) {
return new BizException(ResultCode.CONFLICT, "LOADED门禁未通过:" + message);
}
}
@@ -35,17 +35,17 @@ import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 装柜证据、拣货复核、装柜完成和派送完成。 */
/** 装柜证据、拣货复核和国内装柜完成。 */
@Service
public class LoadingDeliveryService {
private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE";
private static final String VOID_EVIDENCE = "VOID_LOADING_EVIDENCE";
private static final String COMPLETE_LOADING = "COMPLETE_LOADING";
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
@Resource private LoadingRepository repository;
@Resource private LoadingPickRepository loadingPicks;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private LoadingCompletionGateService completionGate;
@Resource private InboundStatusHistoryMapper histories;
@Resource private LoadingEvidenceFileClient files;
@Resource private ObjectMapper objectMapper;
@@ -57,7 +57,7 @@ public class LoadingDeliveryService {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, hash);
if (replay != null) return replay;
requireLoadingStage(tenantId, inboundId);
requireMutableLoadingStage(tenantId, inboundId);
if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
}
@@ -105,6 +105,7 @@ public class LoadingDeliveryService {
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
loadingModelBridge.requireCompleted(tenantId, inboundId);
completionGate.requireBookingTransportAndCustoms(tenantId, inboundId);
requireCompletedPicking(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
@@ -113,24 +114,6 @@ public class LoadingDeliveryService {
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
}
@Transactional
public LoadingActionResponse completeDelivery(long tenantId, long inboundId, long operatorId,
String operatorName, String key, CompleteDeliveryRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, hash);
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
if ("DELIVERED".equals(inbound.processStatus())) {
return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key,
new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId)));
}
if (!"LOADED".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜完成");
if (repository.completeDelivery(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADED", "DELIVERED", COMPLETE_DELIVERY, request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key,
new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId)));
}
public EvidenceListResponse evidence(long tenantId, long inboundId) {
requireLoadingStage(tenantId, inboundId);
return evidenceResponse(tenantId, inboundId);
@@ -142,7 +125,7 @@ public class LoadingDeliveryService {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, VOID_EVIDENCE, key, hash);
if (replay != null) return replay;
requireLoadingStage(tenantId, inboundId);
requireMutableLoadingStage(tenantId, inboundId);
LoadingEvidenceEntity target = repository.listEvidence(tenantId, inboundId).stream()
.filter(item -> Long.valueOf(evidenceId).equals(item.getId())).findFirst().orElse(null);
if (target == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据不存在");
@@ -298,6 +281,16 @@ public class LoadingDeliveryService {
return value;
}
/** Evidence is append-only while loading. Once LOADED is reached this API is read-only;
* corrections are handled by the offline/manual process defined for phase three. */
private Snapshot requireMutableLoadingStage(long tenantId, long inboundId) {
Snapshot value = requireInbound(tenantId, inboundId);
if (!"LOADING".equals(value.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "装柜完成后不允许在线修改证据");
}
return value;
}
private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) {
List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e ->
new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(),
@@ -11,8 +11,20 @@ import java.util.List;
/** PDA部分拣货结算的客户归属锁定查询。 */
@Mapper
public interface InboundSplitSettlementMapper {
@Select("SELECT id,tenant_id tenantId,inbound_role inboundRole,root_inbound_id rootInboundId,"
+ "split_purpose splitPurpose,split_status splitStatus FROM fl_inbound_order WHERE id=#{inboundId} AND deleted=0")
Lineage lineage(@Param("inboundId") long inboundId);
@Select("SELECT COALESCE(MAX(split_sequence),0)+1 FROM fl_inbound_order WHERE tenant_id=#{accountId} AND parent_inbound_id=#{parentInboundId} AND deleted=0 FOR UPDATE")
int nextSplitSequence(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId);
@Select("SELECT id FROM fl_inbound_split_batch WHERE tenant_id=#{accountId} AND operation_id=#{operationId} LIMIT 1 FOR UPDATE")
Long appliedBatch(@Param("accountId") long accountId, @Param("operationId") String operationId);
@Select("SELECT container_booking_id FROM fl_loading_plan WHERE id=#{loadingPlanId}")
Long containerBookingId(@Param("loadingPlanId") long loadingPlanId);
@Select("""
SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole,
root_inbound_id AS rootInboundId,
process_status AS processStatus, total_package_count AS totalPackageCount,
package_count AS packageCount, version
FROM fl_inbound_order
@@ -21,68 +33,52 @@ public interface InboundSplitSettlementMapper {
""")
ParentRow lockParent(@Param("inboundId") long inboundId);
@Select("""
SELECT id
FROM fl_inbound_order
WHERE tenant_id = #{accountId} AND parent_inbound_id = #{parentInboundId}
AND inbound_role = 'CHILD' AND deleted = 0
LIMIT 1
FOR UPDATE
@Insert("""
INSERT INTO fl_inbound_order (id,tenant_id,task_id,order_id,order_no,inbound_code,process_status,
order_status_snapshot,measure_review_status,fee_status,fee_review_status,notification_status,
expected_arrival_at,customer_id,customer_name,company_id,company_name,warehouse_id,warehouse_name,
delivery_warehouse_id,delivery_warehouse_name,business_type,channel_code,channel_name,carrier_id,carrier_name,
tax_mode,pricing_tax_mode,container_mode,customs_inspection,destination_type,destination_warehouse_id,
destination_code,destination_name,hold_flag,current_hold_reason,total_package_count,package_count,
actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,pallet_count,volume_weight_kg,
actual_net_weight_kg,measure_completed_at,inbounded_at,express_company_code,express_company_name,
express_tracking_no,public_remark,created_by,created_by_name,created_at,updated_by,updated_at,deleted,version,
inbound_role,parent_inbound_id,root_inbound_id,split_sequence,split_status,split_purpose)
SELECT #{childId},tenant_id,task_id,order_id,order_no,#{childInboundCode},'INBOUNDED',order_status_snapshot,
'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name,
company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,
channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection,
destination_type,destination_warehouse_id,destination_code,destination_name,hold_flag,current_hold_reason,
#{packageCount},#{packageCount},actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,
pallet_count,volume_weight_kg,actual_net_weight_kg,measure_completed_at,inbounded_at,express_company_code,
express_company_name,express_tracking_no,public_remark,#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),
#{operatorId},CURRENT_TIMESTAMP(3),0,0,'CHILD',#{parentInboundId},#{rootInboundId},#{splitSequence},'NOT_SPLIT',#{splitPurpose}
FROM fl_inbound_order WHERE id=#{parentInboundId} AND tenant_id=#{accountId} AND deleted=0
""")
Long childId(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId);
int cloneChildWithPurpose(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
@Param("rootInboundId") long rootInboundId, @Param("childId") long childId,
@Param("childInboundCode") String childInboundCode, @Param("packageCount") int packageCount,
@Param("splitSequence") int splitSequence, @Param("splitPurpose") String splitPurpose,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_inbound_order (
id, tenant_id, task_id, order_id, order_no, inbound_code, process_status,
order_status_snapshot, measure_review_status, fee_status, fee_review_status, notification_status,
expected_arrival_at, customer_id, customer_name, company_id, company_name,
warehouse_id, warehouse_name, delivery_warehouse_id, delivery_warehouse_name,
business_type, channel_code, channel_name, carrier_id, carrier_name, tax_mode,
pricing_tax_mode, container_mode, customs_inspection, destination_type,
destination_warehouse_id, destination_code, destination_name, hold_flag,
current_hold_reason, total_package_count, actual_gross_weight_kg,
chargeable_weight_kg, actual_volume_cbm, chargeable_volume_cbm, pallet_count,
volume_weight_kg, actual_net_weight_kg, measure_completed_at, inbounded_at,
express_company_code, express_company_name, express_tracking_no, public_remark,
created_by, created_by_name, created_at, updated_by, updated_at, deleted, version,
inbound_role, parent_inbound_id, root_inbound_id, split_sequence, split_status, package_count
)
SELECT #{childId}, tenant_id, task_id, order_id, order_no, #{childInboundCode}, 'INBOUNDED',
order_status_snapshot, 'APPROVED', fee_status, fee_review_status, notification_status,
expected_arrival_at, customer_id, customer_name, company_id, company_name,
warehouse_id, warehouse_name, delivery_warehouse_id, delivery_warehouse_name,
business_type, channel_code, channel_name, carrier_id, carrier_name, tax_mode,
pricing_tax_mode, container_mode, customs_inspection, destination_type,
destination_warehouse_id, destination_code, destination_name, hold_flag,
current_hold_reason, #{packageCount}, actual_gross_weight_kg,
chargeable_weight_kg, actual_volume_cbm, chargeable_volume_cbm, pallet_count,
volume_weight_kg, actual_net_weight_kg, measure_completed_at, inbounded_at,
express_company_code, express_company_name, express_tracking_no, public_remark,
#{operatorId}, #{operatorName}, CURRENT_TIMESTAMP(3), #{operatorId}, CURRENT_TIMESTAMP(3), 0, 0,
'CHILD', id, id, 1, 'NOT_SPLIT', #{packageCount}
FROM fl_inbound_order
WHERE id = #{parentInboundId} AND tenant_id = #{accountId} AND deleted = 0
INSERT INTO fl_inbound_split_batch (id,tenant_id,parent_inbound_id,source_inbound_id,operation_id,idempotency_key,
expected_parent_version,package_count,loaded_child_inbound_id,remainder_child_inbound_id,
loaded_package_count,remaining_package_count,loading_plan_id,container_booking_id,status,remark,
created_by,created_by_name,created_at,version)
VALUES (#{batchId},#{accountId},#{parentInboundId},#{sourceInboundId},#{operationId},#{idempotencyKey},#{expectedParentVersion},
#{packageCount},#{loadedChildId},#{remainderChildId},#{loadedPackageCount},#{remainingPackageCount},
#{loadingPlanId},#{containerBookingId},'PREPARED',#{remark},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),0)
""")
int cloneChild(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
@Param("childId") long childId, @Param("childInboundCode") String childInboundCode,
@Param("packageCount") int packageCount, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_inbound_split_batch
(id, tenant_id, parent_inbound_id, operation_id, idempotency_key, expected_parent_version,
package_count, status, remark, created_by, created_by_name, created_at, version)
VALUES
(#{batchId}, #{accountId}, #{parentInboundId}, #{operationId}, #{idempotencyKey},
#{expectedParentVersion}, #{packageCount}, 'PREPARED', #{remark}, #{operatorId},
#{operatorName}, CURRENT_TIMESTAMP(3), 0)
""")
int insertBatch(@Param("batchId") long batchId, @Param("accountId") long accountId,
@Param("parentInboundId") long parentInboundId, @Param("operationId") String operationId,
@Param("idempotencyKey") String idempotencyKey,
@Param("expectedParentVersion") int expectedParentVersion,
@Param("packageCount") int packageCount, @Param("remark") String remark,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
int insertBatchWithChildren(@Param("batchId") long batchId, @Param("accountId") long accountId,
@Param("parentInboundId") long parentInboundId, @Param("operationId") String operationId,
@Param("sourceInboundId") long sourceInboundId,
@Param("idempotencyKey") String idempotencyKey, @Param("expectedParentVersion") int expectedParentVersion,
@Param("packageCount") int packageCount, @Param("loadedChildId") Long loadedChildId,
@Param("remainderChildId") Long remainderChildId, @Param("loadedPackageCount") int loadedPackageCount,
@Param("remainingPackageCount") int remainingPackageCount, @Param("loadingPlanId") Long loadingPlanId,
@Param("containerBookingId") Long containerBookingId, @Param("remark") String remark,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_inbound_split_item
@@ -158,6 +154,17 @@ public interface InboundSplitSettlementMapper {
@Param("packageCount") int packageCount, @Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_inbound_order SET package_count=0,split_status='FULLY_SPLIT',updated_by=#{operatorId},version=version+1 "
+ "WHERE id=#{parentInboundId} AND tenant_id=#{accountId} AND inbound_role='MASTER' AND deleted=0 AND version=#{expectedVersion}")
int markRootSplit(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_inbound_order SET split_status='FULLY_SPLIT',updated_by=#{operatorId},version=version+1 "
+ "WHERE id=#{sourceInboundId} AND tenant_id=#{accountId} AND inbound_role='CHILD' "
+ "AND split_purpose='REMAINDER' AND split_status='NOT_SPLIT' AND deleted=0 AND version=#{expectedVersion}")
int markRemainderConsumed(@Param("accountId") long accountId, @Param("sourceInboundId") long sourceInboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_split_batch
SET status = 'APPLIED', applied_at = CURRENT_TIMESTAMP(3), version = version + 1
@@ -240,32 +247,35 @@ public interface InboundSplitSettlementMapper {
@Param("inboundId") long inboundId);
@Update("""
UPDATE fl_inbound_measurement
SET total_package_count = #{packageCount}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{measurementId} AND tenant_id = #{accountId} AND inbound_id = #{inboundId}
AND status = 'APPROVED'
UPDATE fl_inbound_order child
JOIN fl_inbound_measurement measurement ON measurement.tenant_id=child.tenant_id
AND measurement.inbound_id=child.id AND measurement.id=#{measurementId}
SET child.actual_gross_weight_kg=(SELECT COALESCE(SUM(item.total_weight_kg),0)
FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id),
child.actual_volume_cbm=(SELECT COALESCE(SUM(item.total_volume_cbm),0)
FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id),
child.chargeable_weight_kg=NULL,child.chargeable_volume_cbm=NULL,
child.volume_weight_kg=(SELECT COALESCE(SUM(item.volume_weight_kg),0)
FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id),
child.actual_net_weight_kg=NULL,child.pallet_count=0,child.version=child.version+1
WHERE child.id=#{childInboundId} AND child.tenant_id=#{accountId} AND child.inbound_role='CHILD'
""")
int updateSourceMeasurementPackageCount(@Param("accountId") long accountId,
@Param("inboundId") long inboundId,
@Param("measurementId") long measurementId,
@Param("packageCount") int packageCount);
int normalizeChildMetrics(@Param("accountId") long accountId, @Param("childInboundId") long childInboundId,
@Param("measurementId") long measurementId);
@Update("""
UPDATE fl_inbound_measurement_item
SET package_count = #{packageCount}, updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{measurementItemId} AND measurement_id = #{measurementId} AND tenant_id = #{accountId}
""")
int updateSourceMeasurementItemPackageCount(@Param("accountId") long accountId,
@Param("measurementId") long measurementId,
@Param("measurementItemId") long measurementItemId,
@Param("packageCount") int packageCount);
record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole, Long rootInboundId,
String processStatus, Integer totalPackageCount, Integer packageCount,
Integer version) {
public ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
String processStatus, Integer totalPackageCount, Integer packageCount, Integer version) {
this(id, tenantId, inboundCode, inboundRole, id, processStatus, totalPackageCount, packageCount, version);
}
}
record MeasurementItemRow(Long measurementItemId, Long measurementId, Long inboundDetailId,
Integer packageCount) {
}
record Lineage(Long id, Long tenantId, String inboundRole, Long rootInboundId,
String splitPurpose, String splitStatus) {}
}
@@ -24,6 +24,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.process_status='INBOUNDED'
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.inbound_detail_id IS NOT NULL
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
@@ -61,6 +62,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE
""")
@@ -4,7 +4,6 @@ import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineIt
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@@ -63,18 +62,6 @@ public interface OutboundDispatchReadMapper {
""")
Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='WAITING_FOR_LOADING',updated_by=#{operatorId},"
+ "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=#{tenantId} AND id=#{inboundId} "
+ "AND deleted=0 AND process_status='LOADED'")
int confirmInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId);
@Update("UPDATE fl_inbound_order SET process_status='LOADED',updated_by=#{operatorId},"
+ "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=#{tenantId} AND id=#{inboundId} "
+ "AND deleted=0 AND process_status='WAITING_FOR_LOADING'")
int reverseInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId);
@Select("""
SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM (
SELECT 'INBOUND' module,h.action action,h.from_status fromStatus,h.to_status toStatus,
@@ -45,12 +45,6 @@ public class OutboundDispatchRepository {
public int reverse(long id, int version, long operatorId, String operatorName, String remark) {
return dispatches.reverse(id, version, operatorId, operatorName, remark);
}
public int confirmInbound(long tenantId, long inboundId, long operatorId) {
return reads.confirmInbound(tenantId, inboundId, operatorId);
}
public int reverseInbound(long tenantId, long inboundId, long operatorId) {
return reads.reverseInbound(tenantId, inboundId, operatorId);
}
public void history(OutboundDispatchHistoryEntity value) { histories.insert(value); }
public boolean claim(long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), operatorId, operation, key, hash) == 1;
@@ -68,8 +68,7 @@ public class OutboundDispatchService {
if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) {
throw conflict("当前出仓单已确认派车");
}
if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1
|| repository.confirmInbound(binding.tenantId(), binding.inboundId(), operatorId) != 1) {
if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
throw concurrent();
}
history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
@@ -92,8 +91,7 @@ public class OutboundDispatchService {
throw conflict("当前出仓单不在等待装船状态");
}
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1
|| repository.reverseInbound(binding.tenantId(), binding.inboundId(), operatorId) != 1) {
if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
throw concurrent();
}
history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(),
@@ -27,7 +27,7 @@ import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/** PDA 任务结算:未装柜箱数生成二级子入库单,并转移其库位资源归属。 */
/** PDA 任务结算:按柜次生成实装/余量履约分片,并结算测量与库位资源。 */
@Service
public class PdaPickSettlementService {
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
@@ -44,10 +44,7 @@ public class PdaPickSettlementService {
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
/**
* 同一父单最多生成一张子单。子单承接该父单所有未装柜数量,而不是只承接 PARTIAL 行的差额。
* 纯跳单才恢复智能装柜候选;同单存在已拣或部分拣货时,跳单行随未装柜数量一并归入子单。
*/
/** 每个柜次生成一张 LOADED 分片;存在余量时另生成可继续分柜的 REMAINDER 分片。 */
@Transactional
public void settle(long taskId, long operatorId, String operatorName) {
if (!picks.allTaskItemsResolved(taskId)) {
@@ -84,15 +81,31 @@ public class PdaPickSettlementService {
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
List<LoadingPickItemEntity> taskItems, long taskId,
long operatorId, String operatorName) {
ParentRow parent = splits.lockParent(parentInboundId);
InboundSplitSettlementMapper.Lineage lineage = splits.lineage(parentInboundId);
if (lineage == null) throw conflict("待拆分入库单不存在");
if ("CHILD".equals(lineage.inboundRole())
&& !("REMAINDER".equals(lineage.splitPurpose()) && "NOT_SPLIT".equals(lineage.splitStatus()))) {
throw conflict("仅未消费的余量子单可以继续分柜");
}
if (!"MASTER".equals(lineage.inboundRole()) && lineage.rootInboundId() == null) {
throw conflict("余量子单缺少根主单");
}
long rootInboundId = "MASTER".equals(lineage.inboundRole()) ? parentInboundId : lineage.rootInboundId();
ParentRow root = splits.lockParent(rootInboundId);
ParentRow parent = rootInboundId == parentInboundId ? root : splits.lockParent(parentInboundId);
if (parent == null || parent.tenantId() == null || parent.tenantId() <= 0
|| !"MASTER".equals(parent.inboundRole()) || !"INBOUNDED".equals(parent.processStatus())) {
|| !("MASTER".equals(parent.inboundRole()) || "CHILD".equals(parent.inboundRole()))
|| !"INBOUNDED".equals(parent.processStatus())) {
throw conflict("待拆分主入库单不在可拆分状态");
}
Long existingChild = splits.childId(parent.tenantId(), parentInboundId);
if (existingChild != null) {
return false;
}
String operationId = "PDA-SPLIT-" + taskId + '-' + parentInboundId;
Long appliedBatchId = splits.appliedBatch(parent.tenantId(), operationId);
if (appliedBatchId != null && appliedBatchId > 0) return false;
var task = picks.task(taskId);
if (task == null || task.getLoadingPlanId() == null) throw conflict("PDA任务缺少真实装柜计划");
long loadingPlanId = task.getLoadingPlanId();
Long containerBookingId = splits.containerBookingId(loadingPlanId);
if (containerBookingId == null) throw conflict("PDA任务装柜计划缺少真实柜次");
int parentAvailable = count(parent.packageCount(), "主入库单可用箱数");
if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable) {
throw conflict("本次已拣箱数与主入库单可用箱数不一致");
@@ -112,14 +125,8 @@ public class PdaPickSettlementService {
throw conflict("父入库单库位剩余箱数与未装柜箱数不一致");
}
long warehouseId = warehouseId(confirmedAllocations);
if (childPackageCount == 0) {
if (!transferItems.isEmpty() || releasedReservationIds.isEmpty()) {
throw conflict("全量拣货后库位资源结算不完整");
}
allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds);
settleWarehouseResources(parentInboundId, 0L, "", taskId, operatorId, operatorName,
warehouseId, parentAvailable, 0, releasedReservationIds, List.of());
return false;
if (childPackageCount == 0 && (!transferItems.isEmpty() || releasedReservationIds.isEmpty())) {
throw conflict("全量拣货后库位资源结算不完整");
}
List<MeasurementItemRow> measurements = splits.lockLatestApprovedItemsForInbound(
@@ -142,56 +149,91 @@ public class PdaPickSettlementService {
throw conflict("主入库单测量明细剩余箱数与未装柜箱数不一致");
}
if (transferItems.isEmpty()) {
if (childPackageCount > 0 && transferItems.isEmpty()) {
throw conflict("父入库单可转移库位剩余箱数与未装柜箱数不一致");
}
long childId = IdWorker.getId();
if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在");
long loadedChildId = IdWorker.getId();
long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L;
long batchId = IdWorker.getId();
String operationId = "PDA-SPLIT-" + taskId + '-' + parentInboundId;
String idempotencyKey = "pda-task-" + taskId;
String childCode = childInboundCode(parent.inboundCode(), childId);
int parentVersion = parent.version() == null ? 0 : parent.version();
requireAffected(splits.cloneChild(parent.tenantId(), parentInboundId, childId, childCode, childPackageCount,
operatorId, operatorName), "子入库单");
requireAffected(splits.insertBatch(batchId, parent.tenantId(), parentInboundId, operationId,
idempotencyKey, parentVersion, childPackageCount, "PDA拣货后未装柜箱数", operatorId, operatorName), "拆分批次");
long childMeasurementId = IdWorker.getId();
String childCode = childInboundCode(parent.inboundCode(), remainderChildId == 0 ? loadedChildId : remainderChildId);
int parentVersion = root.version() == null ? 0 : root.version();
int sequence = splits.nextSplitSequence(parent.tenantId(), rootInboundId);
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, loadedChildId,
childInboundCode(parent.inboundCode(), loadedChildId), pickedPackageCount, sequence, "LOADED", operatorId, operatorName), "实装子入库单");
if (remainderChildId != 0L) {
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, remainderChildId,
childCode, childPackageCount, sequence + 1, "REMAINDER", operatorId, operatorName), "余量子入库单");
}
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
idempotencyKey, parentVersion, parentAvailable, loadedChildId, remainderChildId == 0L ? null : remainderChildId,
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId,
"PDA拣货后生成实装/余量子单", operatorId, operatorName), "拆分批次");
long loadedMeasurementId = IdWorker.getId();
requireAffected(splits.cloneMeasurement(parent.tenantId(), parentInboundId, sourceMeasurementId,
childId, childMeasurementId, batchId, childPackageCount, operatorId), "子入库测量批次");
loadedChildId, loadedMeasurementId, batchId, pickedPackageCount, operatorId), "实装子入库测量批次");
long childMeasurementId = childPackageCount > 0 ? IdWorker.getId() : 0L;
if (childPackageCount > 0) {
requireAffected(splits.cloneMeasurement(parent.tenantId(), parentInboundId, sourceMeasurementId,
remainderChildId, childMeasurementId, batchId, childPackageCount, operatorId), "余量子入库测量批次");
}
int lineNo = 1;
for (MeasurementSplitLine line : measurementLines) {
if (line.pickedPackageCount() <= 0) continue;
long splitItemId = IdWorker.getId();
requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, rootInboundId,
loadedChildId, lineNo, line.item().inboundDetailId(), sourceMeasurementId,
line.item().measurementItemId(), line.pickedPackageCount()), "实装拆分明细");
requireAffected(splits.cloneMeasurementItem(parent.tenantId(), sourceMeasurementId,
line.item().measurementItemId(), loadedMeasurementId, splitItemId, IdWorker.getId(), lineNo,
line.pickedPackageCount()), "实装子入库测量明细");
lineNo++;
}
for (MeasurementSplitLine line : measurementLines) {
if (line.remainingPackageCount() <= 0) {
continue;
}
long splitItemId = IdWorker.getId();
requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, parentInboundId,
childId, lineNo, line.item().inboundDetailId(), sourceMeasurementId,
requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, rootInboundId,
remainderChildId == 0L ? loadedChildId : remainderChildId, lineNo, line.item().inboundDetailId(), sourceMeasurementId,
line.item().measurementItemId(), line.remainingPackageCount()), "拆分明细");
requireAffected(splits.cloneMeasurementItem(parent.tenantId(), sourceMeasurementId,
line.item().measurementItemId(), childMeasurementId, splitItemId, IdWorker.getId(), lineNo,
line.remainingPackageCount()), "子入库测量明细");
lineNo++;
}
requireAffected(splits.updateSourceMeasurementPackageCount(parent.tenantId(), parentInboundId,
sourceMeasurementId, pickedPackageCount), "主入库测量批次");
for (MeasurementSplitLine line : measurementLines) {
requireAffected(splits.updateSourceMeasurementItemPackageCount(parent.tenantId(), sourceMeasurementId,
line.item().measurementItemId(), line.pickedPackageCount()), "主入库测量明细");
requireAffected(splits.normalizeChildMetrics(parent.tenantId(), loadedChildId, loadedMeasurementId),
"实装子单重量体积归一化");
if (remainderChildId != 0L) {
requireAffected(splits.normalizeChildMetrics(parent.tenantId(), remainderChildId, childMeasurementId),
"余量子单重量体积归一化");
}
requireAffected(splits.markRootSplit(parent.tenantId(), rootInboundId, operatorId, parentVersion), "主入库单拆分状态");
if ("CHILD".equals(parent.inboundRole())) {
requireAffected(splits.markRemainderConsumed(parent.tenantId(), parentInboundId, operatorId,
parent.version() == null ? 0 : parent.version()), "余量子单消费状态");
}
requireAffected(splits.markParentPartiallySplit(parent.tenantId(), parentInboundId, childPackageCount,
operatorId, parentVersion), "主入库单拆分状态");
requireAffected(splits.applyBatch(parent.tenantId(), batchId), "拆分批次生效");
requireAffected(splits.insertHistory(IdWorker.getId(), parent.tenantId(), batchId,
operationId + "-APPLY", "{\"childInboundId\":" + childId + ",\"packageCount\":"
+ childPackageCount + "}", operatorId, operatorName, "PDA拣货自动生成子入库单"), "拆分审计");
operationId + "-APPLY", "{\"sourceInboundId\":" + parentInboundId
+ ",\"loadedChildInboundId\":" + loadedChildId
+ ",\"remainderChildInboundId\":" + (remainderChildId == 0L ? "null" : remainderChildId)
+ ",\"loadedPackageCount\":" + pickedPackageCount
+ ",\"remainingPackageCount\":" + childPackageCount
+ ",\"loadingPlanId\":" + loadingPlanId
+ ",\"containerBookingId\":" + containerBookingId + "}", operatorId, operatorName,
"PDA拣货自动生成实装/余量子入库单"), "拆分审计");
allocations.copyRemainingToChild(parent.tenantId(), childId, confirmedAllocations);
if (remainderChildId != 0L) {
allocations.copyRemainingToChild(parent.tenantId(), remainderChildId, confirmedAllocations);
}
allocations.markTransferred(parent.tenantId(), parentInboundId, confirmedAllocations,
releasedReservationIds);
allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds);
settleWarehouseResources(parentInboundId, childId, childCode, taskId, operatorId, operatorName,
settleWarehouseResources(parentInboundId, remainderChildId, childCode, taskId, operatorId, operatorName,
warehouseId, parentAvailable, childPackageCount, releasedReservationIds, transferItems);
return true;
}