Merge remote-tracking branch 'origin/dev' into test

# Conflicts:
#	warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java
#	warehouse/src/main/resources/db/warehouse.sql
This commit is contained in:
2026-08-26 20:51:27 +08:00
1157 changed files with 78457 additions and 2736 deletions
@@ -45,7 +45,7 @@ public interface FirstlegInboundClient {
Boolean customsInspection, String destinationType, Long destinationWarehouseId,
String destinationCode, String destinationName, String publicRemark,
String expressCompanyName, String expressTrackingNo, Integer totalPackageCount,
String pickupMethod, LocalDateTime requestedPickupAt
String pickupMethod, LocalDateTime requestedPickupAt, String fulfillmentType
) {
}
}
@@ -73,8 +73,28 @@ public final class OrderRequests {
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId,
@Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40)
@Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode
@Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode,
@Schema(description = "Fulfillment type; omitted means STANDARD / 履约类型;省略时为普通头程", allowableValues = {"STANDARD", "ONE_CLICK_DROPSHIP"}, defaultValue = "STANDARD")
@Pattern(regexp = "STANDARD|ONE_CLICK_DROPSHIP") String fulfillmentType
) {
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
Party sender, Party receiver, List<Cargo> cargoes, String pickupMethod,
LocalDateTime requestedPickupAt, Long receivingWarehouseId,
String receivingWarehouseName, String channelCode, String channelName,
Long carrierId, String carrierName, String taxMode, String pricingTaxMode,
String containerMode, Boolean customsInspection, String destinationType,
String amazonWarehouseCode, String shipmentId, String referenceId,
String inboundCode) {
this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode,
destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark,
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, inboundCode, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
@@ -89,7 +109,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, null);
shipmentId, referenceId, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
@@ -106,7 +126,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, null);
shipmentId, referenceId, null, null);
}
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
@@ -47,6 +47,7 @@ public class OrderEntity extends BaseEntity {
private String containerMode;
private Boolean customsInspection;
private String destinationType;
private String fulfillmentType;
private String amazonWarehouseCode;
private String shipmentId;
private String referenceId;
@@ -23,6 +23,7 @@ public class MerchantBatchDraftService {
public MerchantBatchDraftDtos.Response create(long tenantId, long userId, String batchKey,
List<MerchantBatchDraftDtos.Item> items) {
if (!StringUtils.hasText(batchKey)) throw new BizException("必须提供 Idempotency-Key");
if (batchKey.trim().length() > 128) throw new BizException("Idempotency-Key 长度不能超过128");
if (items == null || items.isEmpty()) throw new BizException("批量草稿不能为空");
if (items.size() > MAX_ITEMS) throw new BizException("批量草稿最多 50 条");
List<MerchantBatchDraftDtos.Result> results = new ArrayList<>(items.size());
@@ -462,6 +462,7 @@ public class OrderApplicationService {
order.setPricingTaxMode(request.pricingTaxMode());
order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection());
order.setDestinationType(request.destinationType());
order.setFulfillmentType(fulfillmentType(request.fulfillmentType()));
order.setAmazonWarehouseCode("FBA".equals(request.destinationType()) && snapshots.destination() != null
? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode());
order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId());
@@ -486,6 +487,13 @@ public class OrderApplicationService {
}
order.setInboundCode(normalized);
}
private static String fulfillmentType(String value) {
if (value == null) return "STANDARD";
if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value;
throw new BizException(ResultCode.VALIDATION_FAILED,
"履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP");
}
private boolean legacyManualMode() {
return inboundCodeModeProvider.legacyManual();
}
@@ -599,7 +607,8 @@ public class OrderApplicationService {
order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(),
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount,
order.getPickupMethod(), order.getRequestedPickupAt());
order.getPickupMethod(), order.getRequestedPickupAt(),
order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType());
}
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable();
@@ -141,6 +141,7 @@ public class OrderFulfillmentSplitService {
child.setDestinationWarehouseName(root.getDestinationWarehouseName()); child.setReceivingWarehouseId(root.getReceivingWarehouseId());
child.setReceivingWarehouseName(root.getReceivingWarehouseName()); child.setChannelCode(root.getChannelCode());
child.setChannelName(root.getChannelName()); child.setDestinationType(root.getDestinationType());
child.setFulfillmentType(root.getFulfillmentType());
child.setInboundCode(remainderInboundCode(root, sequence));
child.setCreatedBy(operatorId); child.setUpdatedBy(operatorId);
if (orders.insert(child) != 1) throw new BizException(ResultCode.CONFLICT, "余量子订单创建失败");
+21
View File
@@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址',
fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发',
amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码',
shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID',
@@ -61,6 +62,26 @@ CREATE TABLE IF NOT EXISTS ord_order (
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
SET @order_fulfillment_type_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
AND column_name = 'fulfillment_type'
),
'SELECT 1',
'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type'
);
PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql;
EXECUTE order_fulfillment_type_stmt;
DEALLOCATE PREPARE order_fulfillment_type_stmt;
UPDATE baiyue_order.ord_order
SET fulfillment_type = 'STANDARD'
WHERE fulfillment_type IS NULL OR fulfillment_type = '';
ALTER TABLE baiyue_order.ord_order
MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'
COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发';
-- 既有开发库必须先确认不存在跨客户重复码;若存在则在此处失败,不自动改写业务数据。
SET @order_global_inbound_index_sql = IF(
NOT EXISTS(
+12 -7
View File
@@ -379,6 +379,11 @@ paths:
'403': {description: 需要 merchant:order:view 权限}
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
TenantOrderStatisticsResponse:
type: object
@@ -391,12 +396,6 @@ components:
nextMonthStart: {type: string, format: date-time}
zoneId: {type: string, example: Asia/Shanghai}
asOf: {type: string, format: date-time}
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
UnifiedMainStatus:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
@@ -646,6 +645,11 @@ components:
type: object
required: [pricingTaxMode, cargoes]
properties:
fulfillmentType:
type: string
enum: [STANDARD, ONE_CLICK_DROPSHIP]
default: STANDARD
description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType
inboundCode:
type: string
maxLength: 40
@@ -657,7 +661,8 @@ components:
minItems: 1
items: {$ref: '#/components/schemas/FirstLegOrderCargo'}
description: >-
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;fulfillmentType
省略时固定为 STANDARD,一键代发必须显式传 ONE_CLICK_DROPSHIP,且不复用计费 businessType;pricingTaxMode
是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID;
发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。
InboundCodePolicy:
@@ -37,6 +37,9 @@ class MerchantBatchDraftOpenApiContractTest {
.contains("merchant:order:create")
.contains("FirstLegBatchDraftResponse")
.contains("FirstLegBatchDraftResult")
.contains("fulfillmentType:")
.contains("enum: [STANDARD, ONE_CLICK_DROPSHIP]")
.contains("default: STANDARD")
.contains("inboundCode:")
.contains("errorCode:")
.contains("errorMessage:");
@@ -54,6 +54,25 @@ class OrderRequestsValidationTest {
assertThat(validator.validate(draft)).isEmpty();
}
@Test
void fulfillmentTypeIsOptionalButRejectsPricingBusinessValues() {
var standard = request(null, null, "TAX_INCLUDED");
assertThat(validator.validate(standard)).isEmpty();
var invalid = new OrderRequests.SaveRequest(
standard.customerId(), standard.customerName(), standard.transportMode(), standard.tradeTerm(),
standard.destinationCountryCode(), standard.destinationWarehouseId(), standard.destinationWarehouseName(),
standard.expectedShipDate(), standard.customerRemark(), standard.sender(), standard.receiver(),
standard.cargoes(), standard.pickupMethod(), standard.requestedPickupAt(), standard.receivingWarehouseId(),
standard.receivingWarehouseName(), standard.channelCode(), standard.channelName(), standard.carrierId(),
standard.carrierName(), standard.taxMode(), standard.pricingTaxMode(), standard.containerMode(),
standard.customsInspection(), standard.destinationType(), standard.amazonWarehouseCode(),
standard.shipmentId(), standard.referenceId(), standard.inboundCode(), "FBA_SEA");
assertThat(validator.validate(invalid))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("fulfillmentType"));
}
@Test
void validatesTheManuallyEnteredInboundCodeForSingleAndBatchCallers() {
var source = request(null, null, "TAX_INCLUDED");
@@ -0,0 +1,109 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
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.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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@SpringJUnitConfig(MerchantBatchDraftService.class)
class MerchantBatchDraftServiceTest {
@MockBean private MerchantBatchDraftItemService itemService;
@Resource private MerchantBatchDraftService service;
@Test
void createsEachRowAndReturnsOrderReferences() {
doAnswer(invocation -> order(101L, "FL101", "RK-101"))
.doAnswer(invocation -> order(102L, "FL102", "RK-102"))
.when(itemService).create(anyLong(), anyLong(), any(), anyString());
var response = service.create(10L, 20L, "batch-key", List.of(
item("row-1"), item("row-2")));
assertThat(response.total()).isEqualTo(2);
assertThat(response.succeeded()).isEqualTo(2);
assertThat(response.failed()).isZero();
assertThat(response.items()).extracting(MerchantBatchDraftDtos.Result::inboundCode)
.containsExactly("RK-101", "RK-102");
assertThat(response.items()).extracting(MerchantBatchDraftDtos.Result::index)
.containsExactly(0, 1);
}
@Test
void keepsSuccessfulRowsWhenAnotherRowFails() {
doAnswer(invocation -> order(101L, "FL101", "RK-101"))
.doThrow(new BizException("SKU不能为空"))
.when(itemService).create(anyLong(), anyLong(), any(), anyString());
var response = service.create(10L, 20L, "batch-key", List.of(
item("row-1"), item("row-2")));
assertThat(response.succeeded()).isEqualTo(1);
assertThat(response.failed()).isEqualTo(1);
assertThat(response.items().get(0).success()).isTrue();
assertThat(response.items().get(1).success()).isFalse();
assertThat(response.items().get(1).errorMessage()).isEqualTo("SKU不能为空");
}
@Test
void rejectsDuplicateRowWithoutCreatingItTwice() {
doAnswer(invocation -> order(101L, "FL101", "RK-101"))
.when(itemService).create(anyLong(), anyLong(), any(), anyString());
var response = service.create(10L, 20L, "batch-key", List.of(
item("same-row"), item("same-row")));
assertThat(response.succeeded()).isEqualTo(1);
assertThat(response.failed()).isEqualTo(1);
assertThat(response.items().get(1).errorCode()).isEqualTo("A0409");
verify(itemService).create(anyLong(), anyLong(), any(), anyString());
}
@Test
void derivesStablePerRowIdempotencyKeys() {
String first = MerchantBatchDraftService.derivedKey(" batch-key ", "row-1");
String retry = MerchantBatchDraftService.derivedKey("batch-key", "row-1");
String anotherRow = MerchantBatchDraftService.derivedKey("batch-key", "row-2");
assertThat(first).isEqualTo(retry).startsWith("batch-").hasSize(70);
assertThat(anotherRow).isNotEqualTo(first);
}
@Test
void rejectsInvalidBatchBeforeCreatingRows() {
assertThatThrownBy(() -> service.create(10L, 20L, " ", List.of(item("row-1"))))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.create(10L, 20L, "x".repeat(129), List.of(item("row-1"))))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.create(10L, 20L, "batch-key", List.of()))
.isInstanceOf(BizException.class);
verify(itemService, never()).create(anyLong(), anyLong(), any(), anyString());
}
private MerchantBatchDraftDtos.Item item(String rowId) {
return new MerchantBatchDraftDtos.Item(rowId, mock(OrderRequests.SaveRequest.class));
}
private OrderEntity order(long id, String orderNo, String inboundCode) {
OrderEntity order = new OrderEntity();
order.setId(id);
order.setOrderNo(orderNo);
order.setInboundCode(inboundCode);
return order;
}
}
@@ -60,7 +60,7 @@ class OrderApplicationServiceStateTest {
assertThat(orderId).isNotNull();
verify(eligibility, never()).requireApproved(anyLong());
verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT
&& order.getInboundCode() == null));
&& order.getInboundCode() == null && "STANDARD".equals(order.getFulfillmentType())));
}
@Test
@@ -372,6 +372,31 @@ class OrderApplicationServiceStateTest {
assertThat(command.taxMode()).isEqualTo("BUY_DECLARATION");
assertThat(command.pricingTaxMode()).isEqualTo("TAX_EXCLUDED");
assertThat(command.totalPackageCount()).isEqualTo(4);
assertThat(command.fulfillmentType()).isEqualTo("STANDARD");
}
@Test
void explicitDropshipFulfillmentTypeIsPersistedAndSentIndependentlyFromPricingBusinessType() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest source = request();
OrderRequests.SaveRequest dropship = new OrderRequests.SaveRequest(
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(),
source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(),
source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(),
source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(),
source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP");
when(masterData.validate(10001L, dropship)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(10001L, 2L, dropship, "dropship-fulfillment");
verify(repository).insertOrder(argThat(order -> "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())
&& "OVERSEAS".equals(order.getDestinationType())));
}
@Test