优化
This commit is contained in:
@@ -10,6 +10,8 @@ public final class EventTypes {
|
|||||||
public static final String ORDER_CANCELLED = "OrderCancelled";
|
public static final String ORDER_CANCELLED = "OrderCancelled";
|
||||||
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
||||||
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
|
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
|
||||||
|
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
|
||||||
|
"FirstLegContainerShippingNodeRecorded";
|
||||||
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
||||||
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
||||||
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
|
||||||
|
public record FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
String queryId,
|
||||||
|
String customerId,
|
||||||
|
String oceanBookingId,
|
||||||
|
String containerBookingId,
|
||||||
|
String containerNo,
|
||||||
|
String standardNode,
|
||||||
|
int nodeSequence,
|
||||||
|
Instant observedAt,
|
||||||
|
Instant actualAt,
|
||||||
|
String locationCode,
|
||||||
|
String locationName,
|
||||||
|
String providerCode,
|
||||||
|
String providerEventId,
|
||||||
|
String providerNodeCode,
|
||||||
|
String sourceReference,
|
||||||
|
String rawPayloadHash) {
|
||||||
|
|
||||||
|
public FirstLegContainerShippingNodeRecordedV1 {
|
||||||
|
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|
||||||
|
|| oceanBookingId == null || oceanBookingId.isBlank()
|
||||||
|
|| containerBookingId == null || containerBookingId.isBlank()
|
||||||
|
|| containerNo == null || containerNo.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||||
|
}
|
||||||
|
if (standardNode == null || standardNode.isBlank() || nodeSequence < 10
|
||||||
|
|| observedAt == null || actualAt == null) {
|
||||||
|
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
||||||
|
}
|
||||||
|
if (providerCode == null || providerCode.isBlank() || providerEventId == null
|
||||||
|
|| providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank()
|
||||||
|
|| rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) {
|
||||||
|
throw new IllegalArgumentException("船节点交接供应商审计字段不完整");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class FirstLegContainerShippingNodeRecordedV1Test {
|
||||||
|
@Test
|
||||||
|
void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception {
|
||||||
|
var payload = payload();
|
||||||
|
var mapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
var restored = mapper.readValue(mapper.writeValueAsString(payload),
|
||||||
|
FirstLegContainerShippingNodeRecordedV1.class);
|
||||||
|
|
||||||
|
assertThat(restored).isEqualTo(payload);
|
||||||
|
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
||||||
|
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
||||||
|
assertThat(restored.nodeSequence()).isEqualTo(80);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
|
||||||
|
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
"1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
|
||||||
|
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
|
||||||
|
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FirstLegContainerShippingNodeRecordedV1 payload() {
|
||||||
|
return new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
"2088000000000000001", "2088000000000000002", "2088000000000000004",
|
||||||
|
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
|
||||||
|
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||||
|
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
|
||||||
|
"ZIM:evt-1", "a".repeat(64));
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration {
|
|||||||
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) {
|
||||||
|
return GroupedOpenApi.builder().group("pda")
|
||||||
|
.displayName("PDA APIs / PDA 作业接口")
|
||||||
|
.pathsToMatch("/api/pda/**")
|
||||||
|
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||||
|
}
|
||||||
|
|
||||||
private Parameter header(String name, String description, boolean required) {
|
private Parameter header(String name, String description, boolean required) {
|
||||||
return new HeaderParameter().name(name).description(description).required(required)
|
return new HeaderParameter().name(name).description(description).required(required)
|
||||||
.schema(new io.swagger.v3.oas.models.media.StringSchema());
|
.schema(new io.swagger.v3.oas.models.media.StringSchema());
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.baiyue.common.openapi;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PdaOpenApiGroupContractTest {
|
||||||
|
@Test
|
||||||
|
void pdaGroupOnlyMatchesDeviceApis() {
|
||||||
|
BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration();
|
||||||
|
var group = configuration.pdaApi(configuration.correlationHeaderCustomizer());
|
||||||
|
|
||||||
|
assertThat(group.getGroup()).isEqualTo("pda");
|
||||||
|
assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**");
|
||||||
|
assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
|
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class EventBridgeReliabilitySqlContractTest {
|
||||||
|
@Test
|
||||||
|
void schedulingAndLeaseSqlCoverRetryDeadAndExpiredLeaseTakeover() throws Exception {
|
||||||
|
String candidates = sql(BridgeInboxMapper.class.getMethod("dispatchCandidates", int.class), Select.class);
|
||||||
|
String claim = sql(BridgeInboxMapper.class.getMethod(
|
||||||
|
"claim", String.class, String.class, long.class), Update.class);
|
||||||
|
String failed = sql(BridgeInboxMapper.class.getMethod(
|
||||||
|
"markFailed", String.class, String.class, int.class, long.class, String.class), Update.class);
|
||||||
|
String processed = sql(BridgeInboxMapper.class.getMethod(
|
||||||
|
"markProcessed", String.class, String.class), Update.class);
|
||||||
|
|
||||||
|
assertThat(candidates).contains("status IN ('RECEIVED','RETRY')", "next_retry_at <= CURRENT_TIMESTAMP(3)",
|
||||||
|
"status = 'PROCESSING'", "lease_until < CURRENT_TIMESTAMP(3)", "ORDER BY received_at");
|
||||||
|
assertThat(claim).contains("status='PROCESSING'", "lease_owner=#{owner}", "lease_until=DATE_ADD",
|
||||||
|
"status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)");
|
||||||
|
assertThat(failed).contains("retry_count=retry_count+1", "status=CASE", "THEN 'DEAD' ELSE 'RETRY'",
|
||||||
|
"next_retry_at=CASE", "THEN NULL", "last_error=#{error}", "lease_owner=#{owner}");
|
||||||
|
assertThat(processed).contains("status='PROCESSED'", "next_retry_at=NULL", "last_error=NULL",
|
||||||
|
"status='PROCESSING'", "lease_owner=#{owner}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private <A extends java.lang.annotation.Annotation> String sql(Method method, Class<A> type) {
|
||||||
|
A annotation = method.getAnnotation(type);
|
||||||
|
String[] statements = annotation instanceof Select select ? select.value() : ((Update) annotation).value();
|
||||||
|
return String.join(" ", Arrays.asList(statements)).replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -12,6 +12,7 @@ import org.springframework.test.util.ReflectionTestUtils;
|
|||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
@@ -103,6 +104,59 @@ class OverseasEventDispatcherTest {
|
|||||||
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception {
|
||||||
|
for (String target : List.of("OVERSEAS", "CN")) {
|
||||||
|
reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient);
|
||||||
|
event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN");
|
||||||
|
event.setTargetRegion(target);
|
||||||
|
event.setRetryCount(0);
|
||||||
|
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
|
||||||
|
when(inboxMapper.selectById("event-1")).thenReturn(event);
|
||||||
|
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
event.setRetryCount(1);
|
||||||
|
event.setStatus("RETRY");
|
||||||
|
event.setLastError(invocation.getArgument(4));
|
||||||
|
event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds(
|
||||||
|
((Number) invocation.getArgument(3)).longValue()));
|
||||||
|
return 1;
|
||||||
|
}).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString());
|
||||||
|
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> {
|
||||||
|
event.setStatus("PROCESSED");
|
||||||
|
event.setLastError(null);
|
||||||
|
event.setNextRetryAt(null);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
if ("CN".equals(target)) {
|
||||||
|
when(firstlegEventClient.deliver(event))
|
||||||
|
.thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn"))
|
||||||
|
.thenReturn("200");
|
||||||
|
} else {
|
||||||
|
when(eventClient.deliver(event))
|
||||||
|
.thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas"))
|
||||||
|
.thenReturn("200");
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatcher.dispatchOne("event-1");
|
||||||
|
assertThat(event.getStatus()).isEqualTo("RETRY");
|
||||||
|
assertThat(event.getNextRetryAt()).isNotNull();
|
||||||
|
assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId=");
|
||||||
|
dispatcher.dispatchOne("event-1");
|
||||||
|
|
||||||
|
ArgumentCaptor<BridgeDeliveryAttempt> attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
|
||||||
|
verify(attemptMapper, times(2)).updateById(attempts.capture());
|
||||||
|
assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo)
|
||||||
|
.containsExactly(1, 2);
|
||||||
|
assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus)
|
||||||
|
.containsExactly("RETRY", "DELIVERED");
|
||||||
|
assertThat(attempts.getAllValues().get(0).getErrorMessage())
|
||||||
|
.contains("HTTP 503", "requestId=", "traceId=");
|
||||||
|
assertThat(event.getStatus()).isEqualTo("PROCESSED");
|
||||||
|
assertThat(event.getLastError()).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private BridgeDeliveryAttempt updatedAttempt() {
|
private BridgeDeliveryAttempt updatedAttempt() {
|
||||||
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
|
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
|
||||||
verify(attemptMapper).updateById(captor.capture());
|
verify(attemptMapper).updateById(captor.capture());
|
||||||
|
|||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package com.baiyue.firstleg.portschedule.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
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.FirstLegContainerShippingNodeRecordedV1;
|
||||||
|
import com.baiyue.common.event.RegionCode;
|
||||||
|
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||||
|
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||||
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||||
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||||
|
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.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
/** Writes actual carrier node facts to the domestic transactional Outbox. */
|
||||||
|
@Service
|
||||||
|
public class CarrierScheduleNodeOutboxService {
|
||||||
|
@Resource private InboundOutboxMapper outbox;
|
||||||
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
|
||||||
|
private String scheduleZone = "Asia/Shanghai";
|
||||||
|
|
||||||
|
public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact,
|
||||||
|
String containerNo) {
|
||||||
|
if (fact.getActualAt() == null) return;
|
||||||
|
ZoneId zone = ZoneId.of(scheduleZone);
|
||||||
|
var payload = new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()),
|
||||||
|
string(query.getContainerBookingId()), containerNo, fact.getStandardNode(), fact.getNodeSequence(),
|
||||||
|
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
|
||||||
|
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
|
||||||
|
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
|
||||||
|
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||||
|
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
|
||||||
|
long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion());
|
||||||
|
String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId();
|
||||||
|
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
|
||||||
|
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
|
||||||
|
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
|
||||||
|
string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()),
|
||||||
|
aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
|
||||||
|
payloadNode, payloadHash).requireValid();
|
||||||
|
|
||||||
|
InboundOutboxEntity row = new InboundOutboxEntity();
|
||||||
|
row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId());
|
||||||
|
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId());
|
||||||
|
row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
|
||||||
|
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
|
||||||
|
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
|
||||||
|
row.setCreatedAt(LocalDateTime.now());
|
||||||
|
try { outbox.insert(row); } catch (DuplicateKeyException ignored) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private String json(Object value) {
|
||||||
|
try { return objectMapper.writeValueAsString(value); }
|
||||||
|
catch (JsonProcessingException exception) {
|
||||||
|
throw new BizException(ResultCode.INTERNAL_ERROR, "船节点交接事件序列化失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String string(Long value) { return value == null ? null : String.valueOf(value); }
|
||||||
|
}
|
||||||
+7
-4
@@ -13,11 +13,14 @@ import java.util.List;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class LoadingCompletedOutboxRepository {
|
public class LoadingCompletedOutboxRepository {
|
||||||
|
private static final List<String> CROSS_REGION_EVENT_TYPES = List.of(
|
||||||
|
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
|
||||||
|
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED);
|
||||||
@Resource private InboundOutboxMapper mapper;
|
@Resource private InboundOutboxMapper mapper;
|
||||||
|
|
||||||
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||||
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
|
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
|
||||||
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
.in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
|
||||||
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||||
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
|
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
|
||||||
.or().le(InboundOutboxEntity::getNextRetryAt, now))
|
.or().le(InboundOutboxEntity::getNextRetryAt, now))
|
||||||
@@ -29,7 +32,7 @@ public class LoadingCompletedOutboxRepository {
|
|||||||
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||||
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
|
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
|
||||||
.eq(InboundOutboxEntity::getId, id)
|
.eq(InboundOutboxEntity::getId, id)
|
||||||
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
.in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
|
||||||
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||||
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
|
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
|
||||||
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
|
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
|
||||||
@@ -37,7 +40,7 @@ public class LoadingCompletedOutboxRepository {
|
|||||||
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
|
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
|
||||||
}
|
}
|
||||||
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
|
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
|
||||||
.eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
.eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
|
||||||
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
|
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
|
||||||
public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker)
|
public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker)
|
||||||
.set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now)
|
.set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now)
|
||||||
@@ -48,7 +51,7 @@ public class LoadingCompletedOutboxRepository {
|
|||||||
.set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null)
|
.set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null)
|
||||||
.set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); }
|
.set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); }
|
||||||
private LambdaUpdateWrapper<InboundOutboxEntity> base(long id, String status) { return new LambdaUpdateWrapper<InboundOutboxEntity>()
|
private LambdaUpdateWrapper<InboundOutboxEntity> base(long id, String status) { return new LambdaUpdateWrapper<InboundOutboxEntity>()
|
||||||
.eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
.eq(InboundOutboxEntity::getId,id).in(InboundOutboxEntity::getEventType,CROSS_REGION_EVENT_TYPES)
|
||||||
.eq(InboundOutboxEntity::getPublishStatus,status); }
|
.eq(InboundOutboxEntity::getPublishStatus,status); }
|
||||||
private LambdaUpdateWrapper<InboundOutboxEntity> claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); }
|
private LambdaUpdateWrapper<InboundOutboxEntity> claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); }
|
||||||
private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; }
|
private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; }
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/** 至少一次投递装柜完成跨区事件;事件桥按 eventId 幂等接收。 */
|
/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */
|
||||||
@Component @Slf4j
|
@Component @Slf4j
|
||||||
public class LoadingCompletedOutboxRelay {
|
public class LoadingCompletedOutboxRelay {
|
||||||
@Resource private LoadingCompletedOutboxRepository repository;
|
@Resource private LoadingCompletedOutboxRepository repository;
|
||||||
@@ -39,12 +39,13 @@ public class LoadingCompletedOutboxRelay {
|
|||||||
EventEnvelopeValidator.requireValid(event);
|
EventEnvelopeValidator.requireValid(event);
|
||||||
var response=client.receive(internalToken,event);
|
var response=client.receive(internalToken,event);
|
||||||
if(response==null || !response.successful() || response.data()==null
|
if(response==null || !response.successful() || response.data()==null
|
||||||
|| !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件");
|
|| !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认头程跨区事件");
|
||||||
repository.published(row.getId(),workerId,LocalDateTime.now());
|
repository.published(row.getId(),workerId,LocalDateTime.now());
|
||||||
} catch(Exception exception) {
|
} catch(Exception exception) {
|
||||||
String error=safeError(exception);
|
String error=safeError(exception);
|
||||||
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error);
|
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error);
|
||||||
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error);
|
log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}",
|
||||||
|
row.getEventType(), row.getEventKey(), error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package com.baiyue.firstleg.portschedule.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.event.EventEnvelope;
|
||||||
|
import com.baiyue.common.event.EventTypes;
|
||||||
|
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||||
|
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||||
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||||
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
class CarrierScheduleNodeOutboxServiceTest {
|
||||||
|
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
ReflectionTestUtils.setField(service, "outbox", mapper);
|
||||||
|
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||||
|
ReflectionTestUtils.setField(service, "scheduleZone", "Asia/Shanghai");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writesVersionedActualPickupReadyEnvelopeWithStringIds() throws Exception {
|
||||||
|
CarrierScheduleQueryEntity query = query();
|
||||||
|
CarrierScheduleNodeFactEntity fact = fact();
|
||||||
|
|
||||||
|
service.publish(query, fact, "ZCSU7881970");
|
||||||
|
|
||||||
|
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||||
|
verify(mapper).insert(captor.capture());
|
||||||
|
InboundOutboxEntity row = captor.getValue();
|
||||||
|
EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class);
|
||||||
|
assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED);
|
||||||
|
assertThat(envelope.eventVersion()).isEqualTo(1);
|
||||||
|
assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING");
|
||||||
|
assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003");
|
||||||
|
assertThat(envelope.aggregateVersion()).isEqualTo(7);
|
||||||
|
assertThat(envelope.handoverVersion()).isEqualTo(8);
|
||||||
|
assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY");
|
||||||
|
assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void estimatedOnlyFactNeverEntersCrossRegionOutbox() {
|
||||||
|
CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null);
|
||||||
|
service.publish(query(), fact, "ZCSU7881970");
|
||||||
|
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CarrierScheduleQueryEntity query() {
|
||||||
|
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||||
|
value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L);
|
||||||
|
value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L);
|
||||||
|
value.setVersion(7); return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CarrierScheduleNodeFactEntity fact() {
|
||||||
|
CarrierScheduleNodeFactEntity value = new CarrierScheduleNodeFactEntity();
|
||||||
|
value.setId(2088000000000000005L); value.setStandardNode("PICKUP_READY"); value.setNodeSequence(80);
|
||||||
|
value.setObservedAt(LocalDateTime.of(2026, 8, 25, 9, 2));
|
||||||
|
value.setActualAt(LocalDateTime.of(2026, 8, 25, 9, 0)); value.setProviderCode("ZIM");
|
||||||
|
value.setProviderEventId("evt-1"); value.setProviderNodeCode("AVAILABLE");
|
||||||
|
value.setSourceReference("ZIM:evt-1"); value.setRawPayloadHash("a".repeat(64)); return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ springdoc:
|
|||||||
urls:
|
urls:
|
||||||
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
|
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
|
||||||
- { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform }
|
- { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform }
|
||||||
|
- { name: lastleg-pda, url: /lastleg-service/v3/api-docs/pda }
|
||||||
- { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant }
|
- { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant }
|
||||||
- { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform }
|
- { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform }
|
||||||
- { name: file-platform, url: /file-service/v3/api-docs/platform }
|
- { name: file-platform, url: /file-service/v3/api-docs/platform }
|
||||||
|
|||||||
+11
@@ -27,4 +27,15 @@ class OverseasLastlegRouteContractTest {
|
|||||||
.doesNotContain("StripPrefix", "RewritePath");
|
.doesNotContain("StripPrefix", "RewritePath");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void swaggerPublishesAnIndependentLastlegPdaGroup() throws IOException {
|
||||||
|
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
|
||||||
|
assertThat(input).isNotNull();
|
||||||
|
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
assertThat(yaml).contains("name: lastleg-pda", "url: /lastleg-service/v3/api-docs/pda")
|
||||||
|
.contains("Path=/lastleg-service/v3/api-docs/**")
|
||||||
|
.doesNotContain("event-bridge-service/v3/api-docs/pda");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` |
|
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` |
|
||||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||||
| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API |
|
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 |
|
||||||
| PDA 卸柜照片文件上传与归属校验 | `InboundPdaController.java` 的 `/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
|
| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
|
||||||
| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||||
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” |
|
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” |
|
||||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
|
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
|
||||||
|
|||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
package com.baiyue.lastleg.inboundplan.pda.controller;
|
|
||||||
|
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.annotation.Resource;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets")
|
|
||||||
public class InboundPalletAssignmentController {
|
|
||||||
@Resource private InboundPdaWorkflowService service;
|
|
||||||
|
|
||||||
@PostMapping("/{planPalletId}/assign")
|
|
||||||
public ApiResponse<Void> assign(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable String planNo, @PathVariable long planPalletId,
|
|
||||||
@RequestParam(required = false) String customerId,
|
|
||||||
@Valid @RequestBody PdaRequests.Assign request) {
|
|
||||||
requireOperate(user);
|
|
||||||
service.assign(scope(user, customerId), planNo, planPalletId, request);
|
|
||||||
return ApiResponse.success(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
|
||||||
}
|
|
||||||
return requestedCustomer;
|
|
||||||
}
|
|
||||||
return String.valueOf(user.tenantId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireOperate(AuthenticatedUser user) {
|
|
||||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
|
||||||
if (user.permissions() == null || !user.permissions().contains(InboundPdaController.OPERATE_PERMISSION)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
|
||||||
InboundPdaController.OPERATE_PERMISSION + " permission is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
package com.baiyue.lastleg.inboundplan.pda.controller;
|
|
||||||
|
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.annotation.Resource;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.bind.annotation.RequestPart;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}")
|
|
||||||
public class InboundPdaController {
|
|
||||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
|
||||||
@Resource private InboundPdaWorkflowService service;
|
|
||||||
|
|
||||||
@PostMapping("/push")
|
|
||||||
public ApiResponse<PdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable String planNo,
|
|
||||||
@RequestParam(required = false) String customerId,
|
|
||||||
@Valid @RequestBody PdaRequests.Push request) {
|
|
||||||
requireOperate(user);
|
|
||||||
return ApiResponse.success(service.push(scope(user, customerId), planNo, request));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
public ApiResponse<PdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable String planNo,
|
|
||||||
@RequestParam(required = false) String customerId) {
|
|
||||||
return ApiResponse.success(service.task(scope(user, customerId), planNo));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/allocations/{allocationId}/count")
|
|
||||||
public ApiResponse<PdaResponses.Operation> count(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable String planNo,
|
|
||||||
@PathVariable long allocationId,
|
|
||||||
@RequestParam(required = false) String customerId,
|
|
||||||
@Valid @RequestBody PdaRequests.PackageOperation request) {
|
|
||||||
requireOperate(user);
|
|
||||||
return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/allocations/{allocationId}/pick")
|
|
||||||
public ApiResponse<PdaResponses.Operation> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable String planNo,
|
|
||||||
@PathVariable long allocationId,
|
|
||||||
@RequestParam(required = false) String customerId,
|
|
||||||
@Valid @RequestBody PdaRequests.PackageOperation request) {
|
|
||||||
requireOperate(user);
|
|
||||||
return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/cartons/scan")
|
|
||||||
public ApiResponse<PdaResponses.Carton> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable String planNo,
|
|
||||||
@RequestParam(required = false) String customerId,
|
|
||||||
@Valid @RequestBody PdaRequests.ScanCarton request) {
|
|
||||||
requireOperate(user);
|
|
||||||
return ApiResponse.success(service.scanCarton(scope(user, customerId), planNo, request));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/cartons/remove")
|
|
||||||
public ApiResponse<PdaResponses.Carton> removeCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable String planNo,
|
|
||||||
@RequestParam(required = false) String customerId,
|
|
||||||
@Valid @RequestBody PdaRequests.RemoveCarton request) {
|
|
||||||
requireOperate(user);
|
|
||||||
return ApiResponse.success(service.removeCarton(scope(user, customerId), planNo, request));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/allocations/{allocationId}/merge-candidates")
|
|
||||||
public ApiResponse<List<PdaResponses.MergeCandidate>> candidates(
|
|
||||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
|
||||||
@PathVariable long allocationId, @RequestParam(required = false) String customerId) {
|
|
||||||
return ApiResponse.success(service.mergeCandidates(scope(user, customerId), planNo, allocationId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/allocations/{allocationId}/merge")
|
|
||||||
public ApiResponse<PdaResponses.Merge> merge(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable String planNo,
|
|
||||||
@PathVariable long allocationId,
|
|
||||||
@RequestParam(required = false) String customerId,
|
|
||||||
@Valid @RequestBody PdaRequests.ConfirmMerge request) {
|
|
||||||
requireOperate(user);
|
|
||||||
return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/complete")
|
|
||||||
public ApiResponse<PdaResponses.Task> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@PathVariable String planNo,
|
|
||||||
@RequestParam(required = false) String customerId) {
|
|
||||||
requireOperate(user);
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
|
||||||
public ApiResponse<PdaResponses.UnloadPhotoFile> uploadUnloadPhotoFile(
|
|
||||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
|
||||||
@RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) {
|
|
||||||
requireOperate(user);
|
|
||||||
return ApiResponse.success(service.uploadUnloadPhotoFile(
|
|
||||||
scope(user, customerId), planNo, user.userId(), file));
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
|
||||||
}
|
|
||||||
return requestedCustomer;
|
|
||||||
}
|
|
||||||
return String.valueOf(user.tenantId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireOperate(AuthenticatedUser user) {
|
|
||||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
|
||||||
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-32
@@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser;
|
|||||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
|
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
|
||||||
|
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -30,14 +31,14 @@ import java.util.List;
|
|||||||
@Tag(name = "Overseas PDA container receiving")
|
@Tag(name = "Overseas PDA container receiving")
|
||||||
@PreAuthorize("hasAuthority('lastleg:pda:operate')")
|
@PreAuthorize("hasAuthority('lastleg:pda:operate')")
|
||||||
public class ReceivingContainerController {
|
public class ReceivingContainerController {
|
||||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION;
|
||||||
@Resource private ReceivingContainerService service;
|
@Resource private ReceivingContainerService service;
|
||||||
|
|
||||||
@GetMapping("/candidates")
|
@GetMapping("/candidates")
|
||||||
@Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker")
|
@Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker")
|
||||||
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
|
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
Identity identity = identity(user);
|
PdaWarehouseIdentity identity = identity(user);
|
||||||
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
|
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,14 +46,14 @@ public class ReceivingContainerController {
|
|||||||
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
|
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String containerNo) {
|
@PathVariable String containerNo) {
|
||||||
Identity identity = identity(user);
|
PdaWarehouseIdentity identity = identity(user);
|
||||||
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo));
|
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{containerNo}/prepare")
|
@PostMapping("/{containerNo}/prepare")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
|
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||||
Identity identity = identity(user);
|
PdaWarehouseIdentity identity = identity(user);
|
||||||
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(),
|
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(),
|
||||||
containerNo, request));
|
containerNo, request));
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ public class ReceivingContainerController {
|
|||||||
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestPart("file") MultipartFile file) {
|
@RequestPart("file") MultipartFile file) {
|
||||||
Identity identity = currentIdentity(user);
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
|
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
|
||||||
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
|
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
|
||||||
}
|
}
|
||||||
@@ -68,13 +69,13 @@ public class ReceivingContainerController {
|
|||||||
@PostMapping("/current/sign")
|
@PostMapping("/current/sign")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
||||||
Identity identity = currentIdentity(user);
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
|
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/current")
|
@GetMapping("/current")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
|
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
Identity identity = currentIdentity(user);
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.current(identity.operatorKey()));
|
return ApiResponse.success(service.current(identity.operatorKey()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ public class ReceivingContainerController {
|
|||||||
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
|
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
|
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
|
||||||
Identity identity = currentIdentity(user);
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
|
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ public class ReceivingContainerController {
|
|||||||
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
|
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
|
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
|
||||||
Identity identity = currentIdentity(user);
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
|
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,53 +99,38 @@ public class ReceivingContainerController {
|
|||||||
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
|
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
|
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
|
||||||
Identity identity = currentIdentity(user);
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
|
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/receipts/bulk")
|
@PostMapping("/current/receipts/bulk")
|
||||||
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
|
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
|
||||||
Identity identity = currentIdentity(user);
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
|
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/receipts/carton")
|
@PostMapping("/current/receipts/carton")
|
||||||
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
|
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
|
||||||
Identity identity = currentIdentity(user);
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
|
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/complete")
|
@PostMapping("/current/complete")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||||
Identity identity = currentIdentity(user);
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.complete(identity.operatorKey(), request));
|
return ApiResponse.success(service.complete(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Identity identity(AuthenticatedUser user) {
|
private static PdaWarehouseIdentity identity(AuthenticatedUser user) {
|
||||||
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
|
return PdaWarehouseIdentity.require(user);
|
||||||
|| !"PLATFORM".equals(user.appCode())) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated platform warehouse PDA identity is required");
|
|
||||||
}
|
|
||||||
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
|
||||||
}
|
|
||||||
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "PDA operator must be bound to one overseas warehouse");
|
|
||||||
}
|
|
||||||
String operator = String.valueOf(user.userId());
|
|
||||||
return new Identity(operator,
|
|
||||||
user.username() == null || user.username().isBlank() ? operator : user.username(),
|
|
||||||
user.overseasWarehouseId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Identity currentIdentity(AuthenticatedUser user) {
|
private PdaWarehouseIdentity currentIdentity(AuthenticatedUser user) {
|
||||||
Identity identity = identity(user);
|
PdaWarehouseIdentity identity = identity(user);
|
||||||
service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId());
|
service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId());
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private record Identity(String operatorKey, String operatorName, long overseasWarehouseId) { }
|
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.baiyue.lastleg.pda.security;
|
||||||
|
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
public record PdaWarehouseIdentity(String operatorKey, String operatorName, long overseasWarehouseId) {
|
||||||
|
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||||
|
public static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER";
|
||||||
|
|
||||||
|
public static PdaWarehouseIdentity require(AuthenticatedUser user) {
|
||||||
|
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
|
||||||
|
|| !"PLATFORM".equals(user.appCode())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
|
||||||
|
"Authenticated platform warehouse PDA identity is required");
|
||||||
|
}
|
||||||
|
if (!user.permissions().contains(PICKER_ROLE)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "WAREHOUSE_PICKER role is required");
|
||||||
|
}
|
||||||
|
if (!user.permissions().contains(OPERATE_PERMISSION)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
||||||
|
}
|
||||||
|
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||||
|
"PDA operator must be bound to one overseas warehouse");
|
||||||
|
}
|
||||||
|
String operator = String.valueOf(user.userId());
|
||||||
|
return new PdaWarehouseIdentity(operator,
|
||||||
|
user.username() == null || user.username().isBlank() ? operator : user.username(),
|
||||||
|
user.overseasWarehouseId());
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.baiyue.lastleg.pda.warehouse.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public final class PdaWarehouseRequests {
|
||||||
|
private PdaWarehouseRequests() { }
|
||||||
|
|
||||||
|
public record Scan(@NotBlank @Size(max = 128) String code) { }
|
||||||
|
public record HistoryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String operationType) {
|
||||||
|
public HistoryQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.baiyue.lastleg.pda.warehouse.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class PdaWarehouseResponses {
|
||||||
|
private PdaWarehouseResponses() { }
|
||||||
|
|
||||||
|
public record InventoryScan(String queryCode, List<InventoryItem> items) { }
|
||||||
|
public record InventoryItem(String customerId, String customerName, long balanceId, long positionId,
|
||||||
|
String skuCode, String productName, String inboundCode, String cartonMark,
|
||||||
|
long palletId, String palletCode, Long locationId, String locationCode,
|
||||||
|
long availablePackages, long holdPackages, long receivingPackages,
|
||||||
|
String status, long version) { }
|
||||||
|
public record Location(long locationId, String locationCode, String locationType, BigDecimal maxLoadKg,
|
||||||
|
String status, long occupiedPackages, int inventoryPositionCount) { }
|
||||||
|
public record Pallet(long palletId, String palletCode, String status, int version,
|
||||||
|
long locationId, String locationCode, long occupiedPackages,
|
||||||
|
int inventoryPositionCount, List<InventoryItem> inventory) { }
|
||||||
|
public record Operation(long operationId, String operationType, String businessKey, String status,
|
||||||
|
String summary, String customerId, LocalDateTime occurredAt) { }
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package com.baiyue.lastleg.pda.warehouse.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_pda_operation_history")
|
||||||
|
public class PdaOperationHistory {
|
||||||
|
@TableId(type = IdType.INPUT)
|
||||||
|
private Long id;
|
||||||
|
private Long overseasWarehouseId;
|
||||||
|
private String customerId;
|
||||||
|
private String operatorKey;
|
||||||
|
private String operationType;
|
||||||
|
private String businessKey;
|
||||||
|
private String status;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String requestHash;
|
||||||
|
private String summary;
|
||||||
|
private LocalDateTime occurredAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package com.baiyue.lastleg.pda.warehouse.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface PdaWarehouseMapper extends BaseMapper<PdaOperationHistory> {
|
||||||
|
String INVENTORY_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
|
||||||
|
"COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," +
|
||||||
|
"COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," +
|
||||||
|
"COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," +
|
||||||
|
"c.carton_mark,p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
|
||||||
|
"p.warehouse_location_code location_code,p.available_packages,p.hold_packages,p.receiving_packages," +
|
||||||
|
"p.status,p.version FROM ll_inventory_balance b JOIN ll_inventory_position p " +
|
||||||
|
"ON p.balance_id=b.id AND p.customer_id=b.customer_id " +
|
||||||
|
"LEFT JOIN ll_inbound_stock_receipt r ON r.inventory_balance_id=b.id AND r.inventory_position_id=p.id " +
|
||||||
|
"LEFT JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id " +
|
||||||
|
"AND c.warehouse_pallet_id=p.pallet_id AND c.carton_mark=#{code} AND c.status='RECEIVED' ";
|
||||||
|
|
||||||
|
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
|
||||||
|
"AND (UPPER(b.sku_code)=UPPER(#{code}) OR UPPER(r.sku_code)=UPPER(#{code}) " +
|
||||||
|
"OR b.inbound_code=#{code} OR c.carton_mark=#{code}) " +
|
||||||
|
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
|
||||||
|
"ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
|
||||||
|
List<PdaInventoryProjection> scanInventory(@Param("warehouseId") String warehouseId,
|
||||||
|
@Param("code") String code);
|
||||||
|
|
||||||
|
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
|
||||||
|
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
|
||||||
|
"ORDER BY b.customer_id,b.inbound_code,p.id")
|
||||||
|
List<PdaInventoryProjection> inventoryByPallet(@Param("warehouseId") String warehouseId,
|
||||||
|
@Param("palletId") long palletId,
|
||||||
|
@Param("code") String ignored);
|
||||||
|
|
||||||
|
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
|
||||||
|
"AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
|
||||||
|
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
|
||||||
|
"ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
|
||||||
|
List<PdaInventoryProjection> inventoryByLocation(@Param("warehouseId") String warehouseId,
|
||||||
|
@Param("locationId") long locationId,
|
||||||
|
@Param("locationCode") String locationCode,
|
||||||
|
@Param("code") String ignored);
|
||||||
|
|
||||||
|
@Select("<script>SELECT operation_id,operation_type,business_key,status,summary,customer_id,occurred_at FROM (" +
|
||||||
|
"SELECT h.id operation_id,h.operation_type,h.business_key,h.status,h.summary,h.customer_id,h.occurred_at " +
|
||||||
|
"FROM ll_pda_operation_history h WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
|
||||||
|
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
|
||||||
|
"UNION ALL SELECT o.id,CONCAT('RECEIVING_',o.action),s.container_no,'COMPLETED'," +
|
||||||
|
"CONCAT('柜号作业 ',s.container_no),s.customer_id,o.created_at " +
|
||||||
|
"FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
|
||||||
|
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
|
||||||
|
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>" +
|
||||||
|
") x ORDER BY occurred_at DESC,operation_id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||||
|
List<com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses.Operation> history(
|
||||||
|
@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
|
||||||
|
@Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit);
|
||||||
|
|
||||||
|
@Select("<script>SELECT COUNT(*) FROM (SELECT h.id FROM ll_pda_operation_history h " +
|
||||||
|
"WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
|
||||||
|
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
|
||||||
|
"UNION ALL SELECT o.id FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
|
||||||
|
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
|
||||||
|
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>) x</script>")
|
||||||
|
long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
|
||||||
|
@Param("operationType") String operationType);
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.lastleg.pda.warehouse.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class PdaInventoryProjection {
|
||||||
|
private String customerId;
|
||||||
|
private String customerName;
|
||||||
|
private Long balanceId;
|
||||||
|
private Long positionId;
|
||||||
|
private String skuCode;
|
||||||
|
private String productNameZh;
|
||||||
|
private String productNameEn;
|
||||||
|
private String inboundCode;
|
||||||
|
private String cartonMark;
|
||||||
|
private Long palletId;
|
||||||
|
private String palletCode;
|
||||||
|
private Long locationId;
|
||||||
|
private String locationCode;
|
||||||
|
private Long availablePackages;
|
||||||
|
private Long holdPackages;
|
||||||
|
private Long receivingPackages;
|
||||||
|
private String status;
|
||||||
|
private Long version;
|
||||||
|
}
|
||||||
+69
@@ -75,6 +75,15 @@ public class PlatformUsTruckOrderController {
|
|||||||
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
|
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderNo}/review")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||||
|
public ApiResponse<UsTruckOrderResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam String customerId, @PathVariable String orderNo,
|
||||||
|
@Valid @RequestBody UsTruckOrderRequests.Review request) {
|
||||||
|
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
|
||||||
|
return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{orderNo}/receive-pallets")
|
@PostMapping("/{orderNo}/receive-pallets")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||||
@Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
|
@Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
|
||||||
@@ -85,6 +94,66 @@ public class PlatformUsTruckOrderController {
|
|||||||
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
|
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderNo}/review-pallet-overage")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||||
|
public ApiResponse<UsTruckOrderResponses.Detail> reviewPalletOverage(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||||
|
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReviewPalletOverage request) {
|
||||||
|
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
|
||||||
|
return ApiResponse.success(service.reviewPalletOverage(
|
||||||
|
requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderNo}/allocate-pallets")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||||
|
public ApiResponse<UsTruckOrderResponses.Detail> allocatePallets(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||||
|
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.AllocatePallets request) {
|
||||||
|
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
|
||||||
|
return ApiResponse.success(service.allocatePallets(
|
||||||
|
requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderNo}/prepare-dispatch")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||||
|
public ApiResponse<UsTruckOrderResponses.Detail> prepareDispatch(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||||
|
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
|
||||||
|
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
|
||||||
|
return ApiResponse.success(service.prepareDispatch(
|
||||||
|
requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderNo}/dispatch")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||||
|
public ApiResponse<UsTruckOrderResponses.Detail> dispatch(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||||
|
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
|
||||||
|
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
|
||||||
|
return ApiResponse.success(service.dispatch(
|
||||||
|
requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderNo}/start-transit")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||||
|
public ApiResponse<UsTruckOrderResponses.Detail> startTransit(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||||
|
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
|
||||||
|
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
|
||||||
|
return ApiResponse.success(service.startTransit(
|
||||||
|
requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderNo}/complete")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
|
||||||
|
public ApiResponse<UsTruckOrderResponses.Detail> complete(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||||
|
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
|
||||||
|
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
|
||||||
|
return ApiResponse.success(service.complete(
|
||||||
|
requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
private void requirePlatform(AuthenticatedUser user) {
|
private void requirePlatform(AuthenticatedUser user) {
|
||||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||||
|
|||||||
+28
-1
@@ -4,7 +4,10 @@ import jakarta.validation.constraints.DecimalMin;
|
|||||||
import jakarta.validation.constraints.Max;
|
import jakarta.validation.constraints.Max;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -71,7 +74,31 @@ public final class UsTruckOrderRequests {
|
|||||||
|
|
||||||
public record ChangeStatus(
|
public record ChangeStatus(
|
||||||
@NotNull @Min(0) Long version,
|
@NotNull @Min(0) Long version,
|
||||||
@NotBlank @Size(max = 32) String status,
|
@NotBlank @Pattern(regexp = "CANCELLED") String status,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record Review(
|
||||||
|
@NotNull @Min(0) Long version,
|
||||||
|
@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record ReviewPalletOverage(
|
||||||
|
@NotNull @Min(0) Long version,
|
||||||
|
@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record AllocatePallets(
|
||||||
|
@NotNull @Min(0) Long version,
|
||||||
|
@NotEmpty @Size(max = 500) List<@NotNull @Positive Long> palletIds,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record Command(
|
||||||
|
@NotNull @Min(0) Long version,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
@Size(max = 1000) String remark) {}
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
public record ReceivePallets(
|
public record ReceivePallets(
|
||||||
|
|||||||
+3
-2
@@ -11,13 +11,14 @@ public final class UsTruckOrderResponses {
|
|||||||
public record Summary(
|
public record Summary(
|
||||||
Long id, String customerId, String orderNo, String transportType,
|
Long id, String customerId, String orderNo, String transportType,
|
||||||
String origin, String destination, String containerType, int palletCount, int packageCount,
|
String origin, String destination, String containerType, int palletCount, int packageCount,
|
||||||
int receivedPalletCount, int allocatedPalletCount, int shippedPalletCount,
|
int receivedPalletCount, int allocatedPalletCount,
|
||||||
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
|
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
|
||||||
String currency, String contactName, String contactInfo, LocalDate requestedDate,
|
String currency, String contactName, String contactInfo, LocalDate requestedDate,
|
||||||
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||||
|
|
||||||
public record History(
|
public record History(
|
||||||
String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {}
|
String operationType, String fromStatus, String toStatus, String operatorId,
|
||||||
|
String idempotencyKey, String remark, LocalDateTime createdAt) {}
|
||||||
|
|
||||||
public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status,
|
public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status,
|
||||||
Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {}
|
Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ public class UsTruckOrder {
|
|||||||
private Integer packageCount;
|
private Integer packageCount;
|
||||||
private Integer receivedPalletCount;
|
private Integer receivedPalletCount;
|
||||||
private Integer allocatedPalletCount;
|
private Integer allocatedPalletCount;
|
||||||
private Integer shippedPalletCount;
|
|
||||||
private BigDecimal totalWeightKg;
|
private BigDecimal totalWeightKg;
|
||||||
private BigDecimal totalVolumeCbm;
|
private BigDecimal totalVolumeCbm;
|
||||||
private BigDecimal budgetAmount;
|
private BigDecimal budgetAmount;
|
||||||
@@ -37,8 +36,6 @@ public class UsTruckOrder {
|
|||||||
private String remark;
|
private String remark;
|
||||||
private String idempotencyKey;
|
private String idempotencyKey;
|
||||||
private String requestHash;
|
private String requestHash;
|
||||||
private String receiptIdempotencyKey;
|
|
||||||
private String receiptRequestHash;
|
|
||||||
private String createdBy;
|
private String createdBy;
|
||||||
private Long version;
|
private Long version;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.baiyue.lastleg.trucking.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_us_truck_order_operation")
|
||||||
|
public class UsTruckOrderOperation {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private Long orderId;
|
||||||
|
private String customerId;
|
||||||
|
private String operationType;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String requestHash;
|
||||||
|
private Long resultVersion;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+2
@@ -13,9 +13,11 @@ public class UsTruckOrderStatusHistory {
|
|||||||
@TableId(type = IdType.INPUT) private Long id;
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
private Long orderId;
|
private Long orderId;
|
||||||
private String customerId;
|
private String customerId;
|
||||||
|
private String operationType;
|
||||||
private String fromStatus;
|
private String fromStatus;
|
||||||
private String toStatus;
|
private String toStatus;
|
||||||
private String operatorId;
|
private String operatorId;
|
||||||
|
private String idempotencyKey;
|
||||||
private String remark;
|
private String remark;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.baiyue.lastleg.trucking.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UsTruckOrderOperationMapper extends BaseMapper<UsTruckOrderOperation> {
|
||||||
|
@Select("SELECT * FROM ll_us_truck_order_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1")
|
||||||
|
UsTruckOrderOperation findKey(@Param("customerId") String customerId,
|
||||||
|
@Param("orderId") Long orderId,
|
||||||
|
@Param("key") String key);
|
||||||
|
}
|
||||||
+136
-38
@@ -7,9 +7,11 @@ import com.baiyue.common.core.api.PageResult;
|
|||||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
||||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
|
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||||
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
|
||||||
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
|
||||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||||
@@ -30,6 +32,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
@@ -55,20 +58,12 @@ public class UsTruckOrderService {
|
|||||||
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
|
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
|
||||||
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
|
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
|
||||||
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
|
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
|
||||||
private static final java.util.Map<String, Set<String>> TRANSITIONS = java.util.Map.ofEntries(
|
private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
|
||||||
java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)),
|
PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH);
|
||||||
java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)),
|
|
||||||
java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)),
|
|
||||||
java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)),
|
|
||||||
java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)),
|
|
||||||
java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)),
|
|
||||||
java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)),
|
|
||||||
java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)),
|
|
||||||
java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)),
|
|
||||||
java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED)));
|
|
||||||
private static final Set<String> TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
|
private static final Set<String> TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
|
||||||
|
|
||||||
@Resource private UsTruckOrderMapper orders;
|
@Resource private UsTruckOrderMapper orders;
|
||||||
|
@Resource private UsTruckOrderOperationMapper operations;
|
||||||
@Resource private UsTruckOrderPalletMapper pallets;
|
@Resource private UsTruckOrderPalletMapper pallets;
|
||||||
@Resource private UsTruckOrderStatusHistoryMapper histories;
|
@Resource private UsTruckOrderStatusHistoryMapper histories;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
@@ -90,7 +85,7 @@ public class UsTruckOrderService {
|
|||||||
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
|
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
|
||||||
order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null);
|
order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null);
|
||||||
order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
|
order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
|
||||||
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
|
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0);
|
||||||
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
|
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
|
||||||
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
|
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
|
||||||
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
|
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
|
||||||
@@ -103,7 +98,7 @@ public class UsTruckOrderService {
|
|||||||
if (concurrent != null) return replay(customerId, concurrent, hash);
|
if (concurrent != null) return replay(customerId, concurrent, hash);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
|
history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now);
|
||||||
return detail(customerId, order.getOrderNo());
|
return detail(customerId, order.getOrderNo());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +145,7 @@ public class UsTruckOrderService {
|
|||||||
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
|
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
|
||||||
if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark);
|
if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark);
|
||||||
touch(order); orders.updateById(order);
|
touch(order); orders.updateById(order);
|
||||||
history(order, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now());
|
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now());
|
||||||
return detail(customerId, orderNo);
|
return detail(customerId, orderNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,34 +153,37 @@ public class UsTruckOrderService {
|
|||||||
public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
|
public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
|
||||||
UsTruckOrderRequests.ChangeStatus request) {
|
UsTruckOrderRequests.ChangeStatus request) {
|
||||||
UsTruckOrder order = lock(customerId, orderNo);
|
UsTruckOrder order = lock(customerId, orderNo);
|
||||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
UsTruckOrderResponses.Detail replay = replayOperation(order, "CANCEL", request.idempotencyKey(), request);
|
||||||
String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status");
|
if (replay != null) return replay;
|
||||||
String from = order.getStatus();
|
requireVersion(order, request.version());
|
||||||
if (!next.equals(from)) {
|
if (!CANCELLED.equals(norm(request.status())) || !CANCELLABLE.contains(order.getStatus())) {
|
||||||
if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) {
|
throw conflict("Only cancellable truck orders can use the generic status endpoint");
|
||||||
throw conflict("Unsupported truck order transition: " + from + " -> " + next);
|
|
||||||
}
|
|
||||||
order.setStatus(next);
|
|
||||||
if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order);
|
|
||||||
touch(order); orders.updateById(order);
|
|
||||||
history(order, from, next, operatorId, request.remark(), LocalDateTime.now());
|
|
||||||
}
|
}
|
||||||
return detail(customerId, orderNo);
|
return transition(order, operatorId, "CANCEL", request.idempotencyKey(), request,
|
||||||
|
order.getStatus(), CANCELLED, request.remark());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId,
|
||||||
|
UsTruckOrderRequests.Review request) {
|
||||||
|
UsTruckOrder order = lock(customerId, orderNo);
|
||||||
|
UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request);
|
||||||
|
if (replay != null) return replay;
|
||||||
|
requireVersion(order, request.version());
|
||||||
|
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending-review truck orders can be reviewed");
|
||||||
|
String next = "APPROVE".equals(norm(request.decision())) ? WAITING_INBOUND : REJECTED;
|
||||||
|
if (WAITING_INBOUND.equals(next)) createPlannedPallets(order);
|
||||||
|
return transition(order, operatorId, "REVIEW", request.idempotencyKey(), request,
|
||||||
|
PENDING_REVIEW, next, request.remark());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId,
|
public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId,
|
||||||
UsTruckOrderRequests.ReceivePallets request) {
|
UsTruckOrderRequests.ReceivePallets request) {
|
||||||
UsTruckOrder order = lock(customerId, orderNo);
|
UsTruckOrder order = lock(customerId, orderNo);
|
||||||
String key = request.idempotencyKey().trim();
|
UsTruckOrderResponses.Detail replay = replayOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request);
|
||||||
String requestHash = hash(request);
|
if (replay != null) return replay;
|
||||||
if (key.equals(order.getReceiptIdempotencyKey())) {
|
requireVersion(order, request.version());
|
||||||
if (!requestHash.equals(order.getReceiptRequestHash())) {
|
|
||||||
throw conflict("Receipt idempotency key was reused with different pallet data");
|
|
||||||
}
|
|
||||||
return detail(customerId, orderNo);
|
|
||||||
}
|
|
||||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
|
||||||
if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) {
|
if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) {
|
||||||
throw conflict("Only waiting or receiving orders can receive pallets");
|
throw conflict("Only waiting or receiving orders can receive pallets");
|
||||||
}
|
}
|
||||||
@@ -220,18 +218,118 @@ public class UsTruckOrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
order.setReceivedPalletCount(actual);
|
order.setReceivedPalletCount(actual);
|
||||||
order.setReceiptIdempotencyKey(key);
|
|
||||||
order.setReceiptRequestHash(requestHash);
|
|
||||||
String from = order.getStatus();
|
String from = order.getStatus();
|
||||||
String next = actual > planned ? PALLET_OVERAGE_REVIEW
|
String next = actual > planned ? PALLET_OVERAGE_REVIEW
|
||||||
: actual == planned ? WAITING_CONSOLIDATION : RECEIVING;
|
: actual == planned ? WAITING_CONSOLIDATION : RECEIVING;
|
||||||
order.setStatus(next);
|
order.setStatus(next);
|
||||||
touch(order);
|
touch(order);
|
||||||
orders.updateById(order);
|
orders.updateById(order);
|
||||||
history(order, from, next, operatorId, request.remark(), now);
|
recordOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request, operatorId, now);
|
||||||
|
history(order, "RECEIVE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now);
|
||||||
return detail(customerId, orderNo);
|
return detail(customerId, orderNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UsTruckOrderResponses.Detail reviewPalletOverage(String customerId, String orderNo, String operatorId,
|
||||||
|
UsTruckOrderRequests.ReviewPalletOverage request) {
|
||||||
|
UsTruckOrder order = lock(customerId, orderNo);
|
||||||
|
UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW_PALLET_OVERAGE",
|
||||||
|
request.idempotencyKey(), request);
|
||||||
|
if (replay != null) return replay;
|
||||||
|
requireVersion(order, request.version());
|
||||||
|
if (!PALLET_OVERAGE_REVIEW.equals(order.getStatus())) {
|
||||||
|
throw conflict("Only pallet-overage-review orders can be reviewed");
|
||||||
|
}
|
||||||
|
boolean approve = "APPROVE".equals(norm(request.decision()));
|
||||||
|
List<UsTruckOrderPallet> rows = pallets.listOrder(customerId, order.getId());
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
int accepted = value(order.getPalletCount());
|
||||||
|
for (UsTruckOrderPallet pallet : rows) {
|
||||||
|
if (!"OVERAGE_PENDING_REVIEW".equals(pallet.getStatus())) continue;
|
||||||
|
pallet.setStatus(approve ? "RECEIVED" : "OVERAGE_REJECTED");
|
||||||
|
pallet.setUpdatedAt(now);
|
||||||
|
pallets.updateById(pallet);
|
||||||
|
}
|
||||||
|
if (approve) {
|
||||||
|
accepted = value(order.getReceivedPalletCount());
|
||||||
|
order.setPalletCount(accepted);
|
||||||
|
} else {
|
||||||
|
order.setReceivedPalletCount(accepted);
|
||||||
|
}
|
||||||
|
order.setStatus(WAITING_CONSOLIDATION);
|
||||||
|
touch(order);
|
||||||
|
orders.updateById(order);
|
||||||
|
recordOperation(order, "REVIEW_PALLET_OVERAGE", request.idempotencyKey(), request, operatorId, now);
|
||||||
|
history(order, "REVIEW_PALLET_OVERAGE", PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION,
|
||||||
|
operatorId, request.idempotencyKey(), request.remark(), now);
|
||||||
|
return detail(customerId, orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UsTruckOrderResponses.Detail allocatePallets(String customerId, String orderNo, String operatorId,
|
||||||
|
UsTruckOrderRequests.AllocatePallets request) {
|
||||||
|
UsTruckOrder order = lock(customerId, orderNo);
|
||||||
|
UsTruckOrderResponses.Detail replay = replayOperation(order, "ALLOCATE_PALLETS",
|
||||||
|
request.idempotencyKey(), request);
|
||||||
|
if (replay != null) return replay;
|
||||||
|
requireVersion(order, request.version());
|
||||||
|
if (!Set.of(WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED).contains(order.getStatus())) {
|
||||||
|
throw conflict("Only waiting-consolidation or partially-allocated orders can allocate pallets");
|
||||||
|
}
|
||||||
|
Set<Long> requested = new HashSet<>(request.palletIds());
|
||||||
|
if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique");
|
||||||
|
List<UsTruckOrderPallet> rows = pallets.listOrder(customerId, order.getId());
|
||||||
|
List<UsTruckOrderPallet> selected = rows.stream().filter(row -> requested.contains(row.getId())).toList();
|
||||||
|
if (selected.size() != requested.size() || selected.stream().anyMatch(row -> !"RECEIVED".equals(row.getStatus()))) {
|
||||||
|
throw conflict("Selected pallets must belong to the order and be received but unallocated");
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (UsTruckOrderPallet pallet : selected) {
|
||||||
|
pallet.setStatus("ALLOCATED");
|
||||||
|
pallet.setUpdatedAt(now);
|
||||||
|
pallets.updateById(pallet);
|
||||||
|
}
|
||||||
|
int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count()
|
||||||
|
+ selected.size();
|
||||||
|
int accepted = value(order.getReceivedPalletCount());
|
||||||
|
if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets");
|
||||||
|
String from = order.getStatus();
|
||||||
|
String next = allocated == accepted ? ALLOCATED : PARTIALLY_ALLOCATED;
|
||||||
|
order.setAllocatedPalletCount(allocated);
|
||||||
|
order.setStatus(next);
|
||||||
|
touch(order);
|
||||||
|
orders.updateById(order);
|
||||||
|
recordOperation(order, "ALLOCATE_PALLETS", request.idempotencyKey(), request, operatorId, now);
|
||||||
|
history(order, "ALLOCATE_PALLETS", from, next, operatorId,
|
||||||
|
request.idempotencyKey(), request.remark(), now);
|
||||||
|
return detail(customerId, orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UsTruckOrderResponses.Detail prepareDispatch(String customerId, String orderNo, String operatorId,
|
||||||
|
UsTruckOrderRequests.Command request) {
|
||||||
|
return command(customerId, orderNo, operatorId, "PREPARE_DISPATCH", ALLOCATED,
|
||||||
|
PENDING_DISPATCH, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UsTruckOrderResponses.Detail dispatch(String customerId, String orderNo, String operatorId,
|
||||||
|
UsTruckOrderRequests.Command request) {
|
||||||
|
return command(customerId, orderNo, operatorId, "DISPATCH", PENDING_DISPATCH, DISPATCHED, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UsTruckOrderResponses.Detail startTransit(String customerId, String orderNo, String operatorId,
|
||||||
|
UsTruckOrderRequests.Command request) {
|
||||||
|
return command(customerId, orderNo, operatorId, "START_TRANSIT", DISPATCHED, IN_TRANSIT, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UsTruckOrderResponses.Detail complete(String customerId, String orderNo, String operatorId,
|
||||||
|
UsTruckOrderRequests.Command request) {
|
||||||
|
return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request);
|
||||||
|
}
|
||||||
|
|
||||||
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
|
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
|
||||||
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
|
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
|
||||||
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
|
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
|
||||||
|
|||||||
+12
-6
@@ -5,8 +5,7 @@ import com.baiyue.lastleg.dispatch.controller.DispatchController;
|
|||||||
import com.baiyue.lastleg.dispatch.controller.DispatchQueryController;
|
import com.baiyue.lastleg.dispatch.controller.DispatchQueryController;
|
||||||
import com.baiyue.lastleg.dispatch.controller.SmartDispatchController;
|
import com.baiyue.lastleg.dispatch.controller.SmartDispatchController;
|
||||||
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
|
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController;
|
import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController;
|
|
||||||
import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController;
|
import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
@@ -26,10 +25,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
class Phase4BrowserApiBoundaryTest {
|
class Phase4BrowserApiBoundaryTest {
|
||||||
@Test
|
@Test
|
||||||
void inboundPdaAndUnloadPhotoApisUseThePdaAccessDomain() {
|
void onlyStableContainerReceivingUsesThePdaInboundAccessDomain() throws Exception {
|
||||||
assertTrue(basePath(InboundPdaController.class).startsWith("/api/pda/v1/"));
|
assertTrue(basePath(ReceivingContainerController.class).startsWith("/api/pda/v1/"));
|
||||||
assertTrue(basePath(InboundPalletAssignmentController.class).startsWith("/api/pda/v1/"));
|
assertFalse(basePath(ReceivingContainerController.class).contains("/api/platform/"));
|
||||||
assertFalse(basePath(InboundPdaController.class).contains("/api/platform/"));
|
Path controllers = Path.of("src/main/java/com/baiyue/lastleg/inboundplan/pda/controller");
|
||||||
|
String source = Files.walk(controllers).filter(path -> path.toString().endsWith(".java"))
|
||||||
|
.map(path -> {
|
||||||
|
try { return Files.readString(path); }
|
||||||
|
catch (Exception exception) { throw new IllegalStateException(exception); }
|
||||||
|
}).collect(Collectors.joining("\n"));
|
||||||
|
assertFalse(source.contains("/api/pda/v1/last-leg/inbound-plans/{planNo}"));
|
||||||
|
assertFalse(source.contains("@RequestParam(required = false) String customerId"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
-42
@@ -1,42 +0,0 @@
|
|||||||
package com.baiyue.lastleg.inboundplan.pda.controller;
|
|
||||||
|
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
|
|
||||||
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
|
|
||||||
class InboundPalletAssignmentControllerTest {
|
|
||||||
private final InboundPdaWorkflowService service = mock(InboundPdaWorkflowService.class);
|
|
||||||
private final InboundPalletAssignmentController controller = controller();
|
|
||||||
private final PdaRequests.Assign request = new PdaRequests.Assign(1L, 2L, 3L, "assign-key");
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void assignmentRequiresPdaOperatePermissionAndExplicitPlatformCustomer() {
|
|
||||||
assertThrows(ResponseStatusException.class,
|
|
||||||
() -> controller.assign(user(Set.of()), "PLAN-1", 4L, "100", request));
|
|
||||||
assertThrows(ResponseStatusException.class,
|
|
||||||
() -> controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, null, request));
|
|
||||||
|
|
||||||
controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, "100", request);
|
|
||||||
|
|
||||||
verify(service).assign("100", "PLAN-1", 4L, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private AuthenticatedUser user(Set<String> permissions) {
|
|
||||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "PDA", permissions);
|
|
||||||
}
|
|
||||||
|
|
||||||
private InboundPalletAssignmentController controller() {
|
|
||||||
InboundPalletAssignmentController value = new InboundPalletAssignmentController();
|
|
||||||
ReflectionTestUtils.setField(value, "service", service);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1029,13 +1029,111 @@ export async function run(context) {
|
|||||||
const dynamicCandidatesResponse = await context.request('GET',
|
const dynamicCandidatesResponse = await context.request('GET',
|
||||||
`${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
`${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const dynamicCandidates = context.apiSuccess(MODULE, '查询同柜动态增量候选', dynamicCandidatesResponse, '', OPTIONS)
|
const dynamicCandidates = context.apiSuccess(MODULE, '查询同柜动态增量候选', dynamicCandidatesResponse, '', OPTIONS)
|
||||||
const dynamicCandidate = Array.isArray(dynamicCandidates) ? dynamicCandidates.find((item) =>
|
const markerOwnedDynamicCandidates = Array.isArray(dynamicCandidates) ? dynamicCandidates.filter((item) =>
|
||||||
id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : null
|
id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : []
|
||||||
|
const dynamicCandidate = markerOwnedDynamicCandidates[0]
|
||||||
|
const skippedExceptionCandidate = markerOwnedDynamicCandidates[1]
|
||||||
|
const partialExceptionCandidate = markerOwnedDynamicCandidates.find((item, index) =>
|
||||||
|
index > 1 && Number(item?.availablePackageCount) >= 2)
|
||||||
context.assert(MODULE, '动态增量候选保持客户、仓库和渠道隔离', dynamicCandidatesResponse,
|
context.assert(MODULE, '动态增量候选保持客户、仓库和渠道隔离', dynamicCandidatesResponse,
|
||||||
Boolean(dynamicCandidate?.inboundDetailId) && id(dynamicCandidate?.accountId) === tenantId
|
Boolean(dynamicCandidate?.inboundDetailId) && id(dynamicCandidate?.accountId) === tenantId
|
||||||
&& id(dynamicCandidate?.warehouseId) === warehouseId
|
&& id(dynamicCandidate?.warehouseId) === warehouseId
|
||||||
&& dynamicCandidate?.channelCode === initial?.summary?.channelCode,
|
&& dynamicCandidate?.channelCode === initial?.summary?.channelCode,
|
||||||
`inboundId=${incrementalInboundId}, detailId=${id(dynamicCandidate?.inboundDetailId) || 'missing'}, available=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
`inboundId=${incrementalInboundId}, detailId=${id(dynamicCandidate?.inboundDetailId) || 'missing'}, available=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||||
|
context.assert(MODULE, '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细', dynamicCandidatesResponse,
|
||||||
|
Boolean(skippedExceptionCandidate?.inboundDetailId) && Boolean(partialExceptionCandidate?.inboundDetailId)
|
||||||
|
&& id(skippedExceptionCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId)
|
||||||
|
&& id(dynamicCandidate?.inboundDetailId) !== id(skippedExceptionCandidate?.inboundDetailId)
|
||||||
|
&& id(dynamicCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId),
|
||||||
|
`candidateCount=${markerOwnedDynamicCandidates.length}, skipDetailId=${id(skippedExceptionCandidate?.inboundDetailId) || 'missing'}, partialDetailId=${id(partialExceptionCandidate?.inboundDetailId) || 'missing'}`, OPTIONS)
|
||||||
|
if (skippedExceptionCandidate?.inboundDetailId && partialExceptionCandidate?.inboundDetailId) {
|
||||||
|
const beforeSkipPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||||
|
const beforeSkipPlan = context.apiSuccess(MODULE, 'SKIP 异常场景前读取计划即时版本', beforeSkipPlanResponse, '', OPTIONS)
|
||||||
|
const skipAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
|
||||||
|
inboundId: incrementalInboundId, inboundDetailId: id(skippedExceptionCandidate.inboundDetailId), packageCount: 1,
|
||||||
|
expectedVersion: Number(beforeSkipPlan?.version), operationId: `${context.marker}-exception-skip-assign`.slice(0, 128),
|
||||||
|
})
|
||||||
|
const skipAssignedPlan = context.apiSuccess(MODULE, '加入本轮待跳拣异常明细', skipAssignmentResponse, '', OPTIONS)
|
||||||
|
const skipPlanItem = skipAssignedPlan?.items?.find?.((item) =>
|
||||||
|
id(item?.inboundDetailId) === id(skippedExceptionCandidate.inboundDetailId))
|
||||||
|
const skipTaskResponse = await context.request('GET', taskDetailPath)
|
||||||
|
const skipTask = context.apiSuccess(MODULE, '读取待跳拣异常 PDA 明细', skipTaskResponse, '', OPTIONS)
|
||||||
|
const skipTaskItem = skipTask?.items?.find?.((item) => id(item?.loadingPlanItemId) === id(skipPlanItem?.id))
|
||||||
|
const skipInventoryResponse = await context.request('GET',
|
||||||
|
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
|
const skipInventory = context.apiSuccess(MODULE, '跳拣前刷新实时库存快照', skipInventoryResponse, '', OPTIONS)
|
||||||
|
const skipReason = `${context.marker}-箱唛破损待复核`
|
||||||
|
const skipBatchResponse = await request(context, 'POST', batchPath, {
|
||||||
|
warehouseId, loadingPlanId, containerBookingId,
|
||||||
|
expectedVersion: Number(skipTask?.version), inventorySnapshotToken: skipInventory?.snapshotToken,
|
||||||
|
items: [{ itemId: id(skipTaskItem?.id), action: 'SKIP', version: Number(skipTaskItem?.version),
|
||||||
|
reason: skipReason }],
|
||||||
|
}, { 'Idempotency-Key': `${context.marker}-exception-skip-batch`.slice(0, 128) })
|
||||||
|
const skippedTask = context.apiSuccess(MODULE, 'SKIP 携带异常原因并原子解决待拣明细', skipBatchResponse, '', OPTIONS)
|
||||||
|
const skippedTaskItem = skippedTask?.items?.find?.((item) => id(item?.id) === id(skipTaskItem?.id))
|
||||||
|
context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse,
|
||||||
|
Boolean(skipReason.trim()) && skippedTaskItem?.itemStatus === 'SKIPPED'
|
||||||
|
&& Number(skippedTaskItem?.pickedPackageCount) === 0
|
||||||
|
&& skippedTask?.workflowStatus === 'AWAITING_REVIEW' && skippedTask?.taskStatus === 'LOADING',
|
||||||
|
`itemStatus=${skippedTaskItem?.itemStatus || 'missing'}, picked=${skippedTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${skippedTask?.workflowStatus || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const beforePartialPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||||
|
const beforePartialPlan = context.apiSuccess(MODULE, 'PARTIAL 异常场景前读取计划即时版本', beforePartialPlanResponse, '', OPTIONS)
|
||||||
|
const partialAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
|
||||||
|
inboundId: incrementalInboundId, inboundDetailId: id(partialExceptionCandidate.inboundDetailId), packageCount: 2,
|
||||||
|
expectedVersion: Number(beforePartialPlan?.version), operationId: `${context.marker}-exception-partial-assign`.slice(0, 128),
|
||||||
|
})
|
||||||
|
const partialAssignedPlan = context.apiSuccess(MODULE, '加入本轮部分拣货异常明细', partialAssignmentResponse, '', OPTIONS)
|
||||||
|
const partialPlanItem = partialAssignedPlan?.items?.find?.((item) =>
|
||||||
|
id(item?.inboundDetailId) === id(partialExceptionCandidate.inboundDetailId))
|
||||||
|
const unresolvedTaskResponse = await context.request('GET', taskDetailPath)
|
||||||
|
const unresolvedTask = context.apiSuccess(MODULE, '读取存在未解决异常的 PDA 任务', unresolvedTaskResponse, '', OPTIONS)
|
||||||
|
const unresolvedTaskItem = unresolvedTask?.items?.find?.((item) =>
|
||||||
|
id(item?.loadingPlanItemId) === id(partialPlanItem?.id))
|
||||||
|
const operationsBeforeUnresolvedCompleteResponse = await context.request('GET',
|
||||||
|
`${pdaBase}/tasks/${canonicalTaskId}/operations`)
|
||||||
|
const operationsBeforeUnresolvedComplete = context.apiSuccess(MODULE,
|
||||||
|
'未解决异常完成前读取 PDA 操作审计基线', operationsBeforeUnresolvedCompleteResponse, '', OPTIONS)
|
||||||
|
const unresolvedComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
|
||||||
|
expectedTaskVersion: Number(unresolvedTask?.version),
|
||||||
|
operationId: `${context.marker}-complete-with-unresolved`.slice(0, 128),
|
||||||
|
remark: `${context.marker}-must-reject-unresolved`,
|
||||||
|
}, { 'Idempotency-Key': `${context.marker}-complete-with-unresolved-key`.slice(0, 128) })
|
||||||
|
context.addStep(MODULE, '存在未解决异常明细时禁止确认装柜完成', unresolvedComplete,
|
||||||
|
unresolvedComplete.http === 200 && codeOf(unresolvedComplete) === 'A0409'
|
||||||
|
&& String(unresolvedComplete.body?.message || '').includes('尚未处理'),
|
||||||
|
responseEvidence(unresolvedComplete), OPTIONS)
|
||||||
|
const afterUnresolvedCompleteTaskResponse = await context.request('GET', taskDetailPath)
|
||||||
|
const afterUnresolvedCompleteTask = context.apiSuccess(MODULE,
|
||||||
|
'未解决异常完成失败后回查 PDA 任务', afterUnresolvedCompleteTaskResponse, '', OPTIONS)
|
||||||
|
const operationsAfterUnresolvedCompleteResponse = await context.request('GET',
|
||||||
|
`${pdaBase}/tasks/${canonicalTaskId}/operations`)
|
||||||
|
const operationsAfterUnresolvedComplete = context.apiSuccess(MODULE,
|
||||||
|
'未解决异常完成失败后回查 PDA 操作审计', operationsAfterUnresolvedCompleteResponse, '', OPTIONS)
|
||||||
|
context.assert(MODULE, '未解决异常完成失败后 task/items/operation 全部零写入', afterUnresolvedCompleteTaskResponse,
|
||||||
|
Number(afterUnresolvedCompleteTask?.version) === Number(unresolvedTask?.version)
|
||||||
|
&& taskItemsSignature(afterUnresolvedCompleteTask) === taskItemsSignature(unresolvedTask)
|
||||||
|
&& operationsSignature(operationsAfterUnresolvedComplete) === operationsSignature(operationsBeforeUnresolvedComplete),
|
||||||
|
`taskVersion=${afterUnresolvedCompleteTask?.version ?? 'missing'}, operations=${operationsAfterUnresolvedComplete?.length ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const partialInventoryResponse = await context.request('GET',
|
||||||
|
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
|
const partialInventory = context.apiSuccess(MODULE, '部分拣货前刷新实时库存快照', partialInventoryResponse, '', OPTIONS)
|
||||||
|
const partialBatchResponse = await request(context, 'POST', batchPath, {
|
||||||
|
warehouseId, loadingPlanId, containerBookingId,
|
||||||
|
expectedVersion: Number(afterUnresolvedCompleteTask?.version),
|
||||||
|
inventorySnapshotToken: partialInventory?.snapshotToken,
|
||||||
|
items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1,
|
||||||
|
releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }],
|
||||||
|
}, { 'Idempotency-Key': `${context.marker}-exception-partial-batch`.slice(0, 128) })
|
||||||
|
const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS)
|
||||||
|
const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id))
|
||||||
|
context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse,
|
||||||
|
partialTaskItem?.itemStatus === 'PARTIAL' && Number(partialTaskItem?.plannedPackageCount) === 2
|
||||||
|
&& Number(partialTaskItem?.pickedPackageCount) === 1
|
||||||
|
&& partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING',
|
||||||
|
`itemStatus=${partialTaskItem?.itemStatus || 'missing'}, planned=${partialTaskItem?.plannedPackageCount ?? 'missing'}, picked=${partialTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${partialTask?.workflowStatus || 'missing'}`, OPTIONS)
|
||||||
|
}
|
||||||
if (dynamicCandidate?.inboundDetailId) {
|
if (dynamicCandidate?.inboundDetailId) {
|
||||||
const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||||
const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS)
|
const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS)
|
||||||
|
|||||||
@@ -458,6 +458,32 @@ test('phase3 loading proves stale inventory zero-write and defers final completi
|
|||||||
assert.doesNotMatch(loading, /complete-before-downstream/)
|
assert.doesNotMatch(loading, /complete-before-downstream/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstream completion', () => {
|
||||||
|
const loading = source('./phase3-loading.mjs')
|
||||||
|
assertOrder(loading, [
|
||||||
|
'本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细',
|
||||||
|
'加入本轮待跳拣异常明细',
|
||||||
|
'SKIP 携带异常原因并原子解决待拣明细',
|
||||||
|
'合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW',
|
||||||
|
'加入本轮部分拣货异常明细',
|
||||||
|
'未解决异常完成前读取 PDA 操作审计基线',
|
||||||
|
'存在未解决异常明细时禁止确认装柜完成',
|
||||||
|
'未解决异常完成失败后 task/items/operation 全部零写入',
|
||||||
|
'合法 PARTIAL 解决未完成明细',
|
||||||
|
'部分拣货数量守恒并恢复最终可确认状态',
|
||||||
|
'报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING',
|
||||||
|
])
|
||||||
|
assert.match(loading, /action: 'SKIP', version: Number\(skipTaskItem\?\.version\),\s*reason: skipReason/)
|
||||||
|
assert.match(loading, /skipReason = `\$\{context\.marker\}-箱唛破损待复核`/)
|
||||||
|
assert.match(loading, /itemStatus === 'SKIPPED'[\s\S]*pickedPackageCount\) === 0/)
|
||||||
|
assert.match(loading, /action: 'PARTIAL', finalPickedPackageCount: 1/)
|
||||||
|
assert.match(loading, /plannedPackageCount\) === 2[\s\S]*pickedPackageCount\) === 1/)
|
||||||
|
assert.match(loading, /String\(unresolvedComplete\.body\?\.message \|\| ''\)\.includes\('尚未处理'\)/)
|
||||||
|
assert.match(loading, /taskItemsSignature\(afterUnresolvedCompleteTask\) === taskItemsSignature\(unresolvedTask\)/)
|
||||||
|
assert.match(loading, /operationsSignature\(operationsAfterUnresolvedComplete\) === operationsSignature\(operationsBeforeUnresolvedComplete\)/)
|
||||||
|
assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/)
|
||||||
|
})
|
||||||
|
|
||||||
test('phase3 loading fixture forces A0 through reversible public location status APIs', () => {
|
test('phase3 loading fixture forces A0 through reversible public location status APIs', () => {
|
||||||
const entry = source('../admin-test-env-acceptance.mjs')
|
const entry = source('../admin-test-env-acceptance.mjs')
|
||||||
const measurement = source('./inbound-measurement.mjs')
|
const measurement = source('./inbound-measurement.mjs')
|
||||||
|
|||||||
+2
-1
@@ -87,7 +87,8 @@ public class PlatformWarehouseResourceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/pallets")
|
@PostMapping("/pallets")
|
||||||
@Operation(summary = "Create a warehouse pallet", description = "Platform access required")
|
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_CREATE + "')")
|
||||||
|
@Operation(summary = "Create a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_CREATE)
|
||||||
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable long warehouseId,
|
@PathVariable long warehouseId,
|
||||||
@Valid @RequestBody PalletRequests.SaveRequest request) {
|
@Valid @RequestBody PalletRequests.SaveRequest request) {
|
||||||
|
|||||||
+1
-8
@@ -10,7 +10,6 @@ import java.util.Map;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
|
|
||||||
class WarehouseResourcePermissionContractTest {
|
class WarehouseResourcePermissionContractTest {
|
||||||
private static final String PREFIX = "platform:overseas-warehouse:";
|
private static final String PREFIX = "platform:overseas-warehouse:";
|
||||||
@@ -28,15 +27,9 @@ class WarehouseResourcePermissionContractTest {
|
|||||||
assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete");
|
assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete");
|
||||||
assertPermission(methods.get("pallets"), PREFIX + "pallet:list");
|
assertPermission(methods.get("pallets"), PREFIX + "pallet:list");
|
||||||
assertPermission(methods.get("pallet"), PREFIX + "pallet:list");
|
assertPermission(methods.get("pallet"), PREFIX + "pallet:list");
|
||||||
|
assertPermission(methods.get("createPallet"), PREFIX + "pallet:create");
|
||||||
assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit");
|
assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit");
|
||||||
assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete");
|
assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete");
|
||||||
|
|
||||||
Method createPallet = Arrays.stream(PlatformWarehouseResourceController.class.getDeclaredMethods())
|
|
||||||
.filter(method -> "createPallet".equals(method.getName()))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow();
|
|
||||||
assertNull(createPallet.getAnnotation(PreAuthorize.class));
|
|
||||||
assertEquals("Platform access required", createPallet.getAnnotation(Operation.class).description());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertPermission(Method method, String authority) {
|
private static void assertPermission(Method method, String authority) {
|
||||||
|
|||||||
Reference in New Issue
Block a user