This commit is contained in:
2026-08-18 16:03:42 +08:00
parent 12e9f3746e
commit 82ee80226e
15 changed files with 196 additions and 319 deletions
@@ -22,7 +22,7 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}/warehouse-remark") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@PatchMapping("/plans/{planId}/items/{itemId}/warehouse-remark") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<Plan> warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@@ -192,7 +192,8 @@ public final class LoadingListDtos {
String customerRemark,
String warehouseRemark,
int loadSequence,
LocalDateTime frozenAt
LocalDateTime frozenAt,
Integer itemVersion
) {
}
@@ -25,6 +25,7 @@ public interface LoadingCompletedEventMapper {
i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity,
i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg,
i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark
,i.customer_remark, i.internal_remark, i.warehouse_remark
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
@@ -50,5 +51,6 @@ public interface LoadingCompletedEventMapper {
private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg;
private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark;
private String customerRemark; private String internalRemark; private String warehouseRemark;
}
}
@@ -178,7 +178,7 @@ public interface LoadingListQueryMapper {
gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg,
product_quantity, units_per_carton, fba_tracking_no, shipment_no,
shipping_mark, internal_remark, customer_remark, warehouse_remark,
load_sequence, frozen_at
load_sequence, frozen_at, i.version AS item_version
FROM fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id=i.loading_plan_id
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
@@ -367,6 +367,7 @@ public interface LoadingListQueryMapper {
private String warehouseRemark;
private Integer loadSequence;
private LocalDateTime frozenAt;
private Integer itemVersion;
}
@Getter
@@ -10,7 +10,6 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
@Mapper
public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@@ -53,42 +52,4 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status='RELEASED',bill_of_lading_no=#{billOfLadingNo},
vessel_name=#{vesselName},voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt},
estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status='CONFIRMED' AND version=#{expectedVersion}
""")
int release(@Param("oceanBookingId") long oceanBookingId, @Param("billOfLadingNo") String billOfLadingNo,
@Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo,
@Param("estimatedSailingAt") LocalDateTime estimatedSailingAt,
@Param("estimatedArrivalAt") LocalDateTime estimatedArrivalAt,
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status='CHANGE_PENDING',pending_vessel_name=#{vesselName},
pending_voyage_no=#{voyageNo},pending_estimated_sailing_at=#{estimatedSailingAt},
pending_estimated_arrival_at=#{estimatedArrivalAt},change_reason=#{reason},
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status='CONFIRMED' AND version=#{expectedVersion}
""")
int requestChange(@Param("oceanBookingId") long oceanBookingId, @Param("vesselName") String vesselName,
@Param("voyageNo") String voyageNo, @Param("estimatedSailingAt") LocalDateTime estimatedSailingAt,
@Param("estimatedArrivalAt") LocalDateTime estimatedArrivalAt, @Param("reason") String reason,
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status='CONFIRMED',vessel_name=pending_vessel_name,voyage_no=pending_voyage_no,
estimated_sailing_at=pending_estimated_sailing_at,estimated_arrival_at=pending_estimated_arrival_at,
pending_vessel_name=NULL,pending_voyage_no=NULL,pending_estimated_sailing_at=NULL,
pending_estimated_arrival_at=NULL,updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status='CHANGE_PENDING' AND version=#{expectedVersion}
""")
int confirmChange(@Param("oceanBookingId") long oceanBookingId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -47,15 +47,6 @@ public class OceanBookingRepository {
histories.delete(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id));
return bookings.deleteDraft(id, version);
}
public int requestChange(long id, OceanBookingEntity pending, String reason, int version,
long operatorId, String operatorName) {
return bookings.requestChange(id, pending.getPendingVesselName(), pending.getPendingVoyageNo(),
pending.getPendingEstimatedSailingAt(), pending.getPendingEstimatedArrivalAt(), reason,
version, operatorId, operatorName);
}
public int confirmChange(long id, int version, long operatorId, String operatorName) {
return bookings.confirmChange(id, version, operatorId, operatorName);
}
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return requirements.selectList(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); }
public List<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); }
@@ -8,7 +8,7 @@ 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.FirstLegLoadingPlanCompletedV1;
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
@@ -33,7 +33,7 @@ import java.util.Map;
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
@Service
public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 1L;
private static final long HANDOVER_VERSION = 2L;
@Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper;
@@ -50,32 +50,33 @@ public class LoadingCompletedOutboxService {
Row first = rows.get(0);
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV1.Order> orders = byInbound.values().stream()
List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
.map(this::order).toList();
var payload = new FirstLegLoadingPlanCompletedV1(String.valueOf(first.getLoadingPlanId()),
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), orders);
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, 1,
return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 2,
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 FirstLegLoadingPlanCompletedV1.Order order(List<Row> values) {
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
List<FirstLegLoadingPlanCompletedV1.Item> items = sorted.stream().map(row ->
new FirstLegLoadingPlanCompletedV1.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(),
List<FirstLegLoadingPlanCompletedV2.Item> items = sorted.stream().map(row ->
new FirstLegLoadingPlanCompletedV2.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(),
row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(),
row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(),
row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(),
row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark()))
row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark()))
.toList();
return new FirstLegLoadingPlanCompletedV1.Order(string(first.getSourceInboundId()),
return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()),
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
@@ -405,6 +405,18 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark:
patch:
summary: 修改装柜明细仓库备注
description: EDITING/LOADING 可修改或清空 warehouseRemark;COMPLETED、计划/明细版本冲突返回 A0409,写入历史且与 PDA final complete 使用同一并发门禁。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:
post:
summary: 推送智能装柜计划到预装清单
@@ -1531,6 +1543,14 @@ components:
expectedPlanVersion: {type: integer, minimum: 0}
expectedItemVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingWarehouseRemarkRequest:
type: object
required: [expectedPlanVersion, expectedItemVersion, operationId]
properties:
warehouseRemark: {type: string, nullable: true, maxLength: 1000, description: 仓库备注;null/空字符串均表示清空}
expectedPlanVersion: {type: integer, minimum: 0}
expectedItemVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingPlan:
type: object
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
@@ -1562,6 +1582,9 @@ components:
orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
customerName: {type: string, nullable: true}
customerRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单 customerRemark}
internalRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单最新内部备注,不使用 cargoRemark}
warehouseRemark: {type: string, nullable: true, description: 仓库人员逐条维护,可在 EDITING/LOADING 修改}
packageCount: {type: integer, minimum: 1}
version: {type: integer, minimum: 0, description: itemVersion,调整/删除写请求使用此值}
pickedPackageCount: {type: integer, minimum: 0}
@@ -23,6 +23,7 @@ class PlatformSmartLoadingControllerContractTest {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("warehouseRemark","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
}
@@ -42,6 +43,8 @@ class PlatformSmartLoadingControllerContractTest {
.contains("SmartLoadingAssignRequest:")
.contains("SmartLoadingAdjustRequest:")
.contains("SmartLoadingDeleteRequest:")
.contains("SmartLoadingWarehouseRemarkRequest:")
.contains("/items/{itemId}/warehouse-remark:")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
@@ -46,7 +46,8 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().get("orders")).hasSize(2);
assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100");
assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1");
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2);
}
@Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception {
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
@@ -237,7 +238,7 @@ class SmartLoadingServiceTest {
when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10,
new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name",
4, 100, new BigDecimal("10"), "货物备注")));
4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注")));
doAnswer(invocation -> {
Object value = invocation.getArgument(0);
if (value instanceof LoadingPlanItemEntity item) item.setId(88L);
@@ -255,7 +256,9 @@ class SmartLoadingServiceTest {
assertThat(saved.getUnitsPerCarton()).isEqualTo(4);
assertThat(saved.getProductQuantity()).isEqualTo(20);
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5");
assertThat(saved.getInternalRemark()).isEqualTo("货物备注");
assertThat(saved.getCustomerRemark()).isEqualTo("客户备注");
assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注");
assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注");
ArgumentCaptor<LoadingPlanHistoryEntity> historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(historyCaptor.capture());
@@ -378,6 +381,29 @@ class SmartLoadingServiceTest {
verify(repository).touch(1, 2, 7, "u");
}
@Test
void updatesAndClearsWarehouseRemarkWithBothVersions() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
line.setWarehouseRemark("旧仓库备注");
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u")).thenReturn(1);
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest(null, 2, 0, "warehouse-clear"), 7L, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(loadingPickService).lockMutable(1L);
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u");
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getActionType()).isEqualTo("WAREHOUSE_REMARK");
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
}
@Test
void preservesDownstreamCode() {
stubAssignment();