Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
24 changed files with 992 additions and 49 deletions
Showing only changes of commit caffcb90a9 - Show all commits
+1 -1
View File
@@ -17,9 +17,9 @@
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.3.0</version></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,23 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
@FeignClient(name = "file-service", contextId = "orderBatchFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
public interface OrderBatchFileClient {
@PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId, @PathVariable String businessType,
@PathVariable String businessId, @RequestPart("file") MultipartFile file);
record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType,
String businessId, String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,62 @@
package com.baiyue.order.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.CustomerAddressDtos;
import com.baiyue.order.service.CustomerAddressService;
import com.baiyue.order.service.OrderScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/merchant/v1/order-addresses")
public class MerchantCustomerAddressController {
@Resource private CustomerAddressService service;
@GetMapping
@PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')")
public ApiResponse<List<CustomerAddressDtos.Response>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String addressType) {
return ApiResponse.success(service.list(OrderScopeGuard.merchant(user), addressType));
}
@PostMapping
@PreAuthorize("hasAuthority('merchant:order:create')")
@BusinessAudit(event = "ORDER_ADDRESS_CREATE", resourceType = "ORDER_ADDRESS")
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody CustomerAddressDtos.SaveRequest request) {
return ApiResponse.success(service.create(OrderScopeGuard.merchant(user), user.userId(), request));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('merchant:order:edit')")
@BusinessAudit(event = "ORDER_ADDRESS_UPDATE", resourceType = "ORDER_ADDRESS", resourceId = "#id")
public ApiResponse<Void> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody CustomerAddressDtos.SaveRequest request) {
service.update(OrderScopeGuard.merchant(user), user.userId(), id, request);
return ApiResponse.success(null);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('merchant:order:edit')")
@BusinessAudit(event = "ORDER_ADDRESS_DISABLE", resourceType = "ORDER_ADDRESS", resourceId = "#id")
public ApiResponse<Void> disable(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id) {
service.disable(OrderScopeGuard.merchant(user), user.userId(), id);
return ApiResponse.success(null);
}
}
@@ -7,6 +7,7 @@ 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.MerchantBatchImportDtos;
import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.dto.MerchantInboundOrderRow;
import com.baiyue.order.dto.InboundCodePolicyResponse;
@@ -16,6 +17,7 @@ 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 com.baiyue.order.service.MerchantBatchImportService;
import com.baiyue.order.service.InboundCodeAssociationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -33,6 +35,15 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -53,6 +64,8 @@ public class MerchantFirstLegOrderController {
@Resource
private MerchantBatchDraftService batchDraftService;
@Resource
private MerchantBatchImportService batchImportService;
@Resource
private InboundCodeAssociationService inboundCodeAssociations;
@GetMapping("/inbound-code-policy")
@@ -102,6 +115,29 @@ public class MerchantFirstLegOrderController {
OrderScopeGuard.merchant(user), user.userId(), user.username(), idempotencyKey, items));
}
@GetMapping("/batch-imports/template")
@PreAuthorize("hasAuthority('merchant:order:create')")
public ResponseEntity<byte[]> batchImportTemplate() {
byte[] content = batchImportService.template();
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename("first-leg-order-import.xlsx", StandardCharsets.UTF_8).build().toString())
.contentLength(content.length).body(content);
}
@BusinessAudit(event = "ORDER_BATCH_IMPORT", resourceType = "FIRST_LEG_ORDER", description = "Import order drafts from Excel")
@PostMapping(value = "/batch-imports", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('merchant:order:create')")
public ApiResponse<MerchantBatchImportDtos.Response> batchImport(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestParam MerchantBatchImportDtos.Mode mode,
@RequestPart("file") MultipartFile file) {
return ApiResponse.success(batchImportService.importFile(OrderScopeGuard.merchant(user), user.userId(),
user.username(), idempotencyKey, mode, file));
}
@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,32 @@
package com.baiyue.order.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public final class CustomerAddressDtos {
private CustomerAddressDtos() { }
public record SaveRequest(
@NotBlank @Pattern(regexp = "PICKUP|PRIVATE_DESTINATION") String addressType,
@NotBlank @Size(max = 64) String addressName,
@NotBlank @Size(max = 128) String companyName,
@NotBlank @Size(max = 64) String contactName,
@NotBlank @Size(max = 32) String phone,
@Email @Size(max = 128) String email,
@NotBlank @Size(min = 2, max = 2) String countryCode,
@Size(max = 64) String province,
@NotBlank @Size(max = 64) String city,
@Size(max = 64) String district,
@NotBlank @Size(max = 255) String addressLine,
@Size(max = 20) String postalCode,
boolean defaultAddress) { }
public record Response(
@Schema(description = "内部 ID,仅供订单选择和路由关联;Long 按字符串序列化") Long id,
String addressType, String addressName, String companyName, String contactName,
String phone, String email, String countryCode, String province, String city,
String district, String addressLine, String postalCode, boolean defaultAddress) { }
}
@@ -9,7 +9,10 @@ public final class MerchantBatchDraftDtos {
public record Item(
@Schema(description = "前端外层行唯一标识") String clientRowId,
@Schema(description = "该行草稿及其货物明细") OrderRequests.SaveRequest draft) { }
@Schema(description = "该行草稿及其货物明细") OrderRequests.SaveRequest draft,
@Schema(hidden = true) String parseError) {
public Item(String clientRowId, OrderRequests.SaveRequest draft) { this(clientRowId, draft, null); }
}
public record Response(int total, int succeeded, int failed, List<Result> items) { }
@@ -0,0 +1,10 @@
package com.baiyue.order.dto;
public final class MerchantBatchImportDtos {
private MerchantBatchImportDtos() { }
public enum Mode { ATOMIC, PARTIAL }
public record Response(String batchNo, Mode mode, Long sourceFileId, Long errorReportFileId,
MerchantBatchDraftDtos.Response result) { }
}
@@ -13,6 +13,7 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -37,19 +38,17 @@ public final class OrderRequests {
@Schema(description = "Incoterm / 贸易术语", example = "FOB", maxLength = 16)
@Size(max = 16) String tradeTerm,
@Schema(description = "Destination ISO country code / 目的国 ISO 两位代码", example = "US", minLength = 2, maxLength = 2, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(min = 2, max = 2) String destinationCountryCode,
@Size(min = 2, max = 2) String destinationCountryCode,
@Schema(description = "Destination warehouse ID, logical reference / 目的仓库 ID,逻辑引用", example = "190000000000000101")
Long destinationWarehouseId,
@Schema(description = "Destination warehouse name snapshot / 目的仓库名称快照", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 128) String destinationWarehouseName,
@Size(max = 128) String destinationWarehouseName,
@Schema(description = "Expected shipping date / 预计出运日期", example = "2026-08-15", type = "string", format = "date")
LocalDate expectedShipDate,
@Schema(description = "Customer remark / 客户备注", example = "Notify before pickup / 提货前联系", maxLength = 1000)
@Size(max = 1000) String customerRemark,
@Schema(description = "Sender information; required when pickupMethod is PICKUP / 发货方信息,取货方式为 PICKUP 时必填")
@Valid Party sender,
@Schema(description = "Receiver information; required for PRIVATE destination / 收货方信息,私人地址目的地时必填")
@Valid Party receiver,
@JsonIgnore @Schema(hidden = true) @Valid Party sender,
@JsonIgnore @Schema(hidden = true) @Valid Party receiver,
@Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty @Valid List<Cargo> cargoes,
@NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod,
@@ -67,7 +66,8 @@ public final class OrderRequests {
@Schema(description = "Estimate pricing tax mode; independent from taxMode / 参考报价包税维度,独立于 taxMode", allowableValues = {"TAX_INCLUDED", "TAX_EXCLUDED"})
@NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String pricingTaxMode,
@Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode,
@NotNull Boolean customsInspection,
@Schema(description = "Whether customs inspection is required; optional for non-wood cargo / 是否商检;非木制品可不填")
Boolean customsInspection,
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
@Size(max = 32) String amazonWarehouseCode,
@Size(max = 64) String shipmentId,
@@ -75,8 +75,30 @@ public final class OrderRequests {
@Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40)
@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
@Pattern(regexp = "STANDARD|ONE_CLICK_DROPSHIP") String fulfillmentType,
@Schema(description = "Selected customer pickup address ID; required for PICKUP / 已选客户取货地址 ID;PICKUP 时必填")
@Min(1) Long senderAddressId,
@Schema(description = "Selected private destination address ID; required for PRIVATE / 已选私人目的仓地址 ID;PRIVATE 时必填")
@Min(1) Long privateDestinationAddressId
) {
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, String fulfillmentType) {
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, fulfillmentType, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
@@ -92,7 +114,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, inboundCode, null);
shipmentId, referenceId, inboundCode, null, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
@@ -109,7 +131,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, null);
shipmentId, referenceId, null, null, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
@@ -126,7 +148,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, null);
shipmentId, referenceId, null, null, null, null);
}
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
@@ -200,8 +222,24 @@ public final class OrderRequests {
@DecimalMin("0.01") BigDecimal productHeightCm,
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId,
@Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId
@Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId,
@Schema(description = "Authoritative wood-product decision frozen with this cargo line / 木制品权威判定,随货物行冻结", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull Boolean woodenProduct
) {
public Cargo(String cargoName, String hsCode, String packageType, int packageCount,
BigDecimal grossWeightKg, BigDecimal volumeCbm, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, BigDecimal declaredValue, String currency,
boolean dangerousGoods, String cargoRemark, String skuNo, String upcNo,
String cargoNameEn, String customsHsCode, Integer unitsPerPackage, Integer totalQuantity,
BigDecimal netWeightKg, BigDecimal unitDeclaredValue, String material, String usage,
String model, String brand, BigDecimal productLengthCm, BigDecimal productWidthCm,
BigDecimal productHeightCm, String shipmentId, String referenceId, Long productImageFileId) {
this(cargoName, hsCode, packageType, packageCount, grossWeightKg, volumeCbm, lengthCm, widthCm,
heightCm, declaredValue, currency, dangerousGoods, cargoRemark, skuNo, upcNo, cargoNameEn,
customsHsCode, unitsPerPackage, totalQuantity, netWeightKg, unitDeclaredValue, material, usage,
model, brand, productLengthCm, productWidthCm, productHeightCm, shipmentId, referenceId,
productImageFileId, false);
}
}
@Schema(name = "FirstLegOrderQueryRequest", description = "First-leg order page query / 头程订单分页查询条件")
@@ -0,0 +1,31 @@
package com.baiyue.order.entity;
import com.baiyue.common.core.model.BaseEntity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/** 客户自有地址簿;平台仓库主档不在本表维护。 */
@Getter
@Setter
@TableName("ord_customer_address")
public class CustomerAddressEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private String addressType;
private String addressName;
private String companyName;
private String contactName;
private String phone;
private String email;
private String countryCode;
private String province;
private String city;
private String district;
private String addressLine;
private String postalCode;
private Boolean defaultAddress;
private Boolean active;
}
@@ -12,7 +12,7 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.
* 申报价值及危险品标识等货物信息。</p>
*/
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId;
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId; private Boolean woodenProduct;
}
@@ -0,0 +1,8 @@
package com.baiyue.order.mapper;
import com.baiyue.order.entity.CustomerAddressEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CustomerAddressMapper extends BaseMapper<CustomerAddressEntity> { }
@@ -0,0 +1,118 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.CustomerAddressDtos;
import com.baiyue.order.entity.CustomerAddressEntity;
import com.baiyue.order.mapper.CustomerAddressMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class CustomerAddressService {
@Resource private CustomerAddressMapper mapper;
public List<CustomerAddressDtos.Response> list(long tenantId, String addressType) {
if (addressType != null && !addressType.matches("PICKUP|PRIVATE_DESTINATION")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "地址类型不正确");
}
return mapper.selectList(new LambdaQueryWrapper<CustomerAddressEntity>()
.eq(CustomerAddressEntity::getTenantId, tenantId)
.eq(CustomerAddressEntity::getActive, true)
.eq(addressType != null, CustomerAddressEntity::getAddressType, addressType)
.orderByDesc(CustomerAddressEntity::getDefaultAddress)
.orderByDesc(CustomerAddressEntity::getUpdatedAt))
.stream().map(CustomerAddressService::response).toList();
}
@Transactional
public Long create(long tenantId, long userId, CustomerAddressDtos.SaveRequest request) {
CustomerAddressEntity value = new CustomerAddressEntity();
value.setId(IdWorker.getId());
value.setTenantId(tenantId);
apply(value, request);
value.setActive(true);
value.setCreatedBy(userId);
value.setUpdatedBy(userId);
if (request.defaultAddress()) clearDefault(tenantId, request.addressType(), null);
mapper.insert(value);
return value.getId();
}
@Transactional
public void update(long tenantId, long userId, long id, CustomerAddressDtos.SaveRequest request) {
CustomerAddressEntity value = required(tenantId, id);
if (!value.getAddressType().equals(request.addressType())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "地址类型创建后不可修改");
}
if (request.defaultAddress()) clearDefault(tenantId, request.addressType(), id);
apply(value, request);
value.setUpdatedBy(userId);
if (mapper.update(value, new LambdaUpdateWrapper<CustomerAddressEntity>()
.eq(CustomerAddressEntity::getTenantId, tenantId)
.eq(CustomerAddressEntity::getId, id)
.eq(CustomerAddressEntity::getActive, true)) != 1) {
throw new BizException(ResultCode.CONFLICT, "地址已被并发修改");
}
}
@Transactional
public void disable(long tenantId, long userId, long id) {
CustomerAddressEntity value = required(tenantId, id);
value.setActive(false);
value.setDefaultAddress(false);
value.setUpdatedBy(userId);
mapper.update(value, new LambdaUpdateWrapper<CustomerAddressEntity>()
.eq(CustomerAddressEntity::getTenantId, tenantId).eq(CustomerAddressEntity::getId, id));
}
public CustomerAddressEntity required(long tenantId, long id, String expectedType) {
CustomerAddressEntity value = required(tenantId, id);
if (!expectedType.equals(value.getAddressType())) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"PICKUP".equals(expectedType) ? "请选择取货地址" : "请选择私人目的仓地址");
}
return value;
}
private CustomerAddressEntity required(long tenantId, long id) {
CustomerAddressEntity value = mapper.selectOne(new LambdaQueryWrapper<CustomerAddressEntity>()
.eq(CustomerAddressEntity::getTenantId, tenantId)
.eq(CustomerAddressEntity::getId, id)
.eq(CustomerAddressEntity::getActive, true));
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "客户地址不存在或已停用");
return value;
}
private void clearDefault(long tenantId, String type, Long excludedId) {
mapper.update(null, new LambdaUpdateWrapper<CustomerAddressEntity>()
.eq(CustomerAddressEntity::getTenantId, tenantId)
.eq(CustomerAddressEntity::getAddressType, type)
.eq(CustomerAddressEntity::getActive, true)
.ne(excludedId != null, CustomerAddressEntity::getId, excludedId)
.set(CustomerAddressEntity::getDefaultAddress, false));
}
private static void apply(CustomerAddressEntity value, CustomerAddressDtos.SaveRequest source) {
value.setAddressType(source.addressType()); value.setAddressName(source.addressName().trim());
value.setCompanyName(source.companyName().trim()); value.setContactName(source.contactName().trim());
value.setPhone(source.phone().trim()); value.setEmail(source.email());
value.setCountryCode(source.countryCode().trim().toUpperCase()); value.setProvince(source.province());
value.setCity(source.city().trim()); value.setDistrict(source.district());
value.setAddressLine(source.addressLine().trim()); value.setPostalCode(source.postalCode());
value.setDefaultAddress(source.defaultAddress());
}
private static CustomerAddressDtos.Response response(CustomerAddressEntity value) {
return new CustomerAddressDtos.Response(value.getId(), value.getAddressType(), value.getAddressName(),
value.getCompanyName(), value.getContactName(), value.getPhone(), value.getEmail(), value.getCountryCode(),
value.getProvince(), value.getCity(), value.getDistrict(), value.getAddressLine(), value.getPostalCode(),
Boolean.TRUE.equals(value.getDefaultAddress()));
}
}
@@ -26,6 +26,20 @@ public class MerchantBatchDraftItemService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public OrderEntity create(long tenantId, long userId, String username,
OrderRequests.SaveRequest draft, String idempotencyKey) {
validate(draft);
Long id = orders.createDraft(tenantId, userId, username, draft, idempotencyKey);
return repository.findOwned(tenantId, id);
}
@Transactional(propagation = Propagation.MANDATORY)
public OrderEntity createJoined(long tenantId, long userId, String username,
OrderRequests.SaveRequest draft, String idempotencyKey) {
validate(draft);
Long id = orders.createDraft(tenantId, userId, username, draft, idempotencyKey);
return repository.findOwned(tenantId, id);
}
public void validate(OrderRequests.SaveRequest draft) {
var violations = validator.validate(draft);
if (!violations.isEmpty()) {
String message = violations.stream()
@@ -35,7 +49,5 @@ public class MerchantBatchDraftItemService {
.orElse("草稿参数不合法");
throw new BizException(ResultCode.VALIDATION_FAILED, message);
}
Long id = orders.createDraft(tenantId, userId, username, draft, idempotencyKey);
return repository.findOwned(tenantId, id);
}
}
@@ -1,10 +1,12 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.entity.OrderEntity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
@@ -37,7 +39,8 @@ public class MerchantBatchDraftService {
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", "草稿不能为空"));
results.add(MerchantBatchDraftDtos.Result.failure(index, rowId, "A0422",
item != null && StringUtils.hasText(item.parseError()) ? item.parseError() : "草稿不能为空"));
continue;
}
if (!StringUtils.hasText(rowId)) {
@@ -62,6 +65,39 @@ public class MerchantBatchDraftService {
return new MerchantBatchDraftDtos.Response(items.size(), succeeded, items.size() - succeeded, results);
}
@Transactional
public MerchantBatchDraftDtos.Response createAtomic(long tenantId, long userId, String username,
String batchKey, List<MerchantBatchDraftDtos.Item> items) {
validateBatch(batchKey, items);
Set<String> rowIds = new HashSet<>();
for (var item : items) {
if (item == null || item.draft() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED,
item != null && StringUtils.hasText(item.parseError()) ? item.parseError() : "草稿不能为空");
}
if (!StringUtils.hasText(item.clientRowId()) || !rowIds.add(item.clientRowId())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "clientRowId 不能为空且批次内不能重复");
}
itemService.validate(item.draft());
}
List<MerchantBatchDraftDtos.Result> results = new ArrayList<>();
for (int index = 0; index < items.size(); index++) {
var item = items.get(index);
OrderEntity order = itemService.createJoined(tenantId, userId, username, item.draft(),
derivedKey(batchKey, item.clientRowId()));
results.add(MerchantBatchDraftDtos.Result.success(index, item.clientRowId(), order.getId(),
order.getOrderNo(), order.getInboundCode()));
}
return new MerchantBatchDraftDtos.Response(items.size(), items.size(), 0, results);
}
private static void validateBatch(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 条");
}
static String derivedKey(String batchKey, String rowId) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
@@ -0,0 +1,196 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.OrderBatchFileClient;
import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.dto.MerchantBatchImportDtos;
import com.baiyue.order.dto.OrderRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
@Service
public class MerchantBatchImportService {
private static final int MAX_ROWS = 50;
private static final long MAX_BYTES = 5L * 1024 * 1024;
@Resource private MerchantBatchDraftService drafts;
@Resource private OrderBatchFileClient files;
@Resource private ObjectMapper json;
@Value("${baiyue.security.internal-service-token:}") private String token;
private static final List<String> TEMPLATE_HEADERS = List.of(
"clientRowId", "customerName", "transportMode", "tradeTerm", "destinationCountryCode",
"destinationWarehouseId", "destinationWarehouseName", "expectedShipDate", "customerRemark",
"pickupMethod", "requestedPickupAt", "senderAddressId", "receivingWarehouseId",
"receivingWarehouseName", "channelCode", "channelName", "taxMode", "pricingTaxMode",
"containerMode", "customsInspection", "destinationType", "privateDestinationAddressId",
"amazonWarehouseCode", "shipmentId", "referenceId", "fulfillmentType", "cargo.cargoName",
"cargo.packageType", "cargo.packageCount", "cargo.grossWeightKg", "cargo.volumeCbm",
"cargo.material", "cargo.woodenProduct", "cargo.skuNo", "cargo.upcNo", "cargo.shipmentId",
"cargo.referenceId");
public byte[] template() {
try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("订单导入");
Row header = sheet.createRow(0);
for (int i = 0; i < TEMPLATE_HEADERS.size(); i++) header.createCell(i).setCellValue(TEMPLATE_HEADERS.get(i));
Row example = sheet.createRow(1);
example.createCell(0).setCellValue("row-001");
example.createCell(1).setCellValue("示例客户"); example.createCell(2).setCellValue("SEA");
example.createCell(9).setCellValue("SELF_EXPRESS"); example.createCell(12).setCellValue("190000000000000101");
example.createCell(13).setCellValue("国内收货仓"); example.createCell(14).setCellValue("CHANNEL-001");
example.createCell(15).setCellValue("示例渠道"); example.createCell(16).setCellValue("BUY_DECLARATION");
example.createCell(17).setCellValue("TAX_EXCLUDED"); example.createCell(20).setCellValue("OVERSEAS");
example.createCell(26).setCellValue("示例货物"); example.createCell(27).setCellValue("CARTON");
example.createCell(28).setCellValue("1"); example.createCell(29).setCellValue("1.000");
example.createCell(30).setCellValue("0.0100"); example.createCell(32).setCellValue("false");
for (int i = 0; i < TEMPLATE_HEADERS.size(); i++) sheet.setColumnWidth(i, 20 * 256);
workbook.write(out);
return out.toByteArray();
} catch (Exception exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "批量下单模板生成失败");
}
}
public MerchantBatchImportDtos.Response importFile(long tenantId, long userId, String username,
String idempotencyKey, MerchantBatchImportDtos.Mode mode,
MultipartFile file) {
requireFile(file);
String batchNo = batchNo(tenantId, userId, idempotencyKey);
byte[] content;
try { content = file.getBytes(); }
catch (Exception exception) { throw new BizException(ResultCode.BAD_REQUEST, "无法读取 Excel 文件"); }
List<MerchantBatchDraftDtos.Item> items = parse(content);
Long sourceFileId = upload(tenantId, userId, batchNo, "ORDER_BATCH_IMPORT_SOURCE",
new BytesMultipartFile("file", safeName(file.getOriginalFilename()),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content));
MerchantBatchDraftDtos.Response result = mode == MerchantBatchImportDtos.Mode.ATOMIC
? drafts.createAtomic(tenantId, userId, username, idempotencyKey, items)
: drafts.create(tenantId, userId, username, idempotencyKey, items);
Long errorFileId = result.failed() == 0 ? null : upload(tenantId, userId, batchNo,
"ORDER_BATCH_IMPORT_ERROR_REPORT", errorReport(result));
return new MerchantBatchImportDtos.Response(batchNo, mode, sourceFileId, errorFileId, result);
}
private List<MerchantBatchDraftDtos.Item> parse(byte[] content) {
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
var sheet = workbook.getSheetAt(0);
if (sheet.getLastRowNum() < 1) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 没有订单数据行");
List<MerchantBatchDraftDtos.Item> items = new ArrayList<>();
Row header = sheet.getRow(0);
if (header == null || !"clientRowId".equals(text(header.getCell(0)))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
}
List<String> headers = new ArrayList<>();
for (int col = 0; col < header.getLastCellNum(); col++) headers.add(text(header.getCell(col)));
for (int rowNo = 1; rowNo <= sheet.getLastRowNum(); rowNo++) {
Row row = sheet.getRow(rowNo);
if (row == null || !StringUtils.hasText(text(row.getCell(0)))) continue;
if (items.size() >= MAX_ROWS) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 最多 50 条订单");
String rowId = text(row.getCell(0));
try {
var draft = json.createObjectNode();
var cargo = json.createObjectNode();
for (int col = 1; col < headers.size(); col++) {
String field = headers.get(col); String value = text(row.getCell(col));
if (!StringUtils.hasText(field) || !StringUtils.hasText(value)) continue;
if (field.startsWith("cargo.")) cargo.put(field.substring("cargo.".length()), value);
else draft.put(field, value);
}
draft.set("cargoes", json.createArrayNode().add(cargo));
items.add(new MerchantBatchDraftDtos.Item(rowId,
json.treeToValue(draft, OrderRequests.SaveRequest.class)));
} catch (Exception exception) {
items.add(new MerchantBatchDraftDtos.Item(rowId, null, "Excel 行字段格式不正确"));
}
}
if (items.isEmpty()) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 没有订单数据行");
return items;
} catch (BizException exception) { throw exception; }
catch (Exception exception) { throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 文件损坏或格式不正确"); }
}
private MultipartFile errorReport(MerchantBatchDraftDtos.Response result) {
try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("错误报告");
Row header = sheet.createRow(0);
String[] headers = {"rowIndex", "clientRowId", "errorCode", "errorMessage"};
for (int i = 0; i < headers.length; i++) header.createCell(i).setCellValue(headers[i]);
int rowNo = 1;
for (var item : result.items()) if (!item.success()) {
Row row = sheet.createRow(rowNo++);
row.createCell(0).setCellValue(item.index() + 2);
row.createCell(1).setCellValue(item.clientRowId());
row.createCell(2).setCellValue(item.errorCode());
row.createCell(3).setCellValue(item.errorMessage());
}
workbook.write(out);
return new BytesMultipartFile("file", "order-batch-errors.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", out.toByteArray());
} catch (Exception exception) { throw new BizException(ResultCode.INTERNAL_ERROR, "错误报告生成失败"); }
}
private Long upload(long tenantId, long userId, String batchNo, String type, MultipartFile file) {
try {
var response = files.upload(token, userId, tenantId, type, batchNo, file);
if (response == null || !response.successful() || response.data() == null || response.data().fileId() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "批量下单文件存储失败");
}
return response.data().fileId();
} catch (BizException exception) { throw exception; }
catch (RuntimeException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "批量下单文件存储失败"); }
}
private static void requireFile(MultipartFile file) {
if (file == null || file.isEmpty()) throw new BizException(ResultCode.VALIDATION_FAILED, "请选择 Excel 文件");
if (file.getSize() > MAX_BYTES) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 文件不能超过 5MB");
String name = safeName(file.getOriginalFilename()).toLowerCase();
if (!name.endsWith(".xlsx")) throw new BizException(ResultCode.VALIDATION_FAILED, "仅支持 .xlsx 文件");
}
private static String text(Cell cell) {
if (cell == null) return null;
cell.setCellType(org.apache.poi.ss.usermodel.CellType.STRING);
String value = cell.getStringCellValue();
return value == null ? null : value.trim();
}
private static String safeName(String name) { return StringUtils.hasText(name) ? name : "order-batch.xlsx"; }
private static String batchNo(long tenantId, long userId, String key) {
if (!StringUtils.hasText(key)) throw new BizException(ResultCode.BAD_REQUEST, "必须提供 Idempotency-Key");
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest((tenantId + ":" + userId + ":" + key.trim()).getBytes(StandardCharsets.UTF_8));
StringBuilder out = new StringBuilder("OBI-");
for (int i = 0; i < 10; i++) out.append(String.format("%02x", digest[i]));
return out.toString().toUpperCase();
} catch (Exception exception) { throw new IllegalStateException(exception); }
}
private record BytesMultipartFile(String name, String originalFilename, String contentType, byte[] bytes)
implements MultipartFile {
@Override public String getName() { return name; }
@Override public String getOriginalFilename() { return originalFilename; }
@Override public String getContentType() { return contentType; }
@Override public boolean isEmpty() { return bytes == null || bytes.length == 0; }
@Override public long getSize() { return bytes == null ? 0 : bytes.length; }
@Override public byte[] getBytes() { return bytes == null ? new byte[0] : bytes.clone(); }
@Override public java.io.InputStream getInputStream() { return new ByteArrayInputStream(getBytes()); }
@Override public void transferTo(java.io.File dest) throws java.io.IOException {
java.nio.file.Files.write(dest.toPath(), getBytes());
}
}
}
@@ -47,6 +47,7 @@ public class OrderApplicationService {
@Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private ProductImageGuard productImageGuard;
@Resource private ObjectProvider<CustomerAddressService> customerAddressServiceProvider;
@Resource private InboundCodeModeProvider inboundCodeModeProvider;
@Resource private ObjectProvider<FirstlegInboundClient> firstlegProvider;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@@ -78,6 +79,8 @@ public class OrderApplicationService {
private Long createDraftInternal(long tenantId, long userId, String username,
OrderRequests.SaveRequest request) {
AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
validateWoodInspection(request);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
@@ -86,14 +89,14 @@ public class OrderApplicationService {
long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId));
order.setStatus(OrderStatus.DRAFT);
apply(order, tenantId, request, snapshots);
apply(order, tenantId, request, snapshots, addressSnapshots);
order.setCreatedBy(userId); order.setUpdatedBy(userId);
try {
repository.insertOrder(order);
} catch (DuplicateKeyException duplicate) {
throw duplicateInboundCode(order, duplicate);
}
saveChildren(tenantId, userId, order.getId(), request);
saveChildren(tenantId, userId, order.getId(), request, addressSnapshots);
record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, username);
return order.getId();
}
@@ -115,6 +118,8 @@ public class OrderApplicationService {
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
validateWoodInspection(request);
OrderEntity order = require(tenantId, id);
if (allowSubmitted) validateCompleteRequest(request);
productImageGuard.requireOwned(tenantId, request.cargoes());
@@ -126,7 +131,7 @@ public class OrderApplicationService {
}
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
request.destinationType());
apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
apply(order, tenantId, request, snapshots, addressSnapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.SUBMITTED) {
order.setStatus(OrderStatus.SUBMITTED);
}
@@ -136,7 +141,7 @@ public class OrderApplicationService {
throw duplicateInboundCode(order, duplicate);
}
repository.replaceDraftChildren(tenantId, id);
saveChildren(tenantId, userId, id, request);
saveChildren(tenantId, userId, id, request, addressSnapshots);
record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null);
if (before == OrderStatus.SUBMITTED) prepareDraftInbound(order, request);
}
@@ -273,7 +278,7 @@ public class OrderApplicationService {
if (!StringUtils.hasText(order.getPickupMethod()) || order.getReceivingWarehouseId() == null
|| !StringUtils.hasText(order.getReceivingWarehouseName())
|| !StringUtils.hasText(order.getChannelCode()) || !StringUtils.hasText(order.getChannelName())
|| !StringUtils.hasText(order.getTaxMode()) || order.getCustomsInspection() == null
|| !StringUtils.hasText(order.getTaxMode())
|| !StringUtils.hasText(order.getDestinationType())) {
throw new BizException("Order phase-one required fields are incomplete");
}
@@ -287,6 +292,9 @@ public class OrderApplicationService {
throw new BizException(ResultCode.VALIDATION_FAILED,
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
}
if (Boolean.TRUE.equals(cargo.getWoodenProduct()) && !Boolean.TRUE.equals(order.getCustomsInspection())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检");
}
}
var parties = repository.findParties(tenantId, order.getId());
if ("PICKUP".equals(order.getPickupMethod()) && !hasParty(parties, "SENDER")) {
@@ -313,15 +321,16 @@ public class OrderApplicationService {
}
private void validateCompleteRequest(OrderRequests.SaveRequest request) {
if ("PICKUP".equals(request.pickupMethod()) && request.sender() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
if ("PICKUP".equals(request.pickupMethod()) && request.senderAddressId() == null && request.sender() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须选择取货地址");
}
if ("PICKUP".equals(request.pickupMethod()) && request.requestedPickupAt() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写预约提货时间");
}
if ("PRIVATE".equals(request.destinationType()) && request.receiver() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "私人地址必须填写收货信息");
if ("PRIVATE".equals(request.destinationType()) && request.privateDestinationAddressId() == null && request.receiver() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "私人目的仓必须选择客户地址");
}
validateWoodInspection(request);
for (var cargo : request.cargoes()) {
if (!StringUtils.hasText(cargo.cargoName()) || !StringUtils.hasText(cargo.packageType())
|| cargo.packageCount() < 1 || cargo.grossWeightKg() == null
@@ -442,15 +451,20 @@ public class OrderApplicationService {
default -> false;
};
}
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request,
OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses) {
order.setCustomerId(tenantId);
applyInboundCode(order, request.inboundCode());
order.setCustomerName(request.customerName().trim());
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode()
order.setDestinationCountryCode(addresses.privateDestination() != null
? addresses.privateDestination().getCountryCode()
: snapshots.destination() == null ? request.destinationCountryCode()
: snapshots.destination().warehouse().countryCode());
order.setDestinationWarehouseId(request.destinationWarehouseId());
order.setDestinationWarehouseName(snapshots.destination() == null ? request.destinationWarehouseName()
order.setDestinationWarehouseName(addresses.privateDestination() != null
? addresses.privateDestination().getAddressName()
: snapshots.destination() == null ? request.destinationWarehouseName()
: snapshots.destination().warehouse().warehouseName());
order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark());
order.setPickupMethod(request.pickupMethod());
@@ -545,7 +559,8 @@ public class OrderApplicationService {
.map(c -> c.cutoffTime() == null ? "支持收货" : "每日 " + c.cutoffTime() + " 前收货")
.findFirst().orElse(null);
}
private void saveChildren(long tenantId, long userId, long orderId, OrderRequests.SaveRequest request) {
private void saveChildren(long tenantId, long userId, long orderId, OrderRequests.SaveRequest request,
AddressSnapshots addresses) {
int itemNo = 1;
for (var cargo : request.cargoes()) {
OrderCargoEntity item = new OrderCargoEntity();
@@ -566,19 +581,67 @@ public class OrderApplicationService {
item.setProductHeightCm(cargo.productHeightCm()); item.setShipmentId(cargo.shipmentId());
item.setReferenceId(cargo.referenceId());
item.setProductImageFileId(cargo.productImageFileId());
item.setWoodenProduct(cargo.woodenProduct());
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
}
if (request.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", request.sender());
if (request.receiver() != null) saveParty(tenantId, userId, orderId, "RECEIVER", request.receiver());
if (addresses.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", addresses.sender());
if (addresses.privateDestination() != null) saveParty(tenantId, userId, orderId, "RECEIVER", addresses.privateDestination());
}
private void saveParty(long tenantId, long userId, long orderId, String type, OrderRequests.Party party) {
private void saveParty(long tenantId, long userId, long orderId, String type, CustomerAddressEntity party) {
OrderPartyEntity item = new OrderPartyEntity();
item.setTenantId(tenantId); item.setOrderId(orderId); item.setPartyType(type);
item.setCompanyName(party.companyName()); item.setContactName(party.contactName()); item.setPhone(party.phone());
item.setEmail(party.email()); item.setCountryCode(party.countryCode()); item.setProvince(party.province());
item.setCity(party.city()); item.setDistrict(party.district()); item.setAddressLine(party.addressLine());
item.setPostalCode(party.postalCode()); item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertParty(item);
item.setCompanyName(party.getCompanyName()); item.setContactName(party.getContactName()); item.setPhone(party.getPhone());
item.setEmail(party.getEmail()); item.setCountryCode(party.getCountryCode()); item.setProvince(party.getProvince());
item.setCity(party.getCity()); item.setDistrict(party.getDistrict()); item.setAddressLine(party.getAddressLine());
item.setPostalCode(party.getPostalCode()); item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertParty(item);
}
private AddressSnapshots resolveCustomerAddresses(long tenantId, OrderRequests.SaveRequest request) {
CustomerAddressEntity sender = null;
CustomerAddressEntity privateDestination = null;
if ("PICKUP".equals(request.pickupMethod())) {
if (request.senderAddressId() == null) {
if (request.sender() != null) sender = transientAddress(request.sender(), "PICKUP", "临时取货地址");
else throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须选择取货地址");
} else {
sender = customerAddressServiceProvider.getObject().required(tenantId, request.senderAddressId(), "PICKUP");
}
} else if (request.senderAddressId() != null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "非上门提货订单不得选择取货地址");
}
if ("PRIVATE".equals(request.destinationType())) {
if (request.privateDestinationAddressId() == null) {
if (request.receiver() != null) privateDestination = transientAddress(
request.receiver(), "PRIVATE_DESTINATION", request.destinationWarehouseName());
else throw new BizException(ResultCode.VALIDATION_FAILED, "私人目的仓必须选择客户地址");
} else {
privateDestination = customerAddressServiceProvider.getObject().required(
tenantId, request.privateDestinationAddressId(), "PRIVATE_DESTINATION");
}
} else if (request.privateDestinationAddressId() != null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "公共目的仓订单不得选择私人地址");
}
return new AddressSnapshots(sender, privateDestination);
}
static void validateWoodInspection(OrderRequests.SaveRequest request) {
boolean containsWood = request.cargoes() != null && request.cargoes().stream()
.anyMatch(cargo -> cargo != null && Boolean.TRUE.equals(cargo.woodenProduct()));
if (containsWood && !Boolean.TRUE.equals(request.customsInspection())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检");
}
}
private static CustomerAddressEntity transientAddress(OrderRequests.Party source, String type, String name) {
CustomerAddressEntity value = new CustomerAddressEntity();
value.setAddressType(type); value.setAddressName(name); value.setCompanyName(source.companyName());
value.setContactName(source.contactName()); value.setPhone(source.phone()); value.setEmail(source.email());
value.setCountryCode(source.countryCode()); value.setProvince(source.province()); value.setCity(source.city());
value.setDistrict(source.district()); value.setAddressLine(source.addressLine()); value.setPostalCode(source.postalCode());
return value;
}
private record AddressSnapshots(CustomerAddressEntity sender, CustomerAddressEntity privateDestination) { }
private void record(long tenantId, long orderId, OrderStatus from, OrderStatus to, String action,
String reason, long userId, String username) {
OrderStatusHistoryEntity history = new OrderStatusHistoryEntity();
+39
View File
@@ -236,6 +236,33 @@ CREATE TABLE IF NOT EXISTS ord_order_party (
CONSTRAINT fk_party_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
) ENGINE=InnoDB COMMENT='订单收发货方快照表:固化下单时发货人和收货人信息,避免主数据变化影响历史订单';
CREATE TABLE IF NOT EXISTS ord_customer_address (
id BIGINT UNSIGNED NOT NULL COMMENT '客户地址ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
address_type VARCHAR(32) NOT NULL COMMENT 'PICKUP取货地址或PRIVATE_DESTINATION私人目的仓',
address_name VARCHAR(64) NOT NULL COMMENT '客户自定义地址名称',
company_name VARCHAR(128) NOT NULL COMMENT '公司名称',
contact_name VARCHAR(64) NOT NULL COMMENT '联系人',
phone VARCHAR(32) NOT NULL COMMENT '联系电话',
email VARCHAR(128) NULL COMMENT '联系邮箱',
country_code CHAR(2) NOT NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省州',
city VARCHAR(64) NOT NULL COMMENT '城市',
district VARCHAR(64) NULL COMMENT '区县',
address_line VARCHAR(255) NOT NULL COMMENT '详细地址',
postal_code VARCHAR(20) NULL COMMENT '邮政编码',
default_address TINYINT(1) NOT NULL DEFAULT 0 COMMENT '同类型默认地址',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可选',
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_customer_address_scope (tenant_id, address_type, active, updated_at)
) ENGINE=InnoDB COMMENT='客户自有下单地址簿;不承载平台仓库主档';
CREATE TABLE IF NOT EXISTS ord_order_cargo (
id BIGINT UNSIGNED NOT NULL COMMENT '货物明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -272,6 +299,7 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
shipment_id VARCHAR(64) NULL COMMENT '货物级 Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID',
product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID',
wooden_product TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
@@ -284,6 +312,17 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
CONSTRAINT fk_cargo_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
) ENGINE=InnoDB COMMENT='订单货物明细表:记录件数、重量、体积、申报价值和危险品属性';
SET @order_wooden_product_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order_cargo'
AND column_name = 'wooden_product'),
'SELECT 1',
'ALTER TABLE baiyue_order.ord_order_cargo ADD COLUMN wooden_product TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''木制品权威判定快照;1时订单必须商检'' AFTER product_image_file_id'
);
PREPARE order_wooden_product_stmt FROM @order_wooden_product_sql;
EXECUTE order_wooden_product_stmt;
DEALLOCATE PREPARE order_wooden_product_stmt;
CREATE TABLE IF NOT EXISTS ord_order_internal_note (
id BIGINT UNSIGNED NOT NULL COMMENT '内部备注ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
+95 -3
View File
@@ -6,6 +6,76 @@ info:
security:
- bearerAuth: []
paths:
/api/merchant/v1/order-addresses:
get:
summary: 查询当前客户下单地址簿
parameters:
- name: addressType
in: query
required: false
schema: {type: string, enum: [PICKUP, PRIVATE_DESTINATION]}
responses:
'200': {description: 只返回登录客户可用地址}
'403': {description: 需要订单查看、创建或编辑权限}
post:
summary: 保存当前客户下单地址
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerOrderAddressSaveRequest'}
responses:
'200': {description: 返回地址内部 ID,Long 按字符串序列化}
'403': {description: 需要 merchant:order:create 权限}
/api/merchant/v1/order-addresses/{id}:
put:
summary: 修改当前客户下单地址
description: 已创建订单保存独立快照,本操作不会修改历史订单。
parameters:
- {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerOrderAddressSaveRequest'}
responses: {'200': {description: 成功}, '403': {description: 需要 merchant:order:edit 权限}}
delete:
summary: 停用当前客户下单地址
description: 停用后不能用于新订单,历史订单快照不受影响。
parameters:
- {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: {'200': {description: 成功}, '403': {description: 需要 merchant:order:edit 权限}}
/api/merchant/v1/orders/first-leg/batch-imports/template:
get:
summary: 下载 Excel 批量下单模板
responses:
'200': {description: xlsx 模板}
'403': {description: 需要 merchant:order:create 权限}
/api/merchant/v1/orders/first-leg/batch-imports:
post:
summary: 上传 Excel 批量创建订单草稿
description: >-
ATOMIC 在任一行失败时整批回滚;PARTIAL 每行独立事务,错误行不影响成功行。
同一操作者重试必须复用 Idempotency-Key,每行再以 clientRowId 派生稳定幂等键。
原文件和错误报告分别通过 File 的 ORDER_BATCH_IMPORT_SOURCE、ORDER_BATCH_IMPORT_ERROR_REPORT 契约保存。
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
- name: mode
in: query
required: true
schema: {type: string, enum: [ATOMIC, PARTIAL]}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary, description: 最大 5MB,仅 xlsx;最多 50 行}
responses:
'200': {description: 返回逐行结果、原文件 ID 及可选错误报告文件 ID}
'403': {description: 需要 merchant:order:create 权限}
/api/platform/v1/orders/first-leg/inbound-code-policy:
get:
summary: 平台端查询全系统入仓码模式
@@ -686,7 +756,7 @@ components:
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
FirstLegOrderDraft:
type: object
required: [pricingTaxMode, cargoes]
required: [customerName, transportMode, cargoes, pickupMethod, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, taxMode, pricingTaxMode, destinationType]
properties:
fulfillmentType:
type: string
@@ -699,6 +769,9 @@ components:
pattern: '^[A-Za-z0-9._:-]+$'
description: 仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
senderAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PICKUP 时必填;必须是本客户 PICKUP 地址}
privateDestinationAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PRIVATE 时必填;必须是本客户 PRIVATE_DESTINATION 地址}
customsInspection: {type: boolean, nullable: true, description: 非木制品可空;任一货物 woodenProduct=true 时必须为 true}
cargoes:
type: array
minItems: 1
@@ -707,7 +780,25 @@ components:
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;fulfillmentType
省略时固定为 STANDARD,一键代发必须显式传 ONE_CLICK_DROPSHIP,且不复用计费 businessType;pricingTaxMode
是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID;
发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。
发货与私人目的仓只接受客户地址 ID,服务端在订单参与方表冻结快照;地址簿后续修改不回写历史订单。
私人目的仓不得写入或伪装为 Warehouse 平台仓主档。历史存量订单的响应字段仍允许为空。
CustomerOrderAddressSaveRequest:
type: object
required: [addressType, addressName, companyName, contactName, phone, countryCode, city, addressLine, defaultAddress]
properties:
addressType: {type: string, enum: [PICKUP, PRIVATE_DESTINATION]}
addressName: {type: string, maxLength: 64}
companyName: {type: string, maxLength: 128}
contactName: {type: string, maxLength: 64}
phone: {type: string, maxLength: 32}
email: {type: string, format: email, maxLength: 128, nullable: true}
countryCode: {type: string, minLength: 2, maxLength: 2}
province: {type: string, maxLength: 64, nullable: true}
city: {type: string, maxLength: 64}
district: {type: string, maxLength: 64, nullable: true}
addressLine: {type: string, maxLength: 255}
postalCode: {type: string, maxLength: 20, nullable: true}
defaultAddress: {type: boolean}
InboundCodePolicy:
type: object
required: [mode, manualEntryRequired]
@@ -769,7 +860,7 @@ components:
errorMessage: {type: string}
FirstLegOrderCargo:
type: object
required: [skuNo, packageCount]
required: [packageCount, woodenProduct]
properties:
skuNo: {type: string, maxLength: 64}
cargoName: {type: string, maxLength: 128}
@@ -801,6 +892,7 @@ components:
cargoRemark: {type: string, maxLength: 500}
packageType: {type: string, maxLength: 32}
dangerousGoods: {type: boolean}
woodenProduct: {type: boolean, description: 木制品权威判定快照;为 true 时订单 customsInspection 必须为 true}
description: >-
原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验;
productImageFileId 必须属于当前登录客户。
@@ -0,0 +1,26 @@
package com.baiyue.order.controller;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class OrderCustomerFoundationOpenApiContractTest {
@Test
void publishesAddressBatchAndWoodInspectionContracts() throws Exception {
try (var input = getClass().getResourceAsStream("/openapi/order-v1.yaml")) {
assertThat(input).isNotNull();
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
assertThat(yaml)
.contains("/api/merchant/v1/order-addresses:")
.contains("/api/merchant/v1/orders/first-leg/batch-imports:")
.contains("senderAddressId:")
.contains("privateDestinationAddressId:")
.contains("woodenProduct:")
.contains("ORDER_BATCH_IMPORT_SOURCE")
.contains("ATOMIC 在任一行失败时整批回滚")
.doesNotContain("发货单位和收货单位仅通过 sender/receiver");
}
}
}
@@ -0,0 +1,41 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.CustomerAddressDtos;
import com.baiyue.order.entity.CustomerAddressEntity;
import com.baiyue.order.mapper.CustomerAddressMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class CustomerAddressServiceTest {
@Test
void createsCustomerOwnedPrivateAddressWithoutCallingWarehouse() {
CustomerAddressMapper mapper = mock(CustomerAddressMapper.class);
CustomerAddressService service = new CustomerAddressService();
ReflectionTestUtils.setField(service, "mapper", mapper);
var request = new CustomerAddressDtos.SaveRequest("PRIVATE_DESTINATION", "洛杉矶私人仓", "客户海外仓",
"Alex", "+1-213-555-0100", null, "US", "CA", "Los Angeles", null,
"123 Private Ave", "90001", false);
service.create(10001L, 20001L, request);
verify(mapper).insert(any(CustomerAddressEntity.class));
}
@Test
void selectingAnotherCustomersAddressIsReportedAsNotFound() {
CustomerAddressMapper mapper = mock(CustomerAddressMapper.class);
when(mapper.selectOne(any())).thenReturn(null);
CustomerAddressService service = new CustomerAddressService();
ReflectionTestUtils.setField(service, "mapper", mapper);
assertThatThrownBy(() -> service.required(10001L, 90001L, "PICKUP"))
.isInstanceOf(BizException.class).hasMessage("客户地址不存在或已停用");
}
}
@@ -21,6 +21,7 @@ import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(MerchantBatchDraftService.class)
class MerchantBatchDraftServiceTest {
@@ -85,6 +86,21 @@ class MerchantBatchDraftServiceTest {
assertThat(anotherRow).isNotEqualTo(first);
}
@Test
void atomicModeValidatesEveryRowBeforeCreatingJoinedTransactions() {
when(itemService.createJoined(anyLong(), anyLong(), isNull(), any(), anyString()))
.thenReturn(order(101L, "FL101", null), order(102L, "FL102", null));
var response = service.createAtomic(10L, 20L, null, "atomic-key",
List.of(item("row-1"), item("row-2")));
assertThat(response.succeeded()).isEqualTo(2);
verify(itemService, org.mockito.Mockito.times(2)).validate(any());
verify(itemService, org.mockito.Mockito.times(2))
.createJoined(anyLong(), anyLong(), isNull(), any(), anyString());
verify(itemService, never()).create(anyLong(), anyLong(), isNull(), any(), anyString());
}
@Test
void rejectsInvalidBatchBeforeCreatingRows() {
assertThatThrownBy(() -> service.create(10L, 20L, " ", List.of(item("row-1"))))
@@ -0,0 +1,22 @@
package com.baiyue.order.service;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import static org.assertj.core.api.Assertions.assertThat;
class MerchantBatchImportServiceTest {
@Test
void templateUsesStableRowIdentityAndAuthoritativeDraftSchema() throws Exception {
byte[] content = new MerchantBatchImportService().template();
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
var sheet = workbook.getSheetAt(0);
assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isEqualTo("clientRowId");
assertThat(sheet.getRow(0).getCell(11).getStringCellValue()).isEqualTo("senderAddressId");
assertThat(sheet.getRow(0).getCell(21).getStringCellValue()).isEqualTo("privateDestinationAddressId");
assertThat(sheet.getRow(0).getCell(32).getStringCellValue()).isEqualTo("cargo.woodenProduct");
}
}
}
@@ -476,29 +476,31 @@ class OrderApplicationServiceStateTest {
}
@Test
void pickupDraftAllowsMissingSender() {
void pickupDraftRequiresSelectedAddress() {
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);
service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(20002L, 2L, request, "pickup-without-sender");
verify(repository).insertOrder(any());
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(20002L, 2L, request, "pickup-without-sender"))
.isInstanceOf(BizException.class).hasMessage("上门提货时必须选择取货地址");
verify(repository, never()).insertOrder(any());
}
@Test
void privateDraftAllowsMissingReceiver() {
void privateDraftRequiresSelectedAddress() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request(null, "SELF_EXPRESS", null, null, "PRIVATE");
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());
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(20002L, 2L, request, "private-without-receiver"))
.isInstanceOf(BizException.class).hasMessage("私人目的仓必须选择客户地址");
verify(repository, never()).insertOrder(any());
}
@Test
@@ -0,0 +1,37 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderRequests;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class OrderWoodInspectionRuleTest {
@Test
void woodProductRequiresInspectionWithStableMessage() {
OrderRequests.Cargo cargo = mock(OrderRequests.Cargo.class);
OrderRequests.SaveRequest request = mock(OrderRequests.SaveRequest.class);
when(cargo.woodenProduct()).thenReturn(true);
when(request.cargoes()).thenReturn(List.of(cargo));
when(request.customsInspection()).thenReturn(null);
assertThatThrownBy(() -> OrderApplicationService.validateWoodInspection(request))
.isInstanceOf(BizException.class).hasMessage("木制品必须选择商检");
}
@Test
void nonWoodProductAllowsInspectionToBeOmitted() {
OrderRequests.Cargo cargo = mock(OrderRequests.Cargo.class);
OrderRequests.SaveRequest request = mock(OrderRequests.SaveRequest.class);
when(cargo.woodenProduct()).thenReturn(false);
when(request.cargoes()).thenReturn(List.of(cargo));
when(request.customsInspection()).thenReturn(null);
assertThatCode(() -> OrderApplicationService.validateWoodInspection(request)).doesNotThrowAnyException();
}
}