diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index b3d451168..21546d613 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7635,6 +7635,41 @@ BEGIN END$$ DELIMITER ; +CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence ( + id BIGINT UNSIGNED NOT NULL, + container_booking_id BIGINT UNSIGNED NOT NULL, + source_task_id BIGINT UNSIGNED NOT NULL, + evidence_type VARCHAR(32) NOT NULL, + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + seal_no VARCHAR(64) NULL, + remark VARCHAR(1000) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + active_flag TINYINT UNSIGNED NULL DEFAULT 1, + operation_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + void_reason VARCHAR(500) NULL, + voided_by BIGINT UNSIGNED NULL, + voided_by_name VARCHAR(128) NULL, + voided_at DATETIME(3) NULL, + version INT NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(128) NULL, + updated_by BIGINT UNSIGNED NOT NULL, + updated_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag), + UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key), + KEY idx_cabinet_evidence_container (container_booking_id,status), + KEY idx_cabinet_evidence_seal (seal_no,active_flag), + CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id), + CONSTRAINT fk_cabinet_evidence_task FOREIGN KEY (source_task_id) REFERENCES fl_loading_pick_task(id), + CONSTRAINT chk_cabinet_evidence_type CHECK (evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL')), + CONSTRAINT chk_cabinet_evidence_status CHECK (status IN ('ACTIVE','VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜次物理装柜证据;task仅记录来源审计'; + CREATE TABLE IF NOT EXISTS fl_loading_evidence ( id BIGINT UNSIGNED NOT NULL COMMENT '装柜证据记录ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java index 7568c3de9..bd3410cf8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java @@ -6,20 +6,35 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; -/** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */ +/** Validate that a loading evidence file is owned by the physical cabinet. */ @FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient", url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence") public interface LoadingEvidenceFileClient { - @GetMapping("/{tenantId}/{inboundId}/{fileId}") + @PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, @PathVariable long containerBookingId, + @RequestParam String evidenceType, @RequestPart("file") MultipartFile file); + + @GetMapping("/{containerBookingId}/{fileId}") ApiResponse validate(@RequestHeader("X-Internal-Service-Token") String token, - @PathVariable long tenantId, @PathVariable long inboundId, + @PathVariable long containerBookingId, + @RequestParam String evidenceType, @PathVariable long fileId); - @GetMapping("/{tenantId}/{inboundId}/{fileId}/content") + @GetMapping("/{containerBookingId}/{fileId}/content") ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, - @PathVariable long tenantId, @PathVariable long inboundId, + @PathVariable long containerBookingId, + @RequestParam String evidenceType, @PathVariable long fileId); - record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {} + record FileMetadata(Long fileId, String ownerScope, Long tenantId, String businessType, + String businessId, String fileName, String contentType) {} + record UploadResult(Long fileId, String ownerScope, Long tenantId, String businessType, + String businessId, String fileName, String contentType, Long sizeBytes) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 269c7c457..60237777f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -13,10 +13,17 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask; +import com.baiyue.firstleg.dto.LoadingEvidenceDtos.EvidenceList; +import com.baiyue.firstleg.dto.LoadingEvidenceDtos.VoidRequest; import java.util.List; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingPickService; +import com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService; +import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; +import jakarta.validation.constraints.PositiveOrZero; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -41,6 +48,8 @@ import org.springframework.web.bind.annotation.RestController; public class PlatformLoadingPdaController { @Resource private LoadingPickService service; + @Resource + private CabinetLoadingEvidenceService evidenceService; @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") @@ -70,6 +79,50 @@ public class PlatformLoadingPdaController { return ApiResponse.success(service.task(taskId, warehouseId)); } + @PostMapping(value = "/tasks/{taskId}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse upsertEvidence(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @RequestHeader("Idempotency-Key") @NotBlank @Size(max = 128) String idempotencyKey, + @RequestParam @Positive long warehouseId, + @RequestParam @PositiveOrZero int expectedVersion, + @RequestParam @NotBlank @Size(max = 32) String evidenceType, + @RequestParam(required = false) @Size(max = 64) String sealNo, + @RequestParam(required = false) @Size(max = 1000) String remark, + @RequestPart("file") MultipartFile file) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(evidenceService.upload(taskId, warehouseId, idempotencyKey, + expectedVersion, evidenceType, sealNo, remark, file, user.userId(), user.username())); + } + + @GetMapping("/tasks/{taskId}/evidence") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse evidence(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(evidenceService.list(taskId, warehouseId, user.userId())); + } + + @GetMapping("/tasks/{taskId}/evidence/{evidenceId}/content") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ResponseEntity evidenceContent(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, @PathVariable @Positive long evidenceId, + @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + return evidenceService.content(taskId, warehouseId, evidenceId, user.userId()); + } + + @PostMapping("/tasks/{taskId}/evidence/{evidenceId}/void") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse voidEvidence(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, @PathVariable @Positive long evidenceId, + @Valid @RequestBody VoidRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(evidenceService.voidEvidence(taskId, evidenceId, request, + user.userId(), user.username())); + } + @GetMapping("/tasks/{taskId}/inventory-snapshot") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse inventorySnapshot(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingEvidenceDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingEvidenceDtos.java new file mode 100644 index 000000000..42329d88f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingEvidenceDtos.java @@ -0,0 +1,35 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +/** PDA task-scoped cabinet evidence contract. */ +public final class LoadingEvidenceDtos { + private LoadingEvidenceDtos() {} + + public record UpsertRequest(@NotNull @Positive Long warehouseId, + @NotNull @PositiveOrZero Integer expectedVersion, + @NotBlank @Size(max = 32) String evidenceType, + @NotNull @Positive Long fileId, + @Size(max = 64) String sealNo, + @Size(max = 1000) String remark) {} + + public record VoidRequest(@NotNull @Positive Long warehouseId, + @NotNull @PositiveOrZero Integer expectedVersion, + @NotBlank @Size(max = 500) String reason) {} + + public record Evidence(String evidenceId, String evidenceType, String fileId, String fileName, + String sealNo, String remark, String status, Integer version, + LocalDateTime createdAt, LocalDateTime updatedAt) {} + + public record EvidenceList(String taskId, String containerBookingId, String cabinetNo, + String containerNo, List evidences) { + public EvidenceList { evidences = evidences == null ? List.of() : List.copyOf(evidences); } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java index 9ecc38874..dc61e558f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java @@ -4,10 +4,7 @@ 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.CompleteLoadingRequest; -import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; -import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest; -import com.baiyue.firstleg.loading.dto.LoadingResponses.EvidenceListResponse; import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse; import com.baiyue.firstleg.loading.service.LoadingDeliveryService; @@ -18,7 +15,6 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; /** 平台端装柜证据和国内装柜完成操作。 */ @@ -28,79 +24,6 @@ import org.springframework.web.bind.annotation.*; public class PlatformLoadingDeliveryController { @Resource private LoadingDeliveryService service; - @Operation(summary = "新增或修改装柜证据") - @PostMapping("/{inboundId}/loading/evidence") - @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')") - @BusinessAudit(event = "INBOUND_LOADING_EVIDENCE_UPSERT", resourceType = "INBOUND", resourceId = "#inboundId", description = "维护装柜证据") - public ApiResponse evidence( - @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)); - } - - /** PDA现场拍照专用入口。与后台人工上传使用不同权限,避免拣货员获得后台证据维护入口。 */ - @Operation(summary = "PDA追加装柜证据") - @PostMapping("/{inboundId}/loading/evidence/pda") - @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ApiResponse 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 pdaEvidence( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) { - FirstlegScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.evidence(tenantId, inboundId)); - } - - @Operation(summary = "PDA查看装柜证据照片") - @GetMapping("/{inboundId}/loading/evidence/{evidenceId}/content/pda") - @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ResponseEntity pdaEvidenceContent( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, - @PathVariable long evidenceId) { - FirstlegScopeGuard.platform(user, tenantId); - return service.evidenceContent(tenantId, inboundId, evidenceId); - } - - @Operation(summary = "PDA作废错误装柜证据") - @PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda") - @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ApiResponse 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')") - public ApiResponse evidence( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) { - FirstlegScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.evidence(tenantId, inboundId)); - } - - @Operation(summary = "作废装柜证据并保留审计记录") - @PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void") - @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')") - @BusinessAudit(event = "INBOUND_LOADING_EVIDENCE_VOID", resourceType = "INBOUND", resourceId = "#inboundId", description = "作废装柜证据") - public ApiResponse voidEvidence( - @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 = "完成装柜") @PostMapping("/{inboundId}/loading/complete") @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:complete')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java index 6ccc36912..2f863d091 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java @@ -9,18 +9,6 @@ import jakarta.validation.constraints.Size; public final class LoadingRequests { private LoadingRequests() {} - public record UpsertEvidenceRequest( - @NotNull @PositiveOrZero Integer expectedVersion, - @NotBlank @Size(max = 32) String evidenceType, - @NotNull @Positive Long fileId, - @Size(max = 255) String fileName, - @Size(max = 64) String sealNo, - @Size(max = 1000) String remark) {} - - public record VoidEvidenceRequest( - @NotNull @PositiveOrZero Integer expectedVersion, - @NotBlank @Size(max = 500) String reason) {} - public record CompleteLoadingRequest( @NotNull @PositiveOrZero Integer expectedVersion, @Size(max = 1000) String remark) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java index c725b4205..ba288955b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java @@ -1,20 +1,9 @@ package com.baiyue.firstleg.loading.dto; -import java.time.LocalDateTime; -import java.util.List; - public final class LoadingResponses { private LoadingResponses() {} - public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType, - Long fileId, String fileName, String previewUrl, String sealNo, String remark, - Integer version, String status, String voidReason, - Long voidedBy, String voidedByName, LocalDateTime voidedAt, - LocalDateTime createdAt, LocalDateTime updatedAt) {} - - public record EvidenceListResponse(Long inboundId, List evidences) {} - - public record LoadingActionResponse(String mainStatus, EvidenceListResponse evidences) {} + public record LoadingActionResponse(String mainStatus) {} public record PickingResponse(Long id, Long inboundId, String status, Integer version, Integer expectedCount, Integer pickedCount, String remark) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/CabinetLoadingEvidenceEntity.java similarity index 77% rename from firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java rename to firstleg/src/main/java/com/baiyue/firstleg/loading/entity/CabinetLoadingEvidenceEntity.java index b09c4d87c..cf9b3336f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/LoadingEvidenceEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/entity/CabinetLoadingEvidenceEntity.java @@ -10,19 +10,21 @@ import java.time.LocalDateTime; @Getter @Setter -@TableName("fl_loading_evidence") -public class LoadingEvidenceEntity { +@TableName("fl_loading_cabinet_evidence") +public class CabinetLoadingEvidenceEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; - private Long tenantId; - private Long inboundId; + private Long containerBookingId; + private Long sourceTaskId; private String evidenceType; private Long fileId; private String fileName; private String sealNo; private String remark; - /** ACTIVE records count towards the loading gate; VOID records remain for audit only. */ private String status; + private Integer activeFlag; + private String operationKey; + private String requestHash; private String voidReason; private Long voidedBy; private String voidedByName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/CabinetLoadingEvidenceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/CabinetLoadingEvidenceMapper.java new file mode 100644 index 000000000..688a912b8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/CabinetLoadingEvidenceMapper.java @@ -0,0 +1,52 @@ +package com.baiyue.firstleg.loading.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; +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.util.List; + +@Mapper +public interface CabinetLoadingEvidenceMapper extends BaseMapper { + @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id") + List byContainer(@Param("containerBookingId") long containerBookingId); + + @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 LIMIT 1 FOR UPDATE") + CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId, + @Param("type") String type); + + @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE source_task_id=#{taskId} AND operation_key=#{key} LIMIT 1") + CabinetLoadingEvidenceEntity byOperation(@Param("taskId") long taskId, @Param("key") String key); + + @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE evidence_type='SEAL' AND seal_no=#{sealNo} AND active_flag=1 LIMIT 1") + CabinetLoadingEvidenceEntity activeSeal(@Param("sealNo") String sealNo); + + @Select(""" + SELECT plan.container_booking_id + FROM fl_loading_plan_item item + JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id + WHERE item.account_id=#{tenantId} AND item.inbound_id=#{inboundId} AND item.deleted=0 + AND plan.plan_status IN ('EDITING','LOADING','COMPLETED') + ORDER BY CASE plan.plan_status WHEN 'LOADING' THEN 0 WHEN 'EDITING' THEN 1 ELSE 2 END,item.id DESC + LIMIT 1 + """) + Long containerBookingId(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Select(""" + SELECT evidence.* FROM fl_loading_cabinet_evidence evidence + JOIN fl_loading_plan plan ON plan.container_booking_id=evidence.container_booking_id + JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0 + WHERE item.account_id=#{tenantId} AND item.inbound_id=#{inboundId} + ORDER BY evidence.evidence_type,evidence.id + """) + List byInbound(@Param("tenantId") long tenantId, + @Param("inboundId") long inboundId); + + @Update("UPDATE fl_loading_cabinet_evidence SET status='VOID',active_flag=NULL,void_reason=#{reason},voided_by=#{operatorId},voided_by_name=#{operatorName},voided_at=CURRENT_TIMESTAMP(3),updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND container_booking_id=#{containerBookingId} AND active_flag=1 AND version=#{version}") + int voidActive(@Param("id") long id, @Param("containerBookingId") long containerBookingId, + @Param("version") int version, @Param("reason") String reason, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java deleted file mode 100644 index bf5747969..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingEvidenceMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.baiyue.firstleg.loading.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Update; - -@Mapper -public interface LoadingEvidenceMapper extends BaseMapper { - @Update("UPDATE fl_loading_evidence SET status='VOID', void_reason=#{reason}, voided_by=#{operatorId}, " - + "voided_by_name=#{operatorName}, voided_at=CURRENT_TIMESTAMP(3), updated_by=#{operatorId}, " - + "updated_by_name=#{operatorName}, version=version+1 " - + "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND id=#{evidenceId} " - + "AND status='ACTIVE' AND version=#{expectedVersion}") - int voidEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("evidenceId") long evidenceId, @Param("reason") String reason, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, - @Param("expectedVersion") int expectedVersion); -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index 9ffd2e52b..186b04a85 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -2,9 +2,7 @@ package com.baiyue.firstleg.loading.repository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; -import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper; import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; @@ -17,7 +15,6 @@ import java.util.List; @Repository public class LoadingRepository { @Resource private LoadingInboundMapper inbounds; - @Resource private LoadingEvidenceMapper evidences; @Resource private LoadingOperationMapper operations; @Resource private InboundLoadingPickTaskMapper picks; @@ -46,39 +43,6 @@ public class LoadingRepository { return inbounds.aggregateRootLoaded(tenantId, rootInboundId, operatorId); } - public List listEvidence(long tenantId, long inboundId) { - return evidences.selectList(new LambdaQueryWrapper() - .eq(LoadingEvidenceEntity::getTenantId, tenantId) - .eq(LoadingEvidenceEntity::getInboundId, inboundId) - .orderByAsc(LoadingEvidenceEntity::getEvidenceType)); - } - - public LoadingEvidenceEntity findEvidenceBySealNo(long tenantId, String sealNo) { - return evidences.selectOne(new LambdaQueryWrapper() - .eq(LoadingEvidenceEntity::getTenantId, tenantId) - .eq(LoadingEvidenceEntity::getEvidenceType, "SEAL") - .eq(LoadingEvidenceEntity::getStatus, "ACTIVE") - .eq(LoadingEvidenceEntity::getSealNo, sealNo)); - } - - public LoadingEvidenceEntity findActiveEvidenceByFile(long tenantId, long inboundId, - String evidenceType, long fileId) { - return evidences.selectOne(new LambdaQueryWrapper() - .eq(LoadingEvidenceEntity::getTenantId, tenantId) - .eq(LoadingEvidenceEntity::getInboundId, inboundId) - .eq(LoadingEvidenceEntity::getEvidenceType, evidenceType) - .eq(LoadingEvidenceEntity::getFileId, fileId) - .eq(LoadingEvidenceEntity::getStatus, "ACTIVE") - .last("LIMIT 1")); - } - - public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); } - - public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason, - long operatorId, String operatorName, int expectedVersion) { - return evidences.voidEvidence(tenantId, inboundId, evidenceId, reason, operatorId, operatorName, expectedVersion); - } - public LoadingOperationEntity findOperation(long tenantId, long inboundId, long operatorId, String operation, String key) { return operations.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java new file mode 100644 index 000000000..8aa2c2931 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java @@ -0,0 +1,191 @@ +package com.baiyue.firstleg.loading.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.LoadingEvidenceFileClient; +import com.baiyue.firstleg.dto.LoadingEvidenceDtos.Evidence; +import com.baiyue.firstleg.dto.LoadingEvidenceDtos.EvidenceList; +import com.baiyue.firstleg.dto.LoadingEvidenceDtos.UpsertRequest; +import com.baiyue.firstleg.dto.LoadingEvidenceDtos.VoidRequest; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.loading.domain.LoadingEvidenceType; +import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; +import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** Cabinet-owned evidence; taskId is only the audited command scope. */ +@Service +public class CabinetLoadingEvidenceService { + @Resource private CabinetLoadingEvidenceMapper mapper; + @Resource private LoadingPickRepository loadingPicks; + @Resource private LoadingEvidenceFileClient files; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + + @Transactional + public EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, String operatorName) { + Scope scope = scope(taskId, request.warehouseId(), operatorId, true); + requireKey(key); + String requestHash = hash(request); + CabinetLoadingEvidenceEntity replay = mapper.byOperation(taskId, key); + if (replay != null) { + if (!Objects.equals(requestHash, replay.getRequestHash())) throw conflict("相同幂等键的请求内容不一致"); + return view(scope); + } + String type = request.evidenceType().trim(); + if (!LoadingEvidenceType.VALUES.contains(type)) throw bad("不支持的装柜证据类型"); + String sealNo = LoadingEvidenceType.SEAL.equals(type) ? normalizedSeal(request.sealNo()) : null; + CabinetLoadingEvidenceEntity duplicateSeal = sealNo == null ? null : mapper.activeSeal(sealNo); + if (duplicateSeal != null && !Objects.equals(duplicateSeal.getContainerBookingId(), scope.containerBookingId())) { + throw conflict("锁码已用于其他柜次"); + } + var file = validateFile(scope.containerBookingId(), request.fileId(), type); + CabinetLoadingEvidenceEntity current = mapper.activeForUpdate(scope.containerBookingId(), type); + int expected = request.expectedVersion(); + if (current == null && expected != 0) throw conflict("装柜证据版本已变化,请刷新后重试"); + if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试"); + if (current != null && Objects.equals(current.getFileId(), request.fileId()) + && Objects.equals(current.getSealNo(), sealNo)) return view(scope); + if (current != null && mapper.voidActive(current.getId(), scope.containerBookingId(), current.getVersion(), + "REPLACED", operatorId, operatorName) != 1) throw conflict("装柜证据版本已变化,请刷新后重试"); + CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity(); + value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId); + value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName()); + value.setSealNo(sealNo); value.setRemark(request.remark()); value.setStatus("ACTIVE"); value.setActiveFlag(1); + value.setOperationKey(key); value.setRequestHash(requestHash); value.setVersion(0); + value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); + try { mapper.insert(value); } catch (DuplicateKeyException exception) { + CabinetLoadingEvidenceEntity concurrent = mapper.byOperation(taskId, key); + if (concurrent != null && Objects.equals(concurrent.getRequestHash(), requestHash)) return view(scope); + throw conflict("装柜证据发生并发变更,请刷新后重试"); + } + return view(scope); + } + + public EvidenceList upload(long taskId, long warehouseId, String key, int expectedVersion, + String evidenceType, String sealNo, String remark, MultipartFile multipart, + long operatorId, String operatorName) { + Scope scope = scope(taskId, warehouseId, operatorId, true); + try { + var response = files.upload(internalToken, operatorId, scope.containerBookingId(), evidenceType, multipart); + var uploaded = response == null ? null : response.data(); + if (uploaded == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "装柜证据文件上传失败"); + return upsert(taskId, key, new UpsertRequest(warehouseId, expectedVersion, evidenceType, + uploaded.fileId(), sealNo, remark), operatorId, operatorName); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + + public EvidenceList list(long taskId, long warehouseId, long operatorId) { + return view(scope(taskId, warehouseId, operatorId, false)); + } + + public ResponseEntity content(long taskId, long warehouseId, long evidenceId, long operatorId) { + Scope scope = scope(taskId, warehouseId, operatorId, false); + CabinetLoadingEvidenceEntity value = mapper.byContainer(scope.containerBookingId()).stream() + .filter(item -> Objects.equals(item.getId(), evidenceId) && "ACTIVE".equals(item.getStatus())) + .findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在")); + try { return files.download(internalToken, scope.containerBookingId(), value.getEvidenceType(), value.getFileId()); } + catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); } + } + + @Transactional + public EvidenceList voidEvidence(long taskId, long evidenceId, VoidRequest request, + long operatorId, String operatorName) { + Scope scope = scope(taskId, request.warehouseId(), operatorId, true); + CabinetLoadingEvidenceEntity value = mapper.byContainer(scope.containerBookingId()).stream() + .filter(item -> Objects.equals(item.getId(), evidenceId)).findFirst() + .orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在")); + if (!"ACTIVE".equals(value.getStatus())) throw conflict("装柜证据已经作废"); + if (!Objects.equals(value.getVersion(), request.expectedVersion()) || mapper.voidActive(value.getId(), + scope.containerBookingId(), request.expectedVersion(), request.reason().trim(), operatorId, operatorName) != 1) { + throw conflict("装柜证据版本已变化,请刷新后重试"); + } + return view(scope); + } + + public void requireComplete(long containerBookingId) { + Set active = mapper.byContainer(containerBookingId).stream() + .filter(value -> "ACTIVE".equals(value.getStatus())) + .map(CabinetLoadingEvidenceEntity::getEvidenceType).collect(Collectors.toSet()); + if (!active.containsAll(LoadingEvidenceType.VALUES)) throw conflict("柜次装柜证据未完成,请上传四步照片和带锁码照"); + } + + public long requireContainerBookingId(long tenantId, long inboundId) { + Long value = mapper.containerBookingId(tenantId, inboundId); + if (value == null) throw conflict("入仓单未关联有效柜次,不能完成装柜"); + return value; + } + + private EvidenceList view(Scope scope) { + List values = mapper.byContainer(scope.containerBookingId()).stream().map(value -> new Evidence( + id(value.getId()), value.getEvidenceType(), id(value.getFileId()), value.getFileName(), value.getSealNo(), + value.getRemark(), value.getStatus(), value.getVersion(), value.getCreatedAt(), value.getUpdatedAt())).toList(); + return new EvidenceList(id(scope.task().getId()), id(scope.containerBookingId()), scope.task().getCabinetNo(), + scope.task().getContainerNo(), values); + } + + private Scope scope(long taskId, long warehouseId, long operatorId, boolean mutable) { + LoadingPickTaskEntity task = loadingPicks.task(taskId); + var booking = task == null ? null : loadingPicks.activeBookingByTask(taskId); + if (task == null || task.getContainerBookingId() == null || booking == null + || !Objects.equals(task.getContainerBookingId(), booking.getId())) { + throw new BizException(ResultCode.NOT_FOUND, "PDA装柜任务不存在"); + } + if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw new BizException(ResultCode.FORBIDDEN, "无权访问其他仓库的PDA任务"); + var assignment = loadingPicks.assignment(taskId); + if (assignment == null || !Objects.equals(assignment.getAssigneeId(), operatorId)) { + throw new BizException(ResultCode.FORBIDDEN, "PDA任务未由当前操作人领取"); + } + if (mutable && !"LOADING".equals(task.getTaskStatus())) throw conflict("PDA任务不在装柜作业中,证据不可修改"); + return new Scope(task, task.getContainerBookingId()); + } + + private LoadingEvidenceFileClient.FileMetadata validateFile(long containerBookingId, long fileId, String type) { + try { + var response = files.validate(internalToken, containerBookingId, type, fileId); + var value = response == null ? null : response.data(); + if (value == null || !"PLATFORM".equals(value.ownerScope()) || !Objects.equals(value.tenantId(), 0L) + || !Objects.equals(value.businessId(), Long.toString(containerBookingId)) + || !Objects.equals(value.businessType(), "LOADING_" + type)) throw bad("装柜证据文件归属或类型不匹配"); + return value; + } catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); } + } + + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest( + objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } + catch (JsonProcessingException | NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private static String normalizedSeal(String value) { + if (!StringUtils.hasText(value)) throw bad("铅封证据必须填写锁码"); + return value.trim(); + } + private static void requireKey(String key) { if (!StringUtils.hasText(key) || key.length() > 128) throw bad("Idempotency-Key不能为空且长度不能超过128"); } + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } + private static String id(Long value) { return value == null ? null : value.toString(); } + private record Scope(LoadingPickTaskEntity task, long containerBookingId) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 30610cb9a..1222debdf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -2,13 +2,10 @@ package com.baiyue.firstleg.loading.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.client.LoadingEvidenceFileClient; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; -import com.baiyue.firstleg.loading.domain.LoadingEvidenceType; import com.baiyue.firstleg.loading.dto.LoadingRequests.*; import com.baiyue.firstleg.loading.dto.LoadingResponses.*; -import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity; import com.baiyue.firstleg.loading.domain.PickingStatus; @@ -20,11 +17,7 @@ import com.baiyue.firstleg.loading.repository.LoadingRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import feign.FeignException; import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -33,17 +26,12 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Set; -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"; @Resource private LoadingRepository repository; @@ -51,79 +39,16 @@ public class LoadingDeliveryService { @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private LoadingCompletionGateService completionGate; @Resource private InboundStatusHistoryMapper histories; - @Resource private LoadingEvidenceFileClient files; + @Resource private CabinetLoadingEvidenceService cabinetEvidenceService; @Resource private ObjectMapper objectMapper; - @Value("${baiyue.security.internal-service-token:}") private String internalToken; - - @Transactional - public LoadingActionResponse upsertEvidence(long tenantId, long inboundId, long operatorId, - String operatorName, String key, UpsertEvidenceRequest request) { - String hash = hash(request); - LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, hash); - if (replay != null) return replay; - requireMutableLoadingStage(tenantId, inboundId); - if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) { - throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型"); - } - if (request.fileId() == null) throw new BizException(ResultCode.BAD_REQUEST, "装柜证据必须先上传真实文件"); - if (LoadingEvidenceType.SEAL.equals(request.evidenceType()) && !StringUtils.hasText(request.sealNo())) { - throw new BizException(ResultCode.BAD_REQUEST, "铅封证据必须填写锁码"); - } - if (LoadingEvidenceType.SEAL.equals(request.evidenceType())) { - LoadingEvidenceEntity duplicate = repository.findEvidenceBySealNo(tenantId, request.sealNo().trim()); - if (duplicate != null && !Long.valueOf(inboundId).equals(duplicate.getInboundId())) { - throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务"); - } - } - LoadingEvidenceEntity existingFile = repository.findActiveEvidenceByFile( - tenantId, inboundId, request.evidenceType(), request.fileId()); - if (existingFile != null) { - String requestedSealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) - ? request.sealNo().trim() : null; - if (!Objects.equals(existingFile.getSealNo(), requestedSealNo)) { - throw new BizException(ResultCode.CONFLICT, "该带锁码照已绑定其他锁码,请先作废后重新拍摄"); - } - LoadingActionResponse result = new LoadingActionResponse( - requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)); - return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result); - } - var file = validateFile(tenantId, inboundId, request.fileId(), request.evidenceType()); - String fileName = file.fileName(); - String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null; - try { - // Evidence is append-only by type. A new photo never replaces an existing - // one; an incorrect photo is explicitly voided through the void endpoint. - LoadingEvidenceEntity entity = new LoadingEvidenceEntity(); - entity.setTenantId(tenantId); entity.setInboundId(inboundId); - entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId()); - entity.setFileName(fileName); entity.setSealNo(sealNo); entity.setRemark(request.remark()); - entity.setStatus("ACTIVE"); entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName); - entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName); - repository.createEvidence(entity); - } catch (DuplicateKeyException ex) { - throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务"); - } - LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)); - return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result); - } @Transactional public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId, String operatorName, String key, CompleteLoadingRequest request) { - return completeLoading(tenantId, inboundId, tenantId, inboundId, - operatorId, operatorName, key, request); + return completeLoading(tenantId, inboundId, operatorId, operatorName, key, request, false); } private LoadingActionResponse completeLoading(long tenantId, long inboundId, - long evidenceTenantId, long evidenceInboundId, - long operatorId, String operatorName, String key, - CompleteLoadingRequest request) { - return completeLoading(tenantId, inboundId, evidenceTenantId, evidenceInboundId, - operatorId, operatorName, key, request, false); - } - - private LoadingActionResponse completeLoading(long tenantId, long inboundId, - long evidenceTenantId, long evidenceInboundId, long operatorId, String operatorName, String key, CompleteLoadingRequest request, boolean acceptedPdaCompletion) { @@ -134,23 +59,23 @@ public class LoadingDeliveryService { Snapshot inbound = requireInbound(tenantId, inboundId); CompletionTarget target = repository.completionTarget(tenantId, inboundId); if (target != null) { - return completeFulfillmentSlice(tenantId, inboundId, evidenceTenantId, evidenceInboundId, - operatorId, operatorName, key, request, target, acceptedPdaCompletion); + return completeFulfillmentSlice(tenantId, inboundId, operatorId, operatorName, key, + request, target, acceptedPdaCompletion); } if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion); return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, - new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId))); + new LoadingActionResponse(inbound.processStatus())); } if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion); completionGate.requireReleasedBooking(tenantId, inboundId); requirePickingGate(tenantId, inboundId, acceptedPdaCompletion); - requireCompletionEvidence(evidenceTenantId, evidenceInboundId); + requireCabinetEvidence(tenantId, inboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName); return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, - new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId))); + new LoadingActionResponse("LOADED")); } /** @@ -162,23 +87,14 @@ public class LoadingDeliveryService { public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, long operatorId, String operatorName, String key, String remark) { - return completeAfterPdaSettlement(tenantId, inboundId, tenantId, inboundId, - operatorId, operatorName, key, remark); - } - - @Transactional - public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, - long evidenceTenantId, long evidenceInboundId, - long operatorId, - String operatorName, String key, String remark) { CompletionTarget target = repository.completionTarget(tenantId, inboundId); long completionInboundId = target == null ? inboundId : target.completionInboundId(); Snapshot current = repository.findInboundForUpdate(tenantId, completionInboundId); if (current == null || current.version() == null) { throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试"); } - return completeLoading(tenantId, inboundId, evidenceTenantId, evidenceInboundId, - operatorId, operatorName, key, new CompleteLoadingRequest(current.version(), remark), true); + return completeLoading(tenantId, inboundId, operatorId, operatorName, key, + new CompleteLoadingRequest(current.version(), remark), true); } /** @@ -186,29 +102,22 @@ public class LoadingDeliveryService { * every external/business hard gate must pass before the PDA task is mutated. */ public void requirePdaCompletionReady(long tenantId, long inboundId) { - requirePdaCompletionReady(tenantId, inboundId, tenantId, inboundId); - } - - public void requirePdaCompletionReady(long tenantId, long inboundId, - long evidenceTenantId, long evidenceInboundId) { Snapshot inbound = requireInbound(tenantId, inboundId); if (!"LOADING".equals(inbound.processStatus())) { throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING"); } loadingModelBridge.requireStarted(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId); - requireCompletionEvidence(evidenceTenantId, evidenceInboundId); } private LoadingActionResponse completeFulfillmentSlice(long tenantId, long inboundId, - long evidenceTenantId, long evidenceInboundId, long operatorId, String operatorName, String key, CompleteLoadingRequest request, CompletionTarget target, boolean acceptedPdaCompletion) { if ("LOADED".equals(target.loadedChildStatus())) { return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, - new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId))); + new LoadingActionResponse("LOADED")); } if (!"LOADING".equals(target.loadedChildStatus())) { throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中"); @@ -216,52 +125,14 @@ public class LoadingDeliveryService { requireLoadingPlanGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion); completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion); - requireCompletionEvidence(evidenceTenantId, evidenceInboundId); + requireCabinetEvidence(tenantId, target.sourceInboundId()); if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, request.expectedVersion()) != 1) throw concurrent(); repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId); addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName); return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, - new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId))); - } - - public EvidenceListResponse evidence(long tenantId, long inboundId) { - requireLoadingStage(tenantId, inboundId); - return evidenceResponse(tenantId, inboundId); - } - - public ResponseEntity evidenceContent(long tenantId, long inboundId, long evidenceId) { - requireLoadingStage(tenantId, inboundId); - LoadingEvidenceEntity evidence = repository.listEvidence(tenantId, inboundId).stream() - .filter(item -> Long.valueOf(evidenceId).equals(item.getId()) && isActive(item)) - .findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在")); - try { - ResponseEntity response = files.download(internalToken, tenantId, inboundId, evidence.getFileId()); - if (response == null || response.getBody() == null) { - throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在"); - } - return response; - } catch (FeignException ex) { - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); - } - } - - @Transactional - public LoadingActionResponse voidEvidence(long tenantId, long inboundId, long evidenceId, long operatorId, - String operatorName, String key, VoidEvidenceRequest request) { - String hash = hash(request); - LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, VOID_EVIDENCE, key, hash); - if (replay != null) return replay; - 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, "装柜证据不存在"); - if (!isActive(target)) throw new BizException(ResultCode.CONFLICT, "装柜证据已经作废"); - if (repository.voidEvidence(tenantId, inboundId, evidenceId, request.reason().trim(), operatorId, - operatorName, request.expectedVersion()) != 1) throw concurrent(); - LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)); - return complete(tenantId, inboundId, operatorId, VOID_EVIDENCE, key, result); + new LoadingActionResponse("LOADED")); } @Transactional @@ -287,7 +158,7 @@ public class LoadingDeliveryService { operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent(); PickingResponse result = pickResponse(tenantId, inboundId); complete(tenantId, inboundId, operatorId, "START_PICKING", key, - new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId))); + new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus())); return result; } @@ -312,7 +183,7 @@ public class LoadingDeliveryService { task.getStatus(), PickingStatus.COMPLETED, operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent(); PickingResponse result = pickResponse(tenantId, inboundId); complete(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, - new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId))); + new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus())); return result; } @@ -378,35 +249,9 @@ public class LoadingDeliveryService { } } - private void requireCompletionEvidence(long tenantId, long inboundId) { - Map values = repository.listEvidence(tenantId, inboundId).stream() - .filter(LoadingDeliveryService::isActive) - .collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> left)); - boolean filesReady = LoadingEvidenceType.VALUES.stream() - .allMatch(type -> values.get(type) != null && values.get(type).getFileId() != null); - LoadingEvidenceEntity seal = values.get(LoadingEvidenceType.SEAL); - if (!filesReady || seal == null || !StringUtils.hasText(seal.getSealNo())) { - throw new BizException(ResultCode.CONFLICT, "空仓、半仓、满仓、锁仓、带锁码照及锁码必须齐全"); - } - LoadingEvidenceType.VALUES.forEach(type -> { - LoadingEvidenceEntity evidence = values.get(type); - validateFile(tenantId, inboundId, evidence.getFileId(), type); - }); - } - - private LoadingEvidenceFileClient.FileMetadata validateFile(long tenantId, long inboundId, long fileId, String evidenceType) { - try { - var response = files.validate(internalToken, tenantId, inboundId, fileId); - if (response == null || !response.successful() || response.data() == null - || !("LOADING_" + evidenceType).equals(response.data().businessType()) - || !StringUtils.hasText(response.data().contentType()) - || !response.data().contentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) { - throw new BizException(ResultCode.BAD_REQUEST, "证据文件不存在或不属于当前订单"); - } - return response.data(); - } catch (FeignException ex) { - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); - } + private void requireCabinetEvidence(long tenantId, long inboundId) { + cabinetEvidenceService.requireComplete( + cabinetEvidenceService.requireContainerBookingId(tenantId, inboundId)); } private Snapshot requireInbound(long tenantId, long inboundId) { @@ -426,44 +271,6 @@ 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); - CompletionTarget target = repository.completionTarget(tenantId, inboundId); - if (target != null) { - if (!"LOADING".equals(target.loadedChildStatus())) { - throw new BizException(ResultCode.CONFLICT, "装柜完成后不允许在线修改证据"); - } - return value; - } - if (!"LOADING".equals(value.processStatus())) { - throw new BizException(ResultCode.CONFLICT, "装柜完成后不允许在线修改证据"); - } - return value; - } - - private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) { - List values = repository.listEvidence(tenantId, inboundId).stream().map(e -> - new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(), - e.getFileName(), evidencePreviewUrl(tenantId, inboundId, e.getId()), e.getSealNo(), e.getRemark(), e.getVersion(), activeStatus(e), e.getVoidReason(), - e.getVoidedBy(), e.getVoidedByName(), e.getVoidedAt(), e.getCreatedAt(), e.getUpdatedAt())).toList(); - return new EvidenceListResponse(inboundId, values); - } - - private static String evidencePreviewUrl(long tenantId, long inboundId, long evidenceId) { - return "/api/platform/v1/inbounds/" + inboundId + "/loading/evidence/" + evidenceId - + "/content/pda?tenantId=" + tenantId; - } - - private static boolean isActive(LoadingEvidenceEntity evidence) { - return evidence != null && (evidence.getStatus() == null || "ACTIVE".equals(evidence.getStatus())); - } - - private static String activeStatus(LoadingEvidenceEntity evidence) { - return evidence.getStatus() == null ? "ACTIVE" : evidence.getStatus(); - } - private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId, String operation, String key, String hash) { return claimOrReplay(tenantId, inboundId, operatorId, operation, key, hash, false); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 2a4978b09..469ade09a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -149,7 +149,7 @@ public interface ContainerBookingMapper extends BaseMapper'' ORDER BY h.id") List pickExceptionReasons(@Param("id") long id); - @Select("SELECT DISTINCT e.id evidenceId,e.tenant_id tenantId,e.inbound_id inboundId,e.evidence_type evidenceType,e.file_id fileId,e.file_name fileName,e.status,e.created_at uploadedAt FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id WHERE i.loading_plan_id=#{id} AND i.deleted=0 ORDER BY e.created_at,e.id") List photos(@Param("id") long id); + @Select("SELECT e.id evidenceId,NULL tenantId,NULL inboundId,e.evidence_type evidenceType,e.file_id fileId,e.file_name fileName,e.status,e.created_at uploadedAt FROM fl_loading_cabinet_evidence e JOIN fl_loading_plan p ON p.container_booking_id=e.container_booking_id WHERE p.id=#{id} ORDER BY e.created_at,e.id") List photos(@Param("id") long id); @Select("SELECT action_type eventType,operation_id detail,operator_id operatorId,operator_name operatorName,created_at occurredAt FROM fl_loading_pick_operation WHERE pick_task_id=#{id} ORDER BY created_at,id") List pickTimeline(@Param("id") long id); record ExceptionRow(Long id,String action,String reason,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt,String requestHash) {} record PickTaskRow(Long id,Long loadingPlanId,Long containerBookingId,String taskStatus,Integer version) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index af02c7b95..abfdaa551 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -278,11 +278,10 @@ public interface LoadingListQueryMapper { WHERE task.loading_plan_id=#{planId} AND (task.task_status<>'LOADING' OR item.item_status<>'PENDING' OR item.picked_package_count<>0)) - OR EXISTS (SELECT 1 FROM fl_loading_evidence evidence - JOIN fl_loading_plan_item plan_item - ON plan_item.account_id=evidence.tenant_id AND plan_item.inbound_id=evidence.inbound_id - WHERE plan_item.loading_plan_id=#{planId} AND plan_item.deleted=0 - AND evidence.status='ACTIVE') + OR EXISTS (SELECT 1 FROM fl_loading_cabinet_evidence evidence + JOIN fl_loading_plan evidence_plan + ON evidence_plan.container_booking_id=evidence.container_booking_id + WHERE evidence_plan.id=#{planId} AND evidence.status='ACTIVE') THEN 1 ELSE 0 END """) int hasPdaWorkStarted(@Param("planId") long planId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 4c112ee32..59d2c4488 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -70,9 +70,7 @@ public interface OutboundDispatchReadMapper { AND evidence.file_id IS NOT NULL AND evidence.seal_no IS NOT NULL AND evidence.seal_no<>'' THEN 1 ELSE 0 END) validSeal FROM fl_loading_plan plan - JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0 - JOIN fl_loading_evidence evidence ON evidence.inbound_id=item.inbound_id - AND evidence.tenant_id=item.account_id + JOIN fl_loading_cabinet_evidence evidence ON evidence.container_booking_id=plan.container_booking_id WHERE plan.id=#{loadingPlanId} AND plan.container_booking_id=#{containerBookingId} """) Evidence evidence(@Param("containerBookingId") long containerBookingId, @@ -95,7 +93,11 @@ public interface OutboundDispatchReadMapper { COALESCE(e.updated_by,e.created_by),COALESCE(e.updated_by_name,e.created_by_name), CONCAT(e.evidence_type,CASE WHEN e.seal_no IS NULL OR e.seal_no='' THEN '' ELSE CONCAT(':',e.seal_no) END), COALESCE(e.updated_at,e.created_at) - FROM fl_loading_evidence e WHERE e.tenant_id=#{tenantId} AND e.inbound_id=#{inboundId} + FROM fl_loading_cabinet_evidence e + JOIN fl_loading_plan evidence_plan ON evidence_plan.container_booking_id=e.container_booking_id + JOIN fl_loading_plan_item evidence_item ON evidence_item.loading_plan_id=evidence_plan.id + AND evidence_item.deleted=0 + WHERE evidence_item.account_id=#{tenantId} AND evidence_item.inbound_id=#{inboundId} UNION ALL SELECT 'OCEAN_BOOKING',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at FROM fl_ocean_booking_status_history h WHERE h.ocean_booking_id=#{oceanBookingId} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java index 5a8fbe592..02b42f7cf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java @@ -1,9 +1,9 @@ package com.baiyue.firstleg.repository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity; -import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; +import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; import com.baiyue.firstleg.split.entity.PreloadPlanEntity; import com.baiyue.firstleg.split.mapper.PreloadPlanMapper; @@ -26,7 +26,7 @@ import java.util.stream.Collectors; public class UnifiedInboundReadRepository { @Resource private PreloadPlanMapper preloads; @Resource private InboundLoadingPickTaskMapper picks; - @Resource private LoadingEvidenceMapper evidences; + @Resource private CabinetLoadingEvidenceMapper evidences; @Resource private InboundStatusHistoryMapper histories; @Resource private UnifiedInboundTransportMapper transports; @@ -42,11 +42,8 @@ public class UnifiedInboundReadRepository { .eq(InboundLoadingPickTaskEntity::getInboundId, inboundId)); } - public List findEvidence(long tenantId, long inboundId) { - return evidences.selectList(new LambdaQueryWrapper() - .eq(LoadingEvidenceEntity::getTenantId, tenantId) - .eq(LoadingEvidenceEntity::getInboundId, inboundId) - .orderByAsc(LoadingEvidenceEntity::getEvidenceType)); + public List findEvidence(long tenantId, long inboundId) { + return evidences.byInbound(tenantId, inboundId); } public Map findPreloads(Map tenantByInbound) { @@ -65,13 +62,12 @@ public class UnifiedInboundReadRepository { .collect(Collectors.toMap(InboundLoadingPickTaskEntity::getInboundId, Function.identity(), (left, right) -> left)); } - public Map> findEvidence(Map tenantByInbound) { + public Map> findEvidence(Map tenantByInbound) { if (tenantByInbound.isEmpty()) return Map.of(); - return evidences.selectList(new LambdaQueryWrapper() - .in(LoadingEvidenceEntity::getInboundId, tenantByInbound.keySet()) - .orderByAsc(LoadingEvidenceEntity::getEvidenceType)) - .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) - .collect(Collectors.groupingBy(LoadingEvidenceEntity::getInboundId)); + Map> result = new java.util.HashMap<>(); + tenantByInbound.forEach((inboundId, tenantId) -> result.put(inboundId, + evidences.byInbound(tenantId, inboundId))); + return result; } public Map findDeliveryHistory(Map tenantByInbound) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index f511c0ca7..8192fba2b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -134,7 +134,7 @@ public class ContainerOverviewService { .findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "PDA照片不存在或不属于该货柜")); final org.springframework.http.ResponseEntity response; try { - response = evidenceFiles.download(internalToken, photo.tenantId(), photo.inboundId(), fileId); + response = evidenceFiles.download(internalToken, containerBookingId, photo.evidenceType(), fileId); } catch (FeignException.NotFound exception) { throw new BizException(ResultCode.NOT_FOUND, "PDA照片文件不存在或归属不匹配"); } catch (FeignException exception) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 74a68d30e..663cc8c0e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -96,6 +96,8 @@ public class LoadingPickService { @Resource private LoadingDeliveryService loadingDeliveryService; @Resource + private com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService cabinetEvidenceService; + @Resource private ColoadGroupAssignmentPort coloadGroups; @Resource private TruckAppointmentService truckAppointmentService; @@ -520,9 +522,10 @@ public class LoadingPickService { .toList(); if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单")); List taskGroups = coloadGroups(owners); - LoadingPickItemMapper.InboundOwner evidenceOwner = owners.get(0); + runCompletionStage("CABINET_EVIDENCE", () -> + cabinetEvidenceService.requireComplete(task.getContainerBookingId())); - // Phase 1 is read-only: every owner must pass its business gates against the same cabinet evidence owner + // Phase 1 is read-only: cabinet evidence is checked once, then every owner must pass its business gates // before any warehouse resource or inbound status can be changed. This prevents a later customer gate from // leaving earlier customers partially completed. for (int index = 0; index < owners.size(); index++) { @@ -531,7 +534,7 @@ public class LoadingPickService { String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId()); if (!checkpointAtLeast(checkpoint, "SETTLED")) { runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady( - owner.tenantId(), owner.inboundId(), evidenceOwner.tenantId(), evidenceOwner.inboundId())); + owner.tenantId(), owner.inboundId())); if (!checkpointAtLeast(checkpoint, "READY")) { requireCompletionLease(command.getId(), worker); completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), @@ -554,7 +557,7 @@ public class LoadingPickService { } if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) { runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement( - owner.tenantId(), owner.inboundId(), evidenceOwner.tenantId(), evidenceOwner.inboundId(), + owner.tenantId(), owner.inboundId(), command.getOperatorId(), command.getOperatorName(), "PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark())); requireCompletionLease(command.getId(), worker); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index 4f3bd8f81..5a0851d0e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -8,7 +8,7 @@ import com.baiyue.firstleg.dto.UnifiedInboundQuery; import com.baiyue.firstleg.dto.UnifiedInboundSummary; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.dto.CurrentContainerTransport; -import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; import com.baiyue.firstleg.repository.InboundOrderRepository; import com.baiyue.firstleg.repository.UnifiedInboundReadRepository; import com.fasterxml.jackson.databind.JsonNode; @@ -69,7 +69,7 @@ public class UnifiedInboundQueryService { // Phase-three tables are optional during the phase-two measurement workflow. com.baiyue.firstleg.split.entity.PreloadPlanEntity preload = null; com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick = null; - List evidence = List.of(); + List evidence = List.of(); LocalDateTime deliveryAt = null; if (includePhaseThree) { preload = operations.findPreload(tenantId, inbound.getId()); @@ -116,21 +116,21 @@ public class UnifiedInboundQueryService { private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail, com.baiyue.firstleg.split.entity.PreloadPlanEntity preload, com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick, - List evidence, LocalDateTime deliveryAt, + List evidence, LocalDateTime deliveryAt, CurrentContainerTransport transport, String locationCodes, com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) { String mainStatus = mainStatus(inbound.getProcessStatus()); - List activeEvidence = evidence.stream() + List activeEvidence = evidence.stream() .filter(item -> item.getStatus() == null || "ACTIVE".equals(item.getStatus())).toList(); String sealNo = activeEvidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType())) - .map(LoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null); + .map(CabinetLoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null); var preloadSummary = preload == null ? null : new UnifiedInboundSummary.PreloadSummary( preload.getStatus(), preload.getVersion(), itemCount(preload.getItemsJson()), preload.getUpdatedAt()); var loadingSummary = pick == null && activeEvidence.isEmpty() ? null : new UnifiedInboundSummary.LoadingSummary( pick == null ? null : pick.getStatus(), pick == null ? null : pick.getVersion(), pick == null ? null : pick.getVersion(), pick == null ? null : pick.getExpectedCount(), pick == null ? null : pick.getPickedCount(), activeEvidence.size(), sealNo, - pick == null ? activeEvidence.stream().map(LoadingEvidenceEntity::getUpdatedAt) + pick == null ? activeEvidence.stream().map(CabinetLoadingEvidenceEntity::getUpdatedAt) .filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt()); var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus) ? new UnifiedInboundSummary.DeliverySummary( diff --git a/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql index d67dbabb7..7f9f7f4bc 100644 --- a/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql +++ b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql @@ -1,97 +1,40 @@ SET NAMES utf8mb4; USE baiyue_firstleg; -/* 装柜证据、PDA 拣货复核和派送完成。五类证据按有效记录计数,同类型允许多图追加;作废记录保留审计。 */ -CREATE TABLE IF NOT EXISTS fl_loading_evidence ( - id BIGINT UNSIGNED NOT NULL COMMENT '装柜证据记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID', - evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1空仓/PHOTO_STEP_2半仓/PHOTO_STEP_3满仓/PHOTO_STEP_4锁仓/SEAL带锁码照', - file_id BIGINT UNSIGNED NOT NULL COMMENT 'File 服务逻辑文件ID', - file_name VARCHAR(255) NULL COMMENT '上传时文件名称快照', - seal_no VARCHAR(64) NULL COMMENT '锁码文本;带锁码照必填,客户不可见', - remark VARCHAR(1000) NULL COMMENT '证据补充说明', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID已作废;错误照片保留记录不硬删除', - void_reason VARCHAR(500) NULL COMMENT '照片作废原因', - voided_by BIGINT UNSIGNED NULL COMMENT '执行作废的平台操作人ID', - voided_by_name VARCHAR(64) NULL COMMENT '执行作废的平台操作人姓名快照', - voided_at DATETIME(3) NULL COMMENT '照片作废时间', - version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增', - created_by BIGINT UNSIGNED NOT NULL COMMENT '上传证据的操作人ID', - created_by_name VARCHAR(64) NULL COMMENT '上传证据的操作人姓名快照', - updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', - updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '证据记录创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '证据记录最后更新时间', +CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence ( + id BIGINT UNSIGNED NOT NULL, + container_booking_id BIGINT UNSIGNED NOT NULL, + source_task_id BIGINT UNSIGNED NOT NULL, + evidence_type VARCHAR(32) NOT NULL, + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + seal_no VARCHAR(64) NULL, + remark VARCHAR(1000) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + active_flag TINYINT UNSIGNED NULL DEFAULT 1, + operation_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + void_reason VARCHAR(500) NULL, + voided_by BIGINT UNSIGNED NULL, + voided_by_name VARCHAR(128) NULL, + voided_at DATETIME(3) NULL, + version INT NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(128) NULL, + updated_by BIGINT UNSIGNED NOT NULL, + updated_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (id), - KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status), - KEY idx_loading_evidence_seal (tenant_id, seal_no, status), - KEY idx_loading_evidence_time (tenant_id, inbound_id, updated_at), - CONSTRAINT fk_loading_evidence_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='订单装柜空仓/半仓/满仓/锁仓/带锁码照证据'; - -/* Repeatable migration for installations that already created the single-slot table. */ -DROP PROCEDURE IF EXISTS phase3_loading_add_column; -DELIMITER $$ -CREATE PROCEDURE phase3_loading_add_column(IN p_column VARCHAR(64), IN p_definition VARCHAR(512)) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND column_name=p_column - ) THEN - SET @phase3_loading_ddl = CONCAT('ALTER TABLE fl_loading_evidence ADD COLUMN `', p_column, '` ', p_definition); - PREPARE phase3_loading_stmt FROM @phase3_loading_ddl; - EXECUTE phase3_loading_stmt; - DEALLOCATE PREPARE phase3_loading_stmt; - END IF; -END$$ -DELIMITER ; -CALL phase3_loading_add_column('status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''证据状态:ACTIVE有效、VOID已作废;错误照片保留记录不硬删除'''); -CALL phase3_loading_add_column('void_reason', 'VARCHAR(500) NULL COMMENT ''照片作废原因'''); -CALL phase3_loading_add_column('voided_by', 'BIGINT UNSIGNED NULL COMMENT ''执行作废的平台操作人ID'''); -CALL phase3_loading_add_column('voided_by_name', 'VARCHAR(64) NULL COMMENT ''执行作废的平台操作人姓名快照'''); -CALL phase3_loading_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''照片作废时间'''); -DROP PROCEDURE IF EXISTS phase3_loading_add_column; - -SET @phase3_loading_has_legacy_type_index = ( - SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND index_name='uk_loading_evidence_type' -); -SET @phase3_loading_drop_legacy_type_index = IF(@phase3_loading_has_legacy_type_index > 0, - 'ALTER TABLE fl_loading_evidence DROP INDEX uk_loading_evidence_type', 'SELECT 1'); -PREPARE phase3_loading_drop_stmt FROM @phase3_loading_drop_legacy_type_index; -EXECUTE phase3_loading_drop_stmt; -DEALLOCATE PREPARE phase3_loading_drop_stmt; - -SET @phase3_loading_has_type_index = ( - SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND index_name='idx_loading_evidence_type' -); -SET @phase3_loading_add_type_index = IF(@phase3_loading_has_type_index = 0, - 'ALTER TABLE fl_loading_evidence ADD KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status)', 'SELECT 1'); -PREPARE phase3_loading_type_stmt FROM @phase3_loading_add_type_index; -EXECUTE phase3_loading_type_stmt; -DEALLOCATE PREPARE phase3_loading_type_stmt; - -SET @phase3_loading_has_legacy_seal_index = ( - SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND index_name='uk_loading_evidence_seal' -); -SET @phase3_loading_drop_legacy_seal_index = IF(@phase3_loading_has_legacy_seal_index > 0, - 'ALTER TABLE fl_loading_evidence DROP INDEX uk_loading_evidence_seal', 'SELECT 1'); -PREPARE phase3_loading_drop_seal_stmt FROM @phase3_loading_drop_legacy_seal_index; -EXECUTE phase3_loading_drop_seal_stmt; -DEALLOCATE PREPARE phase3_loading_drop_seal_stmt; - -SET @phase3_loading_has_seal_index = ( - SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence' AND index_name='idx_loading_evidence_seal' -); -SET @phase3_loading_add_seal_index = IF(@phase3_loading_has_seal_index = 0, - 'ALTER TABLE fl_loading_evidence ADD KEY idx_loading_evidence_seal (tenant_id, seal_no, status)', 'SELECT 1'); -PREPARE phase3_loading_seal_stmt FROM @phase3_loading_add_seal_index; -EXECUTE phase3_loading_seal_stmt; -DEALLOCATE PREPARE phase3_loading_seal_stmt; + UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag), + UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key), + KEY idx_cabinet_evidence_container (container_booking_id,status), + KEY idx_cabinet_evidence_seal (seal_no,active_flag), + CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id), + CONSTRAINT fk_cabinet_evidence_task FOREIGN KEY (source_task_id) REFERENCES fl_loading_pick_task(id), + CONSTRAINT chk_cabinet_evidence_type CHECK (evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL')), + CONSTRAINT chk_cabinet_evidence_status CHECK (status IN ('ACTIVE','VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜次物理装柜证据;task仅记录来源审计'; CREATE TABLE IF NOT EXISTS fl_loading_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '装柜写操作记录ID', diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 3b70a9548..1c94fc604 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -1,67 +1,8 @@ openapi: 3.0.3 info: - title: Phase 3 Loading Evidence and Picking API + title: Phase 3 Loading 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 - description: Requires the associated inbound to have entered the loading stage. PDA workflowStatus AWAITING_REVIEW does not bypass this gate; canonical claim or picking advances WAIT_SPLIT to LOADING. - 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 - description: Requires the associated inbound process status to be LOADING. PDA workflowStatus AWAITING_REVIEW is a review projection and does not replace the inbound loading-stage gate. - 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 - parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}] - responses: - '200': {description: Business envelope containing the stable evidence array} - post: - summary: Append one real File service object to a loading evidence type - 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; ownership, type and version failures use business error codes'} - /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda: - get: - summary: Download one loading evidence photo for the active PDA worker - description: Uses the existing platform:firstleg:pda:work authority and validates customer, inbound and evidence ownership before proxying File service content. - parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/EvidenceId'}, {$ref: '#/components/parameters/TenantId'}] - responses: - '200': {description: Authenticated image bytes} - /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 - 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 containing the voided record and audit fields} /api/platform/v1/inbounds/{inboundId}/loading/picking/start: post: summary: Refresh the compatibility projection from canonical container PDA tasks @@ -107,38 +48,8 @@ components: parameters: InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - EvidenceId: {name: evidenceId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: - EvidenceResponse: - type: object - required: [id, inboundId, tenantId, evidenceType, fileId, previewUrl, version, status] - properties: - id: {type: integer, format: int64} - inboundId: {type: integer, format: int64} - tenantId: {type: integer, format: int64} - evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]} - fileId: {type: integer, format: int64} - fileName: {type: string} - previewUrl: {type: string, description: Authenticated PDA image-content path; request with the current bearer token} - version: {type: integer} - status: {type: string} - UpsertEvidenceRequest: - type: object - required: [expectedVersion, evidenceType, fileId] - properties: - expectedVersion: {type: integer, minimum: 0} - evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL], description: PHOTO_STEP_1=空仓,PHOTO_STEP_2=半仓,PHOTO_STEP_3=满仓,PHOTO_STEP_4=锁仓,SEAL=带锁码照;同类型可多次追加} - fileId: {type: integer, format: int64, minimum: 1} - fileName: {type: string, maxLength: 255, deprecated: true, description: Ignored; server uses File metadata} - sealNo: {type: string, maxLength: 64, description: Required when evidenceType is SEAL} - remark: {type: string, maxLength: 1000} - VoidEvidenceRequest: - type: object - required: [expectedVersion, reason] - properties: - expectedVersion: {type: integer, minimum: 0} - reason: {type: string, minLength: 1, maxLength: 500, description: 作废原因,记录操作人和时间} PickingRequest: type: object required: [expectedVersion, expectedCount, pickedCount] diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 7df494d3d..65e63be75 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -119,6 +119,57 @@ paths: parameters: [{$ref: '#/components/parameters/TaskId'}] responses: '200': {description: Ordered operation history} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/evidence: + get: + summary: 查询当前领取人的柜次装柜证据 + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: {'200': {description: 仅platform:firstleg:pda:work且任务必须由当前用户领取}} + post: + summary: 通过Firstleg代理上传并登记当前柜次装柜证据 + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}} + - {name: evidenceType, in: query, required: true, schema: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]}} + - {name: sealNo, in: query, required: false, schema: {type: string, maxLength: 64}} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: {file: {type: string, format: binary}} + responses: {'200': {description: 柜级证据列表}} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/evidence/{evidenceId}/content: + get: + summary: 下载当前柜次有效装柜证据 + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: evidenceId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: {'200': {description: 图片字节}} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/evidence/{evidenceId}/void: + post: + summary: 作废当前柜次错误证据 + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: evidenceId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [warehouseId, expectedVersion, reason] + properties: + warehouseId: {type: integer, format: int64, minimum: 1} + expectedVersion: {type: integer, minimum: 0} + reason: {type: string, minLength: 1, maxLength: 500} + responses: {'200': {description: 作废后柜级证据列表}} components: parameters: TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java index 03b0365e7..1bdad00c0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java @@ -9,28 +9,18 @@ import static org.assertj.core.api.Assertions.assertThat; class LoadingDeliveryOpenApiContractTest { @Test - void publishesRealFileFiveEvidenceAndPickingGate() throws Exception { + void removesInboundEvidenceProtocolAndKeepsPickingGate() throws Exception { String yaml = new ClassPathResource("openapi/phase3-loading-delivery-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); 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}/content/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:") .contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:") - .contains("required: [expectedVersion, evidenceType, fileId]") - .contains("previewUrl:") - .contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]") - .contains("同类型可多次追加") - .contains("Required when evidenceType is SEAL") .contains("compatibility projection") - .contains("associated inbound process status to be LOADING") - .contains("workflowStatus AWAITING_REVIEW does not bypass this gate") .contains("must exactly match the server-derived container-task aggregate") .contains("cannot complete canonical picking"); - assertThat(yaml).doesNotContain("/delivery/complete").doesNotContain("DELIVERED"); + assertThat(yaml).doesNotContain("/loading/evidence").doesNotContain("EvidenceResponse:") + .doesNotContain("/delivery/complete"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java index b647e0b0f..cc5abb915 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java @@ -1,17 +1,7 @@ package com.baiyue.firstleg.loading.controller; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest; -import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest; -import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; -import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest; import org.junit.jupiter.api.Test; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import java.lang.reflect.Method; import java.util.Arrays; @@ -20,72 +10,11 @@ import static org.assertj.core.api.Assertions.assertThat; class PlatformLoadingDeliveryControllerContractTest { @Test - void keepsInboundScopedPathsAndPermissions() throws Exception { + void removesInboundEvidenceProtocolAndKeepsLoadingActions() { assertThat(PlatformLoadingDeliveryController.class.getAnnotation(RequestMapping.class).value()) .containsExactly("/api/platform/v1/inbounds"); - - 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 pdaContent = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaEvidenceContent", - AuthenticatedUser.class, long.class, long.class, long.class); - assertThat(pdaContent.getAnnotation(GetMapping.class).value()) - .containsExactly("/{inboundId}/loading/evidence/{evidenceId}/content/pda"); - assertAuthority(pdaContent, "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("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 { - Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod("voidEvidence", - AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class); - assertThat(method.getAnnotation(PostMapping.class).value()) - .containsExactly("/{inboundId}/loading/evidence/{evidenceId}/void"); - assertAuthority(method, "platform:firstleg:inbound:loading:edit"); - assertThat(method.getParameters()[1].getAnnotation(RequestParam.class)).isNotNull(); - assertThat(method.getParameters()[4].getAnnotation(RequestHeader.class).value()) - .isEqualTo("Idempotency-Key"); - } - - private static void post(String name, String path, String authority, Class requestType) throws Exception { - Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod(name, - AuthenticatedUser.class, long.class, long.class, String.class, requestType); - assertThat(method.getAnnotation(PostMapping.class).value()).containsExactly(path); - assertAuthority(method, authority); - assertThat(method.getParameters()[1].getAnnotation(RequestParam.class)).isNotNull(); - assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).value()) - .isEqualTo("Idempotency-Key"); - } - - private static void get(String name, String path, String authority) throws Exception { - Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod(name, - AuthenticatedUser.class, long.class, long.class); - assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly(path); - assertAuthority(method, authority); - assertThat(method.getParameters()[1].getAnnotation(RequestParam.class)).isNotNull(); - } - - private static void assertAuthority(Method method, String authority) { - assertThat(method.getAnnotation(PreAuthorize.class).value()) - .isEqualTo("hasAuthority('" + authority + "')"); + .map(Method::getName)).contains("completeLoading", "startPicking", "completePicking", "picking") + .doesNotContain("evidence", "pdaEvidence", "pdaEvidenceContent", "voidEvidence", "pdaVoidEvidence"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java index 71511c16b..f6e453383 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java @@ -11,43 +11,26 @@ import static org.assertj.core.api.Assertions.assertThat; class LoadingEvidenceSqlContractTest { @Test - void schemaSupportsAppendOnlyEvidenceAndVoidAudit() throws Exception { + void schemaOwnsEvidenceByCabinetWithOneActiveFactPerType() throws Exception { String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql") .getContentAsString(StandardCharsets.UTF_8); - assertThat(sql) - .contains("file_id BIGINT UNSIGNED NOT NULL") - .contains("KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status)") - .contains("status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'") - .contains("void_reason VARCHAR(500)") - .contains("voided_by BIGINT UNSIGNED") - .contains("voided_at DATETIME(3)") - .contains("DROP INDEX uk_loading_evidence_type") - .contains("DROP INDEX uk_loading_evidence_seal") - .contains("idx_loading_evidence_seal (tenant_id, seal_no, status)") - .contains("fl_inbound_loading_pick_task"); + .contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence") + .contains("container_booking_id BIGINT UNSIGNED NOT NULL") + .contains("source_task_id BIGINT UNSIGNED NOT NULL") + .contains("UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)") + .contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)") + .contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)") + .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_evidence"); } @Test - void unifiedEntryMigratesLegacySingleSlotEvidenceWithoutRecreatingUniqueIndexes() throws Exception { + void unifiedEntryContainsCabinetEvidenceTable() throws Exception { Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); String sql = Files.readString(unified, StandardCharsets.UTF_8); - - assertThat(sql) - .contains("file_id BIGINT UNSIGNED NOT NULL") - .contains("status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'") - .contains("phase3_loading_evidence_add_column('status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''ACTIVE/VOID''')") - .contains("phase3_loading_evidence_add_column('void_reason'") - .contains("phase3_loading_evidence_add_column('voided_by'") - .contains("phase3_loading_evidence_add_column('voided_by_name'") - .contains("phase3_loading_evidence_add_column('voided_at'") - .contains("DROP INDEX uk_loading_evidence_type") - .contains("DROP INDEX uk_loading_evidence_seal") - .contains("ADD KEY idx_loading_evidence_type (tenant_id,inbound_id,evidence_type,status)") - .contains("ADD KEY idx_loading_evidence_seal (tenant_id,seal_no,status)") - .doesNotContain("phase3_loading_evidence_add_column('status', \"VARCHAR(16)") - .doesNotContain("UNIQUE KEY uk_loading_evidence_type") - .doesNotContain("UNIQUE KEY uk_loading_evidence_seal"); + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence") + .contains("uk_cabinet_evidence_active") + .contains("uk_cabinet_evidence_operation"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java new file mode 100644 index 000000000..3cf638b57 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.loading.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.LoadingEvidenceFileClient; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; +import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(CabinetLoadingEvidenceService.class) +class CabinetLoadingEvidenceServiceTest { + @MockBean CabinetLoadingEvidenceMapper mapper; + @MockBean LoadingPickRepository loadingPicks; + @MockBean LoadingEvidenceFileClient files; + @MockBean ObjectMapper objectMapper; + @Resource CabinetLoadingEvidenceService service; + + @BeforeEach + void taskScope() { + LoadingPickTaskEntity task = new LoadingPickTaskEntity(); + task.setId(20L); task.setContainerBookingId(99L); task.setTaskStatus("LOADING"); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(99L); booking.setWarehouseId(8L); booking.setRecordStatus("ACTIVE"); + LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity(); + assignment.setPickTaskId(20L); assignment.setAssigneeId(9L); + when(loadingPicks.task(20L)).thenReturn(task); + when(loadingPicks.activeBookingByTask(20L)).thenReturn(booking); + when(loadingPicks.assignment(20L)).thenReturn(assignment); + } + + @Test + void rejectsCrossWarehouseBeforeReadingEvidence() { + assertThatThrownBy(() -> service.list(20L, 7L, 9L)).isInstanceOf(BizException.class); + verify(mapper, never()).byContainer(99L); + } + + @Test + void rejectsOperatorWhoDidNotClaimTaskBeforeReadingEvidence() { + assertThatThrownBy(() -> service.list(20L, 8L, 10L)).isInstanceOf(BizException.class); + verify(mapper, never()).byContainer(99L); + } + + @Test + void rejectsCrossContainerEvidenceBeforeCallingFile() { + when(mapper.byContainer(99L)).thenReturn(List.of()); + assertThatThrownBy(() -> service.content(20L, 8L, 777L, 9L)).isInstanceOf(BizException.class); + verify(files, never()).download(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + void rejectsTaskWhoseActiveBookingIdentityChanged() { + ContainerBookingEntity another = new ContainerBookingEntity(); + another.setId(100L); another.setWarehouseId(8L); + when(loadingPicks.activeBookingByTask(20L)).thenReturn(another); + assertThatThrownBy(() -> service.list(20L, 8L, 9L)).isInstanceOf(BizException.class); + verify(mapper, never()).byContainer(99L); + } + + @Test + void cabinetGateAcceptsFiveFactsRegardlessOfOwnerOrder() { + when(mapper.byContainer(99L)).thenReturn(List.of(evidence("SEAL"), evidence("PHOTO_STEP_4"), + evidence("PHOTO_STEP_2"), evidence("PHOTO_STEP_1"), evidence("PHOTO_STEP_3"))); + service.requireComplete(99L); + verify(mapper).byContainer(99L); + } + + private static CabinetLoadingEvidenceEntity evidence(String type) { + CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity(); + value.setEvidenceType(type); value.setStatus("ACTIVE"); value.setContainerBookingId(99L); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index b9c167b46..113d1256e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -1,601 +1,48 @@ package com.baiyue.firstleg.loading.service; -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.firstleg.client.LoadingEvidenceFileClient; -import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; -import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest; -import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest; -import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; -import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest; -import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; -import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; -import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; -import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; -import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget; import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; -import com.baiyue.firstleg.mapper.LoadingPickItemMapper; -import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; import com.baiyue.firstleg.loading.repository.LoadingRepository; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress; +import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; +import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) +@SpringJUnitConfig(LoadingDeliveryService.class) class LoadingDeliveryServiceTest { - @Mock LoadingRepository repository; - @Mock LoadingModelBridgeService loadingModelBridge; - @Mock LoadingCompletionGateService completionGate; - @Mock LoadingPickRepository loadingPicks; - @Mock InboundStatusHistoryMapper histories; - @Mock LoadingEvidenceFileClient files; - @InjectMocks LoadingDeliveryService service; - - @BeforeEach - void injectObjectMapper() throws Exception { - var field = LoadingDeliveryService.class.getDeclaredField("objectMapper"); - field.setAccessible(true); - field.set(service, new ObjectMapper()); - var token = LoadingDeliveryService.class.getDeclaredField("internalToken"); - token.setAccessible(true); - token.set(service, "token"); - } + @MockBean LoadingRepository repository; + @MockBean LoadingPickRepository loadingPicks; + @MockBean LoadingModelBridgeService loadingModelBridge; + @MockBean LoadingCompletionGateService completionGate; + @MockBean InboundStatusHistoryMapper histories; + @MockBean CabinetLoadingEvidenceService cabinetEvidence; + @MockBean ObjectMapper objectMapper; + @Resource LoadingDeliveryService service; @Test - void returnsAuthenticatedPreviewUrlAndProxiesOwnedEvidenceContent() { - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - LoadingEvidenceEntity target = evidence("PHOTO_STEP_1", 11L, null); - target.setId(99L); target.setInboundId(20L); target.setTenantId(10L); target.setStatus("ACTIVE"); - when(repository.listEvidence(10, 20)).thenReturn(List.of(target)); - when(files.download("token", 10, 20, 11)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3})); + void manualCompletionUsesCabinetEvidenceResolvedFromInbound() throws Exception { + when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request"); + when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"), eq("op-1"), anyString())).thenReturn(true); + when(repository.findInbound(10L, 20L)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); + when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L); + InboundProgress progress = new InboundProgress(); + progress.setExpectedCount(2); progress.setPickedCount(2); progress.setTaskCount(1); + progress.setCompletedTaskCount(1); progress.setUnresolvedCount(0); + when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(progress); + when(repository.completeLoading(10L, 20L, 9L, 3)).thenReturn(1); - var listed = service.evidence(10, 20); - var content = service.evidenceContent(10, 20, 99); + service.completeLoading(10L, 20L, 9L, "operator", "op-1", + new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done")); - assertThat(listed.evidences()).singleElement().satisfies(item -> - assertThat(item.previewUrl()).isEqualTo( - "/api/platform/v1/inbounds/20/loading/evidence/99/content/pda?tenantId=10")); - assertThat(content.getBody()).containsExactly(1, 2, 3); - verify(files).download("token", 10, 20, 11); - } - - @Test - void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); - when(progress.getExpectedCount()).thenReturn(5); - when(progress.getPickedCount()).thenReturn(5); - when(progress.getTaskCount()).thenReturn(1); - when(progress.getCompletedTaskCount()).thenReturn(1); - when(progress.getUnresolvedCount()).thenReturn(0); - when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); - when(repository.listEvidence(10, 20)).thenReturn(List.of( - evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), - evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), - evidence("SEAL", 5L, "LOCK-001"))); - when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { - long fileId = invocation.getArgument(3); - String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; - return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( - fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); - }); - when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1); - 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).requireReleasedBooking(10, 20); - verify(histories).insert(any(InboundStatusHistoryEntity.class)); - } - - @Test - void pdaCompletionPreflightChecksStartedPlanTransportAndEvidenceWithoutWriting() { - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - when(repository.listEvidence(10, 20)).thenReturn(List.of( - evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), - evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), - evidence("SEAL", 5L, "LOCK-001"))); - when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { - long fileId = invocation.getArgument(3); - String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; - return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( - fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); - }); - - service.requirePdaCompletionReady(10, 20); - - verify(loadingModelBridge).requireStarted(10, 20); - verify(completionGate).requireReleasedBooking(10, 20); - verify(loadingModelBridge, never()).requireCompleted(anyLong(), anyLong()); - verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); - verify(repository, never()).claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); - } - - @Test - void pdaCompletionPreflightReportsBookingBlockerBeforeEvidenceOrWrites() { - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - doThrow(new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.CONFLICT, - "LOADED门禁未通过:订舱尚未完成(必须为 RELEASED)")) - .when(completionGate).requireReleasedBooking(10, 20); - - assertThatThrownBy(() -> service.requirePdaCompletionReady(10, 20)) - .hasMessageContaining("订舱尚未完成"); - - verify(repository, never()).listEvidence(anyLong(), anyLong()); - verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); - } - - @Test - void completesLoadedFulfillmentSliceBeforeAggregatingRoot() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "WAIT_SPLIT", 3)); - when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget( - 20L, null, 20L, "LOADING", 3, "LOADING", 3)); - LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); - when(progress.getExpectedCount()).thenReturn(5); - when(progress.getPickedCount()).thenReturn(5); - when(progress.getTaskCount()).thenReturn(1); - when(progress.getCompletedTaskCount()).thenReturn(1); - when(progress.getUnresolvedCount()).thenReturn(0); - when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); - when(repository.listEvidence(10, 20)).thenReturn(List.of( - evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), - evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), - evidence("SEAL", 5L, "LOCK-001"))); - when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { - long fileId = invocation.getArgument(3); - String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; - return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( - fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); - }); - when(repository.completeLoadedChild(10, 20, 7, 3)).thenReturn(1); - - LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "slice-1", - new CompleteLoadingRequest(3, null)); - - assertThat(result.mainStatus()).isEqualTo("LOADED"); - verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireReleasedBooking(10, 20); - verify(repository).completeLoadedChild(10, 20, 7, 3); - verify(repository).aggregateRootLoaded(10, 20, 7); - verify(histories).insert(argThat((InboundStatusHistoryEntity history) -> - Long.valueOf(20L).equals(history.getInboundId()))); - verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); - } - - @Test - void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() { - readyForPdaFinalize(); - CompletionTarget settled = new CompletionTarget(20L, null, 20L, - "LOADING", 7, "LOADING", 7); - when(repository.completionTarget(10, 20)).thenReturn(settled); - when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); - when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1); - - LoadingActionResponse result = service.completeAfterPdaSettlement( - 10, 20, 7, "admin", "pda-full", "完成"); - - assertThat(result.mainStatus()).isEqualTo("LOADED"); - verify(repository).findInboundForUpdate(10, 20); - verify(repository).completeLoadedChild(10, 20, 7, 7); - verify(repository, never()).completeLoadedChild(10, 20, 7, 6); - verify(loadingModelBridge).requireStarted(10, 20); - verify(loadingModelBridge, never()).requireCompleted(10, 20); - } - - @Test - void pdaFinalizeResumesSameProcessingClaimLeftByAnEarlierWorkerFailure() { - readyForPdaFinalizeFacts(); - CompletionTarget settled = new CompletionTarget(20L, null, 20L, - "LOADING", 7, "LOADING", 7); - when(repository.completionTarget(10, 20)).thenReturn(settled); - when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); - when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1); - AtomicReference hash = new AtomicReference<>(); - when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"), - eq("pda-resume"), anyString())).thenAnswer(invocation -> { - hash.set(invocation.getArgument(5)); - return false; - }); - when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "pda-resume")) - .thenAnswer(invocation -> processingOperation(hash.get())); - - LoadingActionResponse result = service.completeAfterPdaSettlement( - 10, 20, 7, "admin", "pda-resume", "完成"); - - assertThat(result.mainStatus()).isEqualTo("LOADED"); - verify(repository).completeLoadedChild(10, 20, 7, 7); - verify(repository).completeOperation(eq(10L), eq(20L), eq(7L), - eq("COMPLETE_LOADING"), eq("pda-resume"), anyString()); - verify(loadingModelBridge).requireStarted(10, 20); - verify(loadingModelBridge, never()).requireCompleted(10, 20); - } - - @Test - void pdaFinalizeConvergesAnAlreadyLoadedInboundBeforeThePlanIsCompleted() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) - .thenReturn(true); - when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8)); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8)); - when(repository.listEvidence(10, 20)).thenReturn(List.of()); - - LoadingActionResponse result = service.completeAfterPdaSettlement( - 10, 20, 7, "admin", "pda-already-loaded", "完成"); - - assertThat(result.mainStatus()).isEqualTo("LOADED"); - verify(loadingModelBridge).requireStarted(10, 20); - verify(loadingModelBridge, never()).requireCompleted(10, 20); - verify(repository).completeOperation(eq(10L), eq(20L), eq(7L), - eq("COMPLETE_LOADING"), eq("pda-already-loaded"), anyString()); - } - - @Test - void pdaFinalizeEntryPointsAreTransactional() throws Exception { - assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement", - long.class, long.class, long.class, String.class, String.class, String.class) - .getAnnotation(Transactional.class)).isNotNull(); - assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement", - long.class, long.class, long.class, long.class, long.class, - String.class, String.class, String.class) - .getAnnotation(Transactional.class)).isNotNull(); - } - - @Test - void pdaPreflightUsesCabinetEvidenceOwnerWithoutCrossCustomerLookup() { - when(repository.findInbound(20, 200)).thenReturn(new Snapshot(200L, 20L, "LOADING", 3)); - when(repository.listEvidence(10, 100)).thenReturn(List.of( - evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), - evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), - evidence("SEAL", 5L, "LOCK-001"))); - when(files.validate(eq("token"), eq(10L), eq(100L), anyLong())).thenAnswer(invocation -> { - long fileId = invocation.getArgument(3); - String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; - return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( - fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); - }); - - service.requirePdaCompletionReady(20, 200, 10, 100); - - verify(loadingModelBridge).requireStarted(20, 200); - verify(completionGate).requireReleasedBooking(20, 200); - verify(repository).listEvidence(10, 100); - verify(repository, never()).listEvidence(20, 100); - verify(files, times(5)).validate(eq("token"), eq(10L), eq(100L), anyLong()); - } - - @Test - void pdaFinalizeKeepsOperationOnBusinessOwnerWhileUsingCabinetEvidenceOwner() { - when(repository.claimOperation(eq(20L), eq(200L), eq(7L), eq("COMPLETE_LOADING"), - eq("pda-multi-customer"), anyString())).thenReturn(true); - when(repository.findInboundForUpdate(20, 200)).thenReturn(new Snapshot(200L, 20L, "LOADING", 7)); - when(repository.findInbound(20, 200)).thenReturn(new Snapshot(200L, 20L, "LOADING", 7)); - LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); - when(progress.getExpectedCount()).thenReturn(1); - when(progress.getPickedCount()).thenReturn(1); - when(progress.getTaskCount()).thenReturn(1); - when(progress.getCompletedTaskCount()).thenReturn(0); - when(progress.getUnresolvedCount()).thenReturn(0); - when(loadingPicks.inboundProgress(20, 200)).thenReturn(progress); - when(repository.listEvidence(10, 100)).thenReturn(List.of( - evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), - evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), - evidence("SEAL", 5L, "LOCK-001"))); - when(files.validate(eq("token"), eq(10L), eq(100L), anyLong())).thenAnswer(invocation -> { - long fileId = invocation.getArgument(3); - String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; - return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( - fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); - }); - when(repository.completeLoading(20, 200, 7, 7)).thenReturn(1); - - LoadingActionResponse result = service.completeAfterPdaSettlement( - 20, 200, 10, 100, 7, "admin", "pda-multi-customer", "完成"); - - assertThat(result.mainStatus()).isEqualTo("LOADED"); - verify(repository).completeLoading(20, 200, 7, 7); - verify(repository).completeOperation(eq(20L), eq(200L), eq(7L), - eq("COMPLETE_LOADING"), eq("pda-multi-customer"), anyString()); - verify(repository, never()).completeOperation(eq(20L), eq(100L), anyLong(), - anyString(), anyString(), anyString()); - verify(repository).listEvidence(10, 100); - verify(repository, never()).listEvidence(20, 100); - } - - @Test - void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() { - readyForPdaFinalize(); - CompletionTarget settled = new CompletionTarget(20L, 21L, 20L, - "LOADING", 7, "LOADING", 2); - when(repository.completionTarget(10, 20)).thenReturn(settled); - when(repository.findInboundForUpdate(10, 21)).thenReturn(new Snapshot(21L, 10L, "LOADING", 2)); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); - when(repository.completeLoadedChild(10, 20, 7, 2)).thenReturn(1); - - LoadingActionResponse result = service.completeAfterPdaSettlement( - 10, 20, 7, "admin", "pda-partial", "完成"); - - assertThat(result.mainStatus()).isEqualTo("LOADED"); - verify(repository).findInboundForUpdate(10, 21); - verify(repository).completeLoadedChild(10, 20, 7, 2); - verify(loadingModelBridge).requireStarted(10, 20); - verify(loadingModelBridge, never()).requireCompleted(10, 20); - } - - @Test - void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() { - readyForPdaFinalize(); - CompletionTarget settled = new CompletionTarget(20L, null, 20L, - "LOADING", 7, "LOADING", 7); - when(repository.completionTarget(10, 20)).thenReturn(settled); - when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); - when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(0); - - assertThatThrownBy(() -> service.completeAfterPdaSettlement( - 10, 20, 7, "admin", "pda-concurrent", "完成")) - .hasMessageContaining("数据已被其他人修改"); - - verify(histories, never()).insert(any(InboundStatusHistoryEntity.class)); - verify(repository, never()).completeOperation(anyLong(), anyLong(), anyLong(), - anyString(), anyString(), anyString()); - } - - @Test - void completesSingleRemainderSourceOnceAndReplaysSameKeyWithoutRepeatingSideEffects() { - AtomicInteger claims = new AtomicInteger(); - AtomicReference requestHash = new AtomicReference<>(); - AtomicReference resultJson = new AtomicReference<>(); - when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"), - eq("slice-replay"), anyString())).thenAnswer(invocation -> { - requestHash.set(invocation.getArgument(5)); - return claims.getAndIncrement() == 0; - }); - doAnswer(invocation -> { - resultJson.set(invocation.getArgument(5)); - return null; - }).when(repository).completeOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"), - eq("slice-replay"), anyString()); - when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay")) - .thenAnswer(invocation -> successfulOperation(requestHash.get(), resultJson.get())); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 5)); - when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget( - 20L, null, 10L, "LOADING", 5, "LOADING", 5)); - LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); - when(progress.getExpectedCount()).thenReturn(5); - when(progress.getPickedCount()).thenReturn(5); - when(progress.getTaskCount()).thenReturn(1); - when(progress.getCompletedTaskCount()).thenReturn(1); - when(progress.getUnresolvedCount()).thenReturn(0); - when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); - when(repository.listEvidence(10, 20)).thenReturn(List.of( - evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), - evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), - evidence("SEAL", 5L, "LOCK-001"))); - when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { - long fileId = invocation.getArgument(3); - String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; - return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( - fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); - }); - when(repository.completeLoadedChild(10, 20, 7, 5)).thenReturn(1); - - LoadingActionResponse first = service.completeLoading(10, 20, 7, "admin", "slice-replay", - new CompleteLoadingRequest(5, "完成")); - LoadingActionResponse replay = service.completeLoading(10, 20, 7, "admin", "slice-replay", - new CompleteLoadingRequest(5, "完成")); - - assertThat(first.mainStatus()).isEqualTo("LOADED"); - assertThat(replay).isEqualTo(first); - verify(repository, times(2)).claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"), - eq("slice-replay"), anyString()); - verify(repository).completeLoadedChild(10, 20, 7, 5); - verify(repository).aggregateRootLoaded(10, 10, 7); - verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireReleasedBooking(10, 20); - verify(histories).insert(any(InboundStatusHistoryEntity.class)); - verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get()); - } - - @Test - void keepsHistoricalLoadedChildAsCompletionTarget() { - CompletionTarget historical = new CompletionTarget( - 20L, 21L, 20L, "WAIT_SPLIT", 3, "LOADING", 1); - - assertThat(historical.completionInboundId()).isEqualTo(21L); - } - - @Test - void rejectsCompletionWhenEvidenceIsIncomplete() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); - when(progress.getExpectedCount()).thenReturn(5); - when(progress.getPickedCount()).thenReturn(5); - when(progress.getTaskCount()).thenReturn(1); - when(progress.getCompletedTaskCount()).thenReturn(1); - when(progress.getUnresolvedCount()).thenReturn(0); - when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); - when(repository.listEvidence(10, 20)).thenReturn(List.of(evidence("SEAL", 5L, "LOCK-001"))); - - assertThatThrownBy(() -> service.completeLoading(10, 20, 7, "admin", "k-missing", - new CompleteLoadingRequest(3, null))).hasMessageContaining("必须齐全"); - verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); - } - - @Test - void evidenceCanBeEditedAndDoesNotChangeMainStatus() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - when(repository.listEvidence(10, 20)).thenReturn(List.of()); - when(files.validate("token", 10, 20, 1)).thenReturn(ApiResponse.success( - new LoadingEvidenceFileClient.FileMetadata(1L, "photo.jpg", "LOADING_PHOTO_STEP_1", "image/jpeg"))); - LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "k2", - new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 1L, "ignored.jpg", null, "updated")); - assertThat(result.mainStatus()).isEqualTo("LOADING"); - verify(repository).createEvidence(any(LoadingEvidenceEntity.class)); - verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); - } - - @Test - void appendsMultiplePhotosOfTheSameType() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - when(repository.listEvidence(10, 20)).thenReturn(List.of()); - when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { - long id = invocation.getArgument(3); - return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(id, "photo-" + id + ".jpg", - "LOADING_PHOTO_STEP_1", "image/jpeg")); - }); - - service.upsertEvidence(10, 20, 7, "admin", "append-1", - new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 11L, null, null, null)); - service.upsertEvidence(10, 20, 7, "admin", "append-2", - new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 12L, null, null, null)); - - verify(repository, times(2)).createEvidence(argThat(value -> - "PHOTO_STEP_1".equals(value.getEvidenceType()) && "ACTIVE".equals(value.getStatus()))); - } - - @Test - void replaysTheSameActiveEvidenceFileWithoutAppendingAnotherRecord() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - LoadingEvidenceEntity existing = evidence("SEAL", 11L, "LOCK-001"); - existing.setId(99L); existing.setInboundId(20L); existing.setTenantId(10L); existing.setStatus("ACTIVE"); - when(repository.findEvidenceBySealNo(10, "LOCK-001")).thenReturn(existing); - when(repository.findActiveEvidenceByFile(10, 20, "SEAL", 11L)).thenReturn(existing); - when(repository.listEvidence(10, 20)).thenReturn(List.of(existing)); - - LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "same-seal-photo", - new UpsertEvidenceRequest(0, "SEAL", 11L, null, "LOCK-001", null)); - - assertThat(result.evidences().evidences()).hasSize(1); - verify(repository, never()).createEvidence(any()); - verify(files, never()).validate(anyString(), anyLong(), anyLong(), anyLong()); - } - - @Test - void voidsPhotoWithReasonAndKeepsTheRecord() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - LoadingEvidenceEntity target = evidence("PHOTO_STEP_1", 11L, null); - target.setId(99L); target.setStatus("ACTIVE"); target.setVersion(2); - when(repository.listEvidence(10, 20)).thenReturn(List.of(target)); - when(repository.voidEvidence(10, 20, 99L, "模糊", 7L, "admin", 2)).thenReturn(1); - - LoadingActionResponse result = service.voidEvidence(10, 20, 99L, 7L, "admin", "void-1", - new VoidEvidenceRequest(2, "模糊")); - - assertThat(result.mainStatus()).isEqualTo("LOADING"); - verify(repository).voidEvidence(10, 20, 99L, "模糊", 7L, "admin", 2); - } - - @Test - void rejectsDuplicateSealUsedByAnotherInbound() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - LoadingEvidenceEntity duplicate = evidence("SEAL", 9L, "LOCK-DUP"); - duplicate.setInboundId(21L); - when(repository.findEvidenceBySealNo(10, "LOCK-DUP")).thenReturn(duplicate); - - assertThatThrownBy(() -> service.upsertEvidence(10, 20, 7, "admin", "k-duplicate-seal", - new UpsertEvidenceRequest(0, "SEAL", 5L, null, "LOCK-DUP", null))) - .hasMessageContaining("锁码已用于其他装柜任务"); - 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); - when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - - assertThatThrownBy(() -> service.completePicking(10, 20, 7, "admin", "k-picking", - new PickingRequest(0, 1, 1, null))).hasMessageContaining("真实柜次"); - verify(repository, never()).createPickTask(any()); - } - - private static LoadingEvidenceEntity evidence(String type, Long fileId, String sealNo) { - LoadingEvidenceEntity value = new LoadingEvidenceEntity(); - value.setId(fileId); value.setInboundId(20L); value.setTenantId(10L); - value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo); - return value; - } - - private void readyForPdaFinalize() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) - .thenReturn(true); - readyForPdaFinalizeFacts(); - } - - private void readyForPdaFinalizeFacts() { - LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); - when(progress.getExpectedCount()).thenReturn(5); - when(progress.getPickedCount()).thenReturn(5); - when(progress.getTaskCount()).thenReturn(1); - // completion worker runs before task finalization; accepted COMPLETING is not COMPLETED yet. - when(progress.getCompletedTaskCount()).thenReturn(0); - when(progress.getUnresolvedCount()).thenReturn(0); - when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); - when(repository.listEvidence(10, 20)).thenReturn(List.of( - evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), - evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), - evidence("SEAL", 5L, "LOCK-001"))); - when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { - long fileId = invocation.getArgument(3); - String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; - return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( - fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); - }); - } - - private static LoadingOperationEntity successfulOperation(String requestHash, String resultJson) { - LoadingOperationEntity value = new LoadingOperationEntity(); - value.setRequestHash(requestHash); - value.setStatus("SUCCESS"); - value.setResultJson(resultJson); - return value; - } - - private static LoadingOperationEntity processingOperation(String requestHash) { - LoadingOperationEntity value = new LoadingOperationEntity(); - value.setRequestHash(requestHash); - value.setStatus("PROCESSING"); - return value; + verify(cabinetEvidence).requireComplete(99L); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index f27f5d3c2..11d6a3460 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -108,7 +108,7 @@ class ContainerOverviewServiceTest { 61L, 52L, 41L, "LOADING", 7)); when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow( 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()))); - when(evidenceFiles.download("internal-token", 81L, 91L, 101L)).thenReturn(ResponseEntity.ok() + when(evidenceFiles.download("internal-token", 41L, "LOADING_DOOR_OPEN", 101L)).thenReturn(ResponseEntity.ok() .contentType(MediaType.IMAGE_JPEG).body(new byte[]{1, 2, 3})); var download = service.downloadPhoto(41L, 101L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index cc478d53f..196529b95 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -23,6 +23,7 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baiyue.firstleg.loading.service.LoadingDeliveryService; +import com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService; import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; @@ -83,6 +84,8 @@ class LoadingPickServiceTest { @MockBean private LoadingDeliveryService loadingDeliveryService; @MockBean + private CabinetLoadingEvidenceService cabinetEvidenceService; + @MockBean private ColoadGroupAssignmentPort coloadGroups; @MockBean private TruckAppointmentService truckAppointmentService; @@ -574,17 +577,17 @@ class LoadingPickServiceTest { verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(settlementService).settle(20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator"); - verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 101L, 50L); - verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 101L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L); verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); - verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator", + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", "PDA-complete-1-50", "checked"); - verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 101L, 50L, 9L, "operator", + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 9L, "operator", "PDA-complete-1-60", "checked"); verify(loadingListService).publishCompletedEvent(10L); InOrder finalization = inOrder(loadingListService, loadingDeliveryService); finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); - finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator", + finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", "PDA-complete-1-50", "checked"); finalization.verify(loadingListService).publishCompletedEvent(10L); } @@ -615,7 +618,7 @@ class LoadingPickServiceTest { verify(settlementService).settle(20L, 9L, "operator"); verify(repository).approve(20L, 9L, "operator", "checked"); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); - verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator", + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", "PDA-complete-not-blocked-50", "checked"); verify(loadingListService).publishCompletedEvent(10L); } @@ -634,7 +637,7 @@ class LoadingPickServiceTest { new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单并发修改")) - .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator", + .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", "PDA-complete-finalize-fails-50", "checked"); assertThatThrownBy(() -> service.complete(20L, @@ -694,7 +697,7 @@ class LoadingPickServiceTest { verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingListService, never()).publishCompletedEvent(anyLong()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), - anyLong(), anyLong(), anyString(), anyString(), anyString()); + anyString(), anyString(), anyString()); } @Test @@ -869,7 +872,7 @@ class LoadingPickServiceTest { } @Test - void workerUsesOneStableCabinetEvidenceOwnerAcrossTwoCustomers() { + void workerUsesCabinetEvidenceOnceWithReversedTwoCustomerOwnerOrder() { LoadingPickTaskEntity completing = task(); completing.setTaskStatus("COMPLETING"); when(repository.task(20L)).thenReturn(completing); @@ -889,12 +892,13 @@ class LoadingPickServiceTest { service.processCompletion(completionCommand(903L, "async-multi"), "worker-multi"); - verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 101L, 50L); - verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 101L, 50L); - verify(loadingDeliveryService).requirePdaCompletionReady(202L, 70L, 101L, 50L); - verify(loadingDeliveryService).requirePdaCompletionReady(202L, 80L, 101L, 50L); + verify(cabinetEvidenceService, times(1)).requireComplete(99L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L); + verify(loadingDeliveryService).requirePdaCompletionReady(202L, 70L); + verify(loadingDeliveryService).requirePdaCompletionReady(202L, 80L); verify(loadingDeliveryService).completeAfterPdaSettlement( - 202L, 70L, 101L, 50L, 9L, "operator", "PDA-async-multi-70", "checked"); + 202L, 70L, 9L, "operator", "PDA-async-multi-70", "checked"); verify(settlementService, times(4)).settleOwner(eq(20L), anyLong(), eq(9L), eq("operator")); verify(repository, times(1)).insert(argThat((LoadingPickOperationEntity operation) -> "COMPLETE_LOADING".equals(operation.getActionType()) @@ -914,7 +918,7 @@ class LoadingPickServiceTest { new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(202L, 70L))); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "第二客户门禁未就绪")) - .when(loadingDeliveryService).requirePdaCompletionReady(202L, 70L, 101L, 50L); + .when(loadingDeliveryService).requirePdaCompletionReady(202L, 70L); assertThatThrownBy(() -> service.processCompletion( completionCommand(904L, "async-owner-ready-fails"), "worker-failure")) @@ -924,7 +928,7 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), - anyLong(), anyLong(), anyString(), anyString(), anyString()); + anyString(), anyString(), anyString()); verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY"); verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(), eq("SETTLED")); @@ -952,8 +956,8 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(), - anyLong(), anyLong(), anyString(), anyString(), anyString()); - verify(loadingDeliveryService).completeAfterPdaSettlement(202L, 60L, 101L, 50L, 9L, "operator", + anyString(), anyString(), anyString()); + verify(loadingDeliveryService).completeAfterPdaSettlement(202L, 60L, 9L, "operator", "PDA-async-resume-60", "checked"); verify(loadingListService).publishCompletedEvent(10L); verify(repository).insert(org.mockito.ArgumentMatchers.argThat(