Test #1
+1
-1
@@ -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) { }
|
||||
}
|
||||
+62
@@ -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();
|
||||
|
||||
@@ -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和数据隔离键',
|
||||
|
||||
@@ -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 必须属于当前登录客户。
|
||||
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user