This commit is contained in:
2026-09-15 13:36:58 +08:00
parent 1bb69698de
commit f9649c44e9
44 changed files with 647 additions and 63 deletions
+1 -1
View File
@@ -41,7 +41,7 @@
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/EventBridgeClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` |
@@ -10,6 +10,7 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2;
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV3;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
@@ -19,6 +20,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -42,11 +44,12 @@ import java.util.Set;
/** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */
@Service
public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L;
private static final long HANDOVER_VERSION = 3L;
@Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private LoadingCartonManifestMapper cartonManifestMapper;
@Resource private LoadingPalletLabelMapper palletLabelMapper;
@Resource private LoadingPalletLabelService palletLabelService;
@Resource private CabinetLoadingEvidenceMapper cabinetEvidenceMapper;
@Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper;
@@ -110,21 +113,42 @@ public class LoadingCompletedOutboxService {
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(),
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(),
cartonMarks, palletManifests)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
var payload = new FirstLegLoadingPlanCompletedV3(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()),
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()),
iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders);
iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders,
loadingEvidence(first.getContainerBookingId()));
JsonNode payloadNode = objectMapper.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
String traceId = EventIdentifiers.newUuid();
return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 2,
return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 3,
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN",
String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION,
traceId, eventKey, null, payloadNode, hash).requireValid();
}
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
if (containerBookingId == null || containerBookingId <= 0) {
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
}
List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> result = cabinetEvidenceMapper
.byContainer(containerBookingId).stream()
.filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus()))
.map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
value.getEvidenceType(), value.getFileId(), value.getFileName()))
.sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType))
.toList();
Set<String> types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)
.collect(java.util.stream.Collectors.toSet());
if (result.size() != 5 || !types.equals(Set.of(
"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) {
throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件");
}
return result;
}
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks,
@@ -10,6 +10,8 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -33,6 +35,7 @@ class LoadingCompletedOutboxServiceTest {
private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class);
private final LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class);
private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class);
private final CabinetLoadingEvidenceMapper cabinetEvidence = mock(CabinetLoadingEvidenceMapper.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService();
@@ -41,14 +44,25 @@ class LoadingCompletedOutboxServiceTest {
ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests);
ReflectionTestUtils.setField(service,"palletLabelMapper",palletLabels);
ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService);
ReflectionTestUtils.setField(service,"cabinetEvidenceMapper",cabinetEvidence);
ReflectionTestUtils.setField(service,"outboxMapper",outbox);
ReflectionTestUtils.setField(service,"objectMapper",mapper);
when(palletLabels.labels(anyLong())).thenReturn(List.of());
when(cabinetEvidence.byContainer(anyLong())).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2), evidence("PHOTO_STEP_3", 3),
evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5)));
when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of(
label(101, "BOX-101"), label(102, "BOX-102"),
label(202, "BOX-202"), label(302, "BOX-302")));
}
private static CabinetLoadingEvidenceEntity evidence(String type, long fileId) {
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setEvidenceType(type); value.setFileId(fileId); value.setFileName(type + ".jpg");
value.setStatus("ACTIVE"); value.setActiveFlag(1);
return value;
}
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101)));
when(snapshots.palletRows(90L)).thenReturn(List.of(
@@ -79,9 +93,12 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30");
assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(first.payload().at("/sourceContainerBookingId").asText()).isEqualTo("91");
assertThat(first.payload().get("loadingEvidence")).hasSize(5);
assertThat(first.payload().at("/loadingEvidence/0/evidenceType").asText()).isEqualTo("PHOTO_STEP_1");
assertThat(first.payload().at("/loadingEvidence/4/evidenceType").asText()).isEqualTo("SEAL");
assertThat(first.payload().has("containerBookingId")).isFalse();
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2);
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:3");
assertThat(first.eventVersion()).isEqualTo(3);
}
@Test void requiresFrozenPhysicalContainerNumberBeforePublishing() {
@@ -39,7 +39,7 @@ class TaillegRealProducerAcceptanceTest {
@Test
void completedLoadingPlanPublishesFirstLegAndDropshipOrders() {
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + PLAN + ":" + CUSTOMER + ":2";
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + PLAN + ":" + CUSTOMER + ":3";
Integer existing = jdbc.queryForObject("SELECT COUNT(*) FROM fl_inbound_outbox WHERE event_key=?",
Integer.class, eventKey);
if (existing == 0) {
@@ -51,6 +51,8 @@ class TaillegRealProducerAcceptanceTest {
assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " +
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class,
eventKey));
assertEquals(5, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.loadingEvidence')) " +
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey));
assertEquals("真实验收客户", jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
@@ -91,6 +93,15 @@ class TaillegRealProducerAcceptanceTest {
990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR");
seedPickItem(990022911L, 990022321L, FIRST_INBOUND, 990022421L, 3);
seedPickItem(990022912L, 990022322L, DROPSHIP_INBOUND, 990022422L, 2);
int evidenceIndex = 0;
for (String type : new String[] {"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"}) {
long id = 990023000L + evidenceIndex;
jdbc.update("INSERT IGNORE INTO fl_loading_cabinet_evidence(id,container_booking_id,source_task_id," +
"evidence_type,file_id,file_name,status,active_flag,operation_key,request_hash,created_by,updated_by) " +
"VALUES(?,?,?,?,?,?,'ACTIVE',1,?,REPEAT('a',64),1,1)",
id, BOOKING, 990022901L, type, id + 1000, type + ".jpg", MARKER + "-EVIDENCE-" + type);
evidenceIndex++;
}
jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN);
}