Test #1
@@ -14413,5 +14413,27 @@ END$$
|
||||
DELIMITER ;
|
||||
|
||||
-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。
|
||||
INSERT INTO baiyue_iam.iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
|
||||
VALUES
|
||||
(5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name),
|
||||
route_path = VALUES(route_path), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
|
||||
|
||||
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT r.id, p.id, r.tenant_id
|
||||
FROM baiyue_iam.iam_role r
|
||||
JOIN baiyue_iam.iam_permission p ON p.tenant_id = r.tenant_id
|
||||
WHERE r.role_code = 'MERCHANT_OWNER' AND r.status = 'ACTIVE' AND r.deleted = 0
|
||||
AND p.permission_code IN ('merchant:order:quick-create', 'merchant:order:create')
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
|
||||
ALTER TABLE baiyue_order.ord_order_cargo
|
||||
MODIFY COLUMN cargo_name VARCHAR(128) NULL COMMENT '货物名称;草稿可空,提交前必填',
|
||||
MODIFY COLUMN package_type VARCHAR(32) NULL COMMENT '包装类型;草稿可空,提交前必填',
|
||||
MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克);草稿可空,提交前必须大于0',
|
||||
MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米);草稿可空,提交前必须大于0';
|
||||
|
||||
SELECT 'PASS' AS migration_status,
|
||||
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
|
||||
|
||||
@@ -175,7 +175,8 @@ VALUES
|
||||
(5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0),
|
||||
(5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0),
|
||||
(5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0),
|
||||
(5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0)
|
||||
(5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0),
|
||||
(5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
|
||||
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
|
||||
|
||||
@@ -6,12 +6,14 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.dto.MerchantBatchDraftDtos;
|
||||
import com.baiyue.order.dto.MerchantInboundOrderQuery;
|
||||
import com.baiyue.order.dto.MerchantInboundOrderRow;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.service.OrderScopeGuard;
|
||||
import com.baiyue.order.service.MerchantInboundOrderListService;
|
||||
import com.baiyue.order.service.MerchantBatchDraftService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@@ -29,6 +31,8 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 商户端头程订单接口,按登录用户所属租户提供订单创建、编辑、查询及状态流转能力。
|
||||
*/
|
||||
@@ -43,6 +47,8 @@ public class MerchantFirstLegOrderController {
|
||||
private OrderApplicationService service;
|
||||
@Resource
|
||||
private MerchantInboundOrderListService inboundOrderListService;
|
||||
@Resource
|
||||
private MerchantBatchDraftService batchDraftService;
|
||||
|
||||
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿")
|
||||
@Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。")
|
||||
@@ -61,6 +67,19 @@ public class MerchantFirstLegOrderController {
|
||||
OrderScopeGuard.merchant(user), user.userId(), request, idempotencyKey));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_BATCH_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Batch create drafts / 批量创建草稿")
|
||||
@Operation(summary = "Batch create drafts / 批量创建草稿",
|
||||
description = "每个数组元素代表一张草稿;单行失败不影响其它行,响应直接返回订单号和入仓码。")
|
||||
@PostMapping("/batch")
|
||||
@PreAuthorize("hasAuthority('merchant:order:create')")
|
||||
public ApiResponse<MerchantBatchDraftDtos.Response> batchCreate(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestBody List<MerchantBatchDraftDtos.Item> items) {
|
||||
return ApiResponse.success(batchDraftService.create(
|
||||
OrderScopeGuard.merchant(user), user.userId(), idempotencyKey, items));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿")
|
||||
@Operation(summary = "Update draft / 修改草稿", description = "Updates an editable draft owned by the authenticated merchant tenant. / 修改当前认证商户租户拥有且可编辑的订单草稿。")
|
||||
@ApiResponses({
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
|
||||
/** 快速下单批量草稿契约:每个外层行对应一张草稿。 */
|
||||
public final class MerchantBatchDraftDtos {
|
||||
private MerchantBatchDraftDtos() { }
|
||||
|
||||
public record Item(
|
||||
@Schema(description = "前端外层行唯一标识") String clientRowId,
|
||||
@Schema(description = "该行草稿及其货物明细") OrderRequests.SaveRequest draft) { }
|
||||
|
||||
public record Response(int total, int succeeded, int failed, List<Result> items) { }
|
||||
|
||||
public record Result(int index, String clientRowId, boolean success, Long orderId,
|
||||
String orderNo, String inboundCode, String errorCode, String errorMessage) {
|
||||
public static Result success(int index, String rowId, Long id, String no, String inbound) {
|
||||
return new Result(index, rowId, true, id, no, inbound, null, null);
|
||||
}
|
||||
public static Result failure(int index, String rowId, String code, String message) {
|
||||
return new Result(index, rowId, false, null, null, null, code, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,17 +104,17 @@ public final class OrderRequests {
|
||||
@Schema(name = "FirstLegOrderCargo", description = "First-leg order cargo line / 头程订单货物明细")
|
||||
public record Cargo(
|
||||
@Schema(description = "Cargo name / 货物名称", example = "Bluetooth speakers / 蓝牙音箱", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 128) String cargoName,
|
||||
@Size(max = 128) String cargoName,
|
||||
@Schema(description = "HS code / 海关编码", example = "8518210000", maxLength = 20)
|
||||
@Size(max = 20) String hsCode,
|
||||
@Schema(description = "Package type / 包装类型", example = "CARTON", maxLength = 32, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 32) String packageType,
|
||||
@Size(max = 32) String packageType,
|
||||
@Schema(description = "Package count / 包裹数量", example = "100", minimum = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Min(1) int packageCount,
|
||||
@Schema(description = "Gross weight in kilograms / 毛重(千克)", example = "1250.50", minimum = "0.001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull @DecimalMin("0.001") BigDecimal grossWeightKg,
|
||||
@DecimalMin("0.000") BigDecimal grossWeightKg,
|
||||
@Schema(description = "Volume in cubic meters / 体积(立方米)", example = "8.5000", minimum = "0.0001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull @DecimalMin("0.0001") BigDecimal volumeCbm,
|
||||
@DecimalMin("0.0000") BigDecimal volumeCbm,
|
||||
@Schema(description = "Package length in centimeters / 包装长度(厘米)", example = "60") BigDecimal lengthCm,
|
||||
@Schema(description = "Package width in centimeters / 包装宽度(厘米)", example = "40") BigDecimal widthCm,
|
||||
@Schema(description = "Package height in centimeters / 包装高度(厘米)", example = "35") BigDecimal heightCm,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** 单个批量行的独立事务边界。 */
|
||||
@Service
|
||||
public class MerchantBatchDraftItemService {
|
||||
@Resource private OrderApplicationService orders;
|
||||
@Resource private OrderRepository repository;
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public OrderEntity create(long tenantId, long userId, OrderRequests.SaveRequest draft, String idempotencyKey) {
|
||||
Long id = orders.createDraft(tenantId, userId, draft, idempotencyKey);
|
||||
return repository.findOwned(tenantId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.MerchantBatchDraftDtos;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** 快速下单批量编排,允许部分成功并保持每行独立幂等。 */
|
||||
@Service
|
||||
public class MerchantBatchDraftService {
|
||||
private static final int MAX_ITEMS = 50;
|
||||
@Resource private MerchantBatchDraftItemService itemService;
|
||||
|
||||
public MerchantBatchDraftDtos.Response create(long tenantId, long userId, String batchKey,
|
||||
List<MerchantBatchDraftDtos.Item> items) {
|
||||
if (!StringUtils.hasText(batchKey)) throw new BizException("必须提供 Idempotency-Key");
|
||||
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());
|
||||
Set<String> rowIds = new HashSet<>();
|
||||
for (int index = 0; index < items.size(); index++) {
|
||||
MerchantBatchDraftDtos.Item item = items.get(index);
|
||||
String rowId = item == null ? null : item.clientRowId();
|
||||
if (item == null || item.draft() == null) {
|
||||
results.add(MerchantBatchDraftDtos.Result.failure(index, rowId, "A0422", "草稿不能为空"));
|
||||
continue;
|
||||
}
|
||||
if (!StringUtils.hasText(rowId)) {
|
||||
results.add(MerchantBatchDraftDtos.Result.failure(index, rowId, "A0422", "clientRowId 不能为空"));
|
||||
continue;
|
||||
}
|
||||
if (!rowIds.add(rowId)) {
|
||||
results.add(MerchantBatchDraftDtos.Result.failure(index, rowId, "A0409", "clientRowId 不能重复"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
OrderEntity order = itemService.create(tenantId, userId, item.draft(), derivedKey(batchKey, rowId));
|
||||
results.add(MerchantBatchDraftDtos.Result.success(index, rowId, order.getId(), order.getOrderNo(), order.getInboundCode()));
|
||||
} catch (Exception ex) {
|
||||
String code = ex instanceof BizException be ? be.getResultCode().code() : "S0500";
|
||||
String message = ex instanceof BizException && StringUtils.hasText(ex.getMessage())
|
||||
? ex.getMessage() : "草稿创建失败";
|
||||
results.add(MerchantBatchDraftDtos.Result.failure(index, rowId, code, message));
|
||||
}
|
||||
}
|
||||
int succeeded = (int) results.stream().filter(MerchantBatchDraftDtos.Result::success).count();
|
||||
return new MerchantBatchDraftDtos.Response(items.size(), succeeded, items.size() - succeeded, results);
|
||||
}
|
||||
|
||||
static String derivedKey(String batchKey, String rowId) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest((batchKey.trim() + "\n" + rowId).getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder out = new StringBuilder("batch-");
|
||||
for (byte b : digest) out.append(String.format("%02x", b));
|
||||
return out.toString();
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("无法生成批量幂等键", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,13 +50,13 @@ public class OrderApplicationService {
|
||||
}
|
||||
|
||||
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
|
||||
validatePrototypeFields(request);
|
||||
productImageGuard.requireOwned(tenantId, request.cargoes());
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
|
||||
OrderEntity order = new OrderEntity();
|
||||
long orderId = IdWorker.getId();
|
||||
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); order.setStatus(OrderStatus.DRAFT);
|
||||
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId));
|
||||
order.setInboundCode("RK-" + orderId); order.setStatus(OrderStatus.DRAFT);
|
||||
apply(order, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId);
|
||||
repository.insertOrder(order);
|
||||
saveChildren(tenantId, userId, order.getId(), request);
|
||||
@@ -82,7 +82,7 @@ public class OrderApplicationService {
|
||||
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
|
||||
boolean allowSubmitted) {
|
||||
OrderEntity order = require(tenantId, id);
|
||||
validatePrototypeFields(request);
|
||||
if (allowSubmitted) validateCompleteRequest(request);
|
||||
productImageGuard.requireOwned(tenantId, request.cargoes());
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
OrderStatus before = order.getStatus();
|
||||
@@ -92,7 +92,9 @@ public class OrderApplicationService {
|
||||
}
|
||||
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
|
||||
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
|
||||
if (before == OrderStatus.REJECTED) order.setInboundCode(null);
|
||||
if (before == OrderStatus.REJECTED && !StringUtils.hasText(order.getInboundCode())) {
|
||||
order.setInboundCode("RK-" + order.getId());
|
||||
}
|
||||
if (before == OrderStatus.SUBMITTED) {
|
||||
order.setStatus(OrderStatus.SUBMITTED);
|
||||
}
|
||||
@@ -123,7 +125,9 @@ public class OrderApplicationService {
|
||||
if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode("RK-" + order.getId());
|
||||
order.setSubmittedAt(LocalDateTime.now());
|
||||
}
|
||||
if (target == OrderStatus.DRAFT) order.setInboundCode(null);
|
||||
if (target == OrderStatus.DRAFT && !StringUtils.hasText(order.getInboundCode())) {
|
||||
order.setInboundCode("RK-" + order.getId());
|
||||
}
|
||||
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
|
||||
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
|
||||
record(tenantId, id, from, target, target.name(), reason, userId, username);
|
||||
@@ -180,29 +184,6 @@ public class OrderApplicationService {
|
||||
if (repository.saveOrder(tenantId, order) != 1) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "订单已被并发修改");
|
||||
}
|
||||
|
||||
private void validatePrototypeFields(OrderRequests.SaveRequest request) {
|
||||
if ("PICKUP".equals(request.pickupMethod()) && request.sender() == null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
|
||||
}
|
||||
if ("PRIVATE".equals(request.destinationType()) && request.receiver() == null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "私人地址必须填写收货信息");
|
||||
}
|
||||
if ("FBA".equals(request.destinationType())) {
|
||||
if (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId())
|
||||
|| !StringUtils.hasText(request.referenceId())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
|
||||
}
|
||||
for (var cargo : request.cargoes()) {
|
||||
if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())
|
||||
|| !StringUtils.hasText(cargo.shipmentId()) || !StringUtils.hasText(cargo.referenceId())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePersistedPrototypeFields(long tenantId, OrderEntity order) {
|
||||
if (!"TAX_INCLUDED".equals(order.getPricingTaxMode())
|
||||
&& !"TAX_EXCLUDED".equals(order.getPricingTaxMode())) {
|
||||
@@ -217,6 +198,15 @@ public class OrderApplicationService {
|
||||
}
|
||||
var cargoes = repository.findCargo(tenantId, order.getId());
|
||||
if (cargoes == null || cargoes.isEmpty()) throw new BizException("At least one cargo line is required");
|
||||
for (var cargo : cargoes) {
|
||||
if (!StringUtils.hasText(cargo.getCargoName()) || !StringUtils.hasText(cargo.getPackageType())
|
||||
|| cargo.getPackageCount() == null || cargo.getPackageCount() < 1
|
||||
|| cargo.getGrossWeightKg() == null || cargo.getGrossWeightKg().signum() <= 0
|
||||
|| cargo.getVolumeCbm() == null || cargo.getVolumeCbm().signum() <= 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
|
||||
}
|
||||
}
|
||||
var parties = repository.findParties(tenantId, order.getId());
|
||||
if ("PICKUP".equals(order.getPickupMethod()) && !hasParty(parties, "SENDER")) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
|
||||
@@ -241,6 +231,38 @@ public class OrderApplicationService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCompleteRequest(OrderRequests.SaveRequest request) {
|
||||
if ("PICKUP".equals(request.pickupMethod()) && request.sender() == null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
|
||||
}
|
||||
if ("PRIVATE".equals(request.destinationType()) && request.receiver() == null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "私人地址必须填写收货信息");
|
||||
}
|
||||
for (var cargo : request.cargoes()) {
|
||||
if (!StringUtils.hasText(cargo.cargoName()) || !StringUtils.hasText(cargo.packageType())
|
||||
|| cargo.packageCount() < 1 || cargo.grossWeightKg() == null
|
||||
|| cargo.grossWeightKg().signum() <= 0 || cargo.volumeCbm() == null
|
||||
|| cargo.volumeCbm().signum() <= 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
|
||||
}
|
||||
}
|
||||
if ("FBA".equals(request.destinationType())) {
|
||||
if (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId())
|
||||
|| !StringUtils.hasText(request.referenceId())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
|
||||
}
|
||||
for (var cargo : request.cargoes()) {
|
||||
if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())
|
||||
|| !StringUtils.hasText(cargo.shipmentId()) || !StringUtils.hasText(cargo.referenceId())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasParty(List<OrderPartyEntity> parties, String partyType) {
|
||||
return parties != null && parties.stream()
|
||||
.anyMatch(party -> party != null && partyType.equals(party.getPartyType()));
|
||||
@@ -385,7 +407,9 @@ public class OrderApplicationService {
|
||||
for (var cargo : request.cargoes()) {
|
||||
OrderCargoEntity item = new OrderCargoEntity();
|
||||
item.setTenantId(tenantId); item.setOrderId(orderId); item.setItemNo(itemNo++);
|
||||
item.setCargoName(cargo.cargoName()); item.setHsCode(cargo.hsCode()); item.setPackageType(cargo.packageType());
|
||||
item.setCargoName(cargo.cargoName());
|
||||
item.setHsCode(cargo.hsCode());
|
||||
item.setPackageType(cargo.packageType());
|
||||
item.setPackageCount(cargo.packageCount()); item.setGrossWeightKg(cargo.grossWeightKg());
|
||||
item.setVolumeCbm(cargo.volumeCbm()); item.setLengthCm(cargo.lengthCm()); item.setWidthCm(cargo.widthCm());
|
||||
item.setHeightCm(cargo.heightCm()); item.setDeclaredValue(cargo.declaredValue()); item.setCurrency(cargo.currency());
|
||||
|
||||
@@ -156,12 +156,12 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
|
||||
item_no INT NOT NULL COMMENT '订单内明细序号',
|
||||
cargo_name VARCHAR(128) NOT NULL COMMENT '货物名称',
|
||||
cargo_name VARCHAR(128) NULL COMMENT '货物名称;草稿可空,提交前必填',
|
||||
hs_code VARCHAR(20) NULL COMMENT '海关商品编码',
|
||||
package_type VARCHAR(32) NOT NULL COMMENT '包装类型',
|
||||
package_type VARCHAR(32) NULL COMMENT '包装类型;草稿可空,提交前必填',
|
||||
package_count INT UNSIGNED NOT NULL COMMENT '包装件数',
|
||||
gross_weight_kg DECIMAL(18,3) NOT NULL COMMENT '毛重(千克)',
|
||||
volume_cbm DECIMAL(18,4) NOT NULL COMMENT '体积(立方米)',
|
||||
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克);草稿可空,提交前必须大于0',
|
||||
volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米);草稿可空,提交前必须大于0',
|
||||
length_cm DECIMAL(12,2) NULL COMMENT '单件长度(厘米)',
|
||||
width_cm DECIMAL(12,2) NULL COMMENT '单件宽度(厘米)',
|
||||
height_cm DECIMAL(12,2) NULL COMMENT '单件高度(厘米)',
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: 柏悦头程订单 API
|
||||
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
|
||||
version: 1.5.0
|
||||
version: 1.6.0
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
@@ -171,6 +171,32 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
/api/merchant/v1/orders/first-leg/batch:
|
||||
post:
|
||||
summary: 批量创建头程订单草稿
|
||||
description: >-
|
||||
一次请求为多个外层行创建草稿;每个数组元素对应一张草稿,元素内 cargoes
|
||||
对应该行的多个产品明细。每项独立处理,允许部分成功;草稿仅校验渠道、目的仓、SKU
|
||||
和箱数,提交审核前再执行完整货物与提货资料校验。最多 50 项。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
items: {$ref: '#/components/schemas/FirstLegBatchDraftItem'}
|
||||
responses:
|
||||
'200':
|
||||
description: 批量处理完成;业务失败通过 items.errorCode 和 items.errorMessage 逐项返回
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FirstLegBatchDraftResponse'}
|
||||
'400': {description: 请求数组为空、超过 50 项或幂等键缺失}
|
||||
'403': {description: 需要 merchant:order:create 权限}
|
||||
/api/merchant/v1/orders/first-leg/{id}/submit:
|
||||
post:
|
||||
summary: 提交订单
|
||||
@@ -534,9 +560,41 @@ components:
|
||||
description: >-
|
||||
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
|
||||
是新建和编辑订单的必填报价维度;历史存量订单的响应字段仍允许为空。
|
||||
FirstLegBatchDraftItem:
|
||||
type: object
|
||||
required: [clientRowId, draft]
|
||||
properties:
|
||||
clientRowId:
|
||||
type: string
|
||||
maxLength: 128
|
||||
description: 前端外层行标识;同一批次内必须唯一,用于幂等重试和结果关联
|
||||
draft:
|
||||
$ref: '#/components/schemas/FirstLegOrderDraft'
|
||||
FirstLegBatchDraftResponse:
|
||||
type: object
|
||||
required: [total, succeeded, failed, items]
|
||||
properties:
|
||||
total: {type: integer, minimum: 0}
|
||||
succeeded: {type: integer, minimum: 0}
|
||||
failed: {type: integer, minimum: 0}
|
||||
items:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/FirstLegBatchDraftResult'}
|
||||
FirstLegBatchDraftResult:
|
||||
type: object
|
||||
required: [index, success]
|
||||
properties:
|
||||
index: {type: integer, minimum: 0}
|
||||
clientRowId: {type: string}
|
||||
success: {type: boolean}
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderNo: {type: string}
|
||||
inboundCode: {type: string, pattern: '^RK-[A-Za-z0-9-]+$'}
|
||||
errorCode: {type: string}
|
||||
errorMessage: {type: string}
|
||||
FirstLegOrderCargo:
|
||||
type: object
|
||||
required: [cargoName, packageType, packageCount, grossWeightKg, volumeCbm]
|
||||
required: [skuNo, packageCount]
|
||||
properties:
|
||||
skuNo: {type: string, maxLength: 64}
|
||||
cargoName: {type: string, maxLength: 128}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.MerchantBatchDraftDtos;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 批量快速下单 OpenAPI 契约,确保前端使用一次数组请求并获得逐项结果。 */
|
||||
class MerchantBatchDraftOpenApiContractTest {
|
||||
@Test
|
||||
void batchDraftEndpointRequiresCreatePermission() throws Exception {
|
||||
var method = MerchantFirstLegOrderController.class.getDeclaredMethod(
|
||||
"batchCreate", AuthenticatedUser.class, String.class, List.class);
|
||||
var authorization = method.getAnnotation(PreAuthorize.class);
|
||||
|
||||
assertThat(authorization).isNotNull();
|
||||
assertThat(authorization.value()).isEqualTo("hasAuthority('merchant:order:create')");
|
||||
assertThat(method.getGenericReturnType().getTypeName())
|
||||
.contains(MerchantBatchDraftDtos.Response.class.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void declaresBatchDraftEndpointAndPartialResultContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/order-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(openapi)
|
||||
.contains("/api/merchant/v1/orders/first-leg/batch:")
|
||||
.contains("items: {$ref: '#/components/schemas/FirstLegBatchDraftItem'}")
|
||||
.contains("maxItems: 50")
|
||||
.contains("merchant:order:create")
|
||||
.contains("FirstLegBatchDraftResponse")
|
||||
.contains("FirstLegBatchDraftResult")
|
||||
.contains("inboundCode:")
|
||||
.contains("errorCode:")
|
||||
.contains("errorMessage:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsSingleDraftEndpointForEditCompatibility() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/order-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi)
|
||||
.contains("/api/merchant/v1/orders/first-leg:")
|
||||
.contains("/api/merchant/v1/orders/first-leg/{id}/submit:")
|
||||
.contains("$ref: '#/components/schemas/FirstLegOrderDraft'");
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,25 @@ class OrderRequestsValidationTest {
|
||||
.anyMatch(violation -> violation.getPropertyPath().toString().equals("pricingTaxMode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftCargoAllowsIncompleteDetails() {
|
||||
var source = request(null, null, "TAX_INCLUDED");
|
||||
var cargo = new OrderRequests.Cargo(
|
||||
null, null, null, 1, null, null,
|
||||
null, null, null, null, null, false, null, "SKU-001", null, null,
|
||||
null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null);
|
||||
var draft = new OrderRequests.SaveRequest(
|
||||
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
|
||||
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
|
||||
source.expectedShipDate(), source.customerRemark(), null, null, List.of(cargo),
|
||||
source.pickupMethod(), source.receivingWarehouseId(), source.receivingWarehouseName(),
|
||||
source.channelCode(), source.channelName(), null, null, source.taxMode(), source.pricingTaxMode(),
|
||||
source.containerMode(), source.customsInspection(), source.destinationType(), null, null, null);
|
||||
|
||||
assertThat(validator.validate(draft)).isEmpty();
|
||||
}
|
||||
|
||||
private OrderRequests.SaveRequest request(Long carrierId, String carrierName, String pricingTaxMode) {
|
||||
var cargo = new OrderRequests.Cargo(
|
||||
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
|
||||
|
||||
+49
-37
@@ -57,7 +57,7 @@ class OrderApplicationServiceStateTest {
|
||||
assertThat(orderId).isNotNull();
|
||||
verify(eligibility, never()).requireApproved(anyLong());
|
||||
verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT
|
||||
&& order.getInboundCode() == null));
|
||||
&& ("RK-" + order.getId()).equals(order.getInboundCode())));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -378,67 +378,55 @@ class OrderApplicationServiceStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickupStillRequiresSender() {
|
||||
void pickupDraftAllowsMissingSender() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
OrderRequests.SaveRequest request = request(null, "PICKUP", null, null, "OVERSEAS");
|
||||
when(masterData.validate(20002L, request)).thenReturn(snapshots(null));
|
||||
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.createDraft(20002L, 2L, request, "pickup-without-sender"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("上门提货时必须填写发货信息")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
|
||||
verifyNoInteractions(masterData);
|
||||
service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.createDraft(20002L, 2L, request, "pickup-without-sender");
|
||||
verify(repository).insertOrder(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void privateDestinationStillRequiresReceiver() {
|
||||
void privateDraftAllowsMissingReceiver() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
OrderRequests.SaveRequest request = request(null, "SELF_EXPRESS", null, null, "PRIVATE");
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.createDraft(20002L, 2L, request, "private-without-receiver"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("私人地址必须填写收货信息")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
|
||||
verifyNoInteractions(masterData);
|
||||
when(masterData.validate(20002L, request)).thenReturn(snapshots(null));
|
||||
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
|
||||
service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.createDraft(20002L, 2L, request, "private-without-receiver");
|
||||
verify(repository).insertOrder(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fbaOrderStillRequiresAmazonWarehouseShipmentAndReferenceIds() {
|
||||
void fbaDraftAllowsMissingOrderIdentifiers() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
OrderRequests.SaveRequest request = fbaRequest(null, null, null);
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.createDraft(20002L, 2L, request, "fba-without-order-identifiers"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("FBA orders require Amazon warehouse code, Shipment ID and Reference ID")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
|
||||
verifyNoInteractions(masterData);
|
||||
when(masterData.validate(20002L, request)).thenReturn(snapshots(null));
|
||||
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
|
||||
service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.createDraft(20002L, 2L, request, "fba-without-order-identifiers");
|
||||
verify(repository).insertOrder(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fbaCargoStillRequiresSkuUpcShipmentAndReferenceIds() {
|
||||
void fbaDraftAllowsMissingCargoIdentifiers() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
OrderRequests.SaveRequest request = fbaRequest("ONT8", "FBA-SHIPMENT-1", "REF-1");
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.createDraft(20002L, 2L, request, "fba-without-cargo-identifiers"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
|
||||
verifyNoInteractions(masterData);
|
||||
when(masterData.validate(20002L, request)).thenReturn(snapshots(null));
|
||||
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
|
||||
service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.createDraft(20002L, 2L, request, "fba-without-cargo-identifiers");
|
||||
verify(repository).insertOrder(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -446,6 +434,11 @@ class OrderApplicationServiceStateTest {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEntity order = persistedDraft("PICKUP", "OVERSEAS");
|
||||
OrderCargoEntity cargo = new OrderCargoEntity();
|
||||
cargo.setCargoName("货物");
|
||||
cargo.setPackageType("CARTON");
|
||||
cargo.setPackageCount(1);
|
||||
cargo.setGrossWeightKg(BigDecimal.ONE);
|
||||
cargo.setVolumeCbm(BigDecimal.ONE);
|
||||
when(repository.findOwned(20002L, 1L)).thenReturn(order);
|
||||
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
|
||||
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
|
||||
@@ -460,6 +453,25 @@ class OrderApplicationServiceStateTest {
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submittingIncompleteDraftRequiresCargoDetails() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
|
||||
OrderCargoEntity cargo = new OrderCargoEntity();
|
||||
cargo.setPackageCount(1);
|
||||
when(repository.findOwned(20002L, 1L)).thenReturn(order);
|
||||
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("提交订单前必须完善货物品名、包装类型、箱数、毛重和体积")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatingAnotherCustomersOrderStopsBeforePersistence() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
|
||||
Reference in New Issue
Block a user