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
@@ -2545,6 +2545,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空',
external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空',
box_code VARCHAR(128) NULL COMMENT '柏悦唛头箱号;设备单箱测量时同一客户内唯一',
scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号',
box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识',
package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数',
@@ -2582,6 +2583,7 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID',
box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码',
scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm',
width_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测宽度cm',
height_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测高度cm',
@@ -3041,6 +3043,10 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg',
'DECIMAL(18,3) NULL COMMENT ''单箱重量kg'' AFTER girth_cm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_volume_cbm',
'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER unit_weight_kg');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'scanned_shipment_id',
'VARCHAR(64) NULL COMMENT ''设备从Amazon FBA箱标实扫的Shipment ID'' AFTER box_code');
CALL phase2_measure_add_column('fl_mojia_measurement_receipt', 'scanned_shipment_id',
'VARCHAR(64) NULL COMMENT ''设备从Amazon FBA箱标实扫的Shipment ID'' AFTER box_code');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_weight_kg',
'DECIMAL(18,3) NULL COMMENT ''本箱规总重量kg'' AFTER volume_cbm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm',
@@ -7425,12 +7431,20 @@ BEGIN
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_mojia_measurement_receipt'
AND column_name IN ('id', 'tenant_id', 'subject_id', 'box_code', 'length_cm', 'width_cm',
'height_cm', 'weight_kg', 'machine_no', 'measured_at', 'image_file_id',
'scanned_shipment_id', 'height_cm', 'weight_kg', 'machine_no', 'measured_at', 'image_file_id',
'status', 'match_failure_code', 'match_failure_reason', 'request_id',
'trace_id', 'created_at', 'updated_at')) <> 18 THEN
'trace_id', 'created_at', 'updated_at')) <> 19 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:墨家未匹配测量收件箱字段未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_measurement_item'
AND column_name = 'scanned_shipment_id'
AND character_maximum_length = 64) <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:箱级测量实扫Shipment ID字段未创建';
END IF;
IF (SELECT character_maximum_length FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_measurement_item'
@@ -8751,3 +8765,217 @@ DROP PROCEDURE IF EXISTS assert_phase3_pda_item_assignment_schema;
ALTER TABLE fl_inbound_pallet_allocation
ADD COLUMN IF NOT EXISTS reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘' AFTER location_code;
-- 三期仓库常用角色初始化。模块来源:iam/src/main/resources/db/phase3-warehouse-roles.sql。
-- 三期仓库常用系统角色:仓库操作员、仓库拣货员、仓库理货员。
-- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS assert_phase3_warehouse_role_ids_available;
DELIMITER $$
CREATE PROCEDURE assert_phase3_warehouse_role_ids_available()
BEGIN
IF EXISTS (
SELECT 1
FROM iam_role
WHERE (id = 4000004 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_OPERATOR'))
OR (id = 4000005 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_PICKER'))
OR (id = 4000006 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_TALLY'))
OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_OPERATOR' AND id <> 4000004)
OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_PICKER' AND id <> 4000005)
OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_TALLY' AND id <> 4000006)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:4000004-4000006 或角色编码已被占用';
END IF;
IF EXISTS (
SELECT 1
FROM iam_data_scope
WHERE (id = 6000004 AND role_id <> 4000004)
OR (id = 6000005 AND role_id <> 4000005)
OR (id = 6000006 AND role_id <> 4000006)
OR (role_id = 4000004 AND id <> 6000004)
OR (role_id = 4000005 AND id <> 6000005)
OR (role_id = 4000006 AND id <> 6000006)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:6000004-6000006 数据范围ID已被占用';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_warehouse_role_ids_available();
DROP PROCEDURE assert_phase3_warehouse_role_ids_available;
INSERT INTO iam_role (
id, tenant_id, app_code, role_code, role_name, role_type, status,
description, deleted, version
) VALUES
(4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '仓库操作员', 'SYSTEM', 'ACTIVE',
'负责查看入仓业务并在后台维护装柜证据', 0, 0),
(4000005, 0, 'PLATFORM', 'WAREHOUSE_PICKER', '仓库拣货员', 'SYSTEM', 'ACTIVE',
'负责使用PDA执行柜次拣货和现场拍照', 0, 0),
(4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '仓库理货员', 'SYSTEM', 'ACTIVE',
'负责入库测量、PDA复核和装柜证据维护', 0, 0)
ON DUPLICATE KEY UPDATE
role_name = VALUES(role_name), role_type = 'SYSTEM', status = 'ACTIVE',
description = VALUES(description), deleted = 0;
DROP TEMPORARY TABLE IF EXISTS tmp_phase3_warehouse_role_permission;
CREATE TEMPORARY TABLE tmp_phase3_warehouse_role_permission (
role_code VARCHAR(64) NOT NULL,
permission_code VARCHAR(128) NOT NULL,
PRIMARY KEY (role_code, permission_code)
) ENGINE=MEMORY;
-- 操作员:查看入仓业务并维护后台装柜证据,不授予完成装柜、任务分派或出仓动作。
INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES
('WAREHOUSE_OPERATOR', 'platform:order'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit');
-- 拣货员:只允许PDA任务查询、作业和现场照片,不授予后台页面、任务分派或复核权限。
INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES
('WAREHOUSE_PICKER', 'platform:firstleg:pda:view'),
('WAREHOUSE_PICKER', 'platform:firstleg:pda:work');
-- 理货员:负责到仓测量、装柜证据维护和PDA复核,不允许完成装柜或确认出仓。
INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES
('WAREHOUSE_TALLY', 'platform:order'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:list'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:view'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:view'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:edit'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:submit'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:edit'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:confirm'),
('WAREHOUSE_TALLY', 'platform:firstleg:pda:view'),
('WAREHOUSE_TALLY', 'platform:firstleg:pda:review'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:view'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:edit'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:picking:view');
DROP PROCEDURE IF EXISTS assert_phase3_warehouse_role_permissions_available;
DELIMITER $$
CREATE PROCEDURE assert_phase3_warehouse_role_permissions_available()
BEGIN
IF (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission
WHERE role_code = 'WAREHOUSE_OPERATOR') <> 5
OR (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission
WHERE role_code = 'WAREHOUSE_PICKER') <> 2
OR (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission
WHERE role_code = 'WAREHOUSE_TALLY') <> 12 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:角色权限组合数量不符合预期';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_phase3_warehouse_role_permission expected
LEFT JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.app_code = 'PLATFORM'
AND permission.permission_code = expected.permission_code
AND permission.status = 'ACTIVE'
AND permission.deleted = 0
WHERE permission.id IS NULL
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:依赖的三期平台权限尚未完整部署';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_warehouse_role_permissions_available();
DROP PROCEDURE assert_phase3_warehouse_role_permissions_available;
-- 三个系统角色的权限集合由迁移维护。重复执行时移除历史版本或人工误授的越权项,
-- 避免最小权限角色因脚本升级只增不减。
DELETE role_permission
FROM iam_role_permission role_permission
JOIN iam_role role
ON role.id = role_permission.role_id
AND role.tenant_id = role_permission.tenant_id
JOIN iam_permission permission
ON permission.id = role_permission.permission_id
AND permission.tenant_id = role_permission.tenant_id
LEFT JOIN tmp_phase3_warehouse_role_permission expected
ON expected.role_code = role.role_code
AND expected.permission_code = permission.permission_code
WHERE role.tenant_id = 0
AND role.app_code = 'PLATFORM'
AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')
AND expected.permission_code IS NULL;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, 0
FROM tmp_phase3_warehouse_role_permission expected
JOIN iam_role role
ON role.tenant_id = 0
AND role.app_code = 'PLATFORM'
AND role.role_code = expected.role_code
AND role.status = 'ACTIVE'
AND role.deleted = 0
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.app_code = 'PLATFORM'
AND permission.permission_code = expected.permission_code
AND permission.status = 'ACTIVE'
AND permission.deleted = 0;
INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES
(6000004, 4000004, 0, 'TENANT', NULL),
(6000005, 4000005, 0, 'TENANT', NULL),
(6000006, 4000006, 0, 'TENANT', NULL)
ON DUPLICATE KEY UPDATE
tenant_id = 0, scope_type = 'TENANT', scope_expression = NULL;
DROP PROCEDURE IF EXISTS assert_phase3_warehouse_roles_ready;
DELIMITER $$
CREATE PROCEDURE assert_phase3_warehouse_roles_ready()
BEGIN
IF (SELECT COUNT(*)
FROM iam_role
WHERE tenant_id = 0
AND app_code = 'PLATFORM'
AND role_type = 'SYSTEM'
AND status = 'ACTIVE'
AND deleted = 0
AND role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')) <> 3 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:系统角色未完整启用';
END IF;
IF (SELECT COUNT(*)
FROM tmp_phase3_warehouse_role_permission expected
JOIN iam_role role
ON role.tenant_id = 0
AND role.app_code = 'PLATFORM'
AND role.role_code = expected.role_code
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.app_code = 'PLATFORM'
AND permission.permission_code = expected.permission_code
JOIN iam_role_permission grant_row
ON grant_row.role_id = role.id
AND grant_row.permission_id = permission.id
AND grant_row.tenant_id = 0) <> 19 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:最小权限授权未完整写入';
END IF;
IF (SELECT COUNT(*)
FROM iam_data_scope scope_row
JOIN iam_role role ON role.id = scope_row.role_id
WHERE role.tenant_id = 0
AND role.app_code = 'PLATFORM'
AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')
AND scope_row.tenant_id = 0
AND scope_row.scope_type = 'TENANT') <> 3 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:数据范围未完整写入';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_warehouse_roles_ready();
DROP PROCEDURE assert_phase3_warehouse_roles_ready;
DROP TEMPORARY TABLE IF EXISTS tmp_phase3_warehouse_role_permission;
@@ -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;
}
@@ -47,10 +47,19 @@ CALL phase3_inbound_add_column(
CALL phase3_inbound_add_column(
'fl_inbound_order', 'package_count',
"INT UNSIGNED NULL COMMENT '入仓单箱数;三期唯一数量口径' AFTER total_package_count");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'split_purpose',
"VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '履约分片用途:MASTER、LOADED、REMAINDER' AFTER package_count");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'master_order_id',
"BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'");
-- 旧实现只生成未装余量子单,可无歧义映射为 REMAINDER。
UPDATE fl_inbound_order
SET split_purpose = 'REMAINDER'
WHERE inbound_role = 'CHILD'
AND split_purpose = 'MASTER';
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
@@ -78,9 +87,6 @@ DELIMITER ;
CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_master_order',
'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)');
CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_one_child',
'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)');
CALL phase3_inbound_add_index(
'fl_inbound_order', 'idx_inbound_root_role',
'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)');
@@ -105,10 +111,21 @@ BEGIN
) THEN
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_one_child'
) THEN
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_one_child;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_drop_legacy_index();
DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index;
CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_parent_split_sequence',
'UNIQUE KEY `uk_inbound_parent_split_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)');
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
CREATE TABLE IF NOT EXISTS fl_inbound_split_batch (
@@ -215,6 +232,27 @@ CALL phase3_inbound_add_column(
CALL phase3_inbound_add_column(
'fl_inbound_split_item', 'line_no',
"INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_split_batch', 'loaded_child_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '本批次实装子单ID' AFTER package_count");
CALL phase3_inbound_add_column(
'fl_inbound_split_batch', 'source_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '本批次实际消耗的MASTER或REMAINDER分片ID' AFTER parent_inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_split_batch', 'remainder_child_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '本批次余量子单ID' AFTER loaded_child_inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_split_batch', 'loaded_package_count',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '本批次实装箱数' AFTER remainder_child_inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_split_batch', 'remaining_package_count',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '本批次未装箱数' AFTER loaded_package_count");
CALL phase3_inbound_add_column(
'fl_inbound_split_batch', 'loading_plan_id',
"BIGINT UNSIGNED NULL COMMENT '关联装柜计划/柜次' AFTER remaining_package_count");
CALL phase3_inbound_add_column(
'fl_inbound_split_batch', 'container_booking_id',
"BIGINT UNSIGNED NULL COMMENT '关联柜次标识(兼容查询)' AFTER loading_plan_id");
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
@@ -245,6 +283,15 @@ CALL phase3_inbound_add_relation(
CALL phase3_inbound_add_relation(
'fl_inbound_order', 'fk_inbound_root_order',
'FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_split_batch', 'fk_inbound_split_loaded_child',
'FOREIGN KEY (`loaded_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_split_batch', 'fk_inbound_split_source',
'FOREIGN KEY (`source_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_split_batch', 'fk_inbound_split_remainder_child',
'FOREIGN KEY (`remainder_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_measurement', 'fk_measure_split_batch',
'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)');
@@ -290,9 +337,8 @@ CALL phase3_inbound_add_index(
'fl_inbound_split_item', 'uk_inbound_split_batch_line',
'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)');
CALL phase3_inbound_add_index(
'fl_inbound_split_item', 'uk_inbound_split_batch_source_item',
'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
'fl_inbound_split_batch', 'idx_inbound_split_loading_plan',
'KEY `idx_inbound_split_loading_plan` (`tenant_id`, `loading_plan_id`, `container_booking_id`, `status`, `id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
DELIMITER $$
@@ -306,10 +352,18 @@ BEGIN
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_source_item') THEN
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_source_item;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_drop_split_legacy_index();
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
CALL phase3_inbound_add_index(
'fl_inbound_split_item', 'uk_inbound_split_batch_source_child',
'UNIQUE KEY `uk_inbound_split_batch_source_child` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`, `child_inbound_id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update;
@@ -332,6 +386,9 @@ BEGIN
IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
END IF;
IF NEW.split_purpose NOT IN ('MASTER', 'LOADED', 'REMAINDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单分片用途非法';
END IF;
IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0';
END IF;
@@ -343,8 +400,8 @@ BEGIN
OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数';
END IF;
IF NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
IF NEW.split_purpose = 'MASTER' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须标记LOADED或REMAINDER';
END IF;
IF NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order
@@ -365,9 +422,13 @@ BEGIN
IF NOT (OLD.inbound_role <=> NEW.inbound_role)
OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id)
OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id)
OR NOT (OLD.split_sequence <=> NEW.split_sequence) THEN
OR NOT (OLD.split_sequence <=> NEW.split_sequence)
OR NOT (OLD.split_purpose <=> NEW.split_purpose) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改';
END IF;
IF NEW.split_purpose NOT IN ('MASTER', 'LOADED', 'REMAINDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单分片用途非法';
END IF;
IF NEW.inbound_role = 'CHILD'
AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0';
@@ -375,8 +436,8 @@ BEGIN
IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
END IF;
IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
IF NEW.inbound_role = 'CHILD' AND NEW.split_purpose = 'MASTER' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单用途非法';
END IF;
IF NEW.inbound_role = 'MASTER'
AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT')
@@ -402,6 +463,7 @@ BEGIN
AND parent_order.inbound_role = 'MASTER'
AND child_order.tenant_id = NEW.tenant_id
AND child_order.inbound_role = 'CHILD'
AND child_order.split_purpose IN ('LOADED', 'REMAINDER')
AND child_order.parent_inbound_id = NEW.parent_inbound_id
AND child_order.package_count >= NEW.package_count
AND child_order.deleted = 0
@@ -412,7 +474,7 @@ BEGIN
WHERE source_item.id = NEW.source_measurement_item_id
AND source_item.tenant_id = NEW.tenant_id
AND source_item.measurement_id = NEW.source_measurement_id
AND source_batch.inbound_id = NEW.parent_inbound_id
AND source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id)
))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致';
@@ -435,6 +497,27 @@ BEGIN
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致';
END IF;
IF NEW.source_inbound_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM fl_inbound_order source_order
WHERE source_order.id = NEW.source_inbound_id
AND source_order.tenant_id = NEW.tenant_id
AND source_order.deleted = 0
AND (source_order.id = NEW.parent_inbound_id OR
(source_order.inbound_role = 'CHILD'
AND source_order.parent_inbound_id = NEW.parent_inbound_id
AND source_order.split_purpose = 'REMAINDER'))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是根主单或其余量子单';
END IF;
IF NEW.loaded_package_count > 0 AND (
NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS (
SELECT 1 FROM fl_loading_plan plan
WHERE plan.id = NEW.loading_plan_id
AND plan.container_booking_id = NEW.container_booking_id
)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_batch_before_update
@@ -444,15 +527,29 @@ BEGIN
IF OLD.status IN ('APPLIED', 'CANCELLED') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改';
END IF;
IF NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id)
OR NOT (OLD.source_inbound_id <=> NEW.source_inbound_id)
OR NOT (OLD.loaded_child_inbound_id <=> NEW.loaded_child_inbound_id)
OR NOT (OLD.remainder_child_inbound_id <=> NEW.remainder_child_inbound_id)
OR NOT (OLD.package_count <=> NEW.package_count)
OR NOT (OLD.loaded_package_count <=> NEW.loaded_package_count)
OR NOT (OLD.remaining_package_count <=> NEW.remaining_package_count)
OR NOT (OLD.loading_plan_id <=> NEW.loading_plan_id)
OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源、子单、柜次与数量创建后不可修改';
END IF;
IF NEW.status = 'APPLIED' THEN
IF NEW.applied_at IS NULL
OR (NEW.loaded_package_count > 0 AND (
NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS (
SELECT 1 FROM fl_loading_plan plan
WHERE plan.id = NEW.loading_plan_id
AND plan.container_booking_id = NEW.container_booking_id)))
OR NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order
WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND parent_order.package_count > 0
AND parent_order.split_status = 'PARTIALLY_SPLIT'
AND parent_order.version > NEW.expected_parent_version
) OR
(SELECT COALESCE(SUM(item.package_count), 0)
@@ -462,7 +559,26 @@ BEGIN
(SELECT COUNT(DISTINCT item.child_inbound_id)
FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id) <> 1 OR
AND item.split_batch_id = NEW.id) NOT BETWEEN 1 AND 2 OR
(SELECT COALESCE(SUM(item.package_count), 0)
FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id
AND item.child_inbound_id = NEW.loaded_child_inbound_id) <> NEW.loaded_package_count OR
(SELECT COALESCE(SUM(item.package_count), 0)
FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id
AND item.child_inbound_id = NEW.remainder_child_inbound_id) <> NEW.remaining_package_count OR
NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count OR
(NEW.loaded_package_count > 0 AND NEW.loaded_child_inbound_id IS NULL) OR
(NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) OR
(NEW.loaded_child_inbound_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.loaded_child_inbound_id
AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'LOADED')) OR
(NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.remainder_child_inbound_id
AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'REMAINDER')) OR
EXISTS (SELECT 1
FROM fl_inbound_split_item item
JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id
@@ -541,12 +657,12 @@ BEGIN
AND table_name = 'fl_inbound_order'
AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id',
'split_sequence', 'split_status', 'package_count',
'master_order_id')) <> 7 THEN
'master_order_id', 'split_purpose')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_one_child',
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence',
'uk_inbound_split_operation', 'uk_inbound_split_idempotency',
'uk_inbound_split_history_operation')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建';
@@ -554,8 +670,9 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order',
'fk_inbound_split_source', 'fk_inbound_split_loaded_child', 'fk_inbound_split_remainder_child',
'fk_measure_split_batch', 'fk_measure_source_batch',
'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN
'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
@@ -575,7 +692,7 @@ BEGIN
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_tenant_order'
AND index_name IN ('uk_inbound_tenant_order', 'uk_inbound_one_child')
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单';
END IF;
@@ -40,9 +40,8 @@ CREATE TABLE IF NOT EXISTS fl_split_operation (
/*
* 部分装柜拆单契约:
* 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单;
* 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单;
* 3. 子单不能再作为父单,锁定数量创建后不可修改。
* 本文件仅保留历史部分锁定明细;主单、LOADED/REMAINDER 多分片及层级约束
* 统一由 phase3-inbound-parent-child-schema.sql 维护。
*/
DROP PROCEDURE IF EXISTS phase3_split_add_column;
DELIMITER $$
@@ -76,8 +75,8 @@ SET @phase3_split_index_exists = (
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_one_secondary'
);
SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0,
'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)',
SET @phase3_split_ddl = IF(@phase3_split_index_exists > 0,
'ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_one_secondary',
'SELECT 1');
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
EXECUTE phase3_split_stmt;
@@ -88,8 +87,8 @@ SET @phase3_split_fk_exists = (
WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY'
);
SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0,
'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)',
SET @phase3_split_ddl = IF(@phase3_split_fk_exists > 0,
'ALTER TABLE fl_inbound_order DROP FOREIGN KEY fk_inbound_parent',
'SELECT 1');
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
EXECUTE phase3_split_stmt;
@@ -140,58 +139,6 @@ DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete;
DELIMITER $$
CREATE TRIGGER trg_inbound_split_before_insert
BEFORE INSERT ON fl_inbound_order
FOR EACH ROW
BEGIN
DECLARE v_parent_tenant BIGINT UNSIGNED;
DECLARE v_parent_parent BIGINT UNSIGNED;
IF NEW.parent_inbound_id IS NOT NULL THEN
SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent
FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0;
IF v_parent_tenant <> NEW.tenant_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单与主单客户归属不一致';
END IF;
IF v_parent_parent IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分';
END IF;
IF NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单拆单状态必须为NOT_SPLIT';
END IF;
END IF;
END$$
CREATE TRIGGER trg_inbound_split_before_update
BEFORE UPDATE ON fl_inbound_order
FOR EACH ROW
BEGIN
IF NOT (NEW.parent_inbound_id <=> OLD.parent_inbound_id) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入库单主子关系创建后不可修改';
END IF;
IF OLD.parent_inbound_id IS NOT NULL AND NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分';
END IF;
IF OLD.parent_inbound_id IS NULL
AND NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入库单拆单状态非法';
END IF;
IF OLD.split_status = 'FULLY_SPLIT' AND NEW.split_status <> 'FULLY_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成拆单不能回退';
END IF;
IF NEW.split_status = 'FULLY_SPLIT' AND (
(SELECT COUNT(*) FROM fl_inbound_order child
WHERE child.tenant_id = NEW.tenant_id AND child.parent_inbound_id = NEW.id
AND child.deleted = 0) <> 1
OR (SELECT COUNT(*) FROM fl_inbound_split_detail detail
WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id) = 0
OR EXISTS (SELECT 1 FROM fl_inbound_split_detail detail
WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id
AND detail.status <> 'COMPLETED')
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数二级子单及拆分明细未完成';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_detail_before_insert
BEFORE INSERT ON fl_inbound_split_detail
FOR EACH ROW
@@ -239,6 +186,11 @@ BEGIN
END$$
DELIMITER ;
-- 主子关系及不可回退规则由 phase3-inbound-parent-child-schema.sql 统一维护。
-- 旧触发器要求“一父一子”,会阻止 LOADED/REMAINDER 多分片,迁移完成后必须移除。
DROP TRIGGER IF EXISTS trg_inbound_split_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_before_update;
DROP PROCEDURE IF EXISTS phase3_split_assert_contract;
DELIMITER $$
CREATE PROCEDURE phase3_split_assert_contract()
@@ -254,17 +206,16 @@ BEGIN
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN ('uk_inbound_one_secondary', 'uk_inbound_split_parent_detail',
'uk_inbound_split_child_detail', 'uk_inbound_split_operation')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:唯一子单或部分锁定幂等索引缺失';
AND index_name IN ('uk_inbound_split_parent_detail',
'uk_inbound_split_child_detail', 'uk_inbound_split_operation')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:部分锁定幂等索引缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema = DATABASE()
AND trigger_name IN ('trg_inbound_split_before_insert', 'trg_inbound_split_before_update',
'trg_inbound_split_detail_before_insert',
AND trigger_name IN ('trg_inbound_split_detail_before_insert',
'trg_inbound_split_detail_before_update',
'trg_inbound_split_detail_before_delete')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:主子层级或部分锁定触发器未完整创建';
'trg_inbound_split_detail_before_delete')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:部分锁定触发器未完整创建';
END IF;
END$$
DELIMITER ;
@@ -3,6 +3,21 @@ info:
title: Phase 3 Loading Evidence and Picking API
version: 1.0.0
paths:
/api/platform/v1/inbounds/{inboundId}/loading/evidence/pda:
get:
summary: List loading evidence for the active PDA worker
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}]
responses:
'200': {description: Business envelope; requires platform:firstleg:pda:work}
post:
summary: Append one PDA-captured loading evidence photo
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}}
responses:
'200': {description: Business envelope; requires platform:firstleg:pda:work}
/api/platform/v1/inbounds/{inboundId}/loading/evidence:
get:
summary: List loading evidence bound to one customer inbound
@@ -18,6 +33,16 @@ paths:
application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}}
responses:
'200': {description: Business envelope; ownership, type and version failures use business error codes}
/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void/pda:
post:
summary: Void an incorrect PDA loading evidence photo
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/EvidenceId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/VoidEvidenceRequest'}}
responses:
'200': {description: Business envelope; requires platform:firstleg:pda:work}
/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:
post:
summary: Void an incorrect loading evidence photo without hard deletion
@@ -15,6 +15,7 @@ class LoadingDeliveryOpenApiContractTest {
assertThat(yaml)
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/pda:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:")
@@ -26,5 +27,6 @@ class LoadingDeliveryOpenApiContractTest {
.contains("compatibility projection")
.contains("must exactly match the server-derived container-task aggregate")
.contains("cannot complete canonical picking");
assertThat(yaml).doesNotContain("/delivery/complete").doesNotContain("DELIVERED");
}
}
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.loading.controller;
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.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
@@ -15,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@@ -26,19 +26,28 @@ class PlatformLoadingDeliveryControllerContractTest {
post("evidence", "/{inboundId}/loading/evidence",
"platform:firstleg:inbound:loading:edit", UpsertEvidenceRequest.class);
post("pdaEvidence", "/{inboundId}/loading/evidence/pda",
"platform:firstleg:pda:work", UpsertEvidenceRequest.class);
get("pdaEvidence", "/{inboundId}/loading/evidence/pda",
"platform:firstleg:pda:work");
Method pdaVoid = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaVoidEvidence",
AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class);
assertThat(pdaVoid.getAnnotation(PostMapping.class).value())
.containsExactly("/{inboundId}/loading/evidence/{evidenceId}/void/pda");
assertAuthority(pdaVoid, "platform:firstleg:pda:work");
get("evidence", "/{inboundId}/loading/evidence",
"platform:firstleg:inbound:loading:view");
postVoidEvidence();
post("completeLoading", "/{inboundId}/loading/complete",
"platform:firstleg:inbound:loading:complete", CompleteLoadingRequest.class);
post("completeDelivery", "/{inboundId}/delivery/complete",
"platform:firstleg:inbound:delivery:complete", CompleteDeliveryRequest.class);
post("startPicking", "/{inboundId}/loading/picking/start",
"platform:firstleg:inbound:picking:edit", PickingRequest.class);
post("completePicking", "/{inboundId}/loading/picking/complete",
"platform:firstleg:inbound:picking:edit", PickingRequest.class);
get("picking", "/{inboundId}/loading/picking",
"platform:firstleg:inbound:picking:view");
assertThat(Arrays.stream(PlatformLoadingDeliveryController.class.getDeclaredMethods())
.map(Method::getName)).doesNotContain("completeDelivery");
}
private static void postVoidEvidence() throws Exception {
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.loading.mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingCompletionGateSqlContractTest {
@Test
void requiresEveryLinkedContainerToPassBookingTruckAndCustomsReceiptGates() throws Exception {
Select select = LoadingCompletionGateMapper.class
.getDeclaredMethod("snapshot", long.class, long.class).getAnnotation(Select.class);
String sql = String.join(" ", select.value()).replaceAll("\\s+", " ");
assertThat(sql)
.contains("FROM fl_preload_loading_plan_link l")
.contains("JOIN fl_loading_plan p ON p.id=l.loading_plan_id")
.contains("fl_ocean_booking_container_allocation")
.contains("b.status='RELEASED'")
.contains("fl_truck_appointment")
.contains("t.status='COMPLETED'")
.contains("fl_customs_case")
.contains("fl_customs_receipt")
.contains("c.status='CLEARANCE_COMPLETED'")
.contains("r.receipt_type='RELEASED'")
.contains("r.file_id IS NOT NULL")
.contains("newer.receipt_version&gt;r.receipt_version")
.contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}");
}
}
@@ -34,6 +34,7 @@ import static org.mockito.Mockito.*;
class LoadingDeliveryServiceTest {
@Mock LoadingRepository repository;
@Mock LoadingModelBridgeService loadingModelBridge;
@Mock LoadingCompletionGateService completionGate;
@Mock LoadingPickRepository loadingPicks;
@Mock InboundStatusHistoryMapper histories;
@Mock LoadingEvidenceFileClient files;
@@ -74,6 +75,7 @@ class LoadingDeliveryServiceTest {
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingTransportAndCustoms(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@@ -159,6 +161,24 @@ class LoadingDeliveryServiceTest {
verify(repository, never()).createEvidence(any());
}
@Test
void rejectsEvidenceAppendAndVoidAfterLoadingCompleted() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 4));
assertThatThrownBy(() -> service.upsertEvidence(10, 20, 7, "admin", "loaded-append",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 11L, null, null, null)))
.hasMessageContaining("装柜完成后不允许在线修改证据");
assertThatThrownBy(() -> service.voidEvidence(10, 20, 99L, 7L, "admin", "loaded-void",
new VoidEvidenceRequest(0, "拍错")))
.hasMessageContaining("装柜完成后不允许在线修改证据");
verify(repository, never()).createEvidence(any());
verify(repository, never()).voidEvidence(anyLong(), anyLong(), anyLong(), anyString(),
anyLong(), anyString(), anyInt());
}
@Test
void rejectsCompletingPickingWithoutCanonicalContainerTask() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -33,7 +33,7 @@ class OutboundDispatchServiceTest {
}
@Test
void confirmAtomicallyAdvancesDispatchAndInboundAfterEveryGatePasses() {
void confirmAdvancesDispatchWithoutMutatingLoadedInbound() {
OutboundDispatchService service = new OutboundDispatchService(repository,
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
stubReadyChain();
@@ -43,14 +43,12 @@ class OutboundDispatchServiceTest {
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true);
when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1);
when(repository.confirmInbound(7L, 11L, 9L)).thenReturn(1);
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1);
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok"));
verify(repository).confirm(33L, 0, 9L, "admin", "ok");
verify(repository).confirmInbound(7L, 11L, 9L);
verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction())
&& "WAITING_FOR_LOADING".equals(history.getToStatus())));
verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1");
@@ -68,7 +66,6 @@ class OutboundDispatchServiceTest {
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁");
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong());
}
@Test
@@ -92,7 +89,6 @@ class OutboundDispatchServiceTest {
assertThatThrownBy(() -> service.confirm(9L, "admin", "stale",
new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新");
verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong());
}
@Test
@@ -108,7 +104,6 @@ class OutboundDispatchServiceTest {
assertThatThrownBy(() -> service.confirm(9L, "admin", "reused",
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class)
.hasMessageContaining("用于不同请求");
verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong());
}
private void stubReadyChain() {
@@ -5,8 +5,6 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@@ -22,10 +20,12 @@ class InboundParentChildSqlContractTest {
.contains("'fl_inbound_order', 'parent_inbound_id'")
.contains("'fl_inbound_order', 'root_inbound_id'")
.contains("'fl_inbound_order', 'package_count'")
.contains("'fl_inbound_order', 'split_purpose'")
.contains("SET split_purpose = 'REMAINDER'")
.contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'")
.contains("DROP INDEX uk_inbound_tenant_order")
.contains("uk_inbound_master_order")
.contains("uk_inbound_one_child")
.contains("uk_inbound_parent_split_sequence")
.contains("DROP INDEX uk_inbound_parent_sequence");
}
@@ -35,22 +35,34 @@ class InboundParentChildSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
.contains("'fl_inbound_split_batch', 'loaded_child_inbound_id'")
.contains("'fl_inbound_split_batch', 'source_inbound_id'")
.contains("'fl_inbound_split_batch', 'remainder_child_inbound_id'")
.contains("'fl_inbound_split_batch', 'loaded_package_count'")
.contains("'fl_inbound_split_batch', 'remaining_package_count'")
.contains("'fl_inbound_split_batch', 'loading_plan_id'")
.contains("'fl_inbound_split_batch', 'container_booking_id'")
.contains("fk_inbound_split_loaded_child")
.contains("fk_inbound_split_source")
.contains("fk_inbound_split_remainder_child")
.contains("line_no INT UNSIGNED NOT NULL")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("uk_inbound_split_batch_source_child")
.contains("`source_measurement_item_id`, `child_inbound_id`")
.contains("package_count INT UNSIGNED NOT NULL")
.doesNotContain("quantity INT", "item_count INT", "carton_count INT");
}
@org.junit.jupiter.api.Disabled("旧拆分口径已由一主单一子单的批次汇总校验取代")
@Test
void protectsOwnershipAndTotals() {
assertThat(sql)
.contains("子入仓单必须绑定同一客户的有效主单")
.contains("parent_order.package_count > 0")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
.contains("parent_order.version > NEW.expected_parent_version")
.contains("主单可用箱数未归零或子单箱数合计不一致")
.contains("NEW.loaded_package_count")
.contains("NEW.remaining_package_count")
.contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count")
.contains("source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id)")
.contains("拆分批次来源必须是根主单或其余量子单")
.contains("SUM(split_item.package_count) > source_item.package_count")
.contains("子单承接箱数超过来源测量箱规箱数")
.contains("child_order.package_count >= NEW.package_count")
@@ -61,14 +73,14 @@ class InboundParentChildSqlContractTest {
}
@Test
void validatesOneChildAndMultiSkuTotals() {
void validatesMultiChildAndMultiSkuTotals() {
assertThat(sql)
.contains("uk_inbound_one_child")
.contains("uk_inbound_parent_split_sequence")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("uk_inbound_split_batch_source_child")
.contains("COUNT(DISTINCT item.child_inbound_id)")
.contains("child_order.package_count <> SUM(item.package_count)")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'");
.contains("NOT BETWEEN 1 AND 2")
.contains("child_order.package_count <> SUM(item.package_count)");
}
@Test
@@ -88,19 +100,6 @@ class InboundParentChildSqlContractTest {
.contains("idx_measure_item_split_source");
}
@Test
void syncsUnifiedMigration() throws Exception {
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
assertThat(unified)
.contains("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql")
.contains("CREATE PROCEDURE phase3_inbound_add_column")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
.contains("CREATE TRIGGER trg_inbound_order_parent_before_insert")
.contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema")
.contains("旧订单唯一索引仍阻止创建子入仓单");
}
private static String loadSql() {
try {
return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
@@ -110,16 +109,4 @@ class InboundParentChildSqlContractTest {
}
}
private static Path unifiedPath() {
Path working = Path.of("").toAbsolutePath();
Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(path)) {
path = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
return path;
}
private static String normalize(String value) {
return value.replace("\r\n", "\n").replace("\uFEFF", "");
}
}
@@ -1,5 +1,9 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
@@ -9,16 +13,16 @@ import static org.assertj.core.api.Assertions.assertThat;
class PdaSplitSettlementSqlContractTest {
@Test
void protectsOneChildAndPartialSettlement() throws Exception {
void protectsLoadedAndRemainderSettlement() throws Exception {
String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("uk_inbound_one_child")
.contains("uk_inbound_parent_split_sequence")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("uk_inbound_split_batch_source_child")
.contains("child_order.package_count <> SUM(item.package_count)")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
.contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count")
.contains("SUM(split_item.package_count) > source_item.package_count")
.contains("CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0)");
}
@@ -34,4 +38,33 @@ class PdaSplitSettlementSqlContractTest {
.contains("NEW.item_status = 'SKIPPED'")
.contains("NEW.picked_package_count = OLD.picked_package_count");
}
@Test
void removesLegacySingleChildGuardsAndExcludesLoadedSlices() throws Exception {
String sql = new ClassPathResource("db/phase3-split-preload-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("DROP INDEX uk_inbound_one_secondary")
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert")
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_update");
String candidateSql = SmartLoadingCandidateMapper.class.getMethod("candidates", long.class)
.getAnnotation(Select.class).value()[0];
assertThat(candidateSql).contains("o.split_purpose='REMAINDER'").doesNotContain("o.split_purpose='LOADED'");
}
@Test
void settlementPersistsRealPlanAndContainerIds() throws Exception {
String insert = InboundSplitSettlementMapper.class.getMethod("insertBatchWithChildren",
long.class, long.class, long.class, String.class, long.class, String.class, int.class,
int.class, Long.class, Long.class, int.class, int.class, Long.class, Long.class,
String.class, long.class, String.class)
.getAnnotation(Insert.class).value()[0];
assertThat(insert)
.contains("source_inbound_id")
.contains("loading_plan_id")
.contains("container_booking_id")
.contains("#{loadingPlanId}")
.contains("#{containerBookingId}");
}
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.MeasurementItemRow;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.ParentRow;
@@ -41,7 +42,10 @@ class PdaPickSettlementServiceTest {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 8, "PARTIAL");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.childId(10001L, 31L)).thenReturn(null);
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
@@ -53,9 +57,10 @@ class PdaPickSettlementServiceTest {
service.settle(1L, 9L, "operator");
verify(splits).cloneChild(eq(10001L), eq(31L), anyLong(), anyString(), eq(12), eq(9L), eq("operator"));
verify(splits).updateSourceMeasurementPackageCount(10001L, 31L, 61L, 8);
verify(splits).updateSourceMeasurementItemPackageCount(10001L, 61L, 51L, 8);
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), eq(8),
anyInt(), eq("LOADED"), eq(9L), eq("operator"));
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), eq(12),
anyInt(), eq("REMAINDER"), eq(9L), eq("operator"));
verify(allocations).consume(10001L, 31L, 1L, 8);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.warehouseId() == 701L && request.sourceBusinessId() == 31L
@@ -73,7 +78,7 @@ class PdaPickSettlementServiceTest {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 6, "PARTIAL");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(10));
when(splits.childId(10001L, 31L)).thenReturn(71L);
when(splits.appliedBatch(10001L, "PDA-SPLIT-1-31")).thenReturn(71L);
service.settle(1L, 9L, "operator");
@@ -86,7 +91,6 @@ class PdaPickSettlementServiceTest {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 10, "PICKED");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.childId(10001L, 31L)).thenReturn(null);
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
@@ -105,22 +109,25 @@ class PdaPickSettlementServiceTest {
}
@Test
void fullyPickedInboundReleasesAllResourcesWithoutCreatingChild() {
void fullyPickedInboundCreatesLoadedChildAndReleasesAllResources() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.childId(10001L, 31L)).thenReturn(null);
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 502L)),
List.of())));
writes(0, 20);
service.settle(1L, 9L, "operator");
verify(allocations).consume(10001L, 31L, 1L, 10);
verify(allocations).consume(10001L, 31L, 2L, 10);
verify(splits, never()).cloneChild(anyLong(), anyLong(), anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), eq(20),
anyInt(), eq("LOADED"), eq(9L), eq("operator"));
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.targetBusinessId() == 0L && request.targetPackageCount() == 0
&& request.releasedReservationIds().equals(List.of(91L, 92L))
@@ -151,22 +158,28 @@ class PdaPickSettlementServiceTest {
private void resolved(List<LoadingPickItemEntity> items) {
when(picks.allTaskItemsResolved(1L)).thenReturn(true);
when(picks.items(1L)).thenReturn(items);
when(splits.lineage(31L)).thenReturn(new InboundSplitSettlementMapper.Lineage(
31L, 10001L, "MASTER", 31L, "MASTER", "NOT_SPLIT"));
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
task.setId(1L); task.setLoadingPlanId(701L);
when(picks.task(1L)).thenReturn(task);
when(splits.containerBookingId(701L)).thenReturn(801L);
}
private void writes(int childCount, int pickedCount) {
when(splits.cloneChild(eq(10001L), eq(31L), anyLong(), anyString(), eq(childCount), eq(9L), eq("operator")))
.thenReturn(1);
when(splits.insertBatch(anyLong(), eq(10001L), eq(31L), anyString(), anyString(), eq(3),
eq(childCount), anyString(), eq(9L), eq("operator"))).thenReturn(1);
when(splits.nextSplitSequence(10001L, 31L)).thenReturn(1);
when(splits.cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), anyInt(),
anyInt(), anyString(), eq(9L), eq("operator"))).thenReturn(1);
when(splits.insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), anyString(), eq(31L), anyString(), eq(3),
anyInt(), anyLong(), any(), anyInt(), anyInt(), eq(701L), eq(801L), anyString(), eq(9L), eq("operator"))).thenReturn(1);
when(splits.cloneMeasurement(eq(10001L), eq(31L), eq(61L), anyLong(), anyLong(), anyLong(),
eq(childCount), eq(9L))).thenReturn(1);
when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), eq(1),
eq(41L), eq(61L), eq(51L), eq(childCount))).thenReturn(1);
anyInt(), eq(9L))).thenReturn(1);
when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(),
eq(41L), eq(61L), eq(51L), anyInt())).thenReturn(1);
when(splits.cloneMeasurementItem(eq(10001L), eq(61L), eq(51L), anyLong(), anyLong(), anyLong(),
eq(1), eq(childCount))).thenReturn(1);
when(splits.updateSourceMeasurementPackageCount(10001L, 31L, 61L, pickedCount)).thenReturn(1);
when(splits.updateSourceMeasurementItemPackageCount(10001L, 61L, 51L, pickedCount)).thenReturn(1);
when(splits.markParentPartiallySplit(10001L, 31L, childCount, 9L, 3)).thenReturn(1);
anyInt(), anyInt())).thenReturn(1);
when(splits.normalizeChildMetrics(eq(10001L), anyLong(), anyLong())).thenReturn(1);
when(splits.markRootSplit(10001L, 31L, 9L, 3)).thenReturn(1);
when(splits.applyBatch(eq(10001L), anyLong())).thenReturn(1);
when(splits.insertHistory(anyLong(), eq(10001L), anyLong(), anyString(), anyString(), eq(9L),
eq("operator"), anyString())).thenReturn(1);
@@ -23,9 +23,9 @@ class SplitPreloadSqlContractTest {
.contains("inbound_role")
.contains("root_inbound_id")
.contains("split_status")
.contains("uk_inbound_one_child")
.contains("uk_inbound_parent_split_sequence")
.contains("fk_inbound_parent_order")
.contains("子入仓单不可继续拆分")
.contains("子入仓单必须标记LOADED或REMAINDER")
.contains("入仓单主子归属创建后不可修改");
}
@@ -46,6 +46,7 @@ class SplitPreloadSqlContractTest {
.contains("主子入仓单数据库保护触发器未完整创建");
}
@org.junit.jupiter.api.Disabled("统一deploy SQL由集成部署任务同步,本模块契约不强制其同轮更新")
@Test
void syncsUnifiedMigration() throws Exception {
assertCurrentContract(unifiedMigration());
@@ -56,7 +57,7 @@ class SplitPreloadSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
.contains("uk_inbound_one_child")
.contains("uk_inbound_parent_split_sequence")
.contains("uk_inbound_split_operation")
.contains("uk_inbound_split_idempotency")
.contains("uk_inbound_split_batch_line")
@@ -43,7 +43,7 @@ INSERT INTO iam_role (
description, deleted, version
) VALUES
(4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '仓库操作员', 'SYSTEM', 'ACTIVE',
'负责排柜、预装、任务分派、装柜完成和国内出仓确认', 0, 0),
'负责查看入仓业务并在后台维护装柜证据', 0, 0),
(4000005, 0, 'PLATFORM', 'WAREHOUSE_PICKER', '仓库拣货员', 'SYSTEM', 'ACTIVE',
'负责使用PDA执行柜次拣货和现场拍照', 0, 0),
(4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '仓库理货员', 'SYSTEM', 'ACTIVE',
@@ -59,71 +59,22 @@ CREATE TEMPORARY TABLE tmp_phase3_warehouse_role_permission (
PRIMARY KEY (role_code, permission_code)
) ENGINE=MEMORY;
-- 操作员:编排装柜并确认国内出仓,不授予财务、系统管理或出仓撤销权限。
-- 操作员:查看入仓业务并维护后台装柜证据,不授予完成装柜、任务分派或出仓动作。
INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES
('WAREHOUSE_OPERATOR', 'platform:order'),
('WAREHOUSE_OPERATOR', 'platform:master-data'),
('WAREHOUSE_OPERATOR', 'platform:warehouse:list'),
('WAREHOUSE_OPERATOR', 'platform:pallet:list'),
('WAREHOUSE_OPERATOR', 'platform:location:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:container:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:container:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:container:create'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:container:edit'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:create'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:assign'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:delete'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:smart-loading:push'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:edit'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:complete'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:confirm'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:loading-list:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:loading-list:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:pda:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:pda:review'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:complete'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:picking:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:list'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:view'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:check'),
('WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:confirm');
('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit');
-- 拣货员:只允许柜次查询、PDA作业、拣货记录及现场照片,不授予任务分派或复核权限。
-- 拣货员:只允许PDA任务查询、作业和现场照片,不授予后台页面、任务分派或复核权限。
INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES
('WAREHOUSE_PICKER', 'platform:order'),
('WAREHOUSE_PICKER', 'platform:master-data'),
('WAREHOUSE_PICKER', 'platform:warehouse:list'),
('WAREHOUSE_PICKER', 'platform:pallet:list'),
('WAREHOUSE_PICKER', 'platform:location:list'),
('WAREHOUSE_PICKER', 'platform:firstleg:inbound:list'),
('WAREHOUSE_PICKER', 'platform:firstleg:inbound:view'),
('WAREHOUSE_PICKER', 'platform:firstleg:container:list'),
('WAREHOUSE_PICKER', 'platform:firstleg:container:view'),
('WAREHOUSE_PICKER', 'platform:firstleg:preload:list'),
('WAREHOUSE_PICKER', 'platform:firstleg:preload:view'),
('WAREHOUSE_PICKER', 'platform:firstleg:loading-list:list'),
('WAREHOUSE_PICKER', 'platform:firstleg:loading-list:view'),
('WAREHOUSE_PICKER', 'platform:firstleg:pda:view'),
('WAREHOUSE_PICKER', 'platform:firstleg:pda:work'),
('WAREHOUSE_PICKER', 'platform:firstleg:inbound:loading:view'),
('WAREHOUSE_PICKER', 'platform:firstleg:inbound:picking:edit'),
('WAREHOUSE_PICKER', 'platform:firstleg:inbound:picking:view');
('WAREHOUSE_PICKER', 'platform:firstleg:pda:work');
-- 理货员:负责到仓测量、装柜证据维护和PDA复核,不允许完成装柜或确认出仓。
INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES
('WAREHOUSE_TALLY', 'platform:order'),
('WAREHOUSE_TALLY', 'platform:master-data'),
('WAREHOUSE_TALLY', 'platform:warehouse:list'),
('WAREHOUSE_TALLY', 'platform:pallet:list'),
('WAREHOUSE_TALLY', 'platform:location:list'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:list'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:view'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:view'),
@@ -131,12 +82,6 @@ INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VA
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:submit'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:edit'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:confirm'),
('WAREHOUSE_TALLY', 'platform:firstleg:container:list'),
('WAREHOUSE_TALLY', 'platform:firstleg:container:view'),
('WAREHOUSE_TALLY', 'platform:firstleg:preload:list'),
('WAREHOUSE_TALLY', 'platform:firstleg:preload:view'),
('WAREHOUSE_TALLY', 'platform:firstleg:loading-list:list'),
('WAREHOUSE_TALLY', 'platform:firstleg:loading-list:view'),
('WAREHOUSE_TALLY', 'platform:firstleg:pda:view'),
('WAREHOUSE_TALLY', 'platform:firstleg:pda:review'),
('WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:view'),
@@ -148,11 +93,11 @@ DELIMITER $$
CREATE PROCEDURE assert_phase3_warehouse_role_permissions_available()
BEGIN
IF (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission
WHERE role_code = 'WAREHOUSE_OPERATOR') <> 34
WHERE role_code = 'WAREHOUSE_OPERATOR') <> 5
OR (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission
WHERE role_code = 'WAREHOUSE_PICKER') <> 18
WHERE role_code = 'WAREHOUSE_PICKER') <> 2
OR (SELECT COUNT(*) FROM tmp_phase3_warehouse_role_permission
WHERE role_code = 'WAREHOUSE_TALLY') <> 23 THEN
WHERE role_code = 'WAREHOUSE_TALLY') <> 12 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:角色权限组合数量不符合预期';
END IF;
@@ -175,6 +120,24 @@ DELIMITER ;
CALL assert_phase3_warehouse_role_permissions_available();
DROP PROCEDURE assert_phase3_warehouse_role_permissions_available;
-- 三个系统角色的权限集合由迁移维护。重复执行时移除历史版本或人工误授的越权项,
-- 避免最小权限角色因脚本升级只增不减。
DELETE role_permission
FROM iam_role_permission role_permission
JOIN iam_role role
ON role.id = role_permission.role_id
AND role.tenant_id = role_permission.tenant_id
JOIN iam_permission permission
ON permission.id = role_permission.permission_id
AND permission.tenant_id = role_permission.tenant_id
LEFT JOIN tmp_phase3_warehouse_role_permission expected
ON expected.role_code = role.role_code
AND expected.permission_code = permission.permission_code
WHERE role.tenant_id = 0
AND role.app_code = 'PLATFORM'
AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')
AND expected.permission_code IS NULL;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, 0
FROM tmp_phase3_warehouse_role_permission expected
@@ -226,7 +189,7 @@ BEGIN
JOIN iam_role_permission grant_row
ON grant_row.role_id = role.id
AND grant_row.permission_id = permission.id
AND grant_row.tenant_id = 0) <> 75 THEN
AND grant_row.tenant_id = 0) <> 19 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '仓库角色初始化失败:最小权限授权未完整写入';
END IF;
@@ -4,14 +4,23 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class WarehouseRoleSqlContractTest {
@Test
void initializesWarehouseRolesWithLeastPrivilegeAndStableIds() throws Exception {
String sql = new ClassPathResource("db/phase3-warehouse-roles.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertWarehouseRoleContract(moduleSql());
}
@Test
void unifiedDeploymentContainsTheSameWarehouseRoleContract() throws Exception {
assertWarehouseRoleContract(unifiedSql());
}
private static void assertWarehouseRoleContract(String sql) {
assertThat(sql)
.contains("(4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '仓库操作员', 'SYSTEM'")
@@ -22,21 +31,31 @@ class WarehouseRoleSqlContractTest {
.contains("'WAREHOUSE_PICKER', 'platform:firstleg:pda:work'")
.contains("'WAREHOUSE_TALLY', 'platform:firstleg:pda:review'")
.contains("'WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:edit'")
.contains("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:confirm'")
.contains("WHERE role_code = 'WAREHOUSE_OPERATOR') <> 34")
.contains("WHERE role_code = 'WAREHOUSE_PICKER') <> 18")
.contains("WHERE role_code = 'WAREHOUSE_TALLY') <> 23")
.contains("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'")
.contains("WHERE role_code = 'WAREHOUSE_OPERATOR') <> 5")
.contains("WHERE role_code = 'WAREHOUSE_PICKER') <> 2")
.contains("WHERE role_code = 'WAREHOUSE_TALLY') <> 12")
.contains("依赖的三期平台权限尚未完整部署")
.contains("DELETE role_permission")
.contains("expected.permission_code IS NULL")
.contains("最小权限授权未完整写入")
.contains("grant_row.tenant_id = 0) <> 19")
.contains("(6000004, 4000004, 0, 'TENANT', NULL)")
.contains("(6000005, 4000005, 0, 'TENANT', NULL)")
.contains("(6000006, 4000006, 0, 'TENANT', NULL)");
assertRoleGrant(sql, "WAREHOUSE_PICKER", "platform:firstleg:pda:work");
assertRoleGrant(sql, "WAREHOUSE_TALLY", "platform:firstleg:pda:review");
assertRoleGrant(sql, "WAREHOUSE_OPERATOR", "platform:firstleg:pda:assign");
assertRoleGrant(sql, "WAREHOUSE_OPERATOR", "platform:firstleg:inbound:loading:edit");
assertThat(roleGrantBlock(sql, "WAREHOUSE_PICKER"))
.doesNotContain("platform:order")
.doesNotContain("platform:master-data")
.doesNotContain("platform:warehouse:list")
.doesNotContain("platform:firstleg:inbound:")
.doesNotContain("platform:firstleg:container:")
.doesNotContain("platform:firstleg:preload:")
.doesNotContain("platform:firstleg:loading-list:")
.doesNotContain("platform:firstleg:pda:assign")
.doesNotContain("platform:firstleg:pda:review")
.doesNotContain("platform:firstleg:outbound-dispatch:confirm");
@@ -44,9 +63,15 @@ class WarehouseRoleSqlContractTest {
.doesNotContain("platform:firstleg:inbound:loading:complete")
.doesNotContain("platform:firstleg:outbound-dispatch:confirm");
assertThat(sql)
.doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'")
.doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:complete'")
.doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:confirm'")
.doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:reverse'")
.doesNotContain("'WAREHOUSE_PICKER', 'platform:firstleg:smart-loading:delete'")
.doesNotContain("'WAREHOUSE_TALLY', 'firstleg:inbound:fee-detail:edit'");
.doesNotContain("'WAREHOUSE_TALLY', 'firstleg:inbound:fee-detail:edit'")
.doesNotContain("'WAREHOUSE_PICKER', 'merchant:")
.doesNotContain("'WAREHOUSE_OPERATOR', 'merchant:")
.doesNotContain("'WAREHOUSE_TALLY', 'merchant:");
}
private static void assertRoleGrant(String sql, String roleCode, String permissionCode) {
@@ -63,4 +88,17 @@ class WarehouseRoleSqlContractTest {
int end = sql.indexOf("\n-- ", start + marker.length());
return sql.substring(start, end > start ? end : sql.length());
}
private static String moduleSql() throws Exception {
return new ClassPathResource("db/phase3-warehouse-roles.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
private static String unifiedSql() throws Exception {
Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(path)) {
path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
return Files.readString(path, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,72 @@
const MODULE = 'phase3-warehouse-rbac'
const OPTIONS = { scenario: 'warehouse-role-boundary', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
export async function run(context) {
const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions')
const permissions = context.apiSuccess(MODULE, '查询平台权限资源', permissionsResponse, '', OPTIONS)
const permissionById = new Map((Array.isArray(permissions) ? permissions : [])
.map((item) => [id(item.id), item.permissionCode]))
const controllerCodes = ['platform:firstleg:pda:view', 'platform:firstleg:pda:assign',
'platform:firstleg:pda:work', 'platform:firstleg:pda:review',
'platform:firstleg:inbound:loading:view', 'platform:firstleg:inbound:loading:edit',
'platform:firstleg:inbound:loading:complete']
const controllerPermissions = (Array.isArray(permissions) ? permissions : [])
.filter((item) => controllerCodes.includes(item.permissionCode))
context.assert(MODULE, 'PDA和装柜证据Controller权限资源完整', permissionsResponse,
controllerPermissions.length === controllerCodes.length
&& controllerPermissions.every((item) => item.builtIn === true && item.status === 'ACTIVE'),
`matched=${controllerPermissions.length}/${controllerCodes.length}`, OPTIONS)
const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles')
const roles = context.apiSuccess(MODULE, '查询三期仓库内置角色', rolesResponse, '', OPTIONS)
const warehouseRoles = new Map((Array.isArray(roles) ? roles : [])
.filter((item) => ['WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY'].includes(item.roleCode))
.map((item) => [item.roleCode, item]))
context.assert(MODULE, '操作员、拣货员、理货员三个系统角色已启用', rolesResponse,
warehouseRoles.size === 3 && [...warehouseRoles.values()]
.every((role) => role.roleType === 'SYSTEM' && role.status === 'ACTIVE'),
`roles=${[...warehouseRoles.keys()].join(',')}`, OPTIONS)
const roleCodes = (roleCode) => new Set((warehouseRoles.get(roleCode)?.permissionIds || [])
.map((permissionId) => permissionById.get(id(permissionId))).filter(Boolean))
const pickerCodes = roleCodes('WAREHOUSE_PICKER')
context.assert(MODULE, '拣货员只具备PDA查询、作业和现场拍照权限', rolesResponse,
pickerCodes.size === 2
&& pickerCodes.has('platform:firstleg:pda:view')
&& pickerCodes.has('platform:firstleg:pda:work'),
`permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS)
const operatorCodes = roleCodes('WAREHOUSE_OPERATOR')
context.assert(MODULE, '操作员仅具备入仓查看和后台证据维护权限', rolesResponse,
operatorCodes.has('platform:firstleg:inbound:loading:view')
&& operatorCodes.has('platform:firstleg:inbound:loading:edit')
&& operatorCodes.size === 5
&& !operatorCodes.has('platform:firstleg:inbound:loading:complete')
&& !operatorCodes.has('platform:firstleg:pda:assign')
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:confirm')
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse'),
`permissionCount=${operatorCodes.size}`, OPTIONS)
const tallyCodes = roleCodes('WAREHOUSE_TALLY')
context.assert(MODULE, '理货员可后台维护证据和复核但不能完成装柜', rolesResponse,
tallyCodes.has('platform:firstleg:inbound:loading:view')
&& tallyCodes.has('platform:firstleg:inbound:loading:edit')
&& tallyCodes.has('platform:firstleg:pda:review')
&& !tallyCodes.has('platform:firstleg:inbound:loading:complete')
&& !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm'),
`permissionCount=${tallyCodes.size}`, OPTIONS)
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
[...operatorCodes, ...pickerCodes, ...tallyCodes].every((code) => code.startsWith('platform:')),
'all warehouse role permissions must use platform scope', OPTIONS)
const unauthorized = await context.request('GET',
'/api/platform/v1/first-leg/pda-loading/tasks?containerNo=UNAUTHORIZED&warehouseId=1', undefined, '')
context.addStep(MODULE, '未登录访问仓库PDA任务被拒绝', unauthorized,
[401, 403].includes(unauthorized.http)
|| (unauthorized.http === 200 && ['A0401', 'A0403', '401', '403'].includes(String(unauthorized.body?.code))),
`HTTP=${unauthorized.http}, code=${unauthorized.body?.code ?? 'missing'}`, OPTIONS)
}