This commit is contained in:
2026-09-01 21:09:33 +08:00
parent 47723c7c0f
commit e82cefe493
25 changed files with 712 additions and 40 deletions
@@ -1,5 +1,5 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record ChannelSummary(String channelCode, String channelName,
long candidateInboundCount, long availablePackageCount, long availableContainerCount) {}
@@ -9,12 +9,12 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason) {}
Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason) {}
Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record AssignInboundRequest(@NotNull @Positive Long inboundId,
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
@@ -64,6 +65,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
@@ -113,7 +113,8 @@ public class SmartLoadingService {
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(),
first.groupTotalBoxes(), first.locked(), first.blockReason());
first.groupTotalBoxes(), first.locked(), first.blockReason(), first.orderStatusSnapshot(),
first.expectedArrivalAt());
}
public Plan draftByBooking(long bookingId) {
@@ -563,7 +564,8 @@ public class SmartLoadingService {
candidate.customerName(), candidate.goodsName(), candidate.availablePackageCount(),
candidate.unitGrossWeightKg(), candidate.unitNetWeightKg(), candidate.unitVolumeCbm(),
group.groupId(), group.groupNo(), group.members().size(), group.fullPackageCount(),
group.lockedPlanId() != null, blocks.get(group.groupId()));
group.lockedPlanId() != null, blocks.get(group.groupId()), candidate.orderStatusSnapshot(),
candidate.expectedArrivalAt());
}).toList();
}
@@ -409,6 +409,7 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:list 权限。每个入库单/入仓码只返回一行;
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
响应携带订单状态快照 orderStatusSnapshot 与预计到货时间 expectedArrivalAt,供平台筛选候选入仓码;
这些数据用于数量级计划,不表示具体箱号已核验归属。
parameters:
- {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -13,7 +13,7 @@ class SmartLoadingCandidateMapperContractTest {
@Test
void candidateHasOnlyTheCanonicalMybatisConstructor() {
assertThat(Candidate.class.getDeclaredConstructors()).singleElement()
.satisfies(constructor -> assertThat(constructor.getParameterCount()).isEqualTo(24));
.satisfies(constructor -> assertThat(constructor.getParameterCount()).isEqualTo(26));
}
@Test
@@ -23,6 +23,8 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(sql)
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("o.order_status_snapshot orderStatusSnapshot")
.contains("o.expected_arrival_at expectedArrivalAt")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("m2.status='APPROVED'")
@@ -51,6 +53,8 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(sql)
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("o.order_status_snapshot orderStatusSnapshot")
.contains("o.expected_arrival_at expectedArrivalAt")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
@@ -122,11 +122,11 @@ class SmartLoadingServiceTest {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "服装", 3,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"),
null, null, null, null, null, null);
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"),
null, null, null, null, null, null);
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidates(100L)).thenReturn(List.of(first, second));
@@ -139,6 +139,8 @@ class SmartLoadingServiceTest {
assertThat(candidate.averageUnitVolumeCbm()).isEqualByComparingTo("0.410000");
assertThat(candidate.availableVolumeCbm()).isEqualByComparingTo("4.1000");
assertThat(candidate.unitGrossWeightKg()).isEqualByComparingTo("2");
assertThat(candidate.orderStatusSnapshot()).isEqualTo("WAIT_SPLIT");
assertThat(candidate.expectedArrivalAt()).isEqualTo(LocalDateTime.parse("2026-09-10T09:30:00"));
});
}
@@ -440,15 +442,15 @@ class SmartLoadingServiceTest {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "A", 30,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"),
null, null, null, null, null, null);
null, null, null, null, null, null, null, null);
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "B", 40,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"),
null, null, null, null, null, null);
null, null, null, null, null, null, null, null);
Candidate third = new Candidate(9L, 10L, 22L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "C", 30,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.8"),
null, null, null, null, null, null);
null, null, null, null, null, null, null, null);
var result = service.proportionalAllocations(List.of(first, second, third), 60);
@@ -1109,7 +1111,7 @@ class SmartLoadingServiceTest {
return new Candidate(tenantId, inboundId, detailId, orderId, "O" + orderId, "I" + inboundId,
100L, "FBA", "FBA渠道", 200L, destinationCode, "FBA仓", "客户", "货物", boxes,
new BigDecimal("2"), new BigDecimal("1.5"),
new BigDecimal("0.5"), null, null, null, null, null, null);
new BigDecimal("0.5"), null, null, null, null, null, null, null, null);
}
private static Candidate withChannel(Candidate value, String channelCode, String channelName) {
@@ -1118,7 +1120,8 @@ class SmartLoadingServiceTest {
value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(),
value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(),
value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(),
value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason());
value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason(),
value.orderStatusSnapshot(), value.expectedArrivalAt());
}
private static ChannelRow channel(String channelCode, String channelName) {
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.order.dto.OrderDeclarationDtos;
/** 订单审核事件投递到头程入库域的内部客户端。 */
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegInboundClient",
@@ -31,6 +32,10 @@ public interface FirstlegInboundClient {
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody InboundReferenceRequest request);
@PostMapping("/order-declaration-updated")
ApiResponse<Void> updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderDeclarationDtos.UpdatedEvent event);
record InboundReferenceRequest(long tenantId, List<Long> orderIds) {
}
@@ -7,7 +7,9 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderDeclarationUpdateService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.UnifiedOrderQueryService;
import com.baiyue.order.service.OrderFulfillmentSplitService;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;
import java.util.List;
/** Platform product-level order query. Legacy first-leg paths remain available. */
@@ -35,6 +38,7 @@ public class PlatformUnifiedOrderController {
@Resource private UnifiedOrderQueryService service;
@Resource private OrderApplicationService orderService;
@Resource private OrderFulfillmentSplitService splitService;
@Resource private OrderDeclarationUpdateService declarationUpdates;
@GetMapping
@PreAuthorize("hasAuthority('platform:order:list')")
@@ -58,6 +62,22 @@ public class PlatformUnifiedOrderController {
return ApiResponse.success(service.platformDetail(OrderScopeGuard.platform(user, tenantId), orderId));
}
@PutMapping("/{orderId}/declaration")
@PreAuthorize("hasAuthority('platform:order:declaration:edit')")
@BusinessAudit(event = "ORDER_DECLARATION_UPDATE", resourceType = "FIRST_LEG_ORDER",
resourceId = "#orderId", tenantId = "#tenantId", description = "平台修改客户原始申报资料")
@Operation(summary = "版本化修改客户原始申报资料",
description = "只修改客户申报快照;不修改客户、收货仓、入仓码、实测、PDA、审核或计费事实。")
public ApiResponse<OrderDeclarationDtos.UpdateResponse> updateDeclaration(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long orderId,
@Valid @RequestBody OrderDeclarationDtos.UpdateRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
return ApiResponse.success(declarationUpdates.update(scopedTenant, orderId, user.userId(),
user.username(), request));
}
@GetMapping("/{orderId}/fulfillment-tree")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "父子订单与柜次数量摘要")
@@ -0,0 +1,69 @@
package com.baiyue.order.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 平台修改客户原始申报资料的独立版本化契约。 */
public final class OrderDeclarationDtos {
private OrderDeclarationDtos() {}
public record UpdateRequest(
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String customerRemark,
LocalDateTime expectedArrivalAt,
@Size(max = 64) String channelCode,
@Size(max = 128) String channelName,
@NotEmpty @Size(max = 500) List<@Valid Cargo> cargoes
) {}
/** itemNo 是订单内稳定业务行号;不以货物数据库 ID 作为编辑契约。 */
public record Cargo(
@NotNull @Min(1) @Max(500) Integer itemNo,
@NotBlank @Size(max = 128) String cargoName,
@Size(max = 128) String cargoNameEn,
@Size(max = 64) String skuNo,
@Size(max = 64) String model,
@NotNull @Min(1) Integer packageCount,
@NotNull @Min(1) Integer totalQuantity,
@NotNull @DecimalMin("0.01") BigDecimal lengthCm,
@NotNull @DecimalMin("0.01") BigDecimal widthCm,
@NotNull @DecimalMin("0.01") BigDecimal heightCm,
@NotNull @DecimalMin("0.001") BigDecimal grossWeightKg,
@NotNull @DecimalMin("0.0001") BigDecimal volumeCbm,
@DecimalMin("0.001") BigDecimal netWeightKg,
@Size(max = 20) String hsCode,
@Size(max = 20) String customsHsCode,
@Min(1) Integer unitsPerPackage,
@DecimalMin("0.00") BigDecimal unitDeclaredValue,
@DecimalMin("0.00") BigDecimal declaredValue,
@Pattern(regexp = "[A-Z]{3}") String currency,
@Size(max = 128) String material,
@Size(max = 128) String usage,
@Size(max = 64) String brand,
@Size(max = 500) String cargoRemark
) {}
public record UpdateResponse(String orderNo, String inboundCode, int previousVersion, int version,
boolean channelChangedBefore, boolean channelChanged,
List<FieldChange> changes) {}
public record FieldChange(Integer itemNo, String field, Object beforeValue, Object afterValue) {}
/** Order -> Firstleg 版本化 Outbox 快照;消费者须按 eventId 幂等并按 version 顺序应用。 */
public record UpdatedEvent(int schemaVersion, String eventId, long tenantId, long orderId,
String orderNo, String inboundCode, int version, long operatorId,
LocalDateTime occurredAt, String customerRemark,
LocalDateTime expectedArrivalAt, String channelCode, String channelName,
boolean channelChanged, List<Cargo> cargoes, List<FieldChange> changes) {}
}
@@ -15,12 +15,12 @@ public final class UnifiedOrderResponse {
Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction,
OrderRelationSummary relation,
OrderStatus internalOrderStatus,
Long customerId, String customerName, String channelCode, String channelName,
Long customerId, String customerName, String channelCode, String channelName, Boolean channelChanged,
Long receivingWarehouseId, String receivingWarehouseName,
Long destinationWarehouseId, String destinationWarehouseName,
Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate,
Integer totalPackageCount, String recommendedPalletType, String locationCodes,
BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
BigDecimal expectedVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
LocalDateTime expectedArrivalAt, String feeReviewStatus,
String companyName, String businessType, String pickupMethod,
String taxMode, String containerMode, Boolean customsInspection,
@@ -31,6 +31,7 @@ public class OrderEntity extends BaseEntity {
private Long destinationWarehouseId;
private String destinationWarehouseName;
private LocalDate expectedShipDate;
private LocalDateTime expectedArrivalAt;
private String customerRemark;
private String pickupMethod;
private LocalDateTime requestedPickupAt;
@@ -40,6 +41,7 @@ public class OrderEntity extends BaseEntity {
private String receivingTime;
private String channelCode;
private String channelName;
private Boolean channelChanged;
private Long carrierId;
private String carrierName;
private String taxMode;
@@ -54,6 +54,56 @@ public class OrderRepository {
.eq(OrderEntity::getId, order.getId())
.eq(OrderEntity::getTenantId, tenantId));
}
public int updateDeclarationHeader(long tenantId, long orderId, int expectedVersion, long operatorId,
String customerRemark, LocalDateTime expectedArrivalAt,
String channelCode, String channelName, boolean channelChanged) {
return orderMapper.update(null, new LambdaUpdateWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId)
.eq(OrderEntity::getId, orderId)
.eq(OrderEntity::getVersion, expectedVersion)
.eq(OrderEntity::getDeleted, false)
.set(OrderEntity::getCustomerRemark, customerRemark)
.set(OrderEntity::getExpectedArrivalAt, expectedArrivalAt)
.set(OrderEntity::getChannelCode, channelCode)
.set(OrderEntity::getChannelName, channelName)
.set(OrderEntity::getChannelChanged, channelChanged)
.set(OrderEntity::getUpdatedBy, operatorId)
.set(OrderEntity::getUpdatedAt, LocalDateTime.now())
.setSql("version = version + 1"));
}
public int updateDeclarationCargo(long tenantId, long orderId, OrderCargoEntity cargo) {
return cargoMapper.update(null, new LambdaUpdateWrapper<OrderCargoEntity>()
.eq(OrderCargoEntity::getTenantId, tenantId)
.eq(OrderCargoEntity::getOrderId, orderId)
.eq(OrderCargoEntity::getId, cargo.getId())
.eq(OrderCargoEntity::getVersion, cargo.getVersion())
.eq(OrderCargoEntity::getDeleted, false)
.set(OrderCargoEntity::getCargoName, cargo.getCargoName())
.set(OrderCargoEntity::getCargoNameEn, cargo.getCargoNameEn())
.set(OrderCargoEntity::getSkuNo, cargo.getSkuNo())
.set(OrderCargoEntity::getModel, cargo.getModel())
.set(OrderCargoEntity::getPackageCount, cargo.getPackageCount())
.set(OrderCargoEntity::getTotalQuantity, cargo.getTotalQuantity())
.set(OrderCargoEntity::getLengthCm, cargo.getLengthCm())
.set(OrderCargoEntity::getWidthCm, cargo.getWidthCm())
.set(OrderCargoEntity::getHeightCm, cargo.getHeightCm())
.set(OrderCargoEntity::getGrossWeightKg, cargo.getGrossWeightKg())
.set(OrderCargoEntity::getVolumeCbm, cargo.getVolumeCbm())
.set(OrderCargoEntity::getNetWeightKg, cargo.getNetWeightKg())
.set(OrderCargoEntity::getHsCode, cargo.getHsCode())
.set(OrderCargoEntity::getCustomsHsCode, cargo.getCustomsHsCode())
.set(OrderCargoEntity::getUnitsPerPackage, cargo.getUnitsPerPackage())
.set(OrderCargoEntity::getUnitDeclaredValue, cargo.getUnitDeclaredValue())
.set(OrderCargoEntity::getDeclaredValue, cargo.getDeclaredValue())
.set(OrderCargoEntity::getCurrency, cargo.getCurrency())
.set(OrderCargoEntity::getMaterial, cargo.getMaterial())
.set(OrderCargoEntity::getProductUsage, cargo.getProductUsage())
.set(OrderCargoEntity::getBrand, cargo.getBrand())
.set(OrderCargoEntity::getCargoRemark, cargo.getCargoRemark())
.set(OrderCargoEntity::getUpdatedBy, cargo.getUpdatedBy())
.set(OrderCargoEntity::getUpdatedAt, cargo.getUpdatedAt())
.setSql("version = version + 1"));
}
public void insertCargo(OrderCargoEntity cargo) { cargoMapper.insert(cargo); }
public void insertParty(OrderPartyEntity party) { partyMapper.insert(party); }
public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); }
@@ -49,9 +49,12 @@ public class DomesticWarehouseOrderExportProviderService {
column("mainStatus", "订单主状态", ExportColumnType.STRING, 16),
column("customerName", "客户", ExportColumnType.STRING, 24),
column("channelName", "渠道", ExportColumnType.STRING, 20),
column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN, 12),
column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING, 20),
column("destinationWarehouseName", "交货仓", ExportColumnType.STRING, 20),
column("expectedShipDate", "预计出运日期", ExportColumnType.DATE, 14),
column("expectedArrivalAt", "预计到货时间", ExportColumnType.DATETIME, 21),
column("expectedVolumeCbm", "预计体积(m³)", ExportColumnType.DECIMAL, 16),
column("totalPackageCount", "箱数", ExportColumnType.INTEGER, 12),
column("totalQuantity", "产品数量", ExportColumnType.INTEGER, 12),
column("actualGrossWeightKg", "实测毛重(kg)", ExportColumnType.DECIMAL, 16),
@@ -124,8 +127,9 @@ public class DomesticWarehouseOrderExportProviderService {
Map<String, Object> row = new LinkedHashMap<>();
row.put("inboundCode", value.inboundCode()); row.put("orderNo", value.orderNo());
row.put("mainStatus", value.mainStatus()); row.put("customerName", value.customerName());
row.put("channelName", value.channelName()); row.put("receivingWarehouseName", value.receivingWarehouseName());
row.put("channelName", value.channelName()); row.put("channelChanged", value.channelChanged()); row.put("receivingWarehouseName", value.receivingWarehouseName());
row.put("destinationWarehouseName", value.destinationWarehouseName()); row.put("expectedShipDate", value.expectedShipDate());
row.put("expectedArrivalAt", value.expectedArrivalAt()); row.put("expectedVolumeCbm", value.expectedVolumeCbm());
row.put("totalPackageCount", value.totalPackageCount()); row.put("totalQuantity", value.totalQuantity());
row.put("actualGrossWeightKg", value.actualGrossWeightKg()); row.put("actualVolumeCbm", value.actualVolumeCbm());
row.put("chargeableWeightKg", value.chargeableWeightKg()); row.put("chargeableVolumeCbm", value.chargeableVolumeCbm());
@@ -466,7 +466,9 @@ public class OrderApplicationService {
? addresses.privateDestination().getAddressName()
: snapshots.destination() == null ? request.destinationWarehouseName()
: snapshots.destination().warehouse().warehouseName());
order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark());
order.setExpectedShipDate(request.expectedShipDate());
order.setExpectedArrivalAt(request.expectedShipDate() == null ? null : request.expectedShipDate().atStartOfDay());
order.setCustomerRemark(request.customerRemark());
order.setPickupMethod(request.pickupMethod());
order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null);
order.setReceivingWarehouseId(request.receivingWarehouseId());
@@ -477,6 +479,7 @@ public class OrderApplicationService {
// keep one canonical code so later estimation and audit lookups are stable.
order.setChannelCode(request.channelCode().trim().toUpperCase(Locale.ROOT));
order.setChannelName(request.channelName());
if (order.getChannelChanged() == null) order.setChannelChanged(false);
order.setCarrierId(request.carrierId());
order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName())
? null : request.carrierName().trim());
@@ -671,7 +674,8 @@ public class OrderApplicationService {
order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(),
order.getCustomerId(), order.getCustomerName(),
order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(),
order.getDestinationWarehouseName(), order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(),
order.getDestinationWarehouseName(), order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt()
: order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(),
order.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(),
order.getCarrierId(), order.getCarrierName(),
order.getTaxMode(), order.getPricingTaxMode(),
@@ -0,0 +1,179 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.entity.OrderStatusHistoryEntity;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** 平台版本化修改客户申报快照;绝不写入仓库实测、PDA、测量审核或计费事实。 */
@Service
public class OrderDeclarationUpdateService {
private static final Set<OrderStatus> EDITABLE = Set.of(
OrderStatus.DRAFT, OrderStatus.REJECTED, OrderStatus.SUBMITTED, OrderStatus.APPROVED);
@Resource private OrderRepository repository;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private UnifiedOrderQueryService unifiedOrders;
@Resource private ObjectMapper json;
@Transactional
public OrderDeclarationDtos.UpdateResponse update(long tenantId, long orderId, long operatorId,
String operatorName,
OrderDeclarationDtos.UpdateRequest request) {
OrderEntity order = repository.findOwned(tenantId, orderId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
if (!EDITABLE.contains(order.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "订单已进入不可逆履约状态,不能修改客户申报资料");
}
if (!Objects.equals(order.getVersion(), request.expectedVersion())) {
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新详情后重试");
}
List<OrderCargoEntity> current = repository.findCargo(tenantId, orderId);
Map<Integer, OrderCargoEntity> byItemNo = new HashMap<>();
current.forEach(item -> byItemNo.put(item.getItemNo(), item));
Set<Integer> requestedItems = new HashSet<>();
request.cargoes().forEach(item -> requestedItems.add(item.itemNo()));
if (requestedItems.size() != request.cargoes().size() || !requestedItems.equals(byItemNo.keySet())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "货物行必须与当前订单完整一致,不允许新增、删除或重复行");
}
String channelCode = value(request.channelCode(), order.getChannelCode());
String channelName = value(request.channelName(), order.getChannelName());
if (!StringUtils.hasText(channelCode) || !StringUtils.hasText(channelName)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "渠道编码和名称必须同时填写");
}
channelCode = channelCode.toUpperCase(Locale.ROOT);
boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode())
|| !Objects.equals(channelName, order.getChannelName());
if (channelChangedNow) {
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode,
order.getDestinationType());
}
if (order.getStatus() == OrderStatus.APPROVED) requireReversibleFulfillment(tenantId, orderId);
List<OrderDeclarationDtos.FieldChange> changes = new ArrayList<>();
change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark());
change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt());
change(changes, null, "channelCode", order.getChannelCode(), channelCode);
change(changes, null, "channelName", order.getChannelName(), channelName);
for (OrderDeclarationDtos.Cargo update : request.cargoes()) {
OrderCargoEntity cargo = byItemNo.get(update.itemNo());
collect(changes, cargo, update);
}
if (changes.isEmpty()) throw new BizException(ResultCode.CONFLICT, "申报资料没有发生变化");
boolean changedBefore = Boolean.TRUE.equals(order.getChannelChanged());
boolean channelChanged = changedBefore || channelChangedNow;
if (repository.updateDeclarationHeader(tenantId, orderId, request.expectedVersion(), operatorId,
request.customerRemark(), request.expectedArrivalAt(), channelCode, channelName, channelChanged) != 1) {
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新详情后重试");
}
for (OrderDeclarationDtos.Cargo update : request.cargoes()) {
OrderCargoEntity cargo = byItemNo.get(update.itemNo());
apply(cargo, update, operatorId);
if (repository.updateDeclarationCargo(tenantId, orderId, cargo) != 1) {
throw new BizException(ResultCode.CONFLICT, "货物资料已变化,请刷新详情后重试");
}
}
int version = request.expectedVersion() + 1;
recordHistory(tenantId, orderId, operatorId, operatorName, order.getStatus(), changes.size());
if (order.getStatus() == OrderStatus.APPROVED) {
writeOutbox(order, operatorId, request, channelCode, channelName, channelChanged, changes, version);
}
return new OrderDeclarationDtos.UpdateResponse(order.getOrderNo(), order.getInboundCode(),
request.expectedVersion(), version, changedBefore, channelChanged, List.copyOf(changes));
}
private void requireReversibleFulfillment(long tenantId, long orderId) {
var row = unifiedOrders.platformDetail(tenantId, orderId).summary();
boolean hasIrreversibleFact = row.measurementCompletedAt() != null
|| row.preload() != null || row.loading() != null || row.coLoadGroup() != null
|| row.feeReviewStatus() != null && !"UNRECORDED".equals(row.feeReviewStatus());
if (hasIrreversibleFact || !"WAIT_MEASURE".equals(row.mainStatus())) {
throw new BizException(ResultCode.CONFLICT,
"订单已产生测量、分柜、装柜或计费事实,不能修改客户申报资料");
}
}
private void collect(List<OrderDeclarationDtos.FieldChange> changes, OrderCargoEntity before,
OrderDeclarationDtos.Cargo after) {
int item = after.itemNo();
change(changes,item,"cargoName",before.getCargoName(),after.cargoName()); change(changes,item,"cargoNameEn",before.getCargoNameEn(),after.cargoNameEn());
change(changes,item,"skuNo",before.getSkuNo(),after.skuNo()); change(changes,item,"model",before.getModel(),after.model());
change(changes,item,"packageCount",before.getPackageCount(),after.packageCount()); change(changes,item,"totalQuantity",before.getTotalQuantity(),after.totalQuantity());
change(changes,item,"lengthCm",before.getLengthCm(),after.lengthCm()); change(changes,item,"widthCm",before.getWidthCm(),after.widthCm());
change(changes,item,"heightCm",before.getHeightCm(),after.heightCm()); change(changes,item,"grossWeightKg",before.getGrossWeightKg(),after.grossWeightKg());
change(changes,item,"volumeCbm",before.getVolumeCbm(),after.volumeCbm()); change(changes,item,"netWeightKg",before.getNetWeightKg(),after.netWeightKg());
change(changes,item,"hsCode",before.getHsCode(),after.hsCode()); change(changes,item,"customsHsCode",before.getCustomsHsCode(),after.customsHsCode());
change(changes,item,"unitsPerPackage",before.getUnitsPerPackage(),after.unitsPerPackage()); change(changes,item,"unitDeclaredValue",before.getUnitDeclaredValue(),after.unitDeclaredValue());
change(changes,item,"declaredValue",before.getDeclaredValue(),after.declaredValue()); change(changes,item,"currency",before.getCurrency(),after.currency());
change(changes,item,"material",before.getMaterial(),after.material()); change(changes,item,"usage",before.getProductUsage(),after.usage());
change(changes,item,"brand",before.getBrand(),after.brand()); change(changes,item,"cargoRemark",before.getCargoRemark(),after.cargoRemark());
}
private void apply(OrderCargoEntity value, OrderDeclarationDtos.Cargo source, long operatorId) {
value.setCargoName(source.cargoName()); value.setCargoNameEn(source.cargoNameEn()); value.setSkuNo(source.skuNo()); value.setModel(source.model());
value.setPackageCount(source.packageCount()); value.setTotalQuantity(source.totalQuantity()); value.setLengthCm(source.lengthCm()); value.setWidthCm(source.widthCm()); value.setHeightCm(source.heightCm());
value.setGrossWeightKg(source.grossWeightKg()); value.setVolumeCbm(source.volumeCbm()); value.setNetWeightKg(source.netWeightKg()); value.setHsCode(source.hsCode()); value.setCustomsHsCode(source.customsHsCode());
value.setUnitsPerPackage(source.unitsPerPackage()); value.setUnitDeclaredValue(source.unitDeclaredValue()); value.setDeclaredValue(source.declaredValue()); value.setCurrency(source.currency());
value.setMaterial(source.material()); value.setProductUsage(source.usage()); value.setBrand(source.brand()); value.setCargoRemark(source.cargoRemark()); value.setUpdatedBy(operatorId); value.setUpdatedAt(LocalDateTime.now());
}
private void recordHistory(long tenantId, long orderId, long operatorId, String operatorName,
OrderStatus status, int changedFields) {
OrderStatusHistoryEntity history = new OrderStatusHistoryEntity();
history.setTenantId(tenantId); history.setOrderId(orderId); history.setFromStatus(status); history.setToStatus(status);
history.setAction("DECLARATION_UPDATE"); history.setReason("平台修改客户申报资料,共 " + changedFields + " 个字段");
history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setOccurredAt(LocalDateTime.now());
repository.insertHistory(history);
}
private void writeOutbox(OrderEntity order, long operatorId, OrderDeclarationDtos.UpdateRequest request,
String channelCode, String channelName, boolean channelChanged,
List<OrderDeclarationDtos.FieldChange> changes, int version) {
LocalDateTime occurredAt = LocalDateTime.now();
var payload = new OrderDeclarationDtos.UpdatedEvent(1, UUID.randomUUID().toString(), order.getTenantId(),
order.getId(), order.getOrderNo(), order.getInboundCode(), version, operatorId, occurredAt,
request.customerRemark(), request.expectedArrivalAt(), channelCode, channelName, channelChanged,
request.cargoes().stream().sorted(Comparator.comparing(OrderDeclarationDtos.Cargo::itemNo)).toList(),
List.copyOf(changes));
OutboxEventEntity event = new OutboxEventEntity();
event.setTenantId(order.getTenantId()); event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId());
event.setEventType("ORDER_DECLARATION_UPDATED_V1");
try { event.setPayloadJson(json.writeValueAsString(payload)); }
catch (JsonProcessingException exception) { throw new BizException("Cannot serialize declaration update event"); }
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(occurredAt);
repository.insertOutbox(event);
}
private void change(List<OrderDeclarationDtos.FieldChange> changes, Integer itemNo, String field,
Object before, Object after) {
if (!Objects.equals(before, after)) changes.add(new OrderDeclarationDtos.FieldChange(itemNo, field, before, after));
}
private String value(String requested, String current) { return StringUtils.hasText(requested) ? requested.trim() : current; }
}
@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@@ -26,8 +27,15 @@ public class OrderInboundOutboxPublisher {
@Scheduled(fixedDelayString = "${baiyue.order.inbound-outbox.fixed-delay-ms:3000}")
public void publishApprovedOrders() {
for (OutboxEventEntity event : repository.pendingInboundEvents(50)) {
if (!"FIRST_LEG_ORDER".equals(event.getAggregateType())
|| !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) {
if (!"FIRST_LEG_ORDER".equals(event.getAggregateType())) {
failPermanently(event, "unsupported order event");
continue;
}
if ("ORDER_DECLARATION_UPDATED_V1".equals(event.getEventType())) {
publishDeclarationUpdate(event);
continue;
}
if (!"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) {
failPermanently(event, "unsupported order event");
continue;
}
@@ -73,6 +81,26 @@ public class OrderInboundOutboxPublisher {
}
}
private void publishDeclarationUpdate(OutboxEventEntity event) {
try {
OrderDeclarationDtos.UpdatedEvent command = json.readValue(
event.getPayloadJson(), OrderDeclarationDtos.UpdatedEvent.class);
if (command == null || event.getTenantId() == null || event.getAggregateId() == null
|| event.getTenantId() != command.tenantId()
|| event.getAggregateId() != command.orderId()) {
failPermanently(event, "declaration event ownership does not match payload");
return;
}
ApiResponse<Void> response = client.updateDeclaration(internalToken, command);
if (response == null || !response.successful()) throw new IllegalStateException("头程申报同步失败");
repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now());
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
failPermanently(event, "invalid declaration event payload");
} catch (Exception exception) {
repository.markOutboxRetry(event.getTenantId(), event.getId(), LocalDateTime.now().plusSeconds(10));
}
}
private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) {
return command.orderNo() == null || command.orderNo().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank()
@@ -284,13 +284,13 @@ public class UnifiedOrderQueryService {
order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null,
relation(order, childCounts),
order.getStatus(), order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(),
order.getExpectedShipDate(), totalPackageCount(cargo), null, null, grossWeightKg(cargo), volumeCbm(cargo),
order.getExpectedShipDate(), totalPackageCount(cargo), null, null, volumeCbm(cargo), null, null,
null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(),
null, null, null, null, netWeightKg(cargo), null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo),
null, null, null, null, null, coLoadGroup,
order.getCustomerRemark(), latestInternalNote,
@@ -300,15 +300,15 @@ public class UnifiedOrderQueryService {
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts), order.getStatus(),
order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(),
inbound.actualGrossWeightKg() != null ? inbound.actualGrossWeightKg() : grossWeightKg(cargo),
inbound.actualVolumeCbm() != null ? inbound.actualVolumeCbm() : volumeCbm(cargo), inbound.expectedArrivalAt(), inbound.feeReviewStatus(),
order.getExpectedShipDate(), inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(),
inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(),
inbound.actualNetWeightKg() != null ? inbound.actualNetWeightKg() : netWeightKg(cargo),
inbound.actualNetWeightKg(),
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
StringUtils.hasText(creatorName) ? creatorName : inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound),
preload(summary.preload()), loading(summary.loading()),
@@ -324,18 +324,17 @@ public class UnifiedOrderQueryService {
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts),
parseOrderStatus(inbound.orderStatusSnapshot()),
inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), inbound.warehouseId(),
inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(),
order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(),
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null,
inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo),
null, inbound.locationCodes(),
inbound.actualGrossWeightKg() != null ? inbound.actualGrossWeightKg() : grossWeightKg(cargo),
inbound.actualVolumeCbm() != null ? inbound.actualVolumeCbm() : volumeCbm(cargo),
inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(),
inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
order != null && order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(),
inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(),
inbound.actualNetWeightKg() != null ? inbound.actualNetWeightKg() : netWeightKg(cargo),
inbound.actualNetWeightKg(),
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo),
measurement(inbound), preload(summary.preload()), loading(summary.loading()),
+2
View File
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID',
destination_warehouse_name VARCHAR(128) NOT NULL COMMENT '目的仓名称快照',
expected_ship_date DATE NULL COMMENT '预计发运日期',
expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间',
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间',
@@ -28,6 +29,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
receiving_time VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
channel_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '订单历史上是否由平台变更过渠道',
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照',
carrier_name VARCHAR(128) NULL COMMENT '船司名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
@@ -0,0 +1,22 @@
-- 尚未执行。测试库执行前需获得当前轮 SQL 授权。
USE baiyue_order;
ALTER TABLE ord_order
ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间' AFTER expected_ship_date,
ADD COLUMN channel_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '订单历史上是否由平台变更过渠道' AFTER channel_name;
USE baiyue_iam;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, deleted)
VALUES
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit',
'修改客户申报资料', NULL, NULL, 121, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role_id, 5102027, 0
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+60 -2
View File
@@ -206,6 +206,25 @@ paths:
responses:
'200': {description: 备注添加成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:note 权限}
/api/platform/v1/orders/{orderId}/declaration:
put:
summary: 版本化修改客户原始申报资料
description: >-
需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID;
不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。
审核通过订单仅在尚无测量、同柜组、分柜、装柜或计费事实时受理,并通过
ORDER_DECLARATION_UPDATED_V1 Outbox 快照同步 Firstleg;消费者按 eventId 幂等、version 顺序应用。
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
- $ref: '#/components/parameters/UnifiedTenantId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'}
responses:
'200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突或存在不可逆履约事实时响应体业务码为 A0409}
'403': {description: 无权限或非平台身份}
/api/merchant/v1/orders:
get:
summary: 客户统一订单列表
@@ -553,6 +572,43 @@ components:
fbaCode: {type: string, maxLength: 64}
createdFrom: {type: string, format: date-time}
createdTo: {type: string, format: date-time}
OrderDeclarationUpdateRequest:
type: object
required: [expectedVersion, cargoes]
properties:
expectedVersion: {type: integer, minimum: 0}
customerRemark: {type: string, nullable: true, maxLength: 1000}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
channelCode: {type: string, maxLength: 64, description: 与 channelName 同时传;变更时重新校验价格渠道,入仓码保持不变}
channelName: {type: string, maxLength: 128}
cargoes: {type: array, minItems: 1, maxItems: 500, items: {$ref: '#/components/schemas/OrderDeclarationCargo'}}
OrderDeclarationCargo:
type: object
required: [itemNo, cargoName, packageCount, totalQuantity, lengthCm, widthCm, heightCm, grossWeightKg, volumeCbm]
properties:
itemNo: {type: integer, minimum: 1, maximum: 500, description: 订单内稳定货物行号}
cargoName: {type: string, maxLength: 128}
cargoNameEn: {type: string, nullable: true, maxLength: 128}
skuNo: {type: string, nullable: true, maxLength: 64}
model: {type: string, nullable: true, maxLength: 64}
packageCount: {type: integer, minimum: 1}
totalQuantity: {type: integer, minimum: 1}
lengthCm: {type: number, minimum: 0.01}
widthCm: {type: number, minimum: 0.01}
heightCm: {type: number, minimum: 0.01}
grossWeightKg: {type: number, minimum: 0.001}
volumeCbm: {type: number, minimum: 0.0001}
netWeightKg: {type: number, nullable: true, minimum: 0.001}
hsCode: {type: string, nullable: true, maxLength: 20}
customsHsCode: {type: string, nullable: true, maxLength: 20}
unitsPerPackage: {type: integer, nullable: true, minimum: 1}
unitDeclaredValue: {type: number, nullable: true, minimum: 0}
declaredValue: {type: number, nullable: true, minimum: 0}
currency: {type: string, nullable: true, pattern: '^[A-Z]{3}$'}
material: {type: string, nullable: true, maxLength: 128}
usage: {type: string, nullable: true, maxLength: 128}
brand: {type: string, nullable: true, maxLength: 64}
cargoRemark: {type: string, nullable: true, maxLength: 500}
TenantOrderStatisticsResponse:
type: object
required: [tenantId, totalSubmittedOrders, currentMonthSubmittedOrders, monthStart, nextMonthStart, zoneId, asOf]
@@ -616,6 +672,7 @@ components:
customerName: {type: string, nullable: true}
channelCode: {type: string, nullable: true, description: 稳定物流渠道编码,仅供业务校验;直接来自当前 Order 或 Firstleg 权威快照,不由 channelName 推导}
channelName: {type: string, nullable: true}
channelChanged: {type: boolean, description: 当前订单历史上是否由平台变更过渠道}
receivingWarehouseId: {type: integer, format: int64, nullable: true}
receivingWarehouseName: {type: string, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -627,8 +684,9 @@ components:
totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖}
recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型}
locationCodes: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;来源 actualGrossWeightKg,不以净重回退}
actualVolumeCbm: {type: number, nullable: true}
expectedVolumeCbm: {type: number, nullable: true, description: Order 客户申报货物体积合计,不得与实测体积混用}
actualGrossWeightKg: {type: number, nullable: true, description: 仅 Firstleg 实测毛重;无实测时返回 null}
actualVolumeCbm: {type: number, nullable: true, description: 仅 Firstleg 实测体积;无实测时返回 null}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达}
companyName: {type: string, nullable: true}
@@ -0,0 +1,52 @@
package com.baiyue.order.controller;
import com.baiyue.order.dto.OrderDeclarationDtos;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PutMapping;
import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class OrderDeclarationUpdateContractTest {
@Test
void endpointUsesDedicatedPermissionAndVersionedDto() throws Exception {
var method = PlatformUnifiedOrderController.class.getDeclaredMethod("updateDeclaration",
com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class,
OrderDeclarationDtos.UpdateRequest.class);
assertThat(method.getAnnotation(PutMapping.class).value()).containsExactly("/{orderId}/declaration");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:order:declaration:edit')");
assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents())
.extracting(component -> component.getName())
.containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt",
"channelCode", "channelName", "cargoes");
assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount",
"totalQuantity", "lengthCm", "widthCm", "heightCm", "grossWeightKg", "volumeCbm")
.doesNotContain("id", "orderId", "actualGrossWeightKg", "actualVolumeCbm",
"measurementReviewStatus", "feeReviewStatus");
}
@Test
void openApiSeparatesDeclaredAndMeasuredFacts() throws Exception {
String source = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = map(new Yaml().load(source));
assertThat(map(document.get("paths")))
.containsKey("/api/platform/v1/orders/{orderId}/declaration");
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
Map<String, Object> row = map(map(schemas.get("UnifiedOrderRow")).get("properties"));
assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged");
assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测");
assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409");
}
@SuppressWarnings("unchecked")
private Map<String, Object> map(Object value) { return (Map<String, Object>) value; }
}
@@ -52,6 +52,7 @@ class DomesticWarehouseOrderExportProviderServiceTest {
.doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId",
"receivingWarehouseId", "destinationWarehouseId");
assertThat(response.columns()).extracting(column -> column.key())
.contains("expectedVolumeCbm", "expectedArrivalAt", "channelChanged")
.doesNotContain("orderId", "tenantId", "customerId", "inboundId");
verify(orders, never()).platformPage(any(), any());
}
@@ -0,0 +1,148 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(OrderDeclarationUpdateService.class)
class OrderDeclarationUpdateServiceTest {
@MockBean private OrderRepository repository;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private UnifiedOrderQueryService unifiedOrders;
@MockBean private ObjectMapper json;
@Resource private OrderDeclarationUpdateService service;
@Test
void updatesCompleteCustomerDeclarationByItemNoAndKeepsInboundCode() {
OrderEntity order = order(OrderStatus.SUBMITTED, 3);
OrderCargoEntity cargo = cargo();
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo));
when(repository.updateDeclarationHeader(eq(100L), eq(200L), eq(3), eq(700L),
any(), any(), anyString(), anyString(), anyBoolean())).thenReturn(1);
when(repository.updateDeclarationCargo(eq(100L), eq(200L), any())).thenReturn(1);
var request = request(3, "NEW", "新渠道", "新备注");
var response = service.update(100L, 200L, 700L, "平台操作员", request);
assertThat(response.orderNo()).isEqualTo("ORD-200");
assertThat(response.inboundCode()).isEqualTo("RK-LOCKED");
assertThat(response.version()).isEqualTo(4);
assertThat(response.channelChanged()).isTrue();
assertThat(response.changes()).extracting(OrderDeclarationDtos.FieldChange::field)
.contains("channelCode", "channelName", "customerRemark", "expectedArrivalAt",
"cargoName", "packageCount", "totalQuantity", "lengthCm", "grossWeightKg");
verify(pricingChannelGuard).requireAvailable(100L, 31L, "NEW", "FBA");
verify(repository).insertHistory(any());
verify(repository, never()).insertOutbox(any());
assertThat(cargo.getPackageCount()).isEqualTo(20);
assertThat(cargo.getTotalQuantity()).isEqualTo(200);
assertThat(cargo.getLengthCm()).isEqualByComparingTo("60");
}
@Test
void rejectsStaleVersionBeforeAnyWrite() {
when(repository.findOwned(100L, 200L)).thenReturn(order(OrderStatus.SUBMITTED, 4));
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
request(3, "OLD", "旧渠道", "备注")))
.isInstanceOf(BizException.class).hasMessageContaining("版本已变化");
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
any(), any(), any(), any(), anyBoolean());
}
@Test
void approvedOrderWithMeasurementFactIsAtomicallyRejected() {
OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
when(detail.summary()).thenReturn(row);
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
when(row.measurementCompletedAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 12, 0));
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
request(3, "OLD", "旧渠道", "备注")))
.isInstanceOf(BizException.class).hasMessageContaining("测量、分柜、装柜或计费事实");
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
any(), any(), any(), any(), anyBoolean());
}
@Test
void reversibleApprovedUpdateProducesVersionedOutboxSnapshot() throws Exception {
OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
any(), any(), anyBoolean())).thenReturn(1);
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1);
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
when(detail.summary()).thenReturn(row);
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
when(row.feeReviewStatus()).thenReturn("UNRECORDED");
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}");
service.update(100L, 200L, 700L, "平台操作员", request(3, "OLD", "旧渠道", "新备注"));
verify(repository).insertOutbox(any());
}
private OrderEntity order(OrderStatus status, int version) {
OrderEntity order = new OrderEntity();
order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200");
order.setInboundCode("RK-LOCKED"); order.setStatus(status); order.setVersion(version);
order.setReceivingWarehouseId(31L); order.setDestinationType("FBA");
order.setChannelCode("OLD"); order.setChannelName("旧渠道"); order.setChannelChanged(false);
order.setCustomerRemark("旧备注");
return order;
}
private OrderCargoEntity cargo() {
OrderCargoEntity value = new OrderCargoEntity();
value.setId(300L); value.setTenantId(100L); value.setOrderId(200L); value.setItemNo(1); value.setVersion(2);
value.setCargoName("旧品名"); value.setCargoNameEn("Old"); value.setSkuNo("SKU-OLD"); value.setModel("M1");
value.setPackageCount(10); value.setTotalQuantity(100); value.setLengthCm(new BigDecimal("50"));
value.setWidthCm(new BigDecimal("40")); value.setHeightCm(new BigDecimal("30"));
value.setGrossWeightKg(new BigDecimal("100")); value.setVolumeCbm(new BigDecimal("1.0"));
return value;
}
private OrderDeclarationDtos.UpdateRequest request(int version, String channelCode,
String channelName, String remark) {
var cargo = new OrderDeclarationDtos.Cargo(1, "新品名", "New", "SKU-NEW", "M2",
20, 200, new BigDecimal("60"), new BigDecimal("50"), new BigDecimal("40"),
new BigDecimal("120"), new BigDecimal("1.2"), new BigDecimal("110"),
"8518", "851821", 10, new BigDecimal("5"), new BigDecimal("1000"),
"USD", "塑料", "音响", "Brand", "新备注");
return new OrderDeclarationDtos.UpdateRequest(version, remark,
LocalDateTime.of(2026, 9, 15, 10, 30), channelCode, channelName, List.of(cargo));
}
}
@@ -129,6 +129,22 @@ class OrderInboundOutboxPublisherTest {
verify(fixture.repository, never()).markOutboxFailed(any(), anyLong());
}
@Test
void declarationUpdateEventIsDeliveredByVersionedContract() {
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11,"
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
+ "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],\"changes\":[]}";
Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload));
when(fixture.client.updateDeclaration(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
fixture.publisher.publishApprovedOrders();
verify(fixture.client).updateDeclaration(eq("internal-token"), any());
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.client, never()).create(any(), any());
}
private Fixture fixture(OutboxEventEntity event) {
return fixture(event, "BUY_DECLARATION");
}
@@ -98,9 +98,10 @@ class UnifiedOrderQueryServiceTest {
assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> {
assertThat(row.totalPackageCount()).isEqualTo(100);
assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("12.5");
assertThat(row.totalNetWeightKg()).isEqualByComparingTo("11.2");
assertThat(row.actualVolumeCbm()).isEqualByComparingTo("0.375");
assertThat(row.expectedVolumeCbm()).isEqualByComparingTo("0.375");
assertThat(row.actualGrossWeightKg()).isNull();
assertThat(row.totalNetWeightKg()).isNull();
assertThat(row.actualVolumeCbm()).isNull();
});
verify(orders).findCargoByOrderIds(10001L, List.of(77L));
}