Merge remote-tracking branch 'origin/test' into test
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.common.core.container;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Platform-wide container types and practical loading limits. */
|
||||
public final class ContainerSpecificationCatalog {
|
||||
public static final String DEFAULT_CONTAINER_TYPE = "40HQ";
|
||||
|
||||
private static final List<Specification> ORDERED_VALUES = List.of(
|
||||
new Specification("40GP", new BigDecimal("56"), new BigDecimal("58"), new BigDecimal("19500"),
|
||||
"美线电商较少使用,通常优先选择40HQ"),
|
||||
new Specification("40HQ", new BigDecimal("65"), new BigDecimal("68"), new BigDecimal("19500"),
|
||||
"轻泡货首选,适合家居、玩具、日用品"),
|
||||
new Specification("45HQ", new BigDecimal("82"), new BigDecimal("86"), new BigDecimal("19500"),
|
||||
"抛货量大时考虑,底盘和海外仓预约资源较少")
|
||||
);
|
||||
private static final Map<String, Specification> VALUES = ORDERED_VALUES.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(Specification::containerType, Function.identity()));
|
||||
|
||||
private ContainerSpecificationCatalog() {}
|
||||
|
||||
public static List<Specification> values() {
|
||||
return ORDERED_VALUES;
|
||||
}
|
||||
|
||||
public static Specification require(String containerType) {
|
||||
String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT);
|
||||
Specification specification = VALUES.get(normalized);
|
||||
if (specification == null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "柜型仅支持40GP、40HQ、45HQ");
|
||||
}
|
||||
return specification;
|
||||
}
|
||||
|
||||
public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {
|
||||
Specification specification = require(containerType);
|
||||
if (capacityCbm == null || maxWeightKg == null
|
||||
|| specification.capacityCbm().compareTo(capacityCbm) != 0
|
||||
|| specification.maxWeightKg().compareTo(maxWeightKg) != 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"柜型容量和货重上限必须使用平台统一定义");
|
||||
}
|
||||
return specification;
|
||||
}
|
||||
|
||||
public record Specification(String containerType, BigDecimal recommendedMinCbm, BigDecimal capacityCbm,
|
||||
BigDecimal maxWeightKg, String description) {}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.common.core.container;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContainerSpecificationCatalogTest {
|
||||
@Test
|
||||
void exposesTheThreeCanonicalContainerTypesInBusinessOrder() {
|
||||
assertThat(ContainerSpecificationCatalog.values())
|
||||
.extracting(ContainerSpecificationCatalog.Specification::containerType)
|
||||
.containsExactly("40GP", "40HQ", "45HQ");
|
||||
assertThat(ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE).isEqualTo("40HQ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesPracticalLoadingRangesAndRejectsRemovedTypes() {
|
||||
var specification = ContainerSpecificationCatalog.requireMatch(
|
||||
"40hq", new BigDecimal("68.000"), new BigDecimal("19500.000"));
|
||||
|
||||
assertThat(specification.containerType()).isEqualTo("40HQ");
|
||||
assertThat(specification.recommendedMinCbm()).isEqualByComparingTo("65");
|
||||
assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HC"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("40GP、40HQ、45HQ");
|
||||
assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch(
|
||||
"40HQ", new BigDecimal("76"), new BigDecimal("19500")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("统一定义");
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,17 @@
|
||||
| 事件 ID 与业务键 | `EventIdentifiers.java` |
|
||||
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
|
||||
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
||||
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
|
||||
| 头程装柜完成 V3(当前生产) | `FirstLegLoadingPlanCompletedV3.java` 在 V2 订单快照上增加物理柜空仓、半仓、满仓、锁仓、带锁码五类不可变照片引用;订单级仍冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`;V1/V2 仅用于历史事件读取 |
|
||||
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
||||
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
||||
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
|
||||
| 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 |
|
||||
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 |
|
||||
| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
|
||||
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
|
||||
| 契约说明 | `package-info.java` |
|
||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
|
||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`FirstLegLoadingPlanCompletedV3Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
|
||||
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
@@ -14,6 +14,7 @@ public final class EventTypes {
|
||||
"FirstLegContainerShippingNodeRecorded";
|
||||
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
||||
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
|
||||
public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted";
|
||||
public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged";
|
||||
public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested";
|
||||
public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted";
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/** 装柜完成 V3:在 V2 货物快照上增加整柜五类国内装柜照片引用。 */
|
||||
public record FirstLegLoadingPlanCompletedV3(
|
||||
String loadingPlanId, String planNo,
|
||||
@JsonProperty("sourceContainerBookingId") String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||
List<FirstLegLoadingPlanCompletedV2.Order> orders, List<LoadingEvidence> loadingEvidence) {
|
||||
public FirstLegLoadingPlanCompletedV3 {
|
||||
if (loadingPlanId == null || loadingPlanId.isBlank()
|
||||
|| containerBookingId == null || containerBookingId.isBlank()) {
|
||||
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
|
||||
}
|
||||
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||
orders = orders == null ? List.of() : List.copyOf(orders);
|
||||
loadingEvidence = loadingEvidence == null ? List.of() : List.copyOf(loadingEvidence);
|
||||
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
|
||||
var owner = orders.get(0);
|
||||
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|
||||
|| !owner.customerName().equals(order.customerName()))) {
|
||||
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
|
||||
}
|
||||
if (loadingEvidence.size() != 5
|
||||
|| loadingEvidence.stream().map(LoadingEvidence::evidenceType).distinct().count() != 5) {
|
||||
throw new IllegalArgumentException("five unique loading evidence types are required");
|
||||
}
|
||||
}
|
||||
|
||||
public record LoadingEvidence(String evidenceType, long fileId, String fileName) {
|
||||
public LoadingEvidence {
|
||||
if (evidenceType == null || !evidenceType.matches("PHOTO_STEP_[1-4]|SEAL")) {
|
||||
throw new IllegalArgumentException("unsupported loading evidence type");
|
||||
}
|
||||
if (fileId <= 0) throw new IllegalArgumentException("loading evidence fileId must be positive");
|
||||
if (fileName == null || fileName.isBlank() || fileName.length() > 255) {
|
||||
throw new IllegalArgumentException("loading evidence fileName is required");
|
||||
}
|
||||
fileName = fileName.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** 海外派送单按目的 FBA 仓签收后,回传对应国内入仓单和订单的完成事实。 */
|
||||
public record OverseasDeliveryCompletedV1(
|
||||
String deliveryOrderId,
|
||||
String deliveryNo,
|
||||
String lastlegOrderId,
|
||||
String lastlegOrderNo,
|
||||
String sourceInboundId,
|
||||
String inboundCode,
|
||||
String sourceOrderId,
|
||||
String sourceOrderNo,
|
||||
String rootOrderId,
|
||||
String fbaWarehouseCode,
|
||||
Instant completedAt
|
||||
) {
|
||||
}
|
||||
@@ -5,6 +5,25 @@ info:
|
||||
paths: {}
|
||||
components:
|
||||
schemas:
|
||||
FirstLegLoadingPlanCompletedV3:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/FirstLegLoadingPlanCompletedV2'
|
||||
- type: object
|
||||
required: [loadingEvidence]
|
||||
properties:
|
||||
loadingEvidence:
|
||||
type: array
|
||||
minItems: 5
|
||||
maxItems: 5
|
||||
description: Exactly one active domestic loading photo reference for each of PHOTO_STEP_1 through PHOTO_STEP_4 and SEAL.
|
||||
items: {$ref: '#/components/schemas/FirstLegLoadingEvidenceV3'}
|
||||
FirstLegLoadingEvidenceV3:
|
||||
type: object
|
||||
required: [evidenceType, fileId, fileName]
|
||||
properties:
|
||||
evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]}
|
||||
fileId: {type: integer, format: int64, minimum: 1, description: Domestic File service identity; never exposed to PDA clients}
|
||||
fileName: {type: string, minLength: 1, maxLength: 255}
|
||||
FirstLegLoadingPlanCompletedV2:
|
||||
type: object
|
||||
required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FirstLegLoadingPlanCompletedV3Test {
|
||||
@Test
|
||||
void carriesExactlyFiveDomesticLoadingPhotoReferences() {
|
||||
var json = new ObjectMapper().valueToTree(payload(evidence()));
|
||||
|
||||
assertThat(json.path("loadingEvidence")).hasSize(5);
|
||||
assertThat(json.at("/loadingEvidence/4/evidenceType").asText()).isEqualTo("SEAL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingOrDuplicatePhotoTypes() {
|
||||
assertThatThrownBy(() -> payload(evidence().subList(0, 4)))
|
||||
.hasMessageContaining("five unique");
|
||||
assertThatThrownBy(() -> payload(List.of(
|
||||
photo("PHOTO_STEP_1", 1), photo("PHOTO_STEP_1", 2), photo("PHOTO_STEP_2", 3),
|
||||
photo("PHOTO_STEP_3", 4), photo("SEAL", 5))))
|
||||
.hasMessageContaining("five unique");
|
||||
}
|
||||
|
||||
private FirstLegLoadingPlanCompletedV3 payload(List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> photos) {
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
||||
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
|
||||
return new FirstLegLoadingPlanCompletedV3(
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH",
|
||||
null, null, null, List.of(order), photos);
|
||||
}
|
||||
|
||||
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> evidence() {
|
||||
return List.of(photo("PHOTO_STEP_1", 1), photo("PHOTO_STEP_2", 2),
|
||||
photo("PHOTO_STEP_3", 3), photo("PHOTO_STEP_4", 4), photo("SEAL", 5));
|
||||
}
|
||||
|
||||
private FirstLegLoadingPlanCompletedV3.LoadingEvidence photo(String type, long id) {
|
||||
return new FirstLegLoadingPlanCompletedV3.LoadingEvidence(type, id, type + ".jpg");
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasDeliveryCompletedV1Test {
|
||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
|
||||
@Test
|
||||
void roundTripsStableSourceIdentifiers() throws Exception {
|
||||
var value = new OverseasDeliveryCompletedV1("31", "DL-31", "41", "LM-41", "51",
|
||||
"NYEXX2609080022", "61", "FL-61", "61", "ACY2",
|
||||
Instant.parse("2026-09-15T08:00:00Z"));
|
||||
|
||||
var restored = mapper.readValue(mapper.writeValueAsBytes(value), OverseasDeliveryCompletedV1.class);
|
||||
|
||||
assertThat(restored).isEqualTo(value);
|
||||
assertThat(restored.sourceInboundId()).isEqualTo("51");
|
||||
assertThat(restored.sourceOrderId()).isEqualTo("61");
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,17 @@ baiyue:
|
||||
pallet-physical:
|
||||
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
|
||||
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
|
||||
domestic-loading-evidence:
|
||||
connect-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_CONNECT_TIMEOUT:3s}
|
||||
read-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_READ_TIMEOUT:30s}
|
||||
clients:
|
||||
domestic-warehouse:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL}
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL}
|
||||
domestic-file:
|
||||
url: ${DOMESTIC_FILE_SERVICE_URL}
|
||||
event-bridge:
|
||||
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
|
||||
warehouse:
|
||||
|
||||
@@ -81,6 +81,8 @@ services:
|
||||
WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME}
|
||||
WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD}
|
||||
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
|
||||
# 与海外 BOL 共用国内 Print;不能在海外 Nacos namespace 中发现该服务。
|
||||
PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}
|
||||
|
||||
event-bridge-service:
|
||||
<<: *overseas-common
|
||||
|
||||
@@ -9919,8 +9919,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
|
||||
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
|
||||
place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照',
|
||||
service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填',
|
||||
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
|
||||
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
|
||||
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
|
||||
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
@@ -9997,8 +9995,6 @@ PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check;
|
||||
EXECUTE ocean_drop_status_stmt;
|
||||
DEALLOCATE PREPARE ocean_drop_status_stmt;
|
||||
ALTER TABLE fl_ocean_booking
|
||||
MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填',
|
||||
MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
|
||||
ADD CONSTRAINT chk_ocean_booking_status CHECK
|
||||
(status IN ('DRAFT','RELEASED'));
|
||||
|
||||
@@ -14879,11 +14875,9 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_po
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_port_name', '目的港名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_arrival_at', '当前确认预计到港时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_sailing_at', '当前确认预计开船时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'freight_term', '海运费支付条款快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'id', '海运订舱ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'place_of_delivery', '最终交货地快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'remark', '平台内部备注');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'service_term', '运输服务条款快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_id', '船公司逻辑ID快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_name', '船公司名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、RELEASED已发布且永久只读');
|
||||
@@ -16603,10 +16597,10 @@ BEGIN
|
||||
@phase3_channel_message = MESSAGE_TEXT;
|
||||
END;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='channel_code') THEN
|
||||
ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER customer_name;
|
||||
ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照' AFTER customer_name;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='channel_name') THEN
|
||||
ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code;
|
||||
ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱计划物流渠道组名称快照' AFTER channel_code;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='warehouse_id') THEN
|
||||
ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照' AFTER channel_name;
|
||||
@@ -19224,7 +19218,7 @@ USE baiyue_firstleg;
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
|
||||
fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照',
|
||||
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
|
||||
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
|
||||
estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
@@ -19232,9 +19226,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id),
|
||||
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
|
||||
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL)
|
||||
AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
|
||||
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id)
|
||||
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '邮件任务ID',
|
||||
@@ -19270,7 +19262,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
|
||||
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
|
||||
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
|
||||
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
|
||||
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
|
||||
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN'))
|
||||
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID',
|
||||
@@ -19283,17 +19275,19 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
|
||||
) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作';
|
||||
|
||||
-- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。
|
||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_terminal_drayage' AND column_name='actual_pickup_at'),
|
||||
'ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at', 'SELECT 1'));
|
||||
PREPARE terminal_drayage_drop_manual_pickup_stmt FROM @ddl;
|
||||
EXECUTE terminal_drayage_drop_manual_pickup_stmt;
|
||||
DEALLOCATE PREPARE terminal_drayage_drop_manual_pickup_stmt;
|
||||
|
||||
ALTER TABLE fl_terminal_drayage
|
||||
DROP CHECK chk_terminal_drayage_times,
|
||||
ADD CONSTRAINT chk_terminal_drayage_times CHECK(
|
||||
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
|
||||
(actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)),
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID',
|
||||
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
|
||||
MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
|
||||
MODIFY COLUMN fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照',
|
||||
MODIFY COLUMN pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
|
||||
MODIFY COLUMN actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
|
||||
MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间',
|
||||
MODIFY COLUMN actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
|
||||
MODIFY COLUMN version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号',
|
||||
@@ -19307,7 +19301,7 @@ ALTER TABLE fl_terminal_drayage
|
||||
ALTER TABLE fl_terminal_drayage_history
|
||||
DROP CHECK chk_terminal_drayage_history_action,
|
||||
ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
|
||||
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')),
|
||||
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')),
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
|
||||
MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
|
||||
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照',
|
||||
@@ -19371,7 +19365,6 @@ BEGIN
|
||||
OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit')
|
||||
OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit')
|
||||
OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit')
|
||||
OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm')
|
||||
OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
@@ -19382,7 +19375,6 @@ BEGIN
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突';
|
||||
@@ -19406,8 +19398,6 @@ BEGIN
|
||||
'维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0),
|
||||
(5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0),
|
||||
(5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm',
|
||||
'确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
@@ -19440,7 +19430,6 @@ BEGIN
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
@@ -19457,8 +19446,7 @@ BEGIN
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
|
||||
@@ -19478,8 +19466,7 @@ BEGIN
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整';
|
||||
END IF;
|
||||
|
||||
|
||||
@@ -350,6 +350,10 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID',
|
||||
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
|
||||
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照',
|
||||
destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照',
|
||||
delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
|
||||
waybill_no VARCHAR(128) NULL COMMENT '承运运单号',
|
||||
bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识',
|
||||
@@ -382,7 +386,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no),
|
||||
UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key),
|
||||
UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id),
|
||||
UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key),
|
||||
KEY idx_ll_delivery_list (customer_id,status,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程派送单';
|
||||
|
||||
@@ -390,6 +394,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name'));
|
||||
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name'));
|
||||
PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt;
|
||||
SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound');
|
||||
SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination');
|
||||
SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)'));
|
||||
PREPARE delivery_destination_index_stmt FROM @ddl; EXECUTE delivery_destination_index_stmt; DEALLOCATE PREPARE delivery_destination_index_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status'));
|
||||
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count'));
|
||||
@@ -1517,6 +1527,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总',
|
||||
recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息',
|
||||
request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no),
|
||||
UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id),
|
||||
KEY idx_ll_outbound_list (customer_id,status,created_at)
|
||||
@@ -1623,6 +1634,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式'''));
|
||||
PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no'));
|
||||
PREPARE transfer_relabel_completed_stmt FROM @ddl; EXECUTE transfer_relabel_completed_stmt; DEALLOCATE PREPARE transfer_relabel_completed_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='palletize_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN palletize_idempotency_key VARCHAR(255) NULL COMMENT ''PDA打托幂等键'', ADD COLUMN palletize_request_hash CHAR(64) NULL COMMENT ''PDA打托请求哈希'', ADD COLUMN confirmation_idempotency_key VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'', ADD COLUMN confirmation_request_hash CHAR(64) NULL COMMENT ''客户测量确认请求哈希'', ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认测量时间'''));
|
||||
PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt;
|
||||
|
||||
@@ -2014,7 +2027,9 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
|
||||
sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
|
||||
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数',
|
||||
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
|
||||
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
|
||||
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
|
||||
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
|
||||
@@ -2080,7 +2095,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
||||
origin_address VARCHAR(500) NULL COMMENT '起运地址',
|
||||
destination VARCHAR(255) NOT NULL COMMENT '目的地',
|
||||
destination_address VARCHAR(500) NULL COMMENT '目的地址',
|
||||
container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集',
|
||||
container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ',
|
||||
pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数',
|
||||
package_count INT NULL COMMENT '可选箱数,零或空值均有效',
|
||||
received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数',
|
||||
@@ -2158,7 +2173,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
|
||||
pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型',
|
||||
origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
|
||||
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号',
|
||||
container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
|
||||
container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
|
||||
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
|
||||
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
|
||||
budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
|
||||
@@ -2607,6 +2622,18 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
|
||||
) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_container_loading_evidence (
|
||||
id BIGINT NOT NULL COMMENT '海外整柜装柜照片投影ID',
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '国内稳定来源柜预订ID',
|
||||
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL',
|
||||
source_file_id BIGINT NOT NULL COMMENT '国内File服务文件ID',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '国内文件名快照',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '投影创建时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type),
|
||||
UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id)
|
||||
) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用';
|
||||
|
||||
-- END INLINE EMPTY-DATABASE BASELINE: LASTLEG
|
||||
|
||||
-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE
|
||||
@@ -5047,4 +5074,16 @@ DELIMITER ;
|
||||
CALL assert_relabel_document_columns_ready();
|
||||
DROP PROCEDURE IF EXISTS assert_relabel_document_columns_ready;
|
||||
|
||||
SELECT 'PASS' AS migration_status, '贴标申请箱唛总文件、SKU总文件及明细文件字段已就绪' AS message;
|
||||
CREATE TABLE IF NOT EXISTS ll_container_loading_evidence (
|
||||
id BIGINT NOT NULL COMMENT '海外整柜装柜照片投影ID',
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '国内稳定来源柜预订ID',
|
||||
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL',
|
||||
source_file_id BIGINT NOT NULL COMMENT '国内File服务文件ID',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '国内文件名快照',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '投影创建时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type),
|
||||
UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id)
|
||||
) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用';
|
||||
|
||||
SELECT 'PASS' AS migration_status, '贴标申请文件字段与国内装柜照片投影已就绪' AS message;
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ public class EventBridgeMetrics {
|
||||
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
|
||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED,
|
||||
EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
|
||||
EventTypes.WALLET_COMMAND_REQUESTED,
|
||||
EventTypes.WALLET_COMMAND_COMPLETED,
|
||||
|
||||
+2
-1
@@ -41,9 +41,10 @@
|
||||
| 货柜与全局智能排柜 | `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` |
|
||||
| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
|
||||
| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
|
||||
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@FeignClient(name = "order-service", contextId = "firstlegOrderFulfillmentCompletionClient",
|
||||
url = "${baiyue.clients.order.url:}")
|
||||
public interface OrderFulfillmentCompletionClient {
|
||||
@PostMapping("/internal/order/v1/fulfillment-completions")
|
||||
ApiResponse<Result> complete(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody Request request);
|
||||
|
||||
record Request(String tenantId, String orderId, String orderNo, String inboundCode,
|
||||
String deliveryNo, Instant completedAt, String eventId, String payloadHash) { }
|
||||
record Result(Long orderId, String status, boolean replayed) { }
|
||||
}
|
||||
+5
-1
@@ -7,6 +7,7 @@ import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.firstleg.service.OverseasContainerEventConsumer;
|
||||
import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer;
|
||||
import com.baiyue.firstleg.service.OverseasDeliveryCompletedConsumer;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -22,6 +23,7 @@ import java.security.MessageDigest;
|
||||
public class InternalOverseasContainerEventController {
|
||||
private final OverseasContainerEventConsumer consumer;
|
||||
private final OverseasInboundOrderDetainedConsumer detainedConsumer;
|
||||
private final OverseasDeliveryCompletedConsumer deliveryCompletedConsumer;
|
||||
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
||||
|
||||
@PostMapping("/internal/firstleg/v1/container-events")
|
||||
@@ -30,7 +32,9 @@ public class InternalOverseasContainerEventController {
|
||||
@RequestBody EventEnvelope event) {
|
||||
authenticate(token);
|
||||
Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
|
||||
? detainedConsumer.consume(event) : consumer.consume(event);
|
||||
? detainedConsumer.consume(event)
|
||||
: EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType())
|
||||
? deliveryCompletedConsumer.consume(event) : consumer.consume(event);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/** Platform-controlled container specifications shared by booking and simulation flows. */
|
||||
public final class ContainerSpecificationCatalog {
|
||||
private static final Map<String, Specification> VALUES = Map.of(
|
||||
"20GP", new Specification("20GP", new BigDecimal("33"), new BigDecimal("28000")),
|
||||
"40GP", new Specification("40GP", new BigDecimal("67"), new BigDecimal("28000")),
|
||||
"40HC", new Specification("40HC", new BigDecimal("76"), new BigDecimal("28000")),
|
||||
"40NR", new Specification("40NR", new BigDecimal("67"), new BigDecimal("28000")),
|
||||
"45HC", new Specification("45HC", new BigDecimal("86"), new BigDecimal("28000"))
|
||||
);
|
||||
|
||||
private ContainerSpecificationCatalog() {}
|
||||
|
||||
public static Specification require(String containerType) {
|
||||
String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT);
|
||||
Specification specification = VALUES.get(normalized);
|
||||
if (specification == null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "柜型必须使用平台固定规格");
|
||||
}
|
||||
return specification;
|
||||
}
|
||||
|
||||
public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {
|
||||
Specification specification = require(containerType);
|
||||
if (capacityCbm == null || maxWeightKg == null
|
||||
|| specification.capacityCbm().compareTo(capacityCbm) != 0
|
||||
|| specification.maxWeightKg().compareTo(maxWeightKg) != 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"柜型容量和限重必须使用平台固定规格");
|
||||
}
|
||||
return specification;
|
||||
}
|
||||
|
||||
public record Specification(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
|
||||
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL")
|
||||
int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL")
|
||||
int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET container_no=#{containerNo},updated_by=#{operatorId},
|
||||
updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
|
||||
@@ -24,6 +24,29 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
|
||||
@Param("detained") boolean detained, @Param("reason") String reason,
|
||||
@Param("sourceVersion") long sourceVersion, @Param("operatorId") long operatorId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status='DELIVERED', updated_by=0, updated_at=#{completedAt}, version=version+1
|
||||
WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADED'
|
||||
""")
|
||||
int completeOverseasDelivery(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("completedAt") java.time.LocalDateTime completedAt);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_inbound_order
|
||||
WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status!='DELIVERED'
|
||||
""")
|
||||
int countNotDeliveredByOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET order_status_snapshot='COMPLETED', updated_by=0, updated_at=#{completedAt}, version=version+1
|
||||
WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0
|
||||
AND (order_status_snapshot IS NULL OR order_status_snapshot!='COMPLETED')
|
||||
""")
|
||||
int markOrderSnapshotCompleted(@Param("tenantId") long tenantId, @Param("orderId") long orderId,
|
||||
@Param("completedAt") java.time.LocalDateTime completedAt);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET hold_flag=#{holdFlag}, current_hold_reason=#{reason},
|
||||
|
||||
@@ -72,8 +72,6 @@ public final class OceanBookingDtos {
|
||||
@Size(max = 32) String destinationPortCode,
|
||||
@Size(max = 128) String destinationPortName,
|
||||
@Size(max = 128) String placeOfDelivery,
|
||||
@Size(max = 32) String serviceTerm,
|
||||
@Size(max = 32) String freightTerm,
|
||||
LocalDateTime estimatedSailingAt,
|
||||
LocalDateTime estimatedArrivalAt,
|
||||
@Size(max = 1000) String remark,
|
||||
|
||||
@@ -37,8 +37,6 @@ public class OceanBookingEntity {
|
||||
private String destinationPortCode;
|
||||
private String destinationPortName;
|
||||
private String placeOfDelivery;
|
||||
private String serviceTerm;
|
||||
private String freightTerm;
|
||||
private LocalDateTime estimatedSailingAt;
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
private String remark;
|
||||
|
||||
+3
-5
@@ -25,7 +25,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
""")
|
||||
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int deleteDraft(@Param("id") long id, @Param("version") int version);
|
||||
@@ -76,16 +76,14 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
AND allocated.requirement_id=requirement.id
|
||||
WHERE booking.status='RELEASED'
|
||||
AND booking.warehouse_id=#{query.warehouseId}
|
||||
AND booking.channel_code IN
|
||||
<foreach collection="channelCodes" item="channelCode" open="(" separator="," close=")">#{channelCode}</foreach>
|
||||
AND booking.channel_code=#{query.channelGroupCode}
|
||||
AND requirement.container_type=#{query.containerType}
|
||||
AND requirement.quantity>COALESCE(allocated.allocated_quantity,0)
|
||||
ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC
|
||||
</script>
|
||||
""")
|
||||
Page<LinkCandidate> selectLinkCandidates(Page<LinkCandidate> page,
|
||||
@Param("query") LinkCandidateQuery query,
|
||||
@Param("channelCodes") java.util.Set<String> channelCodes);
|
||||
@Param("query") LinkCandidateQuery query);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET status=#{toStatus},
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ public class OceanBookingRepository {
|
||||
public PageResult<OceanBookingEntity> page(Query query) {
|
||||
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
|
||||
}
|
||||
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query, java.util.Set<String> channelCodes) {
|
||||
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query, channelCodes));
|
||||
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
|
||||
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query));
|
||||
}
|
||||
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
|
||||
public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); }
|
||||
|
||||
+2
-2
@@ -75,8 +75,8 @@ public class OceanBookingPrintService {
|
||||
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
|
||||
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
|
||||
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
|
||||
fields.put("serviceTerm", text(booking.getServiceTerm()));
|
||||
fields.put("freightTerm", text(booking.getFreightTerm()));
|
||||
fields.put("serviceTerm", "");
|
||||
fields.put("freightTerm", "");
|
||||
fields.put("totalPackagesInWords", "");
|
||||
if (SHIPPING_INSTRUCTION.equals(template)) {
|
||||
fields.put("toCompany", text(booking.getBookingAgentName()));
|
||||
|
||||
+15
-13
@@ -9,7 +9,7 @@ import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.client.WarehousePartnerClient;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
|
||||
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
|
||||
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.*;
|
||||
@@ -75,8 +75,9 @@ public class OceanBookingService {
|
||||
SmartLoadingChannelGroupCatalog.Definition group;
|
||||
try { group = SmartLoadingChannelGroupCatalog.require(query.channelGroupCode()); }
|
||||
catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); }
|
||||
String containerType = ContainerSpecificationCatalog.require(query.containerType()).containerType();
|
||||
return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(),
|
||||
group.code(), query.containerType().trim(), query.page(), query.size()), group.channelCodes());
|
||||
group.code(), containerType, query.page(), query.size()));
|
||||
}
|
||||
|
||||
public List<ChannelOption> channels(long originWarehouseId) {
|
||||
@@ -228,8 +229,7 @@ public class OceanBookingService {
|
||||
List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
||||
|| !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName())
|
||||
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|
||||
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
|
||||
|| !StringUtils.hasText(booking.getVoyageNo()) || booking.getEstimatedSailingAt() == null
|
||||
|| booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null
|
||||
|| (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode()))
|
||||
|| (!StringUtils.hasText(booking.getDestinationPortName()) && !StringUtils.hasText(booking.getDestinationPortCode()))
|
||||
@@ -239,16 +239,18 @@ public class OceanBookingService {
|
||||
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
|
||||
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
|
||||
}
|
||||
private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) {
|
||||
private SmartLoadingChannelGroupCatalog.Definition requireActiveChannelGroup(Long originWarehouseId, String channelGroupCode) {
|
||||
if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓");
|
||||
String normalized = normalizeChannel(channelCode);
|
||||
SmartLoadingChannelGroupCatalog.Definition group;
|
||||
try { group = SmartLoadingChannelGroupCatalog.require(normalizeChannel(channelGroupCode)); }
|
||||
catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); }
|
||||
Long versionId = priceBooks.activeVersionId();
|
||||
if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
|
||||
ChannelRow row = priceBooks.channels(versionId, originWarehouseId).stream()
|
||||
.filter(value -> normalized.equals(normalizeChannel(value.getChannelCode())))
|
||||
.findFirst().orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
|
||||
if (!StringUtils.hasText(row.getChannelName())) throw conflict("所选物流渠道名称配置无效");
|
||||
return channelOption(row, java.util.Map.of());
|
||||
boolean active = priceBooks.channels(versionId, originWarehouseId).stream()
|
||||
.map(ChannelRow::getChannelCode).filter(StringUtils::hasText).map(String::trim)
|
||||
.anyMatch(group.channelCodes()::contains);
|
||||
if (!active) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择");
|
||||
return group;
|
||||
}
|
||||
|
||||
private static ChannelOption channelOption(ChannelRow row, java.util.Map<String, List<String>> taxModes) {
|
||||
@@ -269,7 +271,7 @@ public class OceanBookingService {
|
||||
if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) {
|
||||
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId());
|
||||
warehouseName = warehouse.warehouseName();
|
||||
channelName = requireActiveChannel(warehouse.id(), d.channelCode()).channelName();
|
||||
channelName = requireActiveChannelGroup(warehouse.id(), d.channelCode()).name();
|
||||
}
|
||||
var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode());
|
||||
var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode());
|
||||
@@ -287,7 +289,7 @@ public class OceanBookingService {
|
||||
v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo()));
|
||||
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
|
||||
v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName()));
|
||||
v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm()));
|
||||
v.setPlaceOfDelivery(trim(d.placeOfDelivery()));
|
||||
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
|
||||
v.setRemark(trim(d.remark()));
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public class ContainerRepository {
|
||||
public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo,
|
||||
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking,
|
||||
int version, long operatorId, String operatorName) {
|
||||
return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getChannelCode(), booking.getChannelName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName);
|
||||
return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName);
|
||||
}
|
||||
public int assignRealContainerNo(long id, String containerNo, int version,
|
||||
long operatorId, String operatorName) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingStatus;
|
||||
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
|
||||
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
@@ -89,7 +89,7 @@ public class ContainerService {
|
||||
String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
|
||||
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|
||||
|| !SmartLoadingChannelGroupCatalog.contains(container.getChannelGroupCode(), booking.getChannelCode())
|
||||
|| !java.util.Objects.equals(container.getChannelGroupCode(), booking.getChannelCode())
|
||||
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
|
||||
throw conflict("模拟柜与真实订舱柜仓库、渠道分组或柜型不一致");
|
||||
}
|
||||
|
||||
+28
-4
@@ -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,
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasDeliveryCompletedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient;
|
||||
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerEventInboxMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasDeliveryCompletedConsumer {
|
||||
private final InboundOrderMapper inboundOrders;
|
||||
private final InboundStatusHistoryMapper histories;
|
||||
private final ContainerEventInboxMapper inbox;
|
||||
private final OrderFulfillmentCompletionClient orderClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@Transactional
|
||||
public InboundOrderEntity consume(EventEnvelope event) {
|
||||
validate(event);
|
||||
OverseasDeliveryCompletedV1 payload = payload(event);
|
||||
long inboundId = positive(payload.sourceInboundId(), "来源入仓单 ID 无效");
|
||||
long tenantId = positive(event.tenantId(), "客户 ID 无效");
|
||||
if (!Objects.equals(event.aggregateId(), payload.sourceInboundId())) {
|
||||
throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致");
|
||||
}
|
||||
ContainerEventInboxEntity existing = inbox.selectById(event.eventId());
|
||||
if (existing != null) {
|
||||
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
|
||||
}
|
||||
return required(inboundId, tenantId, payload);
|
||||
}
|
||||
ContainerEventInboxEntity inboxRow = inbox(event);
|
||||
try { inbox.insert(inboxRow); }
|
||||
catch (DuplicateKeyException duplicate) {
|
||||
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
|
||||
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
|
||||
return required(inboundId, tenantId, payload);
|
||||
}
|
||||
InboundOrderEntity inbound = required(inboundId, tenantId, payload);
|
||||
if (!"LOADED".equals(inbound.getProcessStatus()) && !"DELIVERED".equals(inbound.getProcessStatus())) {
|
||||
throw new IllegalArgumentException("来源头程入仓单尚未装柜完成");
|
||||
}
|
||||
if ("LOADED".equals(inbound.getProcessStatus())) {
|
||||
LocalDateTime completedAt = LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC);
|
||||
if (inboundOrders.completeOverseasDelivery(tenantId, inboundId, completedAt) != 1) {
|
||||
throw new IllegalStateException("来源头程入仓单状态并发变化");
|
||||
}
|
||||
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
|
||||
history.setId(IdWorker.getId()); history.setTenantId(tenantId); history.setInboundId(inboundId);
|
||||
history.setFromStatus("LOADED"); history.setToStatus("DELIVERED");
|
||||
history.setAction("COMPLETE_DELIVERY");
|
||||
history.setReason("海外派送单 " + payload.deliveryNo() + " 已签收");
|
||||
history.setOperatorId(0L); history.setOperatorName("OVERSEAS_EVENT_BRIDGE");
|
||||
history.setOccurredAt(completedAt); histories.insert(history);
|
||||
}
|
||||
long sourceOrderId = positive(payload.sourceOrderId(), "来源订单 ID 无效");
|
||||
if (inboundOrders.countNotDeliveredByOrder(tenantId, sourceOrderId) == 0) {
|
||||
var response = orderClient.complete(internalToken, new OrderFulfillmentCompletionClient.Request(
|
||||
event.tenantId(), payload.sourceOrderId(), payload.sourceOrderNo(), payload.inboundCode(),
|
||||
payload.deliveryNo(), payload.completedAt(), event.eventId(), event.payloadHash()));
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| !"COMPLETED".equals(response.data().status())) {
|
||||
throw new IllegalStateException("Order 服务未确认来源订单完成");
|
||||
}
|
||||
inboundOrders.markOrderSnapshotCompleted(tenantId, sourceOrderId,
|
||||
LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC));
|
||||
}
|
||||
inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
inbox.updateById(inboxRow);
|
||||
return required(inboundId, tenantId, payload);
|
||||
}
|
||||
|
||||
private void validate(EventEnvelope event) {
|
||||
event.requireValid();
|
||||
if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1
|
||||
|| event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN
|
||||
|| !"INBOUND_ORDER".equals(event.aggregateType())) {
|
||||
throw new IllegalArgumentException("不支持的海外派送完成事件");
|
||||
}
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
|
||||
throw new IllegalArgumentException("payloadHash 与事件负载不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private OverseasDeliveryCompletedV1 payload(EventEnvelope event) {
|
||||
try {
|
||||
OverseasDeliveryCompletedV1 value = objectMapper.treeToValue(event.payload(), OverseasDeliveryCompletedV1.class);
|
||||
if (value.completedAt() == null || !StringUtils.hasText(value.deliveryNo())
|
||||
|| !StringUtils.hasText(value.sourceOrderId()) || !StringUtils.hasText(value.sourceOrderNo())
|
||||
|| !StringUtils.hasText(value.inboundCode())) throw new IllegalArgumentException("字段不完整");
|
||||
return value;
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalArgumentException("海外派送完成事件负载无效", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private InboundOrderEntity required(long inboundId, long tenantId, OverseasDeliveryCompletedV1 payload) {
|
||||
InboundOrderEntity inbound = inboundOrders.selectActiveByIdForUpdate(inboundId);
|
||||
if (inbound == null || !Objects.equals(inbound.getTenantId(), tenantId)
|
||||
|| !Objects.equals(inbound.getInboundCode(), payload.inboundCode())
|
||||
|| !Objects.equals(String.valueOf(inbound.getOrderId()), payload.sourceOrderId())
|
||||
|| !Objects.equals(inbound.getOrderNo(), payload.sourceOrderNo())
|
||||
|| (StringUtils.hasText(payload.fbaWarehouseCode()) && StringUtils.hasText(inbound.getDestinationCode())
|
||||
&& !Objects.equals(inbound.getDestinationCode(), payload.fbaWarehouseCode()))) {
|
||||
throw new IllegalArgumentException("事件与来源头程入仓单归属不一致");
|
||||
}
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private ContainerEventInboxEntity inbox(EventEnvelope event) {
|
||||
ContainerEventInboxEntity row = new ContainerEventInboxEntity();
|
||||
row.setEventId(event.eventId()); row.setEventType(event.eventType()); row.setEventVersion(event.eventVersion());
|
||||
row.setAggregateId(event.aggregateId()); row.setAggregateVersion(event.aggregateVersion());
|
||||
row.setPayloadHash(event.payloadHash().toLowerCase(Locale.ROOT)); row.setPayloadJson(event.payload().toString());
|
||||
row.setStatus("RECEIVED"); row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static long positive(String value, String message) {
|
||||
try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; }
|
||||
catch (RuntimeException ignored) { }
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
}
|
||||
-14
@@ -122,20 +122,6 @@ public class PlatformTerminalDrayageController {
|
||||
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/pickup")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup:confirm')")
|
||||
@Operation(summary = "确认目的港实际提柜")
|
||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER",
|
||||
resourceId = "#containerBookingId", description = "确认目的港实际提柜")
|
||||
public ApiResponse<View> recordPickup(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActualTimeRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.authorize(service.recordPickup(
|
||||
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/empty-return")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:empty-return:confirm')")
|
||||
@Operation(summary = "确认实际还空柜")
|
||||
|
||||
-1
@@ -17,7 +17,6 @@ public class TerminalDrayageEntity {
|
||||
private Long fleetSupplierId;
|
||||
private String fleetSupplierName;
|
||||
private LocalDateTime pickupAppointmentAt;
|
||||
private LocalDateTime actualPickupAt;
|
||||
private LocalDateTime estimatedEmptyReturnAt;
|
||||
private LocalDateTime actualEmptyReturnAt;
|
||||
private Integer version;
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
@Update("""
|
||||
UPDATE fl_terminal_drayage SET fleet_supplier_id=#{value.fleetSupplierId},
|
||||
fleet_supplier_name=#{value.fleetSupplierName},pickup_appointment_at=#{value.pickupAppointmentAt},
|
||||
actual_pickup_at=#{value.actualPickupAt},estimated_empty_return_at=#{value.estimatedEmptyReturnAt},
|
||||
estimated_empty_return_at=#{value.estimatedEmptyReturnAt},
|
||||
actual_empty_return_at=#{value.actualEmptyReturnAt},updated_by=#{operatorId},
|
||||
updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
@@ -85,8 +86,12 @@ public class ContainerWorkbenchService {
|
||||
}
|
||||
|
||||
private static Query normalize(Query query) {
|
||||
String containerType = trim(query.containerType());
|
||||
if (containerType != null) {
|
||||
containerType = ContainerSpecificationCatalog.require(containerType).containerType();
|
||||
}
|
||||
return new Query(trim(query.containerNo()), trim(query.cabinetNo()), trim(query.billOfLadingNo()),
|
||||
trim(query.keyword()), query.customerId(), query.warehouseId(), trim(query.containerType()),
|
||||
trim(query.keyword()), query.customerId(), query.warehouseId(), containerType,
|
||||
query.shippingCompanyId(), trim(query.departurePortCode()), trim(query.destinationPortCode()),
|
||||
trim(query.destinationWarehouseCode()), query.status(), query.estimatedSailingFrom(),
|
||||
query.estimatedSailingTo(), query.scope(), query.page(), query.size());
|
||||
|
||||
+2
-35
@@ -135,35 +135,6 @@ public class TerminalDrayageService {
|
||||
request.reason(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
ActualTimeRequest request) {
|
||||
String action = "RECORD_PICKUP";
|
||||
WorkContext context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜");
|
||||
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
|
||||
requireVersion(value, request.expectedVersion());
|
||||
if (value.getFleetSupplierId() == null || value.getPickupAppointmentAt() == null) {
|
||||
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
|
||||
}
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
|
||||
if (context.facts() != null && context.facts().latestActualAt() != null
|
||||
&& request.actualAt().isBefore(context.facts().latestActualAt())) {
|
||||
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
||||
}
|
||||
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
||||
request.remark(), operatorId, operatorName);
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
value.setActualPickupAt(request.actualAt());
|
||||
applyAuthoritativeDeadline(context, value);
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
||||
fromVersion, request.remark(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
ActualTimeRequest request) {
|
||||
@@ -297,9 +268,6 @@ public class TerminalDrayageService {
|
||||
result.add("SELECT_FLEET");
|
||||
if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP");
|
||||
}
|
||||
if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence()
|
||||
&& value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null
|
||||
&& actualPickupAt(context, value) == null) result.add("RECORD_PICKUP");
|
||||
if (context.base().unloadingCompletedAt() != null
|
||||
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
|
||||
return List.copyOf(result);
|
||||
@@ -333,8 +301,8 @@ public class TerminalDrayageService {
|
||||
}
|
||||
|
||||
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt()
|
||||
: context.facts() == null ? null : context.facts().pickedUpAt();
|
||||
if (context.facts() != null && context.facts().pickedUpAt() != null) return context.facts().pickedUpAt();
|
||||
return context.base().unloadingCompletedAt();
|
||||
}
|
||||
|
||||
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
|
||||
@@ -400,7 +368,6 @@ public class TerminalDrayageService {
|
||||
case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit";
|
||||
case "SEND_PICKUP_EMAIL" -> "platform:firstleg:terminal-drayage:fleet:edit";
|
||||
case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit";
|
||||
case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm";
|
||||
case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm";
|
||||
default -> "";
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
|
||||
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
|
||||
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
|
||||
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照',
|
||||
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
|
||||
channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照',
|
||||
channel_name VARCHAR(128) NULL COMMENT '订舱计划物流渠道组名称快照',
|
||||
warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照',
|
||||
warehouse_name VARCHAR(128) NULL COMMENT '国内中转仓名称快照',
|
||||
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
|
||||
@@ -24,8 +24,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
|
||||
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
|
||||
place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照',
|
||||
service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填',
|
||||
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
|
||||
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
|
||||
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
|
||||
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
@@ -76,8 +74,6 @@ PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check;
|
||||
EXECUTE ocean_drop_status_stmt;
|
||||
DEALLOCATE PREPARE ocean_drop_status_stmt;
|
||||
ALTER TABLE fl_ocean_booking
|
||||
MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填',
|
||||
MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
|
||||
ADD CONSTRAINT chk_ocean_booking_status CHECK
|
||||
(status IN ('DRAFT','RELEASED'));
|
||||
|
||||
@@ -110,10 +106,10 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
|
||||
CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '订舱箱型需求ID',
|
||||
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
|
||||
container_type VARCHAR(32) NOT NULL COMMENT '所需集装箱尺寸类型编码',
|
||||
container_type VARCHAR(32) NOT NULL COMMENT '平台统一柜型:40GP、40HQ、45HQ;默认40HQ',
|
||||
container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码',
|
||||
capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '该需求柜型可用容量快照m3',
|
||||
max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '该需求柜型最大承重快照kg',
|
||||
capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '统一实装上限快照m3:40GP=58、40HQ=68、45HQ=86',
|
||||
max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '统一货重上限快照kg:19500',
|
||||
quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数',
|
||||
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
|
||||
PRIMARY KEY (id),
|
||||
|
||||
@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
|
||||
vessel_name VARCHAR(128) NULL COMMENT '船名快照',
|
||||
voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订',
|
||||
container_type VARCHAR(32) NOT NULL COMMENT '平台固定柜型:20GP、40GP、40HC、40NR、45HC',
|
||||
container_type VARCHAR(32) NOT NULL COMMENT '平台统一柜型:40GP、40HQ、45HQ;默认40HQ',
|
||||
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照',
|
||||
shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照',
|
||||
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
|
||||
|
||||
@@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
|
||||
fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
|
||||
fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照',
|
||||
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
|
||||
actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
|
||||
estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间',
|
||||
actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号',
|
||||
@@ -20,10 +19,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_terminal_drayage_container(container_booking_id),
|
||||
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
|
||||
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT chk_terminal_drayage_times CHECK(
|
||||
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
|
||||
(actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
|
||||
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id)
|
||||
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
|
||||
@@ -54,7 +50,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
|
||||
drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照',
|
||||
action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_PICKUP/RECORD_EMPTY_RETURN',
|
||||
action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_EMPTY_RETURN',
|
||||
from_status VARCHAR(32) NOT NULL COMMENT '操作前状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '操作后状态',
|
||||
from_version INT UNSIGNED NOT NULL COMMENT '操作前版本',
|
||||
@@ -67,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
|
||||
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
|
||||
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
|
||||
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
|
||||
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
|
||||
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN'))
|
||||
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
|
||||
|
||||
@@ -577,7 +577,7 @@ paths:
|
||||
LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW;
|
||||
Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING
|
||||
=WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、
|
||||
剩余箱数、同柜组和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。
|
||||
剩余箱数、同柜组和容量等事实决定;容积和重量均允许达到柜型基准值的150%,超过即拒绝。
|
||||
待审核及待测量可以先加入清单;确认预装/PDA 前
|
||||
仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。
|
||||
parameters:
|
||||
@@ -719,7 +719,7 @@ paths:
|
||||
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
|
||||
人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道;
|
||||
占用、同柜组和容量门禁不变。
|
||||
柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。
|
||||
柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。
|
||||
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -2143,7 +2143,7 @@ components:
|
||||
allowedActions:
|
||||
type: array
|
||||
description: 已同时按真实业务状态与当前用户权限裁剪
|
||||
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
|
||||
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
|
||||
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
|
||||
terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'}
|
||||
ContainerWorkbenchStatusSummary:
|
||||
@@ -2185,12 +2185,12 @@ components:
|
||||
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
fleetSupplierName: {type: string, nullable: true}
|
||||
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
|
||||
actualPickupAt: {type: string, format: date-time, nullable: true}
|
||||
actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间}
|
||||
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: unloadingCompletedAt加72小时}
|
||||
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnWarning: {type: object, nullable: true}
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
|
||||
ContainerBooking:
|
||||
type: object
|
||||
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
|
||||
@@ -2214,7 +2214,7 @@ components:
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
|
||||
vesselName: {type: string, maxLength: 128, nullable: true}
|
||||
voyageNo: {type: string, maxLength: 64, nullable: true}
|
||||
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型}
|
||||
containerType: {type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型;默认40HQ}
|
||||
shippingCompanyId: {type: string, nullable: true}
|
||||
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
|
||||
departurePortCode: {type: string, maxLength: 32, nullable: true}
|
||||
@@ -2223,8 +2223,8 @@ components:
|
||||
destinationPortName: {type: string, maxLength: 128, nullable: true}
|
||||
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
|
||||
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
|
||||
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量}
|
||||
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重}
|
||||
capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}
|
||||
maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}
|
||||
terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING}
|
||||
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Server-frozen Warehouse supplier name snapshot}
|
||||
loadingAt: {type: string, format: date-time, nullable: true}
|
||||
@@ -2265,7 +2265,7 @@ components:
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
|
||||
vesselName: {type: string, maxLength: 128, nullable: true}
|
||||
voyageNo: {type: string, maxLength: 64, nullable: true}
|
||||
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型}
|
||||
containerType: {type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型;默认40HQ}
|
||||
shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
|
||||
departurePortCode: {type: string, maxLength: 32, nullable: true}
|
||||
@@ -2274,8 +2274,8 @@ components:
|
||||
destinationPortName: {type: string, maxLength: 128, nullable: true}
|
||||
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
|
||||
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
|
||||
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量}
|
||||
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重}
|
||||
capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}
|
||||
maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}
|
||||
terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING}
|
||||
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse and cannot replace the ID}
|
||||
loadingAt: {type: string, format: date-time, nullable: true}
|
||||
@@ -2742,9 +2742,9 @@ components:
|
||||
properties:
|
||||
channelGroupCode: {type: string, maxLength: 64, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜使用的九类计划渠道分组}
|
||||
warehouseId: {type: integer, format: int64, minimum: 1}
|
||||
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型}
|
||||
capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量}
|
||||
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重}
|
||||
containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, description: 与订舱共用的平台统一柜型}
|
||||
capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}
|
||||
maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}
|
||||
ContainerOceanBookingLinkRequest:
|
||||
type: object
|
||||
required: [oceanBookingId, requirementId, expectedVersion]
|
||||
|
||||
@@ -117,7 +117,7 @@ paths:
|
||||
parameters:
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: channelGroupCode, in: query, required: true, schema: {type: string, maxLength: 64}}
|
||||
- {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}}
|
||||
- {name: containerType, in: query, required: true, schema: {type: string, enum: [40GP, 40HQ, 45HQ]}}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
|
||||
responses:
|
||||
@@ -165,7 +165,7 @@ components:
|
||||
properties:
|
||||
customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选启用客户ID;多客户订舱留空,空值不是公开访问授权}
|
||||
customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null}
|
||||
channelCode: {type: string, nullable: true}
|
||||
channelCode: {type: string, nullable: true, description: 固定计划物流渠道组编码}
|
||||
warehouseId: {type: integer, format: int64, nullable: true}
|
||||
billOfLadingNo: {type: string, nullable: true}
|
||||
shippingCompanyId: {type: integer, format: int64, nullable: true}
|
||||
@@ -191,15 +191,13 @@ components:
|
||||
destinationPortCode: {type: string, nullable: true}
|
||||
destinationPortName: {type: string, nullable: true}
|
||||
placeOfDelivery: {type: string, nullable: true}
|
||||
serviceTerm: {type: string, nullable: true}
|
||||
freightTerm: {type: string, nullable: true}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
remark: {type: string, nullable: true}
|
||||
parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}}
|
||||
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
|
||||
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}}
|
||||
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}, maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}}}
|
||||
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
|
||||
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
|
||||
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
|
||||
DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]}
|
||||
|
||||
@@ -38,13 +38,6 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:
|
||||
post:
|
||||
summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点
|
||||
x-permission: platform:firstleg:terminal-drayage:pickup:confirm
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
|
||||
post:
|
||||
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
|
||||
@@ -153,12 +146,12 @@ components:
|
||||
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
fleetSupplierName: {type: string, nullable: true}
|
||||
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
|
||||
actualPickupAt: {type: string, format: date-time, nullable: true}
|
||||
actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间}
|
||||
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时}
|
||||
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
|
||||
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
ContainerActualFacts:
|
||||
@@ -181,7 +174,7 @@ components:
|
||||
type: object
|
||||
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
|
||||
properties:
|
||||
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
|
||||
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
|
||||
fromStatus: {type: string}
|
||||
toStatus: {type: string}
|
||||
fromVersion: {type: integer, minimum: 0}
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
.contains("成功响应中的 version 是递增后的 planVersion")
|
||||
.contains("items[].version 是递增后的 itemVersion")
|
||||
.contains("数量调整或动态增量不会覆盖已有协作备注")
|
||||
.contains("柜内总容积和总重量分别允许达到柜型额定值的150%")
|
||||
.contains("柜内总容积和总重量分别允许达到柜型基准值的150%")
|
||||
.contains("人工分配不限制订单国内仓及渠道与柜次一致")
|
||||
.contains("智能推荐允许跨国内仓,但仍只选择柜次渠道")
|
||||
.contains("canAssign: {type: boolean}")
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContainerSpecificationCatalogTest {
|
||||
@Test
|
||||
void exposesCanonicalSpecificationsAndRejectsAliases() {
|
||||
var specification = ContainerSpecificationCatalog.requireMatch(
|
||||
"40HC", new BigDecimal("76.000"), new BigDecimal("28000.000"));
|
||||
|
||||
assertThat(specification.containerType()).isEqualTo("40HC");
|
||||
assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HQ"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("固定规格");
|
||||
assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch(
|
||||
"40HC", new BigDecimal("68"), new BigDecimal("28000")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("容量和限重");
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -49,12 +49,12 @@ class OceanBookingMapperContractTest {
|
||||
@Test
|
||||
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class, java.util.Set.class)
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("booking.status='RELEASED'")
|
||||
.contains("booking.warehouse_id=#{query.warehouseId}")
|
||||
.contains("<foreach collection=\"channelCodes\"", "#{channelCode}")
|
||||
.contains("booking.channel_code=#{query.channelGroupCode}")
|
||||
.contains("requirement.container_type=#{query.containerType}")
|
||||
.contains("FROM fl_ocean_booking_container_allocation")
|
||||
.contains("container.record_status='ACTIVE'")
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ class OceanBookingSqlContractTest {
|
||||
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
|
||||
.contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
|
||||
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
|
||||
.contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'")
|
||||
.contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'")
|
||||
.doesNotContain("service_term")
|
||||
.doesNotContain("freight_term")
|
||||
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
|
||||
.contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')")
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
|
||||
|
||||
+2
-5
@@ -67,8 +67,6 @@ class OceanBookingPrintServiceTest {
|
||||
Detail detail = detail("DRAFT");
|
||||
detail.oceanBooking().setBillOfLadingNo(null);
|
||||
detail.oceanBooking().setPlaceOfDelivery(null);
|
||||
detail.oceanBooking().setServiceTerm(null);
|
||||
detail.oceanBooking().setFreightTerm(null);
|
||||
detail = new Detail(detail.oceanBooking(), List.of(detail.parties().get(0)),
|
||||
detail.containerRequirements(), List.of(), List.of(), List.of());
|
||||
when(bookings.detail(10L)).thenReturn(detail);
|
||||
@@ -102,8 +100,7 @@ class OceanBookingPrintServiceTest {
|
||||
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
|
||||
booking.setBillOfLadingNo("BL-1"); booking.setVesselName("Vessel"); booking.setVoyageNo("V001");
|
||||
booking.setDeparturePortName("Ningbo"); booking.setDestinationPortName("LA");
|
||||
booking.setPlaceOfDelivery("LA DC"); booking.setServiceTerm("CY-CY");
|
||||
booking.setFreightTerm("PREPAID"); booking.setBookingAgentName("Agent");
|
||||
booking.setPlaceOfDelivery("LA DC"); booking.setBookingAgentName("Agent");
|
||||
booking.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
OceanBookingPartyEntity shipper = new OceanBookingPartyEntity();
|
||||
shipper.setPartyType("SHIPPER"); shipper.setPartyName("Shipper"); shipper.setAddress("Address");
|
||||
@@ -113,7 +110,7 @@ class OceanBookingPrintServiceTest {
|
||||
notify.setPartyType("NOTIFY_PARTY"); notify.setPartyName("Notify");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setContainerType("40HQ"); requirement.setContainerKind("DRY"); requirement.setQuantity(2);
|
||||
requirement.setCapacityCbm(new BigDecimal("68")); requirement.setMaxWeightKg(new BigDecimal("28000"));
|
||||
requirement.setCapacityCbm(new BigDecimal("68")); requirement.setMaxWeightKg(new BigDecimal("19500"));
|
||||
return new Detail(booking, List.of(shipper, consignee, notify), List.of(requirement), List.of(), List.of(), List.of());
|
||||
}
|
||||
}
|
||||
|
||||
+22
-25
@@ -150,7 +150,7 @@ class OceanBookingServiceTest {
|
||||
Detail result = service.create(9L, "admin", "draft-complete", new CreateRequest(0, completeDraft()));
|
||||
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
|
||||
assertThat(inserted.get().getChannelName()).isEqualTo("美西海运");
|
||||
assertThat(inserted.get().getChannelName()).isEqualTo("美森正班");
|
||||
assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓");
|
||||
assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理");
|
||||
assertThat(inserted.get().getCustomerId()).isEqualTo(100L);
|
||||
@@ -160,7 +160,7 @@ class OceanBookingServiceTest {
|
||||
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
|
||||
assertThat(captor.getValue()).singleElement().satisfies(value -> {
|
||||
assertThat(value.getContainerType()).isEqualTo("40HC");
|
||||
assertThat(value.getContainerType()).isEqualTo("40HQ");
|
||||
assertThat(value.getQuantity()).isEqualTo(2);
|
||||
});
|
||||
verify(repository, never()).insertContainer(any());
|
||||
@@ -326,24 +326,22 @@ class OceanBookingServiceTest {
|
||||
|
||||
@Test
|
||||
void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() {
|
||||
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC",
|
||||
new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2,
|
||||
"客户A", "深圳中转仓", "美西海运");
|
||||
when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
|
||||
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HQ",
|
||||
new BigDecimal("68"), new BigDecimal("19500"), 3, 1, 2,
|
||||
"客户A", "深圳中转仓", "美森正班");
|
||||
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
|
||||
|
||||
PageResult<LinkCandidate> result = service.linkCandidates(new LinkCandidateQuery(701L,
|
||||
" MATSON_CLX ", " 40HC ", null, null));
|
||||
" MATSON_CLX ", " 40HQ ", null, null));
|
||||
|
||||
assertThat(result.records()).containsExactly(candidate);
|
||||
assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2);
|
||||
ArgumentCaptor<LinkCandidateQuery> query = ArgumentCaptor.forClass(LinkCandidateQuery.class);
|
||||
@SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> channels = ArgumentCaptor.forClass(java.util.Set.class);
|
||||
verify(repository).linkCandidates(query.capture(), channels.capture());
|
||||
verify(repository).linkCandidates(query.capture());
|
||||
assertThat(query.getValue().channelGroupCode()).isEqualTo("MATSON_CLX");
|
||||
assertThat(query.getValue().containerType()).isEqualTo("40HC");
|
||||
assertThat(query.getValue().containerType()).isEqualTo("40HQ");
|
||||
assertThat(query.getValue().page()).isEqualTo(1L);
|
||||
assertThat(query.getValue().size()).isEqualTo(20L);
|
||||
assertThat(channels.getValue()).contains("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK");
|
||||
verify(repository, never()).find(anyLong());
|
||||
verify(repository, never()).requirements(anyLong());
|
||||
verify(repository, never()).containers(anyLong());
|
||||
@@ -351,15 +349,15 @@ class OceanBookingServiceTest {
|
||||
|
||||
@Test
|
||||
void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() {
|
||||
when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
PageResult<LinkCandidate> result = service.linkCandidates(
|
||||
new LinkCandidateQuery(701L, "MATSON_CLX", "45HC", null, null));
|
||||
new LinkCandidateQuery(701L, "MATSON_CLX", "45HQ", null, null));
|
||||
|
||||
assertThat(result.records()).isEmpty();
|
||||
assertThat(result.total()).isZero();
|
||||
assertThat(result.pages()).isZero();
|
||||
verify(repository, times(1)).linkCandidates(any(), anySet());
|
||||
verify(repository, times(1)).linkCandidates(any());
|
||||
assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName)
|
||||
.doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode");
|
||||
}
|
||||
@@ -386,7 +384,7 @@ class OceanBookingServiceTest {
|
||||
|
||||
private void stubMasterData() {
|
||||
ChannelRow row = new ChannelRow();
|
||||
row.setPriceBookVersionId(51L); row.setChannelCode("SEA-US"); row.setChannelName("美西海运");
|
||||
row.setPriceBookVersionId(51L); row.setChannelCode("MATSON_CLX_TRUCK"); row.setChannelName("美森正班海卡");
|
||||
row.setPriceModel("FBA_WAREHOUSE_RATE");
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row));
|
||||
@@ -402,31 +400,30 @@ class OceanBookingServiceTest {
|
||||
|
||||
private static DraftData partialDraft(String remark) {
|
||||
return new DraftData(null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, remark, null, null);
|
||||
null, null, null, null, null, null, null, null, null, remark, null, null);
|
||||
}
|
||||
|
||||
private static DraftData completeDraft() {
|
||||
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON",
|
||||
return new DraftData(100L, "客户", "MATSON_CLX", 701L, "BL-001", 71L, "船公司", "MATSON",
|
||||
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
"LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
"LAX", LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), null,
|
||||
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null),
|
||||
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
|
||||
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)),
|
||||
List.of(new ContainerRequirementRequest("40HC", "DRY", 2,
|
||||
new BigDecimal("76"), new BigDecimal("28000"))));
|
||||
List.of(new ContainerRequirementRequest("40HQ", "DRY", 2,
|
||||
new BigDecimal("68"), new BigDecimal("19500"))));
|
||||
}
|
||||
|
||||
private static OceanBookingEntity completeBooking(String status, int version) {
|
||||
OceanBookingEntity value = booking(status, version);
|
||||
value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US");
|
||||
value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("MATSON_CLX");
|
||||
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
|
||||
value.setCarrierCode("MATSON");
|
||||
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
|
||||
value.setDeparturePortCode("CNSZX"); value.setDeparturePortName("深圳");
|
||||
value.setDestinationPortCode("USLAX"); value.setDestinationPortName("洛杉矶");
|
||||
value.setPlaceOfDelivery("LAX");
|
||||
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
|
||||
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
|
||||
return value;
|
||||
@@ -447,9 +444,9 @@ class OceanBookingServiceTest {
|
||||
|
||||
private static OceanBookingContainerRequirementEntity requirement() {
|
||||
var value = new OceanBookingContainerRequirementEntity();
|
||||
value.setId(11L); value.setContainerType("40HC"); value.setContainerKind("DRY");
|
||||
value.setQuantity(1); value.setCapacityCbm(new BigDecimal("76"));
|
||||
value.setMaxWeightKg(new BigDecimal("28000")); return value;
|
||||
value.setId(11L); value.setContainerType("40HQ"); value.setContainerKind("DRY");
|
||||
value.setQuantity(1); value.setCapacityCbm(new BigDecimal("68"));
|
||||
value.setMaxWeightKg(new BigDecimal("19500")); return value;
|
||||
}
|
||||
|
||||
private String hash(String action, Long id, Object request) throws Exception {
|
||||
|
||||
+1
@@ -64,6 +64,7 @@ class ContainerBookingMapperContractTest {
|
||||
.filter(candidate -> candidate.getName().equals("linkReleasedBooking"))
|
||||
.findFirst().orElseThrow();
|
||||
String sql = String.join("\n", method.getAnnotation(Update.class).value());
|
||||
assertThat(sql).doesNotContain("channel_code=#{channelCode}", "channel_name=#{channelName}");
|
||||
var parameterNames = Arrays.stream(method.getParameters())
|
||||
.map(parameter -> parameter.getAnnotation(Param.class).value())
|
||||
.toList();
|
||||
|
||||
@@ -85,7 +85,7 @@ class ContainerServiceTest {
|
||||
assertThat(saved.getChannelName()).isNull();
|
||||
assertThat(saved.getWarehouseId()).isEqualTo(101L);
|
||||
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
|
||||
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000");
|
||||
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
|
||||
assertThat(saved.getPlanId()).isEqualTo(501L);
|
||||
assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001");
|
||||
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
|
||||
@@ -96,11 +96,11 @@ class ContainerServiceTest {
|
||||
@DisplayName("新增模拟柜拒绝柜型与固定规格不匹配")
|
||||
void rejectsSimulationWithNonCanonicalSpecification() {
|
||||
SimulationCreateRequest invalid = new SimulationCreateRequest(
|
||||
"MATSON_CLX", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000"));
|
||||
"MATSON_CLX", 101L, "40HQ", new BigDecimal("100"), new BigDecimal("19500"));
|
||||
|
||||
assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("容量和限重");
|
||||
.hasMessageContaining("容量和货重上限");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@@ -204,12 +204,12 @@ class ContainerServiceTest {
|
||||
container.setPlanId(501L);
|
||||
container.setPlanNo("LP-260818-0001");
|
||||
container.setContainerNo(null);
|
||||
container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HC");
|
||||
container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HQ");
|
||||
container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000"));
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX_RAIL_NY");
|
||||
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HQ"); requirement.setCapacityCbm(new BigDecimal("68.0000")); requirement.setMaxWeightKg(new BigDecimal("19500.000"));
|
||||
when(repository.find(11L)).thenReturn(container, container);
|
||||
when(oceanBookings.find(21L)).thenReturn(booking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
@@ -313,15 +313,15 @@ class ContainerServiceTest {
|
||||
void linksMultipleRealContainersToTheSameReleasedBookingRequirement() {
|
||||
ContainerBookingEntity first = booking("WAIT_LOADING", 2);
|
||||
first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L);
|
||||
first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HC");
|
||||
first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HQ");
|
||||
ContainerBookingEntity second = booking("WAIT_LOADING", 4);
|
||||
second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L);
|
||||
second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HC");
|
||||
second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HQ");
|
||||
OceanBookingEntity oceanBooking = new OceanBookingEntity();
|
||||
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
|
||||
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK");
|
||||
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC");
|
||||
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ");
|
||||
when(repository.find(11L)).thenReturn(first);
|
||||
when(repository.find(12L)).thenReturn(second);
|
||||
when(oceanBookings.find(21L)).thenReturn(oceanBooking);
|
||||
@@ -362,19 +362,19 @@ class ContainerServiceTest {
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝装柜仓库不一致")
|
||||
void rejectsLinkWhenWarehouseDiffers() {
|
||||
assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HC");
|
||||
assertThreeFieldMismatch(102L, "MATSON_CLX", "40HQ");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝物流渠道不一致")
|
||||
void rejectsLinkWhenChannelDiffers() {
|
||||
assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HC");
|
||||
assertThreeFieldMismatch(101L, "HEDE", "40HQ");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝柜型不一致")
|
||||
void rejectsLinkWhenContainerTypeDiffers() {
|
||||
assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HC");
|
||||
assertThreeFieldMismatch(101L, "MATSON_CLX", "45HQ");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -418,7 +418,7 @@ class ContainerServiceTest {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
container.setContainerNo(null);
|
||||
container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
|
||||
container.setContainerType("40HC");
|
||||
container.setContainerType("40HQ");
|
||||
OceanBookingEntity oceanBooking = new OceanBookingEntity();
|
||||
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
|
||||
oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel);
|
||||
@@ -440,12 +440,12 @@ class ContainerServiceTest {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
container.setContainerNo(null);
|
||||
container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
|
||||
container.setContainerType("40HC");
|
||||
container.setContainerType("40HQ");
|
||||
OceanBookingEntity oceanBooking = new OceanBookingEntity();
|
||||
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
|
||||
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK");
|
||||
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC");
|
||||
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ");
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(oceanBookings.find(21L)).thenReturn(oceanBooking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
@@ -485,15 +485,15 @@ class ContainerServiceTest {
|
||||
|
||||
private static SaveRequest request() {
|
||||
return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮",
|
||||
"V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
|
||||
"ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A",
|
||||
"V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
|
||||
"ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("19500.000"), 202L, "拖车公司A",
|
||||
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
|
||||
LocalDateTime.of(2026, 8, 25, 8, 0), null,
|
||||
LocalDateTime.of(2026, 8, 27, 8, 0), null);
|
||||
}
|
||||
|
||||
private static SimulationCreateRequest simulation() {
|
||||
return new SimulationCreateRequest("MATSON_CLX", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000"));
|
||||
return new SimulationCreateRequest("MATSON_CLX", 101L, "40HQ", new BigDecimal("68.0000"), new BigDecimal("19500.000"));
|
||||
}
|
||||
|
||||
private static ChannelRow channel() {
|
||||
|
||||
+19
-2
@@ -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() {
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.*;
|
||||
import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerEventInboxMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OverseasDeliveryCompletedConsumerTest {
|
||||
@Test
|
||||
void completesOnlyEventSourceInboundAndOrder() {
|
||||
InboundOrderMapper inbounds = mock(InboundOrderMapper.class);
|
||||
InboundStatusHistoryMapper histories = mock(InboundStatusHistoryMapper.class);
|
||||
ContainerEventInboxMapper inbox = mock(ContainerEventInboxMapper.class);
|
||||
OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class);
|
||||
ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer(
|
||||
inbounds, histories, inbox, orders, json);
|
||||
ReflectionTestUtils.setField(consumer, "internalToken", "token");
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L);
|
||||
inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8");
|
||||
inbound.setProcessStatus("LOADED");
|
||||
when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound);
|
||||
when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1);
|
||||
when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(0);
|
||||
when(orders.complete(eq("token"), any())).thenReturn(ApiResponse.success(
|
||||
new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false)));
|
||||
|
||||
consumer.consume(event(json));
|
||||
|
||||
verify(inbounds).completeOverseasDelivery(eq(88L), eq(101L), any());
|
||||
verify(orders).complete(eq("token"), argThat(request ->
|
||||
"201".equals(request.orderId()) && "IN-1".equals(request.inboundCode())
|
||||
&& "DL-ONT8".equals(request.deliveryNo())));
|
||||
verify(inbounds).markOrderSnapshotCompleted(eq(88L), eq(201L), any());
|
||||
verify(inbounds, never()).completeOverseasDelivery(eq(88L), eq(102L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesOrderOpenWhileAnotherSourceInboundIsNotDelivered() {
|
||||
InboundOrderMapper inbounds = mock(InboundOrderMapper.class);
|
||||
InboundStatusHistoryMapper histories = mock(InboundStatusHistoryMapper.class);
|
||||
ContainerEventInboxMapper inbox = mock(ContainerEventInboxMapper.class);
|
||||
OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class);
|
||||
ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer(
|
||||
inbounds, histories, inbox, orders, json);
|
||||
ReflectionTestUtils.setField(consumer, "internalToken", "token");
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L);
|
||||
inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8");
|
||||
inbound.setProcessStatus("LOADED");
|
||||
when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound);
|
||||
when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1);
|
||||
when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(1);
|
||||
|
||||
consumer.consume(event(json));
|
||||
|
||||
verifyNoInteractions(orders);
|
||||
verify(inbounds, never()).markOrderSnapshotCompleted(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
private EventEnvelope event(ObjectMapper json) {
|
||||
Instant time = Instant.parse("2026-09-15T08:00:00Z");
|
||||
var payload = new OverseasDeliveryCompletedV1("1", "DL-ONT8", "11", "LM-11",
|
||||
"101", "IN-1", "201", "FL-201", "201", "ONT8", time);
|
||||
var node = json.valueToTree(payload);
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000001",
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, RegionCode.OVERSEAS, RegionCode.CN,
|
||||
"88", "INBOUND_ORDER", "101", 2L, 1L,
|
||||
"00000000-0000-4000-8000-000000000099", "delivery-101", null, node,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
|
||||
}
|
||||
}
|
||||
@@ -552,8 +552,8 @@ class SmartLoadingServiceTest {
|
||||
@Test
|
||||
void pagesWorkbenchContainersWithTheSameGlobalFilters() {
|
||||
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
|
||||
100L, "义乌仓", "MATSON_CLX", null, null, "40HC",
|
||||
new BigDecimal("68"), new BigDecimal("28000"),
|
||||
100L, "义乌仓", "MATSON_CLX", null, null, "40HQ",
|
||||
new BigDecimal("68"), new BigDecimal("19500"),
|
||||
1L, "LP-1", "EDITING", 2);
|
||||
WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L,
|
||||
9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"),
|
||||
|
||||
+12
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -33,7 +33,6 @@ class TerminalDrayageOpenApiContractTest {
|
||||
assertPermission("retryEmail", "platform:firstleg:terminal-drayage:fleet:edit");
|
||||
assertPermission("emails", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit");
|
||||
assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm");
|
||||
assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
}
|
||||
|
||||
@@ -43,18 +42,18 @@ class TerminalDrayageOpenApiContractTest {
|
||||
assertThat(yaml).contains(
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:",
|
||||
"platform:firstleg:terminal-drayage:list",
|
||||
"platform:firstleg:terminal-drayage:fleet:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup-appointment:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup:confirm",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm",
|
||||
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
|
||||
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
|
||||
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
|
||||
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
|
||||
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
|
||||
assertThat(yaml).doesNotContain("/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
|
||||
"platform:firstleg:terminal-drayage:pickup:confirm", "RECORD_PICKUP");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+6
-11
@@ -26,24 +26,19 @@ class TerminalDrayageSqlContractTest {
|
||||
assertThat(Files.readString(path)).contains(
|
||||
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (",
|
||||
"CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))",
|
||||
"('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')");
|
||||
"('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothSqlEntrypointsAllowReturnWithoutPickupAndRetainKnownTimeOrdering() throws Exception {
|
||||
for (Path path : java.util.List.of(
|
||||
Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"),
|
||||
Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) {
|
||||
String sql = Files.readString(path);
|
||||
assertThat(sql).contains("actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at")
|
||||
.doesNotContain("actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL");
|
||||
}
|
||||
void bothSqlEntrypointsRemoveTheManualPickupTimestamp() throws Exception {
|
||||
String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
|
||||
assertThat(baseline).doesNotContain("actual_pickup_at", "chk_terminal_drayage_times");
|
||||
String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertThat(release).contains("DROP CHECK chk_terminal_drayage_times",
|
||||
"ADD CONSTRAINT chk_terminal_drayage_times CHECK(",
|
||||
assertThat(release).contains("ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at",
|
||||
"DROP CHECK chk_terminal_drayage_history_action",
|
||||
"ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN");
|
||||
assertThat(release).doesNotContain("chk_terminal_drayage_times");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-4
@@ -142,7 +142,6 @@ class ContainerWorkbenchServiceTest {
|
||||
row.setId(51L);
|
||||
row.setContainerBookingId(11L);
|
||||
row.setVersion(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 4, 9, 0));
|
||||
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.departed(11L)).thenReturn(true);
|
||||
@@ -205,9 +204,9 @@ class ContainerWorkbenchServiceTest {
|
||||
assertThat(result.workbenchStage()).isEqualTo("UNLOADED");
|
||||
assertThat(result.terminalDrayage().status()).isEqualTo("UNLOADED");
|
||||
assertThat(result.terminalDrayage().version()).isZero();
|
||||
assertThat(result.terminalDrayage().actualPickupAt()).isNull();
|
||||
assertThat(result.allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN");
|
||||
assertThat(result.terminalDrayage().allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN");
|
||||
assertThat(result.terminalDrayage().actualPickupAt()).isEqualTo(unloadedAt);
|
||||
assertThat(result.allowedActions()).containsExactly("RECORD_EMPTY_RETURN");
|
||||
assertThat(result.terminalDrayage().allowedActions()).containsExactly("RECORD_EMPTY_RETURN");
|
||||
}
|
||||
verify(mapper, never()).insert(any(com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity.class));
|
||||
}
|
||||
|
||||
+3
-49
@@ -104,40 +104,12 @@ class TerminalDrayageServiceTest {
|
||||
verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsPickupOnceAndAdvancesCanonicalNode() {
|
||||
Context before = context("PICKUP_READY", null);
|
||||
Context after = context("PICKED_UP", null);
|
||||
when(mapper.lockContext(11L)).thenReturn(before);
|
||||
when(mapper.context(11L)).thenReturn(after);
|
||||
when(mapper.operation(11L, "pickup-1")).thenReturn(null);
|
||||
TerminalDrayageEntity row = row(2);
|
||||
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
|
||||
when(mapper.lock(11L)).thenReturn(row);
|
||||
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
|
||||
row.setVersion(3);
|
||||
return 1;
|
||||
});
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var result = service.recordPickup(11L, 7L, "admin", "pickup-1",
|
||||
new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜"));
|
||||
|
||||
assertThat(result.status()).isEqualTo("PICKED_UP");
|
||||
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
|
||||
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
|
||||
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresOverseasUnloadBeforeEmptyReturn() {
|
||||
Context context = context("PICKED_UP", null);
|
||||
when(mapper.lockContext(11L)).thenReturn(context);
|
||||
when(mapper.operation(11L, "return-1")).thenReturn(null);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
when(mapper.lock(11L)).thenReturn(row);
|
||||
|
||||
assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1",
|
||||
@@ -152,7 +124,6 @@ class TerminalDrayageServiceTest {
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0);
|
||||
Context context = context("PICKED_UP", unloadedAt);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
@@ -178,7 +149,7 @@ class TerminalDrayageServiceTest {
|
||||
|
||||
assertThat(result.status()).isEqualTo("UNLOADED");
|
||||
assertThat(result.allowedActions()).contains("RECORD_EMPTY_RETURN");
|
||||
assertThat(result.actualPickupAt()).isNull();
|
||||
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0));
|
||||
assertThat(result.version()).isZero();
|
||||
}
|
||||
|
||||
@@ -206,7 +177,7 @@ class TerminalDrayageServiceTest {
|
||||
var result = service.recordEmptyReturn(11L, 7L, "admin", "return-new", request);
|
||||
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
|
||||
assertThat(result.actualPickupAt()).isNull();
|
||||
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0));
|
||||
assertThat(result.fleetSupplierId()).isNull();
|
||||
assertThat(result.version()).isEqualTo(1);
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
@@ -229,12 +200,12 @@ class TerminalDrayageServiceTest {
|
||||
void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() {
|
||||
Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0));
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var raw = service.detail(11L);
|
||||
assertThat(raw.status()).isEqualTo("UNLOADED");
|
||||
assertThat(raw.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 8, 0));
|
||||
assertThat(service.authorize(raw,
|
||||
Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions())
|
||||
.containsExactly("RECORD_EMPTY_RETURN");
|
||||
@@ -265,22 +236,6 @@ class TerminalDrayageServiceTest {
|
||||
verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedActionsAreReducedByFrozenPermissionMatrix() {
|
||||
Context context = context("PICKUP_READY", null);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var raw = service.detail(11L);
|
||||
var authorized = service.authorize(raw,
|
||||
Set.of("platform:firstleg:terminal-drayage:pickup:confirm"));
|
||||
|
||||
assertThat(raw.allowedActions()).contains("SELECT_FLEET", "SCHEDULE_PICKUP", "RECORD_PICKUP");
|
||||
assertThat(authorized.allowedActions()).containsExactly("RECORD_PICKUP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
|
||||
Context context = context("ARRIVED_DESTINATION_PORT", null);
|
||||
@@ -337,7 +292,6 @@ class TerminalDrayageServiceTest {
|
||||
when(mapper.operation(11L, "return-2")).thenReturn(null);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
when(mapper.lock(11L)).thenReturn(row);
|
||||
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
|
||||
row.setVersion(4);
|
||||
|
||||
@@ -15,7 +15,6 @@ BEGIN
|
||||
OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit')
|
||||
OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit')
|
||||
OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit')
|
||||
OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm')
|
||||
OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
@@ -26,7 +25,6 @@ BEGIN
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突';
|
||||
@@ -50,8 +48,6 @@ BEGIN
|
||||
'维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0),
|
||||
(5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0),
|
||||
(5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm',
|
||||
'确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
@@ -84,7 +80,6 @@ BEGIN
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
@@ -101,8 +96,7 @@ BEGIN
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
|
||||
@@ -122,8 +116,7 @@ BEGIN
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整';
|
||||
END IF;
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ paths:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 查询当前用户有效权限
|
||||
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。
|
||||
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。
|
||||
responses:
|
||||
'200':
|
||||
description: 当前平台成员权限查询成功
|
||||
|
||||
@@ -42,7 +42,6 @@ class IamOpenApiContractTest {
|
||||
.contains("platform:firstleg:terminal-drayage:document:edit")
|
||||
.contains("platform:firstleg:terminal-drayage:fleet:edit")
|
||||
.contains("platform:firstleg:terminal-drayage:pickup-appointment:edit")
|
||||
.contains("platform:firstleg:terminal-drayage:pickup:confirm")
|
||||
.contains("platform:firstleg:terminal-drayage:empty-return:confirm")
|
||||
.contains("不以 `container:list/edit` 兼容放行国外操作")
|
||||
.contains("merchant:warehouse:delivery:list")
|
||||
|
||||
+2
-4
@@ -22,7 +22,6 @@ class ContainerWorkspacePermissionSqlContractTest {
|
||||
"platform:firstleg:terminal-drayage:document:edit",
|
||||
"platform:firstleg:terminal-drayage:fleet:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup-appointment:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup:confirm",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
|
||||
@Test
|
||||
@@ -50,7 +49,6 @@ class ContainerWorkspacePermissionSqlContractTest {
|
||||
"'platform:firstleg:terminal-drayage:document:edit',\n '维护目的港清关与DO/PU',NULL,'POST'",
|
||||
"'platform:firstleg:terminal-drayage:fleet:edit',\n '维护目的港码头车队',NULL,'POST'",
|
||||
"'platform:firstleg:terminal-drayage:pickup-appointment:edit',\n '预约目的港提柜',NULL,'POST'",
|
||||
"'platform:firstleg:terminal-drayage:pickup:confirm',\n '确认目的港实际提柜',NULL,'POST'",
|
||||
"'platform:firstleg:terminal-drayage:empty-return:confirm',\n '确认目的港实际还柜',NULL,'POST'");
|
||||
}
|
||||
|
||||
@@ -75,8 +73,8 @@ class ContainerWorkspacePermissionSqlContractTest {
|
||||
"MERCHANT_OWNER");
|
||||
assertThat(sql).contains(
|
||||
"permission_code IN (",
|
||||
")) <> 9 THEN",
|
||||
")) <> 18 THEN");
|
||||
")) <> 8 THEN",
|
||||
")) <> 16 THEN");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||
| 订单查询与更新 | `service/LastlegOrderService.java` |
|
||||
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
|
||||
@@ -42,22 +42,24 @@
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并由 `DOMESTIC_FILE_SERVICE_URL` 只读访问国内 File,单图不可用不改变拆柜状态 |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
|
||||
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
|
||||
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
|
||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
|
||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
|
||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
|
||||
| 平台排车列表/详情与智能预排 | `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`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
|
||||
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
|
||||
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
|
||||
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
|
||||
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
|
||||
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 |
|
||||
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载,不增加派送完成门禁或签名识别 |
|
||||
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 |
|
||||
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 |
|
||||
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
|
||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
|
||||
@@ -75,7 +77,7 @@
|
||||
- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。
|
||||
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
|
||||
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。
|
||||
- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成 `WAIT_OUTBOUND` 出库单与派送单,但必须早于发车;PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。
|
||||
- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。
|
||||
- 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。
|
||||
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。
|
||||
|
||||
|
||||
+3
@@ -14,6 +14,7 @@ public class LastlegRestClientProperties {
|
||||
private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
|
||||
private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
|
||||
private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
|
||||
private ClientTimeouts domesticLoadingEvidence = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
|
||||
|
||||
public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; }
|
||||
public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); }
|
||||
@@ -27,6 +28,8 @@ public class LastlegRestClientProperties {
|
||||
public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); }
|
||||
public ClientTimeouts getPalletPhysical() { return palletPhysical; }
|
||||
public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); }
|
||||
public ClientTimeouts getDomesticLoadingEvidence() { return domesticLoadingEvidence; }
|
||||
public void setDomesticLoadingEvidence(ClientTimeouts value) { domesticLoadingEvidence = required(value, "domestic-loading-evidence"); }
|
||||
|
||||
private static ClientTimeouts required(ClientTimeouts value, String client) {
|
||||
if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required");
|
||||
|
||||
-8
@@ -64,14 +64,6 @@ public class CustomerVisitController {
|
||||
return ApiResponse.success(service.upload(requiredScope(user, customerId), user.userId(), visitId, file));
|
||||
}
|
||||
|
||||
@PostMapping("/delivery-orders/{deliveryNo}/complete")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')")
|
||||
public ApiResponse<CustomerVisitResponses.Completed> completeDelivery(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) {
|
||||
return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo, request));
|
||||
}
|
||||
|
||||
@PutMapping("/delivery-orders/{deliveryNo}/status")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')")
|
||||
public ApiResponse<CustomerVisitResponses.Completed> updateDeliveryStatus(
|
||||
|
||||
+4
-34
@@ -76,39 +76,6 @@ public class CustomerVisitService {
|
||||
return detail(visit);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo,
|
||||
CustomerVisitRequests.DeliveryStatus request) {
|
||||
if (!"DELIVERED".equals(trim(request.status()).toUpperCase(Locale.ROOT))) {
|
||||
throw badRequest("派送完成接口只接受 DELIVERED 状态");
|
||||
}
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
boolean replayed = "DELIVERED".equals(delivery.getStatus());
|
||||
if (!replayed) {
|
||||
requireVersion(delivery, request.expectedVersion());
|
||||
requireDeliveryTransition(delivery.getStatus(), "DELIVERED");
|
||||
}
|
||||
LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now();
|
||||
if (!replayed) {
|
||||
delivery.setStatus("DELIVERED");
|
||||
delivery.setSignedAt(now);
|
||||
delivery.setUpdatedAt(now);
|
||||
delivery.setVersion(version(delivery) + 1);
|
||||
deliveryOrders.updateById(delivery);
|
||||
for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, customerId)
|
||||
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) {
|
||||
order.setSubOrderStatus("DELIVERY:DELIVERED");
|
||||
order.setUpdatedAt(now);
|
||||
lastMileOrders.updateById(order);
|
||||
}
|
||||
recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now);
|
||||
}
|
||||
CustomerVisit visit = ensureVisit(delivery, now);
|
||||
return new CustomerVisitResponses.Completed(visit.getId(), deliveryNo, delivery.getStatus(), visit.getStatus(), replayed);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId,
|
||||
String deliveryNo,
|
||||
@@ -117,7 +84,6 @@ public class CustomerVisitService {
|
||||
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
|
||||
throw badRequest("不支持的派送状态");
|
||||
}
|
||||
if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo, request);
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
if (target.equals(delivery.getStatus())) {
|
||||
@@ -192,6 +158,10 @@ public class CustomerVisitService {
|
||||
uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
public void ensureForCompletedDelivery(DeliveryOrder delivery, LocalDateTime completedAt) {
|
||||
ensureVisit(delivery, completedAt);
|
||||
}
|
||||
|
||||
private CustomerVisit ensureVisit(DeliveryOrder delivery, LocalDateTime completedAt) {
|
||||
CustomerVisit existing = visits.findByDelivery(delivery.getCustomerId(), delivery.getId());
|
||||
if (existing != null) return existing;
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ public class DeliveryTransferController {
|
||||
|
||||
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
|
||||
@Operation(summary = "Create one pending outbound order and one pending delivery order")
|
||||
@Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse")
|
||||
public ApiResponse<DeliveryResponses.TransferResult> transfer(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
|
||||
@Valid @RequestBody DeliveryRequests.Transfer request) {
|
||||
@@ -46,7 +46,7 @@ public class DeliveryTransferController {
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
@Operation(summary = "List server-authoritative one-click dropship outbound candidates")
|
||||
public ApiResponse<java.util.List<DeliveryResponses.OneClickCandidate>> oneClickCandidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@RequestParam(required = false) String warehouseId) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.oneClickCandidates(customerId, warehouseId));
|
||||
|
||||
+2
-15
@@ -7,7 +7,6 @@ import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Collections;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -24,26 +23,14 @@ public final class DeliveryRequests {
|
||||
@NotBlank @Size(max = 64) String pickupMethod,
|
||||
@NotBlank @Size(max = 64) String loadingType,
|
||||
@Size(max = 64) String vehicleType,
|
||||
@Size(max = 64) String vehicleSize,
|
||||
@jakarta.validation.constraints.Pattern(regexp = "15托|30托") String vehicleSize,
|
||||
@Size(max = 128) String driverName,
|
||||
@Size(max = 64) String driverPhone,
|
||||
@Size(max = 64) String vehiclePlate,
|
||||
@Size(max = 128) String appointmentAccount,
|
||||
@Size(max = 128) String sellerName,
|
||||
@Size(max = 1000) String remark,
|
||||
@NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees,
|
||||
Map<String, List<@NotBlank @Size(max = 128) String>> palletCartonMarks) {
|
||||
public Transfer(String idempotencyKey, LocalDateTime plannedDeliveryAt, String deliveryMethod, String waybillNo,
|
||||
String carrierName, String pickupMethod, String loadingType, String vehicleType, String vehicleSize,
|
||||
String driverName, String driverPhone, String vehiclePlate, String appointmentAccount,
|
||||
String sellerName, String remark, List<FeeItem> fees) {
|
||||
this(idempotencyKey, plannedDeliveryAt, deliveryMethod, waybillNo, carrierName, pickupMethod, loadingType,
|
||||
vehicleType, vehicleSize, driverName, driverPhone, vehiclePlate, appointmentAccount, sellerName, remark, fees, Map.of());
|
||||
}
|
||||
}
|
||||
|
||||
public record PalletCartonSelection(@NotBlank @Size(max = 128) String palletNo,
|
||||
@NotNull @Size(min = 1, max = 500) List<@NotBlank @Size(max = 128) String> cartonMarks) {}
|
||||
@NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees) {}
|
||||
|
||||
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
|
||||
@NotBlank @Size(max = 128) String feeName,
|
||||
|
||||
+10
-4
@@ -7,9 +7,13 @@ import java.util.List;
|
||||
public final class DeliveryResponses {
|
||||
private DeliveryResponses() {}
|
||||
|
||||
public record TransferDelivery(String deliveryNo, String deliveryStatus,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, int palletCount, int packageCount) {}
|
||||
|
||||
public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus,
|
||||
String deliveryNo, String deliveryStatus, int palletCount,
|
||||
int packageCount, boolean replayed) {}
|
||||
int palletCount, int packageCount,
|
||||
List<TransferDelivery> deliveries, boolean replayed) {}
|
||||
|
||||
public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes,
|
||||
String status, int palletCount, int packageCount, LocalDateTime createdAt, String outboundType) {}
|
||||
@@ -25,7 +29,9 @@ public final class DeliveryResponses {
|
||||
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
|
||||
LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
|
||||
String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
|
||||
String bolNo, DeliveryDocumentDtos.Summary documentSummary) {}
|
||||
String bolNo, DeliveryDocumentDtos.Summary documentSummary,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName) {}
|
||||
|
||||
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
|
||||
String receivingAddress, String shipmentNo, String fbaTrackingNo,
|
||||
@@ -45,7 +51,7 @@ public final class DeliveryResponses {
|
||||
public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
String skuCode, int packageCount, int pickedPackageCount, String status) {}
|
||||
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, List<OutboundPalletRow> pallets) {}
|
||||
public record OutboundDetail(OutboundRow outbound, List<DeliveryRow> deliveries, List<OutboundPalletRow> pallets) {}
|
||||
public record OneClickCandidate(Long inventoryPositionId, String orderNo, String customerId, String customerName, String overseasWarehouseId,
|
||||
String overseasWarehouseName, String palletNo, String inboundCode,
|
||||
String skuCode, String productName, long availablePackages) {}
|
||||
|
||||
@@ -23,6 +23,10 @@ public class DeliveryOrder {
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseName;
|
||||
private String customerName;
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String deliveryGroupKey;
|
||||
private String status;
|
||||
private String waybillNo;
|
||||
private String bolNo;
|
||||
|
||||
@@ -25,6 +25,7 @@ public class OutboundOrder {
|
||||
private Integer packageCount;
|
||||
private Integer palletCount;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
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 DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
|
||||
@@ -14,6 +15,6 @@ public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
|
||||
DeliveryOrder lockByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
|
||||
DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id FOR UPDATE")
|
||||
List<DeliveryOrder> lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
|
||||
}
|
||||
|
||||
+2
@@ -15,6 +15,8 @@ public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
|
||||
OutboundOrder findGlobalByNo(@Param("outboundNo") String outboundNo);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE")
|
||||
OutboundOrder lockByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
|
||||
OutboundOrder findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import java.util.Set;
|
||||
|
||||
public final class DeliveryStatusPolicy {
|
||||
private static final Set<String> EDITABLE_TARGETS = Set.of(
|
||||
"WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED");
|
||||
"WAIT_DELIVERY", "DELIVERING", "REJECTED", "CANCELLED");
|
||||
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
||||
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"),
|
||||
"DELIVERING", Set.of("DELIVERED", "REJECTED"),
|
||||
@@ -29,8 +29,8 @@ public final class DeliveryStatusPolicy {
|
||||
public static List<String> allowedActions(String status) {
|
||||
return switch (status == null ? "" : status) {
|
||||
case "WAIT_DELIVERY" -> List.of(
|
||||
"START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
|
||||
case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
|
||||
"START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
|
||||
case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN");
|
||||
case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN");
|
||||
default -> List.of();
|
||||
};
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventIdentifiers;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasDeliveryCompletedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
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.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 在派送完成事务内按来源入仓单冻结海外到国内的完成事件。 */
|
||||
@Service
|
||||
public class DeliveryCompletedOutboxService {
|
||||
@Resource private LastlegEventOutboxMapper outbox;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public void publish(DeliveryOrder delivery, List<LastlegOrder> orders, Instant completedAt) {
|
||||
Objects.requireNonNull(delivery, "delivery");
|
||||
Objects.requireNonNull(completedAt, "completedAt");
|
||||
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
|
||||
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
|
||||
if (!StringUtils.hasText(order.getSourceInboundId())) continue;
|
||||
requireText(order.getSourceOrderId(), "sourceOrderId");
|
||||
requireText(order.getInboundCode(), "inboundCode");
|
||||
requireText(order.getCustomerId(), "customerId");
|
||||
LastlegOrder existing = bySourceInbound.putIfAbsent(order.getSourceInboundId(), order);
|
||||
if (existing != null && (!Objects.equals(existing.getSourceOrderId(), order.getSourceOrderId())
|
||||
|| !Objects.equals(existing.getInboundCode(), order.getInboundCode()))) {
|
||||
throw new IllegalStateException("同一来源入仓单对应的派送完成快照冲突");
|
||||
}
|
||||
}
|
||||
for (LastlegOrder order : bySourceInbound.values()) publishOne(delivery, order, completedAt);
|
||||
}
|
||||
|
||||
private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) {
|
||||
long aggregateVersion = Math.max(1L, delivery.getVersion() == null ? 1L : delivery.getVersion());
|
||||
String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + delivery.getDeliveryNo()
|
||||
+ ':' + order.getSourceInboundId();
|
||||
var payload = new OverseasDeliveryCompletedV1(String.valueOf(delivery.getId()), delivery.getDeliveryNo(),
|
||||
String.valueOf(order.getId()), order.getOrderNo(), order.getSourceInboundId(), order.getInboundCode(),
|
||||
order.getSourceOrderId(), order.getSourceOrderNo(), order.getRootOrderId(),
|
||||
order.getFbaWarehouseCode(), completedAt);
|
||||
JsonNode node = objectMapper.valueToTree(payload);
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt, RegionCode.OVERSEAS, RegionCode.CN,
|
||||
order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), aggregateVersion, 1L,
|
||||
EventIdentifiers.newUuid(), eventKey,
|
||||
EventIdentifiers.isSupported(order.getSourceEventId()) ? order.getSourceEventId() : null,
|
||||
node, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))).requireValid();
|
||||
LastlegEventOutbox row = new LastlegEventOutbox();
|
||||
row.setId(IdWorker.getId()); row.setCustomerId(order.getCustomerId());
|
||||
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(envelope.aggregateId());
|
||||
row.setEventType(envelope.eventType()); row.setEventVersion(envelope.eventVersion());
|
||||
row.setEventKey(eventKey); row.setPayloadJson(json(envelope)); row.setStatus("PENDING");
|
||||
row.setRetryCount(0); row.setCreatedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC));
|
||||
try {
|
||||
outbox.insert(row);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
LastlegEventOutbox existing = outbox.findByEventKey(eventKey);
|
||||
if (existing == null || !Objects.equals(existing.getAggregateId(), order.getSourceInboundId())
|
||||
|| !Objects.equals(existing.getCustomerId(), order.getCustomerId())) throw duplicate;
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (!StringUtils.hasText(value)) throw new IllegalStateException(field + " is required");
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("Cannot serialize delivery-completed event", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -6,6 +6,10 @@ import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.model.*;
|
||||
import com.baiyue.lastleg.customervisit.service.CustomerVisitService;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -17,6 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@@ -26,6 +31,9 @@ public class DeliveryDocumentService {
|
||||
@Resource private DeliveryOperationMapper operations;
|
||||
@Resource private DeliveryDocumentFileGateway files;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private LastlegOrderMapper lastlegOrders;
|
||||
@Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox;
|
||||
@Resource private CustomerVisitService customerVisits;
|
||||
|
||||
public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); }
|
||||
|
||||
@@ -69,7 +77,21 @@ public class DeliveryDocumentService {
|
||||
order.setDocumentsJson(write(categories));
|
||||
order.setVersion(version(order) + 1);
|
||||
order.setUpdatedAt(now);
|
||||
boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty();
|
||||
boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus());
|
||||
if (autoCompleted) {
|
||||
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) {
|
||||
throw conflict("当前派送状态不允许通过签收文件完成");
|
||||
}
|
||||
order.setStatus("DELIVERED");
|
||||
order.setSignedAt(now);
|
||||
}
|
||||
orders.updateById(order);
|
||||
if (autoCompleted) {
|
||||
List<LastlegOrder> completedOrders = completeRelatedOrders(order, now);
|
||||
deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC));
|
||||
customerVisits.ensureForCompletedDelivery(order, now);
|
||||
}
|
||||
Detail result = detail(order, false);
|
||||
DeliveryDocumentRevision revision = new DeliveryDocumentRevision();
|
||||
revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion());
|
||||
@@ -79,6 +101,7 @@ public class DeliveryDocumentService {
|
||||
operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId);
|
||||
operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类");
|
||||
operation.setCreatedAt(now); operations.insert(operation);
|
||||
if (autoCompleted) recordAutomaticCompletion(order, operatorId, now);
|
||||
return result;
|
||||
}
|
||||
public List<History> history(String customerId, String deliveryNo) {
|
||||
@@ -110,6 +133,25 @@ public class DeliveryDocumentService {
|
||||
(int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed);
|
||||
}
|
||||
private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
|
||||
private List<LastlegOrder> completeRelatedOrders(DeliveryOrder delivery, LocalDateTime now) {
|
||||
List<LastlegOrder> completedOrders = lastlegOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, delivery.getCustomerId())
|
||||
.eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()));
|
||||
for (LastlegOrder order : completedOrders) {
|
||||
order.setSubOrderStatus("DELIVERY:DELIVERED");
|
||||
order.setUpdatedAt(now);
|
||||
lastlegOrders.updateById(order);
|
||||
}
|
||||
return completedOrders;
|
||||
}
|
||||
private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) {
|
||||
DeliveryOperation operation = new DeliveryOperation();
|
||||
operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId());
|
||||
operation.setOperatorId(operatorId); operation.setOperatorName(operatorId);
|
||||
operation.setOperationNode("RECEIPT_AUTO_COMPLETE");
|
||||
operation.setResult("签收文件保存成功,派送单自动完成");
|
||||
operation.setCreatedAt(now); operations.insert(operation);
|
||||
}
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
|
||||
+133
-50
@@ -73,6 +73,11 @@ import java.time.format.DateTimeFormatter;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
@Service
|
||||
public class DeliveryTransferService {
|
||||
@@ -103,6 +108,7 @@ public class DeliveryTransferService {
|
||||
@Resource private RelabelItemMapper relabelItems;
|
||||
@Resource private RelabelApplicationService relabelService;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
|
||||
@@ -128,10 +134,10 @@ public class DeliveryTransferService {
|
||||
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (replay != null) {
|
||||
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
|
||||
return result(replay, requireDelivery(customerId, replay.getId()), true);
|
||||
return result(replay, requireDeliveries(customerId, replay.getId()), true);
|
||||
}
|
||||
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
|
||||
if (existing != null) return result(existing, requireDelivery(customerId, existing.getId()), true);
|
||||
if (existing != null) return result(existing, requireDeliveries(customerId, existing.getId()), true);
|
||||
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
|
||||
throw conflict("Dispatch cannot be transferred to delivery in its current status");
|
||||
}
|
||||
@@ -161,11 +167,11 @@ public class DeliveryTransferService {
|
||||
// the document created by the winning transaction rather than receive a false failure.
|
||||
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
|
||||
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
|
||||
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
|
||||
}
|
||||
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
|
||||
if (concurrent != null) {
|
||||
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
|
||||
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
|
||||
}
|
||||
throw conflict("Dispatch was concurrently transferred to delivery");
|
||||
}
|
||||
@@ -189,43 +195,61 @@ public class DeliveryTransferService {
|
||||
outboundPallets.insert(item);
|
||||
}
|
||||
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
||||
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId()));
|
||||
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
||||
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
|
||||
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName());
|
||||
Map<String, List<DispatchItem>> destinationGroups = items.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
this::deliveryGroupKey, LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
||||
List<DeliveryOrder> deliveries = new ArrayList<>();
|
||||
int groupIndex = 0;
|
||||
for (Map.Entry<String, List<DispatchItem>> entry : destinationGroups.entrySet()) {
|
||||
List<DispatchItem> groupItems = entry.getValue();
|
||||
DispatchItem destination = groupItems.get(0);
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
||||
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId()));
|
||||
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
||||
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
|
||||
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName());
|
||||
delivery.setDestinationWarehouseId(trim(destination.getDestinationWarehouseId()));
|
||||
delivery.setDestinationWarehouseCode(firstText(
|
||||
firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()),
|
||||
destination.getDestinationWarehouseId()));
|
||||
delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName()));
|
||||
delivery.setDeliveryGroupKey(entry.getKey());
|
||||
LastlegOrder firstOrder = firstTailOrder(customerId, groupItems);
|
||||
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
|
||||
delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim());
|
||||
delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod()));
|
||||
delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize()));
|
||||
delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName()));
|
||||
delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount()));
|
||||
delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt());
|
||||
delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
|
||||
delivery.setPalletCount(groupItems.size());
|
||||
delivery.setTotalVolumeCbm(groupItems.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
delivery.setTotalWeightKg(totalWeight(customerId, groupItems)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
|
||||
delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId);
|
||||
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now);
|
||||
bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
|
||||
deliveries.add(delivery);
|
||||
}
|
||||
DeliveryOrder feeDelivery = deliveries.get(0);
|
||||
LastlegOrder firstOrder = firstTailOrder(customerId, items);
|
||||
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
|
||||
delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim());
|
||||
delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod()));
|
||||
delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize()));
|
||||
delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName()));
|
||||
delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount()));
|
||||
delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt());
|
||||
delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount());
|
||||
delivery.setTotalVolumeCbm(items.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
delivery.setTotalWeightKg(totalWeight(customerId, items)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
|
||||
delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId);
|
||||
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
|
||||
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
|
||||
for (DeliveryRequests.FeeItem input : feeInputs) {
|
||||
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()),
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, delivery.getCustomerName(),
|
||||
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", delivery.getId(), delivery.getDeliveryNo(),
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(),
|
||||
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(),
|
||||
firstOrder == null ? null : firstOrder.getId(), null,
|
||||
firstOrder == null ? null : firstOrder.getInboundCode(), null,
|
||||
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
|
||||
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(delivery.getOverseasWarehouseId()),
|
||||
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()),
|
||||
input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(),
|
||||
input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
|
||||
input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
|
||||
input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
|
||||
input.periodStart(), input.periodEnd()));
|
||||
}
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "CREATED", now);
|
||||
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
|
||||
return result(outbound, delivery, false);
|
||||
return result(outbound, deliveries, false);
|
||||
}
|
||||
|
||||
private void validateTransferFees(DeliveryRequests.Transfer request) {
|
||||
@@ -254,17 +278,33 @@ public class DeliveryTransferService {
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) {
|
||||
String requestHash = oneClickRequestHash(request);
|
||||
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId()));
|
||||
if (existing != null) return replayOneClick(existing, requestHash);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String selectedWarehouseId = validateOneClickItems(customerId, request.items());
|
||||
InventoryPosition firstPosition = inventoryPositions.selectById(request.items().get(0).inventoryPositionId());
|
||||
if (firstPosition == null || !customerId.equals(firstPosition.getCustomerId())) {
|
||||
throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用");
|
||||
}
|
||||
String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓");
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
||||
outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId()));
|
||||
outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim());
|
||||
outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size());
|
||||
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||
outboundOrders.insert(outbound);
|
||||
outbound.setRequestHash(requestHash);
|
||||
// Claim the unique command key before taking inventory locks. A racing insert
|
||||
// waits for the first transaction; use a current read after it commits, not
|
||||
// the REPEATABLE READ snapshot established by the initial lookup.
|
||||
try {
|
||||
outboundOrders.insert(outbound);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
OutboundOrder concurrent = outboundOrders.lockByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (concurrent == null) throw duplicate;
|
||||
return replayOneClick(concurrent, requestHash);
|
||||
}
|
||||
selectedWarehouseId = validateOneClickItems(customerId, request.items());
|
||||
Set<Long> physicalPalletIds = new HashSet<>();
|
||||
for (DeliveryRequests.OneClickItem input : request.items()) {
|
||||
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
|
||||
@@ -286,6 +326,23 @@ public class DeliveryTransferService {
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
private DeliveryResponses.PdaTask replayOneClick(OutboundOrder existing, String requestHash) {
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(existing.getOutboundType())
|
||||
|| !requestHash.equals(existing.getRequestHash())) {
|
||||
throw conflict("相同幂等键不能用于不同的一件代发出库请求");
|
||||
}
|
||||
return taskResult(existing, pdaTasks.lockByOutbound(existing.getCustomerId(), existing.getId()));
|
||||
}
|
||||
|
||||
String oneClickRequestHash(DeliveryRequests.OneClickOutbound request) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (JsonProcessingException | NoSuchAlgorithmException error) {
|
||||
throw new IllegalStateException("无法生成一件代发出库请求摘要", error);
|
||||
}
|
||||
}
|
||||
|
||||
public List<DeliveryResponses.OneClickCandidate> oneClickCandidates(String customerId, String warehouseId) {
|
||||
return dropshipProfiles.listOutboundCandidates(customerId,
|
||||
StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream()
|
||||
@@ -491,8 +548,19 @@ public class DeliveryTransferService {
|
||||
}
|
||||
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId());
|
||||
if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory");
|
||||
List<DropshipCarton> cartons = StringUtils.hasText(item.getCartonMarks())
|
||||
? Arrays.stream(item.getCartonMarks().split(",")).map(String::trim).filter(StringUtils::hasText)
|
||||
List<String> submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks();
|
||||
List<String> selectedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList();
|
||||
Set<String> frozenMarks = new HashSet<>(Arrays.asList(Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")));
|
||||
if (!submittedMarks.isEmpty() && (selectedMarks.size() != submittedMarks.size()
|
||||
|| selectedMarks.size() != request.packages()
|
||||
|| StringUtils.hasText(item.getCartonMarks()) && !frozenMarks.containsAll(selectedMarks))) {
|
||||
throw conflict("拣货箱唛必须唯一、数量一致且属于当前出库明细");
|
||||
}
|
||||
if (StringUtils.hasText(item.getCartonMarks()) && selectedMarks.isEmpty()) {
|
||||
throw conflict("请提交本次实际拣货的指定箱唛");
|
||||
}
|
||||
List<DropshipCarton> cartons = !selectedMarks.isEmpty()
|
||||
? selectedMarks.stream()
|
||||
.map(mark -> dropshipCartons.findByProfileAndMark(customerId, profile.getId(), mark)).filter(Objects::nonNull)
|
||||
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && item.getSkuCode().equals(carton.getSkuCode())).toList()
|
||||
: dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages());
|
||||
@@ -572,17 +640,19 @@ public class DeliveryTransferService {
|
||||
|| !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) {
|
||||
throw conflict("Outbound order cannot be cancelled after execution has started");
|
||||
}
|
||||
DeliveryOrder existingDelivery = deliveryOrders.lockByOutbound(customerId, outbound.getId());
|
||||
if (existingDelivery != null && !"WAIT_DELIVERY".equals(existingDelivery.getStatus()))
|
||||
List<DeliveryOrder> existingDeliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId());
|
||||
if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus())))
|
||||
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
DeliveryOrder delivery = existingDelivery;
|
||||
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); }
|
||||
for (DeliveryOrder delivery : existingDeliveries) {
|
||||
delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1);
|
||||
delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
|
||||
}
|
||||
OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId());
|
||||
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
|
||||
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
|
||||
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); }
|
||||
if (delivery != null) {
|
||||
if (!existingDeliveries.isEmpty()) {
|
||||
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
|
||||
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
|
||||
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
|
||||
@@ -644,6 +714,8 @@ public class DeliveryTransferService {
|
||||
OutboundPalletItem item) {
|
||||
if (item.getWarehousePalletId() == null) return List.of();
|
||||
List<DeliveryResponses.PdaPickPosition> result = new ArrayList<>();
|
||||
Set<String> frozenMarks = new HashSet<>(Arrays.asList(
|
||||
Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")));
|
||||
for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) {
|
||||
long available = value(position.getAvailablePackages());
|
||||
if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue;
|
||||
@@ -654,7 +726,9 @@ public class DeliveryTransferService {
|
||||
List<String> marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId())
|
||||
.stream().filter(carton -> "AVAILABLE".equals(carton.getStatus()))
|
||||
.filter(carton -> Objects.equals(item.getSkuCode(), carton.getSkuCode()))
|
||||
.filter(carton -> !StringUtils.hasText(item.getCartonMarks()) || frozenMarks.contains(carton.getCartonMark()))
|
||||
.map(DropshipCarton::getCartonMark).filter(StringUtils::hasText).distinct().limit(available).toList();
|
||||
if (profile != null && marks.isEmpty()) continue;
|
||||
if (!matchesOutboundItem(customerId, warehouseId, item, balance) && marks.isEmpty()) continue;
|
||||
if (marks.isEmpty()) {
|
||||
result.add(pdaPosition(position, item, balance, null, available, value(position.getPickedPackages())));
|
||||
@@ -669,7 +743,7 @@ public class DeliveryTransferService {
|
||||
InventoryBalance balance, String cartonMark,
|
||||
long available, long picked) {
|
||||
return new DeliveryResponses.PdaPickPosition(position.getId(), position.getPalletNo(), cartonMark,
|
||||
balance.getSkuCode(), balance.getInboundCode(), available, picked);
|
||||
item.getSkuCode(), balance.getInboundCode(), available, picked);
|
||||
}
|
||||
|
||||
private boolean matchesOutboundItem(String customerId, String warehouseId, OutboundPalletItem item,
|
||||
@@ -718,13 +792,13 @@ public class DeliveryTransferService {
|
||||
OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo));
|
||||
if (outbound == null) throw notFound("Outbound order not found");
|
||||
String customerId = outbound.getCustomerId();
|
||||
DeliveryOrder delivery = deliveryOrders.lockByOutbound(customerId, outbound.getId());
|
||||
if (delivery == null && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) {
|
||||
List<DeliveryOrder> deliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId());
|
||||
if (deliveries.isEmpty() && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) {
|
||||
throw new IllegalStateException("Delivery document is missing for dispatch outbound order");
|
||||
}
|
||||
List<DeliveryResponses.OutboundPalletRow> pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream()
|
||||
.map(this::outboundPalletRow).toList();
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), delivery == null ? null : deliveryRow(delivery), pallets);
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveries.stream().map(this::deliveryRow).toList(), pallets);
|
||||
}
|
||||
|
||||
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
|
||||
@@ -747,7 +821,8 @@ public class DeliveryTransferService {
|
||||
public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) {
|
||||
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("Delivery order not found");
|
||||
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId());
|
||||
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream()
|
||||
.filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList();
|
||||
List<Long> orderIds = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList();
|
||||
Map<Long, LastlegOrder> ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream()
|
||||
.filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order));
|
||||
@@ -789,14 +864,16 @@ public class DeliveryTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
private DeliveryOrder requireDelivery(String customerId, Long outboundId) {
|
||||
DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, outboundId);
|
||||
if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order");
|
||||
return value;
|
||||
private List<DeliveryOrder> requireDeliveries(String customerId, Long outboundId) {
|
||||
List<DeliveryOrder> values = deliveryOrders.lockByOutbound(customerId, outboundId);
|
||||
if (values.isEmpty()) throw new IllegalStateException("Delivery documents are missing for outbound order");
|
||||
return values;
|
||||
}
|
||||
private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) {
|
||||
private DeliveryResponses.TransferResult result(OutboundOrder o, List<DeliveryOrder> deliveries, boolean replayed) {
|
||||
return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(),
|
||||
d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed);
|
||||
o.getPalletCount(), o.getPackageCount(), deliveries.stream().map(d -> new DeliveryResponses.TransferDelivery(
|
||||
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
|
||||
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
|
||||
}
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt(), o.getOutboundType()); }
|
||||
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
|
||||
@@ -810,12 +887,18 @@ public class DeliveryTransferService {
|
||||
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
|
||||
value(candidate.getAvailablePackages()));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); }
|
||||
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
|
||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
|
||||
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
|
||||
private String deliveryGroupKey(DispatchItem item) {
|
||||
String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode());
|
||||
if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT);
|
||||
if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim();
|
||||
throw conflict("Dispatch pallet is missing its destination FBA warehouse");
|
||||
}
|
||||
private String resolveLxCodes(String customerId, List<DispatchItem> items) {
|
||||
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
|
||||
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ public class DispatchController {
|
||||
|
||||
@PutMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')")
|
||||
@Operation(summary = "Edit vehicle, driver, appointment and route data")
|
||||
@Operation(summary = "Edit vehicle, driver and appointment data; route is derived from assigned FBA warehouses")
|
||||
public ApiResponse<DispatchResponse> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
|
||||
@@ -20,18 +20,18 @@ public final class DispatchRequests {
|
||||
}
|
||||
|
||||
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
|
||||
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 32) String fbaAppointmentStatus,
|
||||
@Size(max = 32) String vehicleAppointmentStatus,
|
||||
@Size(max = 64) String draftToken,
|
||||
@Size(max = 64) String relabelNo) {
|
||||
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo,
|
||||
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
|
||||
String fbaAppointmentStatus, String vehicleAppointmentStatus) {
|
||||
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null);
|
||||
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null);
|
||||
}
|
||||
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo,
|
||||
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
|
||||
String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) {
|
||||
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null);
|
||||
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public final class DispatchRequests {
|
||||
public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); }
|
||||
}
|
||||
|
||||
public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull @Min(1) @Max(500) Integer capacity) {}
|
||||
public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {}
|
||||
|
||||
public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) {
|
||||
}
|
||||
@@ -51,7 +51,6 @@ public final class DispatchRequests {
|
||||
|
||||
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
|
||||
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
|
||||
@Size(max = 255) String route,
|
||||
@Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 32) String fbaAppointmentStatus,
|
||||
@Size(max = 32) String vehicleAppointmentStatus,
|
||||
|
||||
+28
-8
@@ -134,6 +134,9 @@ public class DispatchService {
|
||||
|
||||
@Transactional
|
||||
public DispatchContainerResponse createDraftContainer(String operatorId, DispatchRequests.DraftContainer request) {
|
||||
if (!Set.of(15, 30).contains(request.capacity())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆大小仅支持15托或30托");
|
||||
}
|
||||
DispatchDraft draft = ensureDraft(operatorId, request.draftToken());
|
||||
int next = draftContainers.findByToken(request.draftToken()).stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0) + 1;
|
||||
DispatchDraftContainer c = new DispatchDraftContainer(); c.setId(IdWorker.getId()); c.setDraftId(draft.getId()); c.setDraftToken(request.draftToken()); c.setSequenceNo(next); c.setCapacity(request.capacity()); c.setCreatedAt(LocalDateTime.now()); c.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(c);
|
||||
@@ -313,7 +316,7 @@ public class DispatchService {
|
||||
dispatch.setCreatedBy(trimToNull(operatorId));
|
||||
dispatch.setCreatedAt(now);
|
||||
}
|
||||
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected));
|
||||
dispatch.setRoute(candidateRoute(selected));
|
||||
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
|
||||
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
|
||||
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
|
||||
@@ -439,7 +442,7 @@ public class DispatchService {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
|
||||
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
|
||||
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
|
||||
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
|
||||
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
|
||||
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
|
||||
@@ -514,18 +517,19 @@ public class DispatchService {
|
||||
"Only a pending dispatch can be edited");
|
||||
}
|
||||
String customerId = dispatch.getCustomerId();
|
||||
List<DispatchItem> assignedItems = items.findByDispatchOrder(customerId, dispatch.getId());
|
||||
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
|
||||
dispatch.setDriverName(trimToNull(request.driverName()));
|
||||
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
|
||||
dispatch.setCarrierName(trimToNull(request.carrierName()));
|
||||
dispatch.setRoute(trimToNull(request.route()));
|
||||
dispatch.setRoute(itemRoute(assignedItems));
|
||||
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
|
||||
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
|
||||
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
|
||||
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
|
||||
dispatch.setUpdatedAt(LocalDateTime.now());
|
||||
orders.updateById(dispatch);
|
||||
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
|
||||
return response(dispatch, assignedItems);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -557,6 +561,7 @@ public class DispatchService {
|
||||
dispatch.setPalletCount(remaining.size());
|
||||
dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
|
||||
.mapToInt(Integer::intValue).sum());
|
||||
dispatch.setRoute(itemRoute(remaining));
|
||||
dispatch.setUpdatedAt(now);
|
||||
orders.updateById(dispatch);
|
||||
return response(dispatch, remaining);
|
||||
@@ -620,7 +625,7 @@ public class DispatchService {
|
||||
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
|
||||
.distinct().collect(Collectors.joining(","));
|
||||
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
|
||||
order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(),
|
||||
order.getDriverPhone(), order.getCarrierName(), orderNos, itemRoute(itemList),
|
||||
order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(),
|
||||
order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(),
|
||||
order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0),
|
||||
@@ -637,9 +642,24 @@ public class DispatchService {
|
||||
bolLineList == null ? List.of() : bolLineList);
|
||||
}
|
||||
|
||||
private static String route(List<DispatchPalletCandidate> selected) {
|
||||
return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText)
|
||||
.distinct().collect(Collectors.joining(" -> "));
|
||||
private static String candidateRoute(List<DispatchPalletCandidate> selected) {
|
||||
return selected.stream().map(DispatchService::candidateFbaCode).filter(StringUtils::hasText)
|
||||
.distinct().collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
private static String itemRoute(List<DispatchItem> assignedItems) {
|
||||
return assignedItems.stream().map(DispatchService::itemFbaCode).filter(StringUtils::hasText)
|
||||
.distinct().collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
private static String candidateFbaCode(DispatchPalletCandidate candidate) {
|
||||
return StringUtils.hasText(candidate.fbaWarehouseCode())
|
||||
? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode());
|
||||
}
|
||||
|
||||
private static String itemFbaCode(DispatchItem item) {
|
||||
return StringUtils.hasText(item.getFbaWarehouseCode())
|
||||
? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode());
|
||||
}
|
||||
|
||||
private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) {
|
||||
|
||||
-2
@@ -5,13 +5,11 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record ManualInboundPlanRequest(
|
||||
@NotBlank @Size(max = 128) String customerId,
|
||||
@NotNull Long orderId,
|
||||
@NotNull @Min(0) Integer expectedOrderVersion,
|
||||
@NotNull LocalDate plannedArrivalDate,
|
||||
@NotNull @Min(1) @Max(999) Integer palletCount,
|
||||
@NotBlank @Size(max = 32) String palletType,
|
||||
@NotNull Boolean assigned,
|
||||
|
||||
+5
@@ -75,6 +75,11 @@ public interface InboundPlanOrderMapper {
|
||||
String findSourceContainerBookingId(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT estimated_warehouse_at FROM ll_order " +
|
||||
"WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id")
|
||||
List<LocalDateTime> containerEstimatedWarehouseTimes(
|
||||
@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||
|
||||
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
String findOrderType(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
+12
@@ -91,6 +91,18 @@ public class ReceivingContainerController {
|
||||
.contentLength(download.content().length).body(download.content());
|
||||
}
|
||||
|
||||
@GetMapping("/current/domestic-loading-photos/{evidenceType}/content")
|
||||
public ResponseEntity<byte[]> downloadDomesticLoadingPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String evidenceType) {
|
||||
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||
var download = service.downloadDomesticLoadingPhoto(identity.operatorKey(), evidenceType);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(MediaType.parseMediaType(download.contentType()))
|
||||
.contentLength(download.content().length).body(download.content());
|
||||
}
|
||||
|
||||
@PostMapping("/current/sign")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
||||
|
||||
+6
-3
@@ -32,7 +32,8 @@ public final class ReceivingContainerResponses {
|
||||
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
String status, String operatorName, int version,
|
||||
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||
List<SignedPhoto> photos, List<DomesticLoadingPhoto> domesticLoadingPhotos,
|
||||
LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||
LocalDateTime receivingAt, LocalDateTime completedAt,
|
||||
int totalPackages, int receivedPackages, int pendingPackages,
|
||||
boolean hasDifference, List<Inbound> inbounds) {
|
||||
@@ -42,7 +43,7 @@ public final class ReceivingContainerResponses {
|
||||
LocalDateTime completedAt, int totalPackages, int receivedPackages,
|
||||
int pendingPackages, List<Inbound> inbounds) {
|
||||
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
|
||||
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
|
||||
version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages,
|
||||
receivedPackages, pendingPackages, false, inbounds);
|
||||
}
|
||||
|
||||
@@ -52,13 +53,15 @@ public final class ReceivingContainerResponses {
|
||||
LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages,
|
||||
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
|
||||
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
|
||||
status, operatorName, version, photos, preparedAt, signedAt, receivingAt,
|
||||
status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt,
|
||||
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
|
||||
}
|
||||
}
|
||||
|
||||
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { }
|
||||
|
||||
public record DomesticLoadingPhoto(String evidenceType, String label, String downloadPath) { }
|
||||
|
||||
public record Inbound(String inboundCode, String destinationType,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
String destinationWarehouseId,
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("ll_container_loading_evidence")
|
||||
public class ContainerLoadingEvidence {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String sourceContainerBookingId;
|
||||
private String evidenceType;
|
||||
private Long sourceFileId;
|
||||
private String fileName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.gateway;
|
||||
|
||||
public interface DomesticLoadingEvidenceGateway {
|
||||
DownloadedFile download(long containerBookingId, long fileId);
|
||||
|
||||
record DownloadedFile(byte[] content, String fileName, String contentType) { }
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.gateway;
|
||||
|
||||
import com.baiyue.lastleg.config.LastlegRestClientFactory;
|
||||
import com.baiyue.lastleg.config.LastlegRestClientProperties;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Component
|
||||
public class HttpDomesticLoadingEvidenceGateway implements DomesticLoadingEvidenceGateway {
|
||||
@Resource private RestClient.Builder restClientBuilder;
|
||||
@Resource private LastlegRestClientFactory restClientFactory;
|
||||
@Resource private LastlegRestClientProperties restClientProperties;
|
||||
@Value("${baiyue.clients.domestic-file.url:http://127.0.0.1:9205}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
private RestClient client;
|
||||
|
||||
@PostConstruct
|
||||
void initializeClient() {
|
||||
client = restClientFactory.build(restClientBuilder, baseUrl,
|
||||
restClientProperties.getDomesticLoadingEvidence());
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadedFile download(long containerBookingId, long fileId) {
|
||||
ResponseEntity<byte[]> response = client.get()
|
||||
.uri("/internal/file/v1/loading-evidence/{containerBookingId}/{fileId}/content",
|
||||
containerBookingId, fileId)
|
||||
.header("X-Internal-Service-Token", internalToken).retrieve()
|
||||
.onStatus(HttpStatusCode::is4xxClientError, (request, result) -> {
|
||||
throw new ResponseStatusException(result.getStatusCode().value() == 404
|
||||
? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST, "国内装柜照片不可用");
|
||||
})
|
||||
.onStatus(HttpStatusCode::is5xxServerError, (request, result) -> {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "国内装柜照片暂时无法加载");
|
||||
}).toEntity(byte[].class);
|
||||
byte[] content = response.getBody() == null ? new byte[0] : response.getBody();
|
||||
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
String fileName = null;
|
||||
try { fileName = disposition == null ? null : ContentDisposition.parse(disposition).getFilename(); }
|
||||
catch (IllegalArgumentException ignored) { }
|
||||
String contentType = response.getHeaders().getContentType() == null
|
||||
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
|
||||
return new DownloadedFile(content, fileName == null ? fileId + ".bin" : fileName, contentType);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence;
|
||||
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 ContainerLoadingEvidenceMapper extends BaseMapper<ContainerLoadingEvidence> {
|
||||
@Select("SELECT * FROM ll_container_loading_evidence WHERE source_container_booking_id=#{bookingId} ORDER BY CASE evidence_type WHEN 'PHOTO_STEP_1' THEN 1 WHEN 'PHOTO_STEP_2' THEN 2 WHEN 'PHOTO_STEP_3' THEN 3 WHEN 'PHOTO_STEP_4' THEN 4 ELSE 5 END")
|
||||
List<ContainerLoadingEvidence> listByContainer(@Param("bookingId") String bookingId);
|
||||
|
||||
@Select("SELECT * FROM ll_container_loading_evidence WHERE source_container_booking_id=#{bookingId} AND evidence_type=#{type} LIMIT 1")
|
||||
ContainerLoadingEvidence find(@Param("bookingId") String bookingId, @Param("type") String type);
|
||||
}
|
||||
+51
-2
@@ -23,11 +23,13 @@ import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.DomesticLoadingEvidenceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
@@ -67,6 +69,8 @@ public class ReceivingContainerService {
|
||||
private static final String PLATFORM_SCOPE = "0";
|
||||
private static final String PHOTO_DOWNLOAD_PATH =
|
||||
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
|
||||
private static final String DOMESTIC_PHOTO_DOWNLOAD_PATH =
|
||||
"/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{type}/content";
|
||||
private static final String PALLET_MERGE_HINT =
|
||||
"该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托";
|
||||
|
||||
@@ -86,6 +90,8 @@ public class ReceivingContainerService {
|
||||
@Resource private DropshipOrderProfileMapper dropshipProfiles;
|
||||
@Resource private WarehouseResourceGateway warehouse;
|
||||
@Resource private UnloadPhotoFileGateway unloadPhotos;
|
||||
@Resource private DomesticLoadingEvidenceGateway domesticEvidenceFiles;
|
||||
@Resource private ContainerLoadingEvidenceMapper domesticEvidence;
|
||||
@Resource private InboundPdaWorkflowService workflow;
|
||||
@Resource private ObjectMapper sourceObjectMapper;
|
||||
|
||||
@@ -247,6 +253,19 @@ public class ReceivingContainerService {
|
||||
return unloadPhotos.download(PLATFORM_SCOPE, session.getId(), parsedFileId);
|
||||
}
|
||||
|
||||
public DomesticLoadingEvidenceGateway.DownloadedFile downloadDomesticLoadingPhoto(
|
||||
String operatorKey, String evidenceType) {
|
||||
ReceivingContainerSession session = requireCurrent(operatorKey, false);
|
||||
String type = requiredEvidenceType(evidenceType);
|
||||
var evidence = domesticEvidence.find(session.getSourceContainerBookingId(), type);
|
||||
if (evidence == null) throw notFound("当前柜没有该国内装柜照片");
|
||||
long bookingId;
|
||||
try { bookingId = Long.parseLong(session.getSourceContainerBookingId()); }
|
||||
catch (NumberFormatException exception) { throw new ResponseStatusException(
|
||||
HttpStatus.SERVICE_UNAVAILABLE, "当前柜国内照片来源无效", exception); }
|
||||
return domesticEvidenceFiles.download(bookingId, evidence.getSourceFileId());
|
||||
}
|
||||
|
||||
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
|
||||
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
|
||||
}
|
||||
@@ -488,8 +507,8 @@ public class ReceivingContainerService {
|
||||
accepted += packages;
|
||||
match.allocation().setPickedPackageCount(packages);
|
||||
}
|
||||
markReceiving(session);
|
||||
}
|
||||
markReceiving(session);
|
||||
List<InboundRef> group = matches.stream().map(DropshipPalletRef::ref).distinct().toList();
|
||||
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
|
||||
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
|
||||
@@ -983,11 +1002,41 @@ public class ReceivingContainerService {
|
||||
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
|
||||
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
|
||||
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
|
||||
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
|
||||
value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session),
|
||||
session.getPreparedAt(), session.getSignedAt(),
|
||||
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
|
||||
hasFbaOverReceipt(session, containerPlans), inbounds);
|
||||
}
|
||||
|
||||
private List<ReceivingContainerResponses.DomesticLoadingPhoto> domesticLoadingPhotos(
|
||||
ReceivingContainerSession session) {
|
||||
String bookingId = trim(session.getSourceContainerBookingId());
|
||||
if (bookingId == null) return List.of();
|
||||
return domesticEvidence.listByContainer(bookingId).stream()
|
||||
.map(value -> new ReceivingContainerResponses.DomesticLoadingPhoto(value.getEvidenceType(),
|
||||
evidenceLabel(value.getEvidenceType()), DOMESTIC_PHOTO_DOWNLOAD_PATH.replace("{type}", value.getEvidenceType())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static String requiredEvidenceType(String value) {
|
||||
String type = trim(value);
|
||||
if (type == null || !Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL").contains(type)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "不支持的国内装柜照片类型");
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
private static String evidenceLabel(String type) {
|
||||
return switch (type) {
|
||||
case "PHOTO_STEP_1" -> "空仓";
|
||||
case "PHOTO_STEP_2" -> "半仓";
|
||||
case "PHOTO_STEP_3" -> "满仓";
|
||||
case "PHOTO_STEP_4" -> "锁仓";
|
||||
case "SEAL" -> "带锁码";
|
||||
default -> type;
|
||||
};
|
||||
}
|
||||
|
||||
private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages,
|
||||
ReceivingFbaPalletCount existing) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
+36
-12
@@ -38,6 +38,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
@@ -103,6 +104,8 @@ public class InboundPlanService {
|
||||
}
|
||||
String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream()
|
||||
.map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList());
|
||||
LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate(
|
||||
containerFacts.get(0).sourceContainerBookingId());
|
||||
List<SourceOrderSnapshot> snapshots = orderMapper.lockOrder(customerId, request.orderId());
|
||||
if (snapshots == null || snapshots.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在");
|
||||
@@ -152,7 +155,7 @@ public class InboundPlanService {
|
||||
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
|
||||
}
|
||||
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
|
||||
request, receivingWarehouse);
|
||||
request, receivingWarehouse, plannedArrivalDate);
|
||||
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
|
||||
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId());
|
||||
command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key);
|
||||
@@ -426,21 +429,25 @@ public class InboundPlanService {
|
||||
return detail(customerId, existing);
|
||||
}
|
||||
validateOrders(orders);
|
||||
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null);
|
||||
}
|
||||
|
||||
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
|
||||
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
|
||||
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse) {
|
||||
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, manual,
|
||||
receivingWarehouse, null, Map.of());
|
||||
String sourceContainerBookingId = trimToNull(orderMapper.findSourceContainerBookingId(customerId, selectedOrderId));
|
||||
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null,
|
||||
requiredContainerPlannedArrivalDate(sourceContainerBookingId));
|
||||
}
|
||||
|
||||
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
|
||||
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
|
||||
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse,
|
||||
LocalDate plannedArrivalDate) {
|
||||
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, manual,
|
||||
receivingWarehouse, plannedArrivalDate, null, Map.of());
|
||||
}
|
||||
|
||||
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
|
||||
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
|
||||
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse,
|
||||
LocalDate plannedArrivalDate,
|
||||
PreparedPlan preparedPlan,
|
||||
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reservedPallets) {
|
||||
String containerNo = requiredContainerNo(orders.get(0).containerNo());
|
||||
@@ -463,7 +470,7 @@ public class InboundPlanService {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception);
|
||||
}
|
||||
plan.setStatus("PENDING_ASSIGNMENT");
|
||||
plan.setPlannedArrivalDate(manual == null ? null : manual.plannedArrivalDate());
|
||||
plan.setPlannedArrivalDate(plannedArrivalDate);
|
||||
plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase());
|
||||
plan.setAssignedFlag(manual != null && manual.assigned());
|
||||
plan.setMergeRequired(manual != null && manual.mergeRequired());
|
||||
@@ -550,6 +557,7 @@ public class InboundPlanService {
|
||||
if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
|
||||
List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId);
|
||||
if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
|
||||
LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate(bookingId);
|
||||
if (snapshotVersion != null) {
|
||||
ContainerInboundPlanPreviewResponse current = previewContainer(bookingId);
|
||||
if (current.snapshotVersion() != snapshotVersion || !Objects.equals(current.snapshotHash(), snapshotHash)
|
||||
@@ -639,7 +647,7 @@ public class InboundPlanService {
|
||||
.map(value -> value.reservation().palletCode()).toList();
|
||||
}
|
||||
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), effectivePalletNos,
|
||||
wholePallets.get(pendingIndex), null, null, prepared, reserved));
|
||||
wholePallets.get(pendingIndex), null, null, plannedArrivalDate, prepared, reserved));
|
||||
}
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
InboundPlan existing = existingPlans.get(index);
|
||||
@@ -752,6 +760,22 @@ public class InboundPlanService {
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDate requiredContainerPlannedArrivalDate(String sourceContainerBookingId) {
|
||||
String bookingId = trimToNull(sourceContainerBookingId);
|
||||
if (bookingId == null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单未绑定来源柜,无法取得预计到仓时间");
|
||||
}
|
||||
List<LocalDateTime> values = orderMapper.containerEstimatedWarehouseTimes(bookingId);
|
||||
if (values == null || values.isEmpty() || values.stream().anyMatch(Objects::isNull)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间缺失,请先补齐柜级时间");
|
||||
}
|
||||
LocalDateTime expected = values.get(0);
|
||||
if (values.stream().anyMatch(value -> !Objects.equals(expected, value))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间不一致,请先修正柜级时间");
|
||||
}
|
||||
return expected.toLocalDate();
|
||||
}
|
||||
|
||||
private void requirePlanContainer(InboundPlan plan, String containerNo) {
|
||||
String frozen = requiredContainerNo(plan.getContainerNo());
|
||||
if (!Objects.equals(containerNo, frozen)) {
|
||||
|
||||
+5
-1
@@ -1,5 +1,7 @@
|
||||
package com.baiyue.lastleg.looseboard.service;
|
||||
|
||||
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
@@ -203,7 +205,9 @@ public class LocalLooseboardService {
|
||||
order.setOriginAddress(trim(originAddress));
|
||||
order.setDestination(destination.trim());
|
||||
order.setDestinationAddress(trim(destinationAddress));
|
||||
order.setContainerType(trim(containerType));
|
||||
order.setContainerType(StringUtils.hasText(containerType)
|
||||
? ContainerSpecificationCatalog.require(containerType).containerType()
|
||||
: ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE);
|
||||
order.setPackageCount(packageCount);
|
||||
order.setTotalWeightKg(totalWeightKg);
|
||||
order.setTotalVolumeCbm(totalVolumeCbm);
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ public class UnloadCompletedOutboxRepository {
|
||||
private static final List<String> OVERSEAS_TO_CN_TYPES = List.of(
|
||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED,
|
||||
EventTypes.WALLET_COMMAND_REQUESTED);
|
||||
@Resource private LastlegEventOutboxMapper mapper;
|
||||
|
||||
|
||||
+47
-3
@@ -7,6 +7,8 @@ import com.baiyue.lastleg.dropship.dto.DropshipRequests;
|
||||
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||
@@ -46,12 +48,13 @@ public class LoadingCompletedConsumer {
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private ContainerShippingGateMapper shippingGates;
|
||||
@Resource private LastlegSourcePalletMapper sourcePallets;
|
||||
@Resource private ContainerLoadingEvidenceMapper loadingEvidence;
|
||||
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public int consume(EventEnvelope envelope) {
|
||||
envelope.requireValid();
|
||||
if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())
|
||||
|| (envelope.eventVersion() != 1 && envelope.eventVersion() != 2))
|
||||
|| (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3))
|
||||
throw new IllegalArgumentException("不支持的尾程交接事件");
|
||||
if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS)
|
||||
throw new IllegalArgumentException("尾程交接事件区域无效");
|
||||
@@ -64,7 +67,7 @@ public class LoadingCompletedConsumer {
|
||||
String sourceContainerBookingId = sourceContainerBookingId(envelope, payload);
|
||||
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
|
||||
OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId());
|
||||
if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload, envelope.payload());
|
||||
if (envelope.eventVersion() >= 2) validateCartonSkuMappings(payload, envelope.payload());
|
||||
freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo);
|
||||
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
|
||||
if(existing!=null) {
|
||||
@@ -85,6 +88,8 @@ public class LoadingCompletedConsumer {
|
||||
return countOrders(envelope,containerNo);
|
||||
}
|
||||
|
||||
projectLoadingEvidence(envelope, sourceContainerBookingId);
|
||||
|
||||
// The loading-plan and container-shipping events are independent aggregate streams.
|
||||
// Seeding the shipping version from the loading-plan version makes a valid first node
|
||||
// (aggregateVersion=0) look stale whenever the loading plan has a higher version.
|
||||
@@ -100,6 +105,45 @@ public class LoadingCompletedConsumer {
|
||||
return payload.orders().size();
|
||||
}
|
||||
|
||||
private void projectLoadingEvidence(EventEnvelope envelope, String sourceContainerBookingId) {
|
||||
if (envelope.eventVersion() < 3) return;
|
||||
JsonNode values = envelope.payload().path("loadingEvidence");
|
||||
if (!values.isArray() || values.size() != 5) {
|
||||
throw new IllegalArgumentException("V3 尾程交接事件必须包含五类国内装柜照片");
|
||||
}
|
||||
Set<String> expected = Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL");
|
||||
Set<String> actual = new HashSet<>();
|
||||
for (JsonNode value : values) {
|
||||
String type = value.path("evidenceType").asText("").trim();
|
||||
long fileId = value.path("fileId").asLong(0);
|
||||
String fileName = value.path("fileName").asText("").trim();
|
||||
if (!expected.contains(type) || !actual.add(type) || fileId <= 0
|
||||
|| fileName.isBlank() || fileName.length() > 255) {
|
||||
throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片引用无效");
|
||||
}
|
||||
ContainerLoadingEvidence existing = loadingEvidence.find(sourceContainerBookingId, type);
|
||||
if (existing != null) {
|
||||
if (!Objects.equals(existing.getSourceFileId(), fileId)
|
||||
|| !Objects.equals(existing.getFileName(), fileName)) {
|
||||
throw new IllegalStateException("同一来源柜的国内装柜照片快照冲突");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ContainerLoadingEvidence row = new ContainerLoadingEvidence();
|
||||
row.setId(IdWorker.getId());
|
||||
row.setSourceContainerBookingId(sourceContainerBookingId);
|
||||
row.setEvidenceType(type); row.setSourceFileId(fileId); row.setFileName(fileName);
|
||||
row.setCreatedAt(LocalDateTime.now());
|
||||
try { loadingEvidence.insert(row); }
|
||||
catch (DuplicateKeyException duplicate) {
|
||||
ContainerLoadingEvidence raced = loadingEvidence.find(sourceContainerBookingId, type);
|
||||
if (raced == null || !Objects.equals(raced.getSourceFileId(), fileId)
|
||||
|| !Objects.equals(raced.getFileName(), fileName)) throw duplicate;
|
||||
}
|
||||
}
|
||||
if (!actual.equals(expected)) throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片类型不完整");
|
||||
}
|
||||
|
||||
private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) {
|
||||
try {
|
||||
ObjectMapper compatibleMapper = objectMapper.copy()
|
||||
@@ -134,7 +178,7 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
|
||||
private String sourceContainerBookingId(EventEnvelope envelope, FirstLegLoadingPlanCompletedV1 payload) {
|
||||
String value = envelope.eventVersion() == 2
|
||||
String value = envelope.eventVersion() >= 2
|
||||
? envelope.payload().path("sourceContainerBookingId").asText(null)
|
||||
: payload.containerBookingId();
|
||||
if (value == null || value.isBlank() || value.trim().length() > 128) {
|
||||
|
||||
+6
-2
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
|
||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||
@@ -84,7 +85,9 @@ public class UsTruckOrderService {
|
||||
order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null);
|
||||
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
|
||||
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())
|
||||
? ContainerSpecificationCatalog.require(request.containerType()).containerType()
|
||||
: ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE);
|
||||
order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
|
||||
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0);
|
||||
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
|
||||
@@ -138,7 +141,8 @@ public class UsTruckOrderService {
|
||||
if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); }
|
||||
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
|
||||
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
|
||||
setText(request.containerType(), v -> order.setContainerType(norm(v)));
|
||||
setText(request.containerType(), v -> order.setContainerType(
|
||||
ContainerSpecificationCatalog.require(v).containerType()));
|
||||
if (request.palletCount() != null) order.setPalletCount(request.palletCount());
|
||||
if (request.packageCount() != null) order.setPackageCount(request.packageCount());
|
||||
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
|
||||
|
||||
@@ -66,6 +66,9 @@ baiyue:
|
||||
pallet-physical:
|
||||
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
|
||||
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
|
||||
domestic-loading-evidence:
|
||||
connect-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_CONNECT_TIMEOUT:3s}
|
||||
read-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_READ_TIMEOUT:30s}
|
||||
clients:
|
||||
print:
|
||||
url: ${PRINT_SERVICE_URL:}
|
||||
@@ -74,6 +77,8 @@ baiyue:
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
|
||||
domestic-file:
|
||||
url: ${DOMESTIC_FILE_SERVICE_URL:http://127.0.0.1:9205}
|
||||
event-bridge:
|
||||
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
|
||||
firstleg:
|
||||
|
||||
@@ -257,6 +257,10 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID',
|
||||
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
|
||||
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照',
|
||||
destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照',
|
||||
delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
|
||||
waybill_no VARCHAR(128) NULL COMMENT '承运运单号',
|
||||
bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识',
|
||||
@@ -289,7 +293,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no),
|
||||
UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key),
|
||||
UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id),
|
||||
UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key),
|
||||
KEY idx_ll_delivery_list (customer_id,status,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程派送单';
|
||||
|
||||
@@ -297,6 +301,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name'));
|
||||
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name'));
|
||||
PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt;
|
||||
SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound');
|
||||
SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination');
|
||||
SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)'));
|
||||
PREPARE delivery_destination_index_stmt FROM @ddl; EXECUTE delivery_destination_index_stmt; DEALLOCATE PREPARE delivery_destination_index_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status'));
|
||||
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count'));
|
||||
@@ -1424,6 +1434,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总',
|
||||
recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息',
|
||||
request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no),
|
||||
UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id),
|
||||
KEY idx_ll_outbound_list (customer_id,status,created_at)
|
||||
@@ -1929,7 +1940,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||
order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
|
||||
sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
|
||||
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
|
||||
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/COMPLETED',
|
||||
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
|
||||
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
|
||||
@@ -1953,20 +1964,6 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history (
|
||||
KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='货物废弃审批历史';
|
||||
|
||||
-- Backfill applications created before cargo discard became a first-class tail-leg order.
|
||||
INSERT IGNORE INTO ll_order (
|
||||
id,order_no,customer_id,order_type,inbound_code,inbound_order_no,source_region,source_inbound_id,
|
||||
source_order_id,source_order_no,overseas_warehouse_id,overseas_warehouse_name,package_count,
|
||||
current_status,version,source_event_id,source_loading_plan_id,source_payload_hash,created_at,updated_at
|
||||
)
|
||||
SELECT a.id,a.discard_no,a.customer_id,'CARGO_DISCARD',a.inbound_codes,a.discard_no,'LASTLEG_DISCARD',
|
||||
CONCAT('CARGO_DISCARD:',a.id),CAST(a.id AS CHAR),a.discard_no,a.overseas_warehouse_id,a.overseas_warehouse_name,
|
||||
a.total_packages,a.status,0,CONCAT('CARGO_DISCARD:',a.id),'CARGO_DISCARD',a.request_hash,a.created_at,a.updated_at
|
||||
FROM ll_cargo_discard_application a
|
||||
LEFT JOIN ll_order o ON o.customer_id=a.customer_id AND o.source_region='LASTLEG_DISCARD'
|
||||
AND o.source_inbound_id=CONCAT('CARGO_DISCARD:',a.id)
|
||||
WHERE o.id IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号',
|
||||
@@ -2010,7 +2007,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
||||
origin_address VARCHAR(500) NULL COMMENT '起运地址',
|
||||
destination VARCHAR(255) NOT NULL COMMENT '目的地',
|
||||
destination_address VARCHAR(500) NULL COMMENT '目的地址',
|
||||
container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集',
|
||||
container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ',
|
||||
pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数',
|
||||
package_count INT NULL COMMENT '可选箱数,零或空值均有效',
|
||||
received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数',
|
||||
@@ -2088,7 +2085,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
|
||||
pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型',
|
||||
origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
|
||||
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号',
|
||||
container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
|
||||
container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
|
||||
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
|
||||
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
|
||||
budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
|
||||
@@ -2536,3 +2533,15 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
|
||||
id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
|
||||
) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_container_loading_evidence (
|
||||
id BIGINT NOT NULL COMMENT '海外整柜装柜照片投影ID',
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '国内稳定来源柜预订ID',
|
||||
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL',
|
||||
source_file_id BIGINT NOT NULL COMMENT '国内File服务文件ID',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '国内文件名快照',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '投影创建时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type),
|
||||
UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id)
|
||||
) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用';
|
||||
|
||||
@@ -5,7 +5,7 @@ info:
|
||||
description: |
|
||||
所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。
|
||||
GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。
|
||||
状态 PUT /delivery-orders/{deliveryNo}/status 请求保持 {status,expectedVersion,remark},expectedVersion 必传。
|
||||
RECEIPT 签收文件只有在 PUT 保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。allowedActions 不再返回人工 COMPLETE_DELIVERY。
|
||||
START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。
|
||||
资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。
|
||||
security: [{ bearerAuth: [] }]
|
||||
|
||||
@@ -80,6 +80,22 @@ paths:
|
||||
application/octet-stream: { schema: { type: string, format: binary } }
|
||||
'403': { description: fileId 未绑定到当前操作员的当前柜会话三类签收照片 }
|
||||
'404': { description: 当前柜会话或照片不存在 }
|
||||
/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{evidenceType}/content:
|
||||
get:
|
||||
summary: 查看当前柜国内装柜照片
|
||||
description: 权限 lastleg:pda:operate;只按当前操作员已锁定的物理柜读取国内装柜完成快照,不返回客户或内部文件ID,加载失败不改变拆柜状态。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: evidenceType, in: path, required: true, schema: { type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] } }
|
||||
responses:
|
||||
'200':
|
||||
description: 国内装柜照片字节流;禁止缓存。
|
||||
content:
|
||||
image/jpeg: { schema: { type: string, format: binary } }
|
||||
image/png: { schema: { type: string, format: binary } }
|
||||
application/octet-stream: { schema: { type: string, format: binary } }
|
||||
'404': { description: 当前柜或指定类型照片不存在 }
|
||||
'503': { description: 国内照片服务暂不可用 }
|
||||
/api/pda/v1/last-leg/receiving-containers/current/sign:
|
||||
post:
|
||||
summary: 签收当前柜
|
||||
@@ -434,7 +450,7 @@ paths:
|
||||
/api/platform/v1/last-leg/inbound-plans/manual:
|
||||
post:
|
||||
summary: 平台按单票手工生成入库计划
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
@@ -446,7 +462,7 @@ paths:
|
||||
responses:
|
||||
'200': { description: 统一 ApiResponse 包装的入库计划详情 }
|
||||
'400': { description: 客户、入仓码、仓库主数据或请求字段无效 }
|
||||
'409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 }
|
||||
'409': { description: 订单版本、状态、暂扣状态、柜级预计到仓时间、重复计划或幂等键冲突 }
|
||||
/api/platform/v1/last-leg/inbound-plans/convert-container:
|
||||
post:
|
||||
summary: 按整柜批量生成剩余入库计划
|
||||
@@ -620,11 +636,11 @@ paths:
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:
|
||||
post:
|
||||
summary: 排车转出库与派送单
|
||||
description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。
|
||||
description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;一个排车仍生成一张出库单,并按目的FBA仓分别生成派送单,返回 deliveries 数组;同一幂等键或已转换排车重复调用返回全部既有单据。派送费用只发布一次,避免按拆分派送单重复计费。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } }
|
||||
responses: { '200': { description: 出库与派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘或幂等冲突 } }
|
||||
responses: { '200': { description: 一张出库单与按目的FBA仓拆分的派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘、目的FBA仓或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/outbound-orders:
|
||||
get:
|
||||
summary: 分页查询出库单
|
||||
@@ -649,10 +665,10 @@ paths:
|
||||
/api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates:
|
||||
get:
|
||||
summary: 查询一件代发出库候选库存
|
||||
description: 权限 platform:overseas-warehouse:outbound:create;按客户及可选海外仓一次查询服务端权威候选,仅返回 ACTIVE 一件代发档案、OCCUPIED 正可用库存、AVAILABLE 箱唛与 SKU 余额共同覆盖且未被排车、转仓或待执行出库占用的维度。创建命令会再次校验,不信任前端候选快照。
|
||||
description: 权限 platform:overseas-warehouse:outbound:create;仅平台身份可查询,customerId 为可选客户筛选条件,未传时跨客户查询,可按海外仓筛选。仅返回 ACTIVE 一件代发档案、OCCUPIED 正可用库存、AVAILABLE 箱唛与 SKU 余额共同覆盖且未被排车、转仓或待执行出库占用的维度。创建命令仍必须指定唯一客户并再次校验客户与仓库,不信任前端候选快照。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
- { name: warehouseId, in: query, required: false, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
@@ -664,7 +680,7 @@ paths:
|
||||
description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: '出库单、出库托盘及关联派送单详情;一件代发等独立出库无派送单时 delivery 为 null,排车转派送必须存在派送单;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } }
|
||||
responses: { '200': { description: '出库单、出库托盘及关联 deliveries 派送单数组;一件代发等独立出库时数组为空,排车转派送按目的FBA仓返回一项或多项;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } }
|
||||
/api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:
|
||||
post:
|
||||
summary: 取消未执行出库单
|
||||
@@ -676,7 +692,7 @@ paths:
|
||||
/api/platform/v1/last-leg/delivery-orders:
|
||||
get:
|
||||
summary: 分页查询平台派送单
|
||||
description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。每行返回服务端计算的 allowedActions,页面不得自行推导状态门禁。
|
||||
description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。同一排车按目的FBA仓显示为不同派送单,每行返回目的仓快照和服务端计算的 allowedActions,页面不得自行推导状态门禁;完成动作不再由人工触发。
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses: { '200': { description: 派送单分页 } }
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}:
|
||||
@@ -685,7 +701,7 @@ paths:
|
||||
description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 派送单详情 }, '404': { description: 派送单不存在 } }
|
||||
responses: { '200': { description: 派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓 }, '404': { description: 派送单不存在 } }
|
||||
/api/platform/v1/last-leg/exception-orders:
|
||||
get:
|
||||
summary: 分页查询异常订单
|
||||
@@ -762,18 +778,10 @@ paths:
|
||||
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } }
|
||||
responses: { '200': { description: 已创建退回异常单 } }
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/complete:
|
||||
post:
|
||||
summary: 标记派送完成并生成回访记录
|
||||
description: 权限 platform:overseas-warehouse:delivery:status。仅 WAIT_DELIVERY/DELIVERING 可完成;DELIVERED 同状态重放成功,其他终态或倒退返回 409。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } }
|
||||
responses: { '200': { description: 派送状态与回访记录 }, '409': { description: 版本或状态冲突 } }
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:
|
||||
put:
|
||||
summary: 更新派送状态
|
||||
description: 权限 platform:overseas-warehouse:delivery:status。只允许 WAIT_DELIVERY→DELIVERING/DELIVERED/REJECTED/CANCELLED、DELIVERING→DELIVERED/REJECTED;同状态幂等,终态不可倒退。
|
||||
description: 权限 platform:overseas-warehouse:delivery:status。人工只允许 WAIT_DELIVERY→DELIVERING/REJECTED/CANCELLED、DELIVERING→REJECTED;DELIVERED 仅由签收资料保存成功自动触发,同状态幂等,终态不可倒退。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } }
|
||||
@@ -1949,7 +1957,7 @@ components:
|
||||
currentAccount: { type: boolean }
|
||||
ReceivingSession:
|
||||
type: object
|
||||
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
|
||||
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
|
||||
properties:
|
||||
containerNo: { type: string }
|
||||
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
|
||||
@@ -1959,6 +1967,7 @@ components:
|
||||
operatorName: { type: string, nullable: true }
|
||||
version: { type: integer }
|
||||
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } }
|
||||
domesticLoadingPhotos: { type: array, maxItems: 5, items: { $ref: '#/components/schemas/DomesticLoadingPhoto' }, description: 当前物理柜国内装柜五类只读照片,不含客户信息与内部文件ID }
|
||||
preparedAt: { type: string, format: date-time, nullable: true }
|
||||
signedAt: { type: string, format: date-time, nullable: true }
|
||||
receivingAt: { type: string, format: date-time, nullable: true }
|
||||
@@ -1976,6 +1985,13 @@ components:
|
||||
fileName: { type: string }
|
||||
contentType: { type: string }
|
||||
sizeBytes: { type: integer, format: int64 }
|
||||
DomesticLoadingPhoto:
|
||||
type: object
|
||||
required: [evidenceType, label, downloadPath]
|
||||
properties:
|
||||
evidenceType: { type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] }
|
||||
label: { type: string, enum: [空仓, 半仓, 满仓, 锁仓, 带锁码] }
|
||||
downloadPath: { type: string, description: 当前接柜会话鉴权下载路径 }
|
||||
ReceivingCartonResolved:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingInbound' }
|
||||
@@ -2097,7 +2113,6 @@ components:
|
||||
required: [planPalletIds]
|
||||
properties:
|
||||
planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } }
|
||||
route: { type: string, nullable: true, maxLength: 255 }
|
||||
fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 }
|
||||
fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING }
|
||||
vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING }
|
||||
@@ -2126,7 +2141,7 @@ components:
|
||||
required: [draftToken, capacity]
|
||||
properties:
|
||||
draftToken: { type: string, minLength: 1, maxLength: 64 }
|
||||
capacity: { type: integer, minimum: 1, maximum: 500 }
|
||||
capacity: { type: integer, enum: [15, 30] }
|
||||
SmartDispatchDraft:
|
||||
type: object
|
||||
required: [draftToken, containers]
|
||||
@@ -2144,13 +2159,12 @@ components:
|
||||
palletIds: { type: array, items: { type: string } }
|
||||
DispatchUpdate:
|
||||
type: object
|
||||
description: 普通编辑字段;不包含 status 或 departAt。
|
||||
description: 普通编辑字段;不包含 status、departAt 或路线,路线由已排托盘关联的 FBA 仓自动汇总。
|
||||
properties:
|
||||
vehiclePlate: { type: string, nullable: true, maxLength: 64 }
|
||||
driverName: { type: string, nullable: true, maxLength: 128 }
|
||||
driverPhone: { type: string, nullable: true, maxLength: 64 }
|
||||
carrierName: { type: string, nullable: true, maxLength: 255 }
|
||||
route: { type: string, nullable: true, maxLength: 255 }
|
||||
fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 }
|
||||
fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 }
|
||||
vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 }
|
||||
@@ -2167,7 +2181,7 @@ components:
|
||||
pickupMethod: { type: string, maxLength: 64 }
|
||||
loadingType: { type: string, maxLength: 64 }
|
||||
vehicleType: { type: string, nullable: true, maxLength: 64 }
|
||||
vehicleSize: { type: string, nullable: true, maxLength: 64 }
|
||||
vehicleSize: { type: string, nullable: true, enum: [15托, 30托] }
|
||||
driverName: { type: string, nullable: true, maxLength: 128 }
|
||||
driverPhone: { type: string, nullable: true, maxLength: 64 }
|
||||
vehiclePlate: { type: string, nullable: true, maxLength: 64 }
|
||||
@@ -2179,13 +2193,13 @@ components:
|
||||
type: object
|
||||
required: [status, expectedVersion]
|
||||
properties:
|
||||
status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, DELIVERED, REJECTED, CANCELLED] }
|
||||
status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, REJECTED, CANCELLED] }
|
||||
expectedVersion: { type: integer, format: int64, minimum: 0 }
|
||||
remark: { type: string, nullable: true, maxLength: 500 }
|
||||
DeliveryAllowedAction:
|
||||
type: string
|
||||
enum: [START_DELIVERY, COMPLETE_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN]
|
||||
description: START/COMPLETE/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口。
|
||||
enum: [START_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN]
|
||||
description: START/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口;派送完成由签收资料保存成功自动触发。
|
||||
DeliveryMarkReturn:
|
||||
type: object
|
||||
required: [inboundOrderId, reason, returnDirection, items, idempotencyKey]
|
||||
@@ -2266,6 +2280,7 @@ components:
|
||||
remark: { type: string, nullable: true, maxLength: 500 }
|
||||
OneClickOutboundCreate:
|
||||
type: object
|
||||
description: 同一幂等键只重放相同请求;收件人、明细、箱唛或备注变化返回 A0409。
|
||||
required: [recipient, items, idempotencyKey]
|
||||
properties:
|
||||
recipient: { type: string, maxLength: 1000 }
|
||||
@@ -2282,6 +2297,11 @@ components:
|
||||
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
packages: { type: integer, format: int64, minimum: 1 }
|
||||
sku: { type: string, maxLength: 128 }
|
||||
cartonMarks:
|
||||
type: array
|
||||
maxItems: 200
|
||||
description: 指定出库箱唛,提供时必须唯一且与箱数一致。
|
||||
items: { type: string, minLength: 1, maxLength: 128 }
|
||||
OneClickOutboundCandidate:
|
||||
type: object
|
||||
required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages]
|
||||
@@ -2608,13 +2628,12 @@ components:
|
||||
items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
|
||||
ManualInboundPlanRequest:
|
||||
type: object
|
||||
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。
|
||||
required: [customerId, orderId, expectedOrderVersion, plannedArrivalDate, palletCount, palletType, assigned, mergeRequired, urgent]
|
||||
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。
|
||||
required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent]
|
||||
properties:
|
||||
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
orderId: { type: integer, format: int64, minimum: 1 }
|
||||
expectedOrderVersion: { type: integer, minimum: 0 }
|
||||
plannedArrivalDate: { type: string, format: date }
|
||||
palletCount: { type: integer, minimum: 1, maximum: 999 }
|
||||
palletType: { type: string, minLength: 1, maxLength: 64 }
|
||||
assigned: { type: boolean }
|
||||
@@ -2704,7 +2723,7 @@ components:
|
||||
transportType: { type: string, nullable: true }
|
||||
origin: { type: string }
|
||||
destination: { type: string }
|
||||
containerType: { type: string, nullable: true }
|
||||
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 }
|
||||
palletCount: { type: integer }
|
||||
packageCount: { type: integer }
|
||||
receivedPalletCount: { type: integer }
|
||||
@@ -2760,7 +2779,7 @@ components:
|
||||
transportType: { type: string }
|
||||
origin: { type: string }
|
||||
destination: { type: string }
|
||||
containerType: { type: string, nullable: true }
|
||||
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 }
|
||||
packageCount: { type: integer }
|
||||
totalWeightKg: { type: number }
|
||||
totalVolumeCbm: { type: number }
|
||||
@@ -3101,7 +3120,7 @@ components:
|
||||
originAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
destination: { type: string, maxLength: 255 }
|
||||
destinationAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
containerType: { type: string, nullable: true, maxLength: 32 }
|
||||
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 }
|
||||
palletCount: { type: integer, minimum: 1 }
|
||||
packageCount: { type: integer, nullable: true, minimum: 0 }
|
||||
totalWeightKg: { type: number, minimum: 0 }
|
||||
@@ -3123,7 +3142,7 @@ components:
|
||||
originAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
destination: { type: string, maxLength: 255 }
|
||||
destinationAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
containerType: { type: string, nullable: true, maxLength: 32 }
|
||||
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 }
|
||||
palletCount: { type: integer, minimum: 1 }
|
||||
packageCount: { type: integer, minimum: 0 }
|
||||
totalWeightKg: { type: number, minimum: 0 }
|
||||
@@ -3154,7 +3173,7 @@ components:
|
||||
originAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
destination: { type: string, maxLength: 255 }
|
||||
destinationAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
containerType: { type: string, nullable: true, maxLength: 32 }
|
||||
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 }
|
||||
packageCount: { type: integer, minimum: 0 }
|
||||
totalWeightKg: { type: number, minimum: 0 }
|
||||
totalVolumeCbm: { type: number, minimum: 0 }
|
||||
@@ -3177,7 +3196,7 @@ components:
|
||||
originAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
destination: { type: string, maxLength: 255 }
|
||||
destinationAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
containerType: { type: string, nullable: true, maxLength: 32 }
|
||||
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 }
|
||||
packageCount: { type: integer, minimum: 0 }
|
||||
totalWeightKg: { type: number, minimum: 0 }
|
||||
totalVolumeCbm: { type: number, minimum: 0 }
|
||||
|
||||
@@ -603,6 +603,11 @@ components:
|
||||
inventoryPositionId: { type: string, pattern: '^[0-9]+$' }
|
||||
packages: { type: integer, format: int64, minimum: 1 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
cartonMarks:
|
||||
type: array
|
||||
maxItems: 500
|
||||
description: 本次实际扫描的箱唛。一件代发指定箱唛任务分批拣货时必须提交本次子集,数量与 packages 一致;换标任务仍须提交全部指定箱唛。
|
||||
items: { type: string, minLength: 1, maxLength: 255 }
|
||||
PdaOutboundTaskDetail:
|
||||
type: object
|
||||
required: [task, items]
|
||||
|
||||
+7
-47
@@ -6,7 +6,6 @@ import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -56,22 +55,12 @@ class CustomerVisitServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysDeliveredToDeliveredWithoutChangingDeliveryOrAddingHistory() {
|
||||
DeliveryOrder delivery = delivery("DELIVERED");
|
||||
CustomerVisit visit = new CustomerVisit();
|
||||
visit.setId(33L);
|
||||
visit.setStatus("PENDING");
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
|
||||
void rejectsManualDeliveryCompletionBeforeLoadingTheOrder() {
|
||||
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null)))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("不支持");
|
||||
|
||||
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null));
|
||||
|
||||
assertThat(result.replayed()).isTrue();
|
||||
assertThat(result.deliveryStatus()).isEqualTo("DELIVERED");
|
||||
assertThat(result.visitId()).isEqualTo(33L);
|
||||
verify(deliveryOrders, never()).updateById(delivery);
|
||||
verifyNoInteractions(deliveryOperations);
|
||||
verifyNoInteractions(deliveryOrders, deliveryOperations);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,26 +78,7 @@ class CustomerVisitServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void completingDeliveryUpdatesOnlySubOrderStatusAndKeepsMainFlowStatus() {
|
||||
DeliveryOrder delivery = delivery("DELIVERING");
|
||||
delivery.setVersion(1L);
|
||||
LastlegOrder tail = new LastlegOrder();
|
||||
tail.setId(21L); tail.setCustomerId("1001"); tail.setCurrentStatus("INBOUND_COMPLETED");
|
||||
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(lastMileOrders.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(tail));
|
||||
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
|
||||
|
||||
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
|
||||
|
||||
assertThat(result.replayed()).isFalse();
|
||||
assertThat(tail.getCurrentStatus()).isEqualTo("INBOUND_COMPLETED");
|
||||
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startMeansDeliveringAndOnlyCompleteSignsAndCreatesVisit() {
|
||||
void startMeansDeliveringWithoutSigningOrCreatingVisit() {
|
||||
DeliveryOrder delivery = delivery("WAIT_DELIVERY"); delivery.setVersion(0L);
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
var started = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
@@ -118,17 +88,7 @@ class CustomerVisitServiceTest {
|
||||
assertThat(delivery.getDispatchedAt()).isNotNull();
|
||||
assertThat(delivery.getSignedAt()).isNull();
|
||||
verify(visits, never()).insert(org.mockito.ArgumentMatchers.any(CustomerVisit.class));
|
||||
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
|
||||
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
|
||||
var completed = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
|
||||
assertThat(completed.deliveryStatus()).isEqualTo("DELIVERED");
|
||||
assertThat(delivery.getSignedAt()).isNotNull();
|
||||
assertThat(delivery.getVersion()).isEqualTo(2);
|
||||
assertThat(completed.visitId()).isEqualTo(33);
|
||||
assertThat(service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)).replayed()).isTrue();
|
||||
verify(deliveryOrders, org.mockito.Mockito.times(2)).updateById(delivery);
|
||||
verify(deliveryOrders).updateById(delivery);
|
||||
}
|
||||
|
||||
private DeliveryOrder delivery(String status) {
|
||||
|
||||
+1
-1
@@ -67,6 +67,6 @@ class DeliveryLongIdJsonContractTest {
|
||||
null, null, "PS-1", null, null, null, null, "CREATED", null,
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
|
||||
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null));
|
||||
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user