fix: complete order measurement downstream flow

This commit is contained in:
2026-09-18 14:58:40 +08:00
parent 02d2474141
commit c8a99f9fff
43 changed files with 621 additions and 52 deletions
@@ -5,6 +5,8 @@ import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import java.math.BigDecimal;
import java.util.List;
@@ -36,10 +38,21 @@ public interface OrderCargoClient {
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long orderId);
@PutMapping("/{tenantId}/{orderId}/remarks")
ApiResponse<RemarksUpdateResponse> updateRemarks(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long orderId,
@RequestBody RemarksUpdateRequest request);
record MeasurementDeclarationSnapshot(
String referenceId, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String defaultActualGoodsName) {}
record RemarksUpdateRequest(String internalRemark, String warehouseRemark,
String customerServiceRemark, long operatorId) {}
record RemarksUpdateResponse(int version, String customerRemark, String internalRemark,
String warehouseRemark, String customerServiceRemark) {}
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
@@ -12,7 +12,9 @@ import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.service.InboundCartonLabelService;
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
import com.baiyue.firstleg.service.OrderDeclarationSyncService;
import com.baiyue.firstleg.service.OrderRemarksSyncService;
import com.baiyue.firstleg.dto.OrderDeclarationSync;
import com.baiyue.firstleg.dto.OrderRemarksSync;
import com.baiyue.firstleg.dto.UnifiedInboundDetail;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.dto.UnifiedInboundRequest;
@@ -43,6 +45,8 @@ public class InternalInboundOrderController {
private UnifiedInboundQueryService unifiedQueryService;
@Resource
private OrderDeclarationSyncService declarationSync;
@Resource
private OrderRemarksSyncService remarksSync;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private byte[] expectedToken;
@@ -127,6 +131,14 @@ public class InternalInboundOrderController {
return ApiResponse.success(null);
}
@PostMapping("/order-remarks-updated")
public ApiResponse<Integer> updateOrderRemarks(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody OrderRemarksSync.Command command) {
requireToken(token);
return ApiResponse.success(remarksSync.sync(command));
}
@PostMapping("/unified-orders/query")
public ApiResponse<PageResult<UnifiedInboundSummary>> unifiedPage(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
public final class OrderRemarksSync {
private OrderRemarksSync() {
}
public record Command(
@Positive long tenantId,
@Positive long orderId,
@Size(max = 1000) String customerRemark,
@Size(max = 1000) String internalRemark,
@Size(max = 1000) String warehouseRemark,
@Size(max = 1000) String customerServiceRemark,
@NotNull @Positive Long operatorId,
@Size(max = 128) String operatorName
) {
}
}
@@ -1,5 +1,5 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import org.apache.ibatis.annotations.*;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import org.apache.ibatis.annotations.*; import java.util.List;
@Mapper public interface LoadingPlanItemMapper extends BaseMapper<LoadingPlanItemEntity> {
@Update("""
UPDATE fl_loading_plan_item SET deleted=1,removed_by=#{operatorId},removed_by_name=#{operatorName},
@@ -30,4 +30,69 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
@Param("customerServiceRemark") String customerServiceRemark,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("""
SELECT DISTINCT i.loading_plan_id
FROM fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status IN ('EDITING','LOADING')
WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL
AND (#{excludedItemId} IS NULL OR i.id&lt;&gt;#{excludedItemId})
AND NOT (i.customer_remark <=> #{customerRemark}
AND i.internal_remark <=> #{internalRemark}
AND i.warehouse_remark <=> #{warehouseRemark}
AND i.customer_service_remark <=> #{customerServiceRemark})
""")
List<Long> findMutablePlanIdsWithDifferentRemarks(@Param("tenantId") long tenantId,
@Param("orderId") long orderId,
@Param("customerRemark") String customerRemark,
@Param("internalRemark") String internalRemark,
@Param("warehouseRemark") String warehouseRemark,
@Param("customerServiceRemark") String customerServiceRemark,
@Param("excludedItemId") Long excludedItemId);
@Update("""
UPDATE fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status IN ('EDITING','LOADING')
SET i.customer_remark=#{customerRemark}, i.internal_remark=#{internalRemark}, i.warehouse_remark=#{warehouseRemark},
i.customer_service_remark=#{customerServiceRemark}, i.updated_by=#{operatorId},
i.updated_by_name=#{operatorName}, i.updated_at=CURRENT_TIMESTAMP(3), i.version=i.version+1
WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL
AND (#{excludedItemId} IS NULL OR i.id&lt;&gt;#{excludedItemId})
AND NOT (i.customer_remark <=> #{customerRemark}
AND i.internal_remark <=> #{internalRemark}
AND i.warehouse_remark <=> #{warehouseRemark}
AND i.customer_service_remark <=> #{customerServiceRemark})
""")
int syncMutableOrderRemarks(@Param("tenantId") long tenantId, @Param("orderId") long orderId,
@Param("customerRemark") String customerRemark,
@Param("internalRemark") String internalRemark,
@Param("warehouseRemark") String warehouseRemark,
@Param("customerServiceRemark") String customerServiceRemark,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName,
@Param("excludedItemId") Long excludedItemId);
@Select("""
SELECT DISTINCT i.loading_plan_id
FROM fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status IN ('EDITING','LOADING')
WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL
AND NOT (i.customer_remark <=> #{customerRemark})
""")
List<Long> findMutablePlanIdsWithDifferentCustomerRemark(@Param("tenantId") long tenantId,
@Param("orderId") long orderId,
@Param("customerRemark") String customerRemark);
@Update("""
UPDATE fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status IN ('EDITING','LOADING')
SET i.customer_remark=#{customerRemark}, i.updated_by=#{operatorId}, i.updated_by_name=#{operatorName},
i.updated_at=CURRENT_TIMESTAMP(3), i.version=i.version+1
WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL
AND NOT (i.customer_remark <=> #{customerRemark})
""")
int syncMutableOrderCustomerRemark(@Param("tenantId") long tenantId, @Param("orderId") long orderId,
@Param("customerRemark") String customerRemark,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -1,5 +1,5 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanEntity; import org.apache.ibatis.annotations.*;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanEntity; import org.apache.ibatis.annotations.*; import java.util.List;
@Mapper public interface LoadingPlanMapper extends BaseMapper<LoadingPlanEntity> {
@Update("""
UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},
@@ -13,4 +13,16 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
WHERE id=#{planId} AND plan_status='EDITING'
""")
int startPicking(@Param("planId") long planId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
<script>
UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE plan_status IN ('EDITING','LOADING') AND id IN
<foreach collection="planIds" item="planId" open="(" separator="," close=")">#{planId}</foreach>
</script>
""")
int touchForOrderRemarks(@Param("planIds") List<Long> planIds,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -25,6 +25,7 @@ import java.util.Objects;
public class OrderDeclarationSyncService {
@Resource private OrderDeclarationSyncMapper mapper;
@Resource private ObjectMapper json;
@Resource private OrderRemarksSyncService remarksSync;
@Transactional(readOnly = true)
public ChannelChangeValidationResponse validateChannelChange(ChannelChangeValidationRequest request) {
@@ -86,6 +87,8 @@ public class OrderDeclarationSyncService {
normalizeChannel(event.channelCode()), event.channelName(), event.operatorId()) != 1) {
throw conflict("Firstleg 入库申报快照已发生变化");
}
remarksSync.syncCustomerRemark(event.tenantId(), event.orderId(), event.customerRemark(),
event.operatorId(), "order-declaration");
}
private void recordEvent(UpdatedEvent event, String hash) {
@@ -0,0 +1,57 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.OrderRemarksSync;
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class OrderRemarksSyncService {
@Resource private LoadingPlanItemMapper items;
@Resource private LoadingPlanMapper plans;
@Transactional
public int sync(OrderRemarksSync.Command command) {
return sync(command, null, null);
}
@Transactional
public int sync(OrderRemarksSync.Command command, Long alreadyTouchedPlanId) {
return sync(command, alreadyTouchedPlanId, null);
}
@Transactional
public int sync(OrderRemarksSync.Command command, Long alreadyTouchedPlanId, Long excludedItemId) {
List<Long> affectedPlanIds = items.findMutablePlanIdsWithDifferentRemarks(
command.tenantId(), command.orderId(), command.customerRemark(), command.internalRemark(), command.warehouseRemark(),
command.customerServiceRemark(), excludedItemId);
if (affectedPlanIds.isEmpty()) return 0;
int updated = items.syncMutableOrderRemarks(command.tenantId(), command.orderId(),
command.customerRemark(), command.internalRemark(), command.warehouseRemark(), command.customerServiceRemark(),
command.operatorId(), command.operatorName(), excludedItemId);
if (updated > 0) {
List<Long> plansToTouch = affectedPlanIds.stream()
.filter(planId -> !planId.equals(alreadyTouchedPlanId)).toList();
if (!plansToTouch.isEmpty()) {
plans.touchForOrderRemarks(plansToTouch, command.operatorId(), command.operatorName());
}
}
return updated;
}
@Transactional
public int syncCustomerRemark(long tenantId, long orderId, String customerRemark,
long operatorId, String operatorName) {
List<Long> affectedPlanIds = items.findMutablePlanIdsWithDifferentCustomerRemark(
tenantId, orderId, customerRemark);
if (affectedPlanIds.isEmpty()) return 0;
int updated = items.syncMutableOrderCustomerRemark(
tenantId, orderId, customerRemark, operatorId, operatorName);
if (updated > 0) plans.touchForOrderRemarks(affectedPlanIds, operatorId, operatorName);
return updated;
}
}
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import com.baiyue.firstleg.dto.OrderRemarksSync;
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
@@ -56,6 +57,7 @@ public class SmartLoadingService {
@Resource private FbaPricingService fbaPricingService;
@Resource private PriceBookQueryUseCase priceBooks;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private OrderRemarksSyncService orderRemarksSync;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { }
@@ -1099,6 +1101,9 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (item.getVersion() != request.expectedItemVersion()) {
throw conflict("装柜计划或明细已被其他人修改,请刷新后重试");
}
if (Objects.equals(item.getInternalRemark(), internalRemark)
&& Objects.equals(item.getWarehouseRemark(), warehouseRemark)
&& Objects.equals(item.getCustomerServiceRemark(), customerServiceRemark)) {
@@ -1109,16 +1114,30 @@ public class SmartLoadingService {
"version", item.getVersion()));
try {
touch(plan, operatorId, operatorName);
ApiResponse<OrderCargoClient.RemarksUpdateResponse> response = orderCargoClient.updateRemarks(
internalToken, item.getAccountId(), item.getOrderId(),
new OrderCargoClient.RemarksUpdateRequest(internalRemark, warehouseRemark,
customerServiceRemark, operatorId));
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
response == null ? "订单备注服务暂不可用" : response.message());
}
if (repository.updateRemarks(planId, itemId, request.expectedPlanVersion() + 1,
request.expectedItemVersion(), internalRemark, warehouseRemark, customerServiceRemark,
operatorId, operatorName) != 1) {
request.expectedItemVersion(), response.data().internalRemark(), response.data().warehouseRemark(),
response.data().customerServiceRemark(), operatorId, operatorName) != 1) {
throw conflict("装柜计划或明细已被其他人修改,请刷新后重试");
}
orderRemarksSync.sync(new OrderRemarksSync.Command(item.getAccountId(), item.getOrderId(),
response.data().customerRemark(),
response.data().internalRemark(), response.data().warehouseRemark(),
response.data().customerServiceRemark(), operatorId, operatorName), planId, itemId);
} catch (DataAccessException exception) {
if (hasSqlState(exception, "45000")) {
throw conflict("装柜计划或明细状态已变化,备注未保存,请刷新后重试");
}
throw exception;
} catch (FeignException exception) {
throw unavailable();
}
history(planId, itemId, "REMARKS", plan.getPlanStatus(), plan.getPlanStatus(), operation,
before, json(values("internalRemark", internalRemark, "warehouseRemark", warehouseRemark,
@@ -292,6 +292,32 @@ paths:
schema: {$ref: '#/components/schemas/OrderDeclarationUpdatedEvent'}
responses:
'200': {description: 成功、幂等重放或旧版本已忽略;冲突时响应体 code=A0409}
/internal/firstleg/v1/inbounds/order-remarks-updated:
post:
summary: 同步 Order 权威协作备注到未冻结预装明细
description: >-
仅接受内部服务令牌。按客户与订单关联全部 EDITING/LOADING 且未冻结的预装明细,
同步内部、仓库和客服备注并递增受影响计划与明细版本;已完成装柜的冻结快照不修改。
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [tenantId, orderId, operatorId]
properties:
tenantId: {type: integer, format: int64, minimum: 1}
orderId: {type: integer, format: int64, minimum: 1}
customerRemark: {type: string, nullable: true, maxLength: 1000}
internalRemark: {type: string, nullable: true, maxLength: 1000}
warehouseRemark: {type: string, nullable: true, maxLength: 1000}
customerServiceRemark: {type: string, nullable: true, maxLength: 1000}
operatorId: {type: integer, format: int64, minimum: 1}
operatorName: {type: string, nullable: true, maxLength: 128}
responses:
'200': {description: data 返回已同步的预装明细数量}
/internal/firstleg/v1/inbounds/unified-orders/query:
post:
summary: 按统一订单条件分页查询履约摘要(Order 内部)
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.service.InboundCartonLabelService;
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
import com.baiyue.firstleg.service.OrderDeclarationSyncService;
import com.baiyue.firstleg.service.OrderRemarksSyncService;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.dto.OrderDeclarationSync;
import com.baiyue.common.core.api.PageResult;
@@ -36,6 +37,8 @@ class InternalInboundOrderReferenceControllerTest {
private UnifiedInboundQueryService unifiedQueryService;
@MockBean
private OrderDeclarationSyncService declarationSync;
@MockBean
private OrderRemarksSyncService remarksSync;
@Resource
private InternalInboundOrderController controller;
@@ -35,6 +35,7 @@ class OrderDeclarationSyncServiceTest {
}
@MockBean private OrderDeclarationSyncMapper mapper;
@MockBean private OrderRemarksSyncService remarksSync;
@Resource private OrderDeclarationSyncService service;
@Resource private ObjectMapper json;
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.OrderRemarksSync;
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(OrderRemarksSyncService.class)
class OrderRemarksSyncServiceTest {
@MockBean private LoadingPlanItemMapper items;
@MockBean private LoadingPlanMapper plans;
@jakarta.annotation.Resource private OrderRemarksSyncService service;
@Test
void updatesAllMutableOrderItemsAndTouchesAffectedPlans() {
var command = new OrderRemarksSync.Command(10L, 20L, "客户", "内部", "仓库", "客服", 30L, "操作人");
when(items.findMutablePlanIdsWithDifferentRemarks(
10L, 20L, "客户", "内部", "仓库", "客服", null)).thenReturn(List.of(101L, 102L));
when(items.syncMutableOrderRemarks(
10L, 20L, "客户", "内部", "仓库", "客服", 30L, "操作人", null)).thenReturn(3);
assertThat(service.sync(command)).isEqualTo(3);
verify(plans).touchForOrderRemarks(List.of(101L, 102L), 30L, "操作人");
}
@Test
void leavesFrozenOrAlreadyMatchingPlansUntouched() {
var command = new OrderRemarksSync.Command(10L, 20L, null, null, null, null, 30L, null);
when(items.findMutablePlanIdsWithDifferentRemarks(
10L, 20L, null, null, null, null, null)).thenReturn(List.of());
assertThat(service.sync(command)).isZero();
verify(items, never()).syncMutableOrderRemarks(
10L, 20L, null, null, null, null, 30L, null, null);
verify(plans, never()).touchForOrderRemarks(org.mockito.ArgumentMatchers.anyList(),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.nullable(String.class));
}
}
@@ -102,6 +102,7 @@ class SmartLoadingServiceTest {
@MockBean private PriceBookQueryUseCase priceBooks;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private DropshipPalletizationMapper dropshipPallets;
@MockBean private OrderRemarksSyncService orderRemarksSync;
@Resource private SmartLoadingService service;
@TestConfiguration
@@ -119,6 +120,12 @@ class SmartLoadingServiceTest {
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
new BigDecimal("10"), new BigDecimal("2"))));
when(cargoClient.updateRemarks(anyString(), anyLong(), anyLong(), any()))
.thenAnswer(invocation -> {
OrderCargoClient.RemarksUpdateRequest request = invocation.getArgument(3);
return ApiResponse.success(new OrderCargoClient.RemarksUpdateResponse(2, null,
request.internalRemark(), request.warehouseRemark(), request.customerServiceRemark()));
});
when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString()))
.thenReturn(new FbaPricingService.ContainerFeeRecalculationResult(
FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0));
@@ -1955,6 +1962,7 @@ class SmartLoadingServiceTest {
private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) {
LoadingPlanItemEntity value = new LoadingPlanItemEntity();
value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account);
value.setOrderId(30L); value.setOrderNo("O30");
value.setInboundId(10L); value.setInboundDetailId(detail); value.setPackageCount(boxes);
value.setVersion(0); value.setDeleted(false);
return value;