diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 4aead6e4a..18743f32c 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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; diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 4159ce349..ad2f958a5 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -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), diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index ddd63de6d..f072f41e7 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -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 batchCreate( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestBody List 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({ diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantBatchDraftDtos.java b/order/src/main/java/com/baiyue/order/dto/MerchantBatchDraftDtos.java new file mode 100644 index 000000000..b94bd111a --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/MerchantBatchDraftDtos.java @@ -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 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); + } + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index e56713a34..42cce323b 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -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, diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java new file mode 100644 index 000000000..cfdd87a5a --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java @@ -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); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java new file mode 100644 index 000000000..56b4e7f34 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java @@ -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 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 results = new ArrayList<>(items.size()); + Set 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); + } + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 4d3621c4b..1cbc6b5e4 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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 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()); diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 85bffca92..e78cbd8c7 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -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 '单件高度(厘米)', diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index e409d7a5d..efd6c2cd1 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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} diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java new file mode 100644 index 000000000..7329bc794 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java @@ -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'"); + } +} diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index 9ffd224f5..1cbe646e3 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -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, diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index aa492f2de..1de9ce7f6 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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);