This commit is contained in:
2026-08-20 15:20:05 +08:00
parent 6c7a670f1e
commit b55b049e77
30 changed files with 727 additions and 12 deletions
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_event_outbox")
public class LastlegEventOutbox {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String aggregateType;
private String aggregateId;
private String eventType;
private Integer eventVersion;
private String eventKey;
private String payloadJson;
private String status;
private Integer retryCount;
private LocalDateTime nextRetryAt;
private LocalDateTime publishedAt;
private String lastError;
private LocalDateTime createdAt;
}
@@ -56,6 +56,7 @@ public class LastlegOrder {
private String warehouseRemark;
private String sourceEventId;
private String sourceLoadingPlanId;
private String sourceContainerBookingId;
private Long sourceLoadingPlanVersion;
private Long sourceHandoverVersion;
private String sourcePayloadHash;
@@ -105,6 +105,15 @@ public class InboundPdaController {
return ApiResponse.success(service.complete(scope(user, customerId), planNo));
}
@PostMapping("/unload-photos")
public ApiResponse<PdaResponses.Task> uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.UnloadPhotos request) {
requireOperate(user);
return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
@@ -1,8 +1,11 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
public final class PdaRequests {
private PdaRequests() {}
@@ -22,4 +25,7 @@ public final class PdaRequests {
public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
public record UnloadPhotos(@NotEmpty List<@Positive Long> fileIds, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
}
@@ -8,7 +8,9 @@ public final class PdaResponses {
private PdaResponses() {}
public record Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt) {}
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt) {}
public record Operation(String result, long allocationId, int requiredPackageCount,
int countedPackageCount, int pickedPackageCount,
@@ -21,6 +21,9 @@ public class InboundPdaTask {
private LocalDateTime pushedAt;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
private String unloadPhotoFileIds;
private LocalDateTime unloadPhotoUploadedAt;
private LocalDateTime estimatedEmptyReturnAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Integer version;
@@ -17,6 +17,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.core.type.TypeReference;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -44,6 +45,7 @@ public class InboundPdaWorkflowService {
@Resource private PdaWorkflowRepository repository;
@Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@@ -372,6 +374,11 @@ public class InboundPdaWorkflowService {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) return task(task, planNo);
List<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|| task.getEstimatedEmptyReturnAt() == null) {
throw conflict("Upload unload photos before completing the PDA inbound task");
}
Map<Long, Integer> pickedByLine = new HashMap<>();
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
for (InboundPlanPalletAllocation allocation : allocations) {
@@ -402,9 +409,30 @@ public class InboundPdaWorkflowService {
plan.setStatus("COMPLETED");
plan.setUpdatedAt(now);
repository.update(plan);
unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds);
return task(task, planNo);
}
@Transactional
public PdaResponses.Task uploadUnloadPhotos(String customerId, String planNo, PdaRequests.UnloadPhotos request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Task.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
List<Long> fileIds = request.fileIds().stream().distinct().sorted().toList();
if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated");
LocalDateTime uploadedAt = LocalDateTime.now();
task.setUnloadPhotoFileIds(write(fileIds));
task.setUnloadPhotoUploadedAt(uploadedAt);
task.setEstimatedEmptyReturnAt(uploadedAt.plusDays(2));
task.setUpdatedAt(uploadedAt);
task.setVersion(value(task.getVersion()) + 1);
repository.update(task);
PdaResponses.Task result = task(task, planNo);
saveOperation(customerId, task.getId(), null, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request, result);
return result;
}
private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId,
int packageCount, BigDecimal movedVolume) {
source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount);
@@ -582,7 +610,17 @@ public class InboundPdaWorkflowService {
private PdaResponses.Task task(InboundPdaTask task, String planNo) {
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt());
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt());
}
private List<Long> unloadPhotoFileIds(InboundPdaTask task) {
if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of();
try {
return objectMapper.readValue(task.getUnloadPhotoFileIds(), new TypeReference<List<Long>>() {});
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read unload photo file IDs", exception);
}
}
private BigDecimal perPackageVolume(InboundPlanLine line) {
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
/** Writes the overseas-to-CN handover to the local transactional outbox. */
@Service
public class InboundUnloadCompletedOutboxService {
@Resource private LastlegEventOutboxMapper mapper;
@Resource private LastlegOrderMapper orders;
@Resource private ObjectMapper objectMapper;
public void publish(InboundPlan plan, InboundPdaTask task, List<Long> photoFileIds) {
LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId());
if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null
|| sourceOrder.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC);
var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(),
plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null);
JsonNode payloadNode = objectMapper.valueToTree(payload);
long aggregateVersion = Math.max(1, task.getVersion() == null ? 1 : task.getVersion());
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId();
String traceId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING",
sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox event = new LastlegEventOutbox();
event.setId(IdWorker.getId()); event.setCustomerId(task.getCustomerId());
event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId());
event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion());
event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING");
event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
mapper.insert(event);
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot serialize unload-completed event", exception);
}
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LastlegEventOutboxMapper extends BaseMapper<LastlegEventOutbox> {
}
@@ -120,7 +120,8 @@ public class LoadingCompletedConsumer {
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId());
order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());
try {
@@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)',
source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID',
source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID',
source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID',
source_loading_plan_version BIGINT COMMENT '来源装柜计划版本',
source_handover_version BIGINT COMMENT '交接版本',
source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要',
@@ -163,6 +164,8 @@ CREATE TABLE IF NOT EXISTS ll_order_status_history (
-- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。
ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱';
ALTER TABLE ll_order COMMENT='尾程订单主表';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt;
ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细';
ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史';
ALTER TABLE ll_event_conflict COMMENT='事件冲突记录';
@@ -237,6 +240,7 @@ ALTER TABLE ll_order
MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)',
MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID',
MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID',
MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID',
MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本',
MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本',
MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要',
@@ -410,6 +414,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation (
-- 修复既有入库计划表的注释;历史“每柜一张计划”记录不在这里猜测拆分。
ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id'));
PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt;
ALTER TABLE ll_inbound_plan
MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)',
MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号',
@@ -491,12 +497,33 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pda_task (
task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '业务状态',
operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(255) NULL COMMENT '操作人姓名',
pushed_at DATETIME(3) NULL COMMENT '任务下发时间', started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间',
unload_photo_file_ids JSON NULL COMMENT '卸柜照片文件ID列表', unload_photo_uploaded_at DATETIME(3) NULL COMMENT '卸柜照片上传成功时间',
estimated_empty_return_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功后两天)',
version INT NOT NULL DEFAULT 0 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 '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (customer_id, plan_id),
UNIQUE KEY uk_ll_inbound_pda_task_no (task_no)
) ENGINE=InnoDB COMMENT='入仓PDA作业任务';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at'));
PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids'));
PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at'));
PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt;
CREATE TABLE IF NOT EXISTS ll_event_outbox (
id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本',
event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间',
last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key),
KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at)
) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱';
CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID',
allocation_id BIGINT NULL COMMENT '分配记录ID', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
@@ -22,6 +22,7 @@ import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -42,6 +43,7 @@ class InboundPdaWorkflowServiceTest {
@Mock PdaWorkflowRepository repository;
@Mock WarehouseResourceGateway warehouse;
@Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox;
private InboundPdaWorkflowService service;
private InboundPlan plan;
@@ -61,6 +63,7 @@ class InboundPdaWorkflowServiceTest {
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
plan = plan();
@@ -300,6 +303,47 @@ class InboundPdaWorkflowServiceTest {
verify(repository, never()).update(plan);
}
@Test
void completionRequiresSuccessfullyUploadedUnloadPhotos() {
activeTask();
task.setUnloadPhotoFileIds(null);
task.setUnloadPhotoUploadedAt(null);
task.setEstimatedEmptyReturnAt(null);
assertThrows(ResponseStatusException.class, () -> service.complete("100", "LIP1"));
verify(repository, never()).allocations(plan.getId());
verify(unloadCompletedOutbox, never()).publish(any(), any(), any());
}
@Test
void uploadUnloadPhotosRecordsEstimatedEmptyReturnTwoDaysLaterAndIsIdempotent() {
activeTask();
PdaRequests.UnloadPhotos request = new PdaRequests.UnloadPhotos(List.of(103L, 101L),
"unload-photo-1", "7", "worker");
var result = service.uploadUnloadPhotos("100", "LIP1", request);
assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds());
assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt());
verify(repository).update(task);
ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class);
verify(repository).insert(operation.capture());
when(repository.operation("100", "unload-photo-1")).thenReturn(operation.getValue());
var replay = service.uploadUnloadPhotos("100", "LIP1", request);
assertEquals(result, replay);
verify(repository, times(1)).update(task);
}
@Test
void uploadUnloadPhotosRejectsDuplicateFileIds() {
activeTask();
assertThrows(ResponseStatusException.class, () -> service.uploadUnloadPhotos("100", "LIP1",
new PdaRequests.UnloadPhotos(List.of(101L, 101L), "unload-photo-duplicate", "7", "worker")));
verify(repository, never()).update(task);
}
@Test
void completesWhenMergedAllocationsPreserveExactLineTotals() {
activeTask();
@@ -316,6 +360,7 @@ class InboundPdaWorkflowServiceTest {
verify(repository).update(plan);
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target, moved)), any(), any());
verify(unloadCompletedOutbox).publish(eq(plan), eq(task), eq(List.of(101L)));
}
@Test
@@ -387,6 +432,9 @@ class InboundPdaWorkflowServiceTest {
value.setCustomerId("100");
value.setTaskNo("PDA2");
value.setStatus("PDA_PENDING");
value.setUnloadPhotoFileIds("[101]");
value.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0));
value.setEstimatedEmptyReturnAt(LocalDateTime.of(2026, 8, 22, 10, 0));
value.setVersion(0);
return value;
}