fix(firstleg): scope loading evidence to cabinet tasks
This commit is contained in:
@@ -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和数据隔离键',
|
||||
|
||||
@@ -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<UploadResult> 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<FileMetadata> 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<byte[]> 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) {}
|
||||
}
|
||||
|
||||
+53
@@ -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<EvidenceList> 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<EvidenceList> 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<byte[]> 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<EvidenceList> 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> inventorySnapshot(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
@@ -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<Evidence> evidences) {
|
||||
public EvidenceList { evidences = evidences == null ? List.of() : List.copyOf(evidences); }
|
||||
}
|
||||
}
|
||||
-77
@@ -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<LoadingActionResponse> 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<LoadingActionResponse> pdaEvidence(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpsertEvidenceRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.upsertEvidence(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "PDA查询装柜证据")
|
||||
@GetMapping("/{inboundId}/loading/evidence/pda")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<EvidenceListResponse> pdaEvidence(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.evidence(tenantId, inboundId));
|
||||
}
|
||||
|
||||
@Operation(summary = "PDA查看装柜证据照片")
|
||||
@GetMapping("/{inboundId}/loading/evidence/{evidenceId}/content/pda")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ResponseEntity<byte[]> 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<LoadingActionResponse> pdaVoidEvidence(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
|
||||
@PathVariable long evidenceId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody VoidEvidenceRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.voidEvidence(tenantId, inboundId, evidenceId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询装柜证据")
|
||||
@GetMapping("/{inboundId}/loading/evidence")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
|
||||
public ApiResponse<EvidenceListResponse> 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<LoadingActionResponse> 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')")
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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<EvidenceResponse> 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) {}
|
||||
|
||||
+7
-5
@@ -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;
|
||||
+52
@@ -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<CabinetLoadingEvidenceEntity> {
|
||||
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id")
|
||||
List<CabinetLoadingEvidenceEntity> 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<CabinetLoadingEvidenceEntity> 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);
|
||||
}
|
||||
@@ -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<LoadingEvidenceEntity> {
|
||||
@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);
|
||||
}
|
||||
@@ -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<LoadingEvidenceEntity> listEvidence(long tenantId, long inboundId) {
|
||||
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
|
||||
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
|
||||
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
|
||||
}
|
||||
|
||||
public LoadingEvidenceEntity findEvidenceBySealNo(long tenantId, String sealNo) {
|
||||
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.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<LoadingEvidenceEntity>()
|
||||
.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<LoadingOperationEntity>()
|
||||
|
||||
+191
@@ -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<byte[]> 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<String> 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<Evidence> 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) {}
|
||||
}
|
||||
+17
-210
@@ -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<byte[]> 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<byte[]> 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<String, LoadingEvidenceEntity> 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<EvidenceResponse> 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);
|
||||
|
||||
@@ -149,7 +149,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_plan WHERE container_booking_id=#{id}") int hasLoadingPlan(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0") int hasActiveLoadingItems(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id WHERE p.container_booking_id=#{id}") int hasPickTask(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) 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 JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_cabinet_evidence e WHERE e.container_booking_id=#{id} AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{id}") int hasDocuments(@Param("id") long id);
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},
|
||||
|
||||
@@ -29,7 +29,7 @@ import java.util.List;
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status IN ('PICKED','PARTIAL','SKIPPED')") int completedItemCount(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status NOT IN ('PICKED','PARTIAL','SKIPPED')") int unresolvedItemCount(@Param("id") long id);
|
||||
@Select("SELECT DISTINCT h.operation_reason FROM fl_loading_pick_history h WHERE h.pick_task_id=#{id} AND h.operation_reason IS NOT NULL AND h.operation_reason<>'' ORDER BY h.id") List<String> 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<PhotoRow> 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<PhotoRow> 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<TimelineRow> 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) {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+6
-4
@@ -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}
|
||||
|
||||
+10
-14
@@ -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<LoadingEvidenceEntity> findEvidence(long tenantId, long inboundId) {
|
||||
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
|
||||
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
|
||||
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
|
||||
public List<CabinetLoadingEvidenceEntity> findEvidence(long tenantId, long inboundId) {
|
||||
return evidences.byInbound(tenantId, inboundId);
|
||||
}
|
||||
|
||||
public Map<Long, PreloadPlanEntity> findPreloads(Map<Long, Long> tenantByInbound) {
|
||||
@@ -65,13 +62,12 @@ public class UnifiedInboundReadRepository {
|
||||
.collect(Collectors.toMap(InboundLoadingPickTaskEntity::getInboundId, Function.identity(), (left, right) -> left));
|
||||
}
|
||||
|
||||
public Map<Long, List<LoadingEvidenceEntity>> findEvidence(Map<Long, Long> tenantByInbound) {
|
||||
public Map<Long, List<CabinetLoadingEvidenceEntity>> findEvidence(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.in(LoadingEvidenceEntity::getInboundId, tenantByInbound.keySet())
|
||||
.orderByAsc(LoadingEvidenceEntity::getEvidenceType))
|
||||
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.collect(Collectors.groupingBy(LoadingEvidenceEntity::getInboundId));
|
||||
Map<Long, List<CabinetLoadingEvidenceEntity>> result = new java.util.HashMap<>();
|
||||
tenantByInbound.forEach((inboundId, tenantId) -> result.put(inboundId,
|
||||
evidences.byInbound(tenantId, inboundId)));
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<Long, InboundStatusHistoryEntity> findDeliveryHistory(Map<Long, Long> tenantByInbound) {
|
||||
|
||||
@@ -134,7 +134,7 @@ public class ContainerOverviewService {
|
||||
.findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "PDA照片不存在或不属于该货柜"));
|
||||
final org.springframework.http.ResponseEntity<byte[]> 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) {
|
||||
|
||||
@@ -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<GroupSnapshot> 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);
|
||||
|
||||
@@ -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<LoadingEvidenceEntity> evidence = List.of();
|
||||
List<CabinetLoadingEvidenceEntity> 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<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
|
||||
List<CabinetLoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
|
||||
CurrentContainerTransport transport, String locationCodes,
|
||||
com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) {
|
||||
String mainStatus = mainStatus(inbound.getProcessStatus());
|
||||
List<LoadingEvidenceEntity> activeEvidence = evidence.stream()
|
||||
List<CabinetLoadingEvidenceEntity> 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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}}
|
||||
|
||||
+3
-13
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-74
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-29
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+89
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
-585
@@ -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<String> 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<String> requestHash = new AtomicReference<>();
|
||||
AtomicReference<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -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.<LoadingPickOperationEntity>argThat(
|
||||
|
||||
Reference in New Issue
Block a user