优化
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
| 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
|
||||
| 海外派送完成内部回写 | `controller/InternalOrderFulfillmentCompletionController.java`、`service/OrderFulfillmentCompletionService.java`;只接受 Firstleg 内部服务身份和已校验的来源订单/入仓码快照,幂等将对应订单推进为 `COMPLETED` 并写状态历史 |
|
||||
| 商户订单列表、批量草稿与中文 XLSX 导入 | `service/MerchantInboundOrderListService.java`、`MerchantBatchDraftService.java`、`MerchantBatchDraftItemService.java`、`MerchantBatchImportService.java`;动态目录客户端 `client/MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`,客户地址由 `CustomerAddressService.java` 提供;接口见 `controller/MerchantFirstLegOrderController.java` 的 `/batch-imports/template`、`/batch-imports` |
|
||||
| 平台旧系统订单导入 | `controller/PlatformFirstLegOrderController.java` 的 `/legacy-import`、`service/PlatformLegacyOrderImportService.java`;固定按名称精确解析启用客户“IT测试公司”,保留旧入仓码,按内部仓库目录和该客户当前价格簿逐行校验,成功行直接进入 `SUBMITTED` 待审核;一件代发缺 SKU 的行失败,普通 FBA 缺 SKU 时使用稳定 `LEGACY-{入仓码}` 测试占位码 |
|
||||
| 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` |
|
||||
| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` |
|
||||
| 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 |
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.order.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "firstleg-service", contextId = "legacyImportPricingClient",
|
||||
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing")
|
||||
public interface LegacyImportPricingClient {
|
||||
@GetMapping("/channels")
|
||||
ApiResponse<List<Channel>> channels(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam("tenantId") long tenantId,
|
||||
@RequestParam("originWarehouseId") long originWarehouseId);
|
||||
|
||||
record Channel(String channelCode, String channelName) { }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.order.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "legacyImportWarehouseClient",
|
||||
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
|
||||
public interface LegacyImportWarehouseClient {
|
||||
@GetMapping("/order-catalog")
|
||||
ApiResponse<List<Item>> catalog(@RequestHeader("X-Internal-Service-Token") String token);
|
||||
|
||||
record Item(Long id, String warehouseCode, String warehouseName, String warehouseType,
|
||||
String countryCode) { }
|
||||
}
|
||||
@@ -22,6 +22,14 @@ public interface TenantEligibilityClient {
|
||||
@RequestParam("size") long size,
|
||||
@RequestParam("customerId") long customerId);
|
||||
|
||||
@GetMapping("/finance-customers")
|
||||
ApiResponse<PageResult<CustomerSummary>> customerSummaryByKeyword(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestParam("page") long page,
|
||||
@RequestParam("size") long size,
|
||||
@RequestParam("keyword") String keyword,
|
||||
@RequestParam("status") String status);
|
||||
|
||||
record EligibilityResponse(boolean eligible, String tenantStatus, String reason) { }
|
||||
record CustomerSummary(Long id, String tenantCode, String tenantName, String status,
|
||||
String contactName, String contactPhone) { }
|
||||
|
||||
@@ -19,6 +19,8 @@ import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.service.OrderScopeGuard;
|
||||
import com.baiyue.order.service.InboundCodeAssociationService;
|
||||
import com.baiyue.order.service.BatchOrderReviewPreviewService;
|
||||
import com.baiyue.order.service.PlatformLegacyOrderImportService;
|
||||
import com.baiyue.order.dto.PlatformLegacyOrderImportDtos;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@@ -36,6 +38,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -55,6 +60,20 @@ public class PlatformFirstLegOrderController {
|
||||
private InboundCodeAssociationService inboundCodeAssociations;
|
||||
@Resource
|
||||
private BatchOrderReviewPreviewService batchReviewPreview;
|
||||
@Resource
|
||||
private PlatformLegacyOrderImportService legacyImport;
|
||||
|
||||
@BusinessAudit(event = "ORDER_LEGACY_IMPORT", resourceType = "FIRST_LEG_ORDER", description = "导入旧系统订单")
|
||||
@PostMapping(value = "/legacy-import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:order:import')")
|
||||
@Operation(summary = "导入旧系统订单", description = "固定归属 IT测试公司,保留旧入仓码并逐行创建待审核订单。")
|
||||
public ApiResponse<PlatformLegacyOrderImportDtos.Response> importLegacy(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
OrderScopeGuard.platform(user);
|
||||
return ApiResponse.success(legacyImport.importFile(user.userId(), user.username(), idempotencyKey, file));
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-code-policy")
|
||||
@PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class PlatformLegacyOrderImportDtos {
|
||||
private PlatformLegacyOrderImportDtos() { }
|
||||
|
||||
public record Response(int total, int succeeded, int failed, List<Result> items) { }
|
||||
|
||||
public record Result(int excelRow, boolean success, String legacyInboundCode,
|
||||
Long orderId, String orderNo, String errorMessage) {
|
||||
public static Result success(int row, String code, Long orderId, String orderNo) {
|
||||
return new Result(row, true, code, orderId, orderNo, null);
|
||||
}
|
||||
public static Result failure(int row, String code, String message) {
|
||||
return new Result(row, false, code, null, null, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,13 +76,13 @@ public class OrderApplicationService {
|
||||
if (idempotency.replayed()) {
|
||||
return idempotency.record().getResultOrderId();
|
||||
}
|
||||
Long orderId = createDraftInternal(tenantId, userId, username, request);
|
||||
Long orderId = createDraftInternal(tenantId, userId, username, request, false);
|
||||
completeIdempotency(idempotency, orderId);
|
||||
return orderId;
|
||||
}
|
||||
|
||||
private Long createDraftInternal(long tenantId, long userId, String username,
|
||||
OrderRequests.SaveRequest request) {
|
||||
OrderRequests.SaveRequest request, boolean allowLegacyInboundCode) {
|
||||
AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
|
||||
validateWoodInspection(request);
|
||||
validateRequestCargoQuantities(request.cargoes(), false);
|
||||
@@ -94,7 +94,7 @@ 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, addressSnapshots);
|
||||
apply(order, tenantId, request, snapshots, addressSnapshots, allowLegacyInboundCode);
|
||||
order.setCreatedBy(userId); order.setUpdatedBy(userId);
|
||||
try {
|
||||
repository.insertOrder(order);
|
||||
@@ -106,6 +106,19 @@ public class OrderApplicationService {
|
||||
return order.getId();
|
||||
}
|
||||
|
||||
/** 旧系统并行期专用:保留旧入仓码,并在同一事务中直接创建为待审核订单。 */
|
||||
@Transactional
|
||||
public Long importLegacySubmittedOrder(long tenantId, long userId, String username,
|
||||
OrderRequests.SaveRequest request, String idempotencyKey) {
|
||||
IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "IMPORT_LEGACY_ORDER", 0L,
|
||||
idempotencyKey, requestHash("IMPORT_LEGACY_ORDER", request));
|
||||
if (idempotency.replayed()) return idempotency.record().getResultOrderId();
|
||||
Long orderId = createDraftInternal(tenantId, userId, username, request, true);
|
||||
transit(tenantId, userId, username, orderId, OrderStatus.SUBMITTED, null);
|
||||
completeIdempotency(idempotency, orderId);
|
||||
return orderId;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
|
||||
updateOrderData(tenantId, userId, id, request, false);
|
||||
@@ -137,7 +150,7 @@ public class OrderApplicationService {
|
||||
}
|
||||
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
|
||||
request.destinationType());
|
||||
apply(order, tenantId, request, snapshots, addressSnapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
|
||||
apply(order, tenantId, request, snapshots, addressSnapshots, false); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
|
||||
if (before == OrderStatus.SUBMITTED) {
|
||||
order.setStatus(OrderStatus.SUBMITTED);
|
||||
}
|
||||
@@ -575,9 +588,10 @@ public class OrderApplicationService {
|
||||
};
|
||||
}
|
||||
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request,
|
||||
OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses) {
|
||||
OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses,
|
||||
boolean allowLegacyInboundCode) {
|
||||
order.setCustomerId(tenantId);
|
||||
applyInboundCode(order, request.inboundCode());
|
||||
applyInboundCode(order, request.inboundCode(), allowLegacyInboundCode);
|
||||
order.setCustomerName(request.customerName().trim());
|
||||
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
|
||||
order.setDestinationCountryCode(addresses.privateDestination() != null
|
||||
@@ -616,10 +630,10 @@ public class OrderApplicationService {
|
||||
? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode());
|
||||
order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId());
|
||||
}
|
||||
private void applyInboundCode(OrderEntity order, String requestedCode) {
|
||||
private void applyInboundCode(OrderEntity order, String requestedCode, boolean allowLegacyInboundCode) {
|
||||
String normalized = StringUtils.hasText(requestedCode)
|
||||
? requestedCode.trim().toUpperCase(Locale.ROOT) : null;
|
||||
if (!legacyManualMode()) {
|
||||
if (!legacyManualMode() && !allowLegacyInboundCode) {
|
||||
if (normalized != null && !normalized.equals(order.getInboundCode())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统由新系统自动生成入仓码,请勿人工填写");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.LegacyImportPricingClient;
|
||||
import com.baiyue.order.client.LegacyImportWarehouseClient;
|
||||
import com.baiyue.order.client.TenantEligibilityClient;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.dto.PlatformLegacyOrderImportDtos;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
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.math.BigDecimal;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class PlatformLegacyOrderImportService {
|
||||
private static final String FIXED_CUSTOMER = "IT测试公司";
|
||||
private static final int MAX_ROWS = 200;
|
||||
private static final long MAX_BYTES = 5L * 1024 * 1024;
|
||||
private static final List<String> HEADERS = List.of("客户公司", "入仓码", "订单状态", "渠道", "品名/材质",
|
||||
"买单/退税", "目的仓", "邮编", "货件编号", "FBA仓追踪编码", "SKU", "总箱数", "预报毛重",
|
||||
"预报体积", "货重比", "交货仓", "备注");
|
||||
private static final ThreadLocal<DataFormatter> FORMATTER =
|
||||
ThreadLocal.withInitial(() -> new DataFormatter(Locale.SIMPLIFIED_CHINESE));
|
||||
|
||||
@Resource private TenantEligibilityClient tenants;
|
||||
@Resource private LegacyImportWarehouseClient warehouses;
|
||||
@Resource private LegacyImportPricingClient pricing;
|
||||
@Resource private OrderApplicationService orders;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String token;
|
||||
|
||||
public PlatformLegacyOrderImportDtos.Response importFile(long userId, String username,
|
||||
String idempotencyKey, MultipartFile file) {
|
||||
byte[] content = read(file);
|
||||
Customer customer = fixedCustomer();
|
||||
Catalog catalog = catalog(customer.id());
|
||||
List<ParsedRow> rows = parse(content, customer, catalog);
|
||||
List<PlatformLegacyOrderImportDtos.Result> results = new ArrayList<>();
|
||||
int succeeded = 0;
|
||||
for (ParsedRow row : rows) {
|
||||
if (row.error() != null) {
|
||||
results.add(PlatformLegacyOrderImportDtos.Result.failure(row.excelRow(), row.legacyInboundCode(), row.error()));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Long orderId = orders.importLegacySubmittedOrder(customer.id(), userId, username, row.request(),
|
||||
derivedKey(idempotencyKey, row.legacyInboundCode()));
|
||||
var saved = orders.detail(customer.id(), orderId).order();
|
||||
results.add(PlatformLegacyOrderImportDtos.Result.success(row.excelRow(), row.legacyInboundCode(),
|
||||
orderId, saved.getOrderNo()));
|
||||
succeeded++;
|
||||
} catch (BizException exception) {
|
||||
results.add(PlatformLegacyOrderImportDtos.Result.failure(row.excelRow(), row.legacyInboundCode(),
|
||||
exception.getMessage()));
|
||||
}
|
||||
}
|
||||
return new PlatformLegacyOrderImportDtos.Response(rows.size(), succeeded, rows.size() - succeeded, results);
|
||||
}
|
||||
|
||||
private List<ParsedRow> parse(byte[] content, Customer customer, Catalog catalog) {
|
||||
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
|
||||
var sheet = workbook.getSheetAt(0);
|
||||
Row header = sheet.getRow(2);
|
||||
if (header == null) throw invalid("未找到旧系统表头(应位于第 3 行)");
|
||||
for (int i = 0; i < HEADERS.size(); i++) {
|
||||
if (!HEADERS.get(i).equals(text(header, i))) throw invalid("旧系统 Excel 表头不正确:缺少“" + HEADERS.get(i) + "”");
|
||||
}
|
||||
List<ParsedRow> rows = new ArrayList<>();
|
||||
for (int index = 3; index <= sheet.getLastRowNum(); index++) {
|
||||
Row source = sheet.getRow(index);
|
||||
if (source == null || !StringUtils.hasText(text(source, 1))) continue;
|
||||
if (rows.size() >= MAX_ROWS) throw invalid("单次最多导入 " + MAX_ROWS + " 条订单");
|
||||
try { rows.add(new ParsedRow(index + 1, text(source, 1), request(source, customer, catalog), null)); }
|
||||
catch (BizException exception) { rows.add(new ParsedRow(index + 1, text(source, 1), null, exception.getMessage())); }
|
||||
}
|
||||
if (rows.isEmpty()) throw invalid("Excel 没有订单数据行");
|
||||
return rows;
|
||||
} catch (BizException exception) { throw exception; }
|
||||
catch (Exception exception) { throw invalid("Excel 文件损坏或格式不正确"); }
|
||||
}
|
||||
|
||||
private OrderRequests.SaveRequest request(Row row, Customer customer, Catalog catalog) {
|
||||
String inboundCode = required(row, 1, "入仓码").toUpperCase(Locale.ROOT);
|
||||
if (!inboundCode.matches("[A-Za-z0-9._:-]+")) throw invalid("入仓码格式不正确");
|
||||
String receivingName = required(row, 15, "交货仓");
|
||||
var receiving = uniqueWarehouse(catalog.domestic(), receivingName, "国内收货仓");
|
||||
String destinationName = required(row, 6, "目的仓");
|
||||
String destinationType;
|
||||
String fulfillmentType = "STANDARD";
|
||||
Long destinationId = null;
|
||||
String destinationWarehouseName;
|
||||
String destinationCountry = "US";
|
||||
String amazonCode = null;
|
||||
if ("私人仓".equals(destinationName)) {
|
||||
destinationType = "PRIVATE";
|
||||
destinationWarehouseName = "私人仓 " + required(row, 7, "邮编");
|
||||
} else if (destinationName.endsWith("一件代发")) {
|
||||
destinationType = "OVERSEAS";
|
||||
fulfillmentType = "ONE_CLICK_DROPSHIP";
|
||||
var warehouse = uniqueWarehouse(catalog.overseas(), destinationName.replace("一件代发", ""), "海外仓目的仓");
|
||||
destinationId = warehouse.id(); destinationWarehouseName = warehouse.warehouseName();
|
||||
destinationCountry = warehouse.countryCode();
|
||||
} else {
|
||||
destinationType = "FBA";
|
||||
var warehouse = uniqueWarehouse(catalog.fba(), destinationName, "FBA目的仓");
|
||||
destinationId = warehouse.id(); destinationWarehouseName = warehouse.warehouseName();
|
||||
destinationCountry = warehouse.countryCode(); amazonCode = warehouse.warehouseCode();
|
||||
}
|
||||
var channel = uniqueChannel(catalog.channels(receiving.id()), required(row, 3, "渠道"));
|
||||
CargoText cargoText = cargoText(required(row, 4, "品名/材质"));
|
||||
int packages = positiveInt(required(row, 11, "总箱数"), "总箱数");
|
||||
BigDecimal grossWeight = positiveDecimal(required(row, 12, "预报毛重"), "预报毛重");
|
||||
BigDecimal volume = positiveDecimal(required(row, 13, "预报体积"), "预报体积");
|
||||
List<SkuEntry> skuEntries = skuEntries(text(row, 10));
|
||||
if ("ONE_CLICK_DROPSHIP".equals(fulfillmentType) && skuEntries.isEmpty()) {
|
||||
throw invalid("一件代发订单缺少 SKU");
|
||||
}
|
||||
String shipmentId = cleanReference(text(row, 8));
|
||||
String referenceId = cleanReference(text(row, 9));
|
||||
String remark = joinRemark(text(row, 16), "旧系统客户:" + text(row, 0));
|
||||
List<OrderRequests.Cargo> cargoes = new ArrayList<>();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(fulfillmentType)) {
|
||||
int skuPackages = skuEntries.stream().mapToInt(SkuEntry::packages).sum();
|
||||
if (skuPackages != packages) throw invalid("SKU 明细箱数合计与总箱数不一致");
|
||||
for (int index = 0; index < skuEntries.size(); index++) {
|
||||
SkuEntry entry = skuEntries.get(index);
|
||||
BigDecimal ratio = BigDecimal.valueOf(entry.packages()).divide(BigDecimal.valueOf(packages), 8, java.math.RoundingMode.HALF_UP);
|
||||
BigDecimal lineWeight = index == skuEntries.size() - 1
|
||||
? grossWeight.subtract(cargoes.stream().map(OrderRequests.Cargo::grossWeightKg).reduce(BigDecimal.ZERO, BigDecimal::add))
|
||||
: grossWeight.multiply(ratio).setScale(3, java.math.RoundingMode.HALF_UP);
|
||||
BigDecimal lineVolume = index == skuEntries.size() - 1
|
||||
? volume.subtract(cargoes.stream().map(OrderRequests.Cargo::volumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add))
|
||||
: volume.multiply(ratio).setScale(4, java.math.RoundingMode.HALF_UP);
|
||||
cargoes.add(cargo(cargoText, entry.packages(), lineWeight, lineVolume, entry.sku(), null, null));
|
||||
}
|
||||
} else {
|
||||
String sku = skuEntries.isEmpty() && "FBA".equals(destinationType) ? "LEGACY-" + inboundCode
|
||||
: skuEntries.isEmpty() ? null : skuEntries.get(0).sku();
|
||||
cargoes.add(cargo(cargoText, packages, grossWeight, volume, sku, shipmentId, referenceId));
|
||||
}
|
||||
OrderRequests.Party receiver = "PRIVATE".equals(destinationType)
|
||||
? new OrderRequests.Party(customer.name(), "旧系统导入", "未提供", null, "US", null,
|
||||
"未提供", null, destinationWarehouseName, required(row, 7, "邮编"))
|
||||
: null;
|
||||
return new OrderRequests.SaveRequest(customer.id(), customer.name(), "SEA", "FOB", destinationCountry,
|
||||
destinationId, destinationWarehouseName, null, remark, null, receiver, cargoes, "SELF_TRUCK", null,
|
||||
receiving.id(), receiving.warehouseName(), channel.channelCode(), channel.channelName(), null, null,
|
||||
"买单".equals(required(row, 5, "买单/退税")) ? "BUY_DECLARATION" : "TAX_REFUND",
|
||||
channel.channelName().contains("包税") && !channel.channelName().contains("不包税") ? "TAX_INCLUDED" : "TAX_EXCLUDED",
|
||||
"LCL", false, destinationType, amazonCode, shipmentId, referenceId, inboundCode, fulfillmentType,
|
||||
"ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null);
|
||||
}
|
||||
|
||||
private Customer fixedCustomer() {
|
||||
try {
|
||||
var response = tenants.customerSummaryByKeyword(token, 1, 200, FIXED_CUSTOMER, "ACTIVE");
|
||||
if (response == null || !response.successful() || response.data() == null) throw unavailable("固定客户资料暂不可用");
|
||||
var matches = response.data().records().stream().filter(value -> FIXED_CUSTOMER.equals(value.tenantName())).toList();
|
||||
if (matches.size() != 1) throw invalid("固定客户“" + FIXED_CUSTOMER + "”不存在、未启用或名称不唯一");
|
||||
return new Customer(matches.get(0).id(), matches.get(0).tenantName());
|
||||
} catch (BizException exception) { throw exception; }
|
||||
catch (RuntimeException exception) { throw unavailable("固定客户资料暂不可用"); }
|
||||
}
|
||||
|
||||
private Catalog catalog(long tenantId) {
|
||||
try {
|
||||
var response = warehouses.catalog(token);
|
||||
if (response == null || !response.successful() || response.data() == null) throw unavailable("仓库目录暂不可用");
|
||||
var domestic = response.data().stream().filter(v -> "DOMESTIC".equals(v.warehouseType())).toList();
|
||||
Map<Long, List<LegacyImportPricingClient.Channel>> channels = new LinkedHashMap<>();
|
||||
for (var warehouse : domestic) channels.put(warehouse.id(), channels(tenantId, warehouse.id()));
|
||||
return new Catalog(domestic,
|
||||
response.data().stream().filter(v -> "FBA".equals(v.warehouseType())).toList(),
|
||||
response.data().stream().filter(v -> "OVERSEAS".equals(v.warehouseType())).toList(), channels);
|
||||
} catch (BizException exception) { throw exception; }
|
||||
catch (RuntimeException exception) { throw unavailable("仓库目录暂不可用"); }
|
||||
}
|
||||
|
||||
private List<LegacyImportPricingClient.Channel> channels(long tenantId, long warehouseId) {
|
||||
var response = pricing.channels(token, tenantId, warehouseId);
|
||||
if (response == null || !response.successful() || response.data() == null) throw unavailable("渠道目录暂不可用");
|
||||
return response.data();
|
||||
}
|
||||
|
||||
private byte[] read(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) throw invalid("请选择旧系统导出的 Excel 文件");
|
||||
if (file.getSize() > MAX_BYTES) throw invalid("Excel 文件不能超过 5MB");
|
||||
String name = file.getOriginalFilename();
|
||||
if (!StringUtils.hasText(name) || !name.toLowerCase(Locale.ROOT).endsWith(".xlsx")) throw invalid("仅支持 .xlsx 文件");
|
||||
try { return file.getBytes(); } catch (Exception exception) { throw invalid("无法读取 Excel 文件"); }
|
||||
}
|
||||
|
||||
private static LegacyImportWarehouseClient.Item uniqueWarehouse(List<LegacyImportWarehouseClient.Item> values,
|
||||
String source, String label) {
|
||||
String normalized = normalize(source);
|
||||
List<LegacyImportWarehouseClient.Item> exact = values.stream().filter(value ->
|
||||
normalized.equals(normalize(value.warehouseCode())) || normalized.equals(normalize(value.warehouseName()))).toList();
|
||||
List<LegacyImportWarehouseClient.Item> matches = exact.isEmpty() ? values.stream().filter(value ->
|
||||
normalize(value.warehouseName()).contains(normalized) || normalized.contains(normalize(value.warehouseName()))).toList() : exact;
|
||||
if (matches.size() != 1) throw invalid(label + "“" + source + "”无法唯一匹配当前主数据");
|
||||
return matches.get(0);
|
||||
}
|
||||
|
||||
private static LegacyImportPricingClient.Channel uniqueChannel(List<LegacyImportPricingClient.Channel> values, String source) {
|
||||
String normalized = normalize(source);
|
||||
List<LegacyImportPricingClient.Channel> exact = values.stream().filter(value -> normalized.equals(normalize(value.channelName()))).toList();
|
||||
if (exact.size() != 1) throw invalid("渠道“" + source + "”无法唯一匹配 IT测试公司的当前价格簿");
|
||||
return exact.get(0);
|
||||
}
|
||||
|
||||
private static CargoText cargoText(String source) {
|
||||
String cleaned = source.replaceAll("-\\d+件\\s*$", "").trim();
|
||||
String[] parts = cleaned.split("-", 2);
|
||||
return new CargoText(parts[0].trim(), parts.length > 1 ? parts[1].trim() : null);
|
||||
}
|
||||
|
||||
private static OrderRequests.Cargo cargo(CargoText text, int packages, BigDecimal grossWeight,
|
||||
BigDecimal volume, String sku, String shipmentId, String referenceId) {
|
||||
return new OrderRequests.Cargo(text.name(), null, "CARTON", packages, grossWeight, volume,
|
||||
null, null, null, null, null, false, null, sku, null, null, null, 1, packages,
|
||||
null, null, text.material(), null, null, null, null, null, null,
|
||||
shipmentId, referenceId, null, false, null);
|
||||
}
|
||||
|
||||
private static List<SkuEntry> skuEntries(String source) {
|
||||
if (!StringUtils.hasText(source)) return List.of();
|
||||
List<SkuEntry> result = new ArrayList<>();
|
||||
for (String line : source.split("\\R")) {
|
||||
String value = line.trim();
|
||||
if (value.isBlank() || "/".equals(value)) continue;
|
||||
var matcher = java.util.regex.Pattern.compile("^(.+?)-(\\d+)件$").matcher(value);
|
||||
if (matcher.matches()) result.add(new SkuEntry(matcher.group(1).trim(), positiveInt(matcher.group(2), "SKU 箱数")));
|
||||
else result.add(new SkuEntry(value, 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String text(Row row, int column) { return row == null ? "" : FORMATTER.get().formatCellValue(row.getCell(column)).trim(); }
|
||||
private static String required(Row row, int column, String label) { String value = text(row, column); if (!StringUtils.hasText(value)) throw invalid(label + "不能为空"); return value; }
|
||||
private static int positiveInt(String value, String label) { try { int parsed = new BigDecimal(value).intValueExact(); if (parsed < 1) throw new NumberFormatException(); return parsed; } catch (Exception e) { throw invalid(label + "必须为正整数"); } }
|
||||
private static BigDecimal positiveDecimal(String value, String label) { try { BigDecimal parsed = new BigDecimal(value); if (parsed.signum() <= 0) throw new NumberFormatException(); return parsed; } catch (Exception e) { throw invalid(label + "必须大于 0"); } }
|
||||
private static String cleanReference(String value) { if (!StringUtils.hasText(value)) return null; String first = Arrays.stream(value.split("\\R")).map(String::trim).filter(v -> !v.isBlank()).findFirst().orElse(""); first = first.replaceAll("-\\d+件$", ""); return "/".equals(first) ? null : first; }
|
||||
private static String joinRemark(String left, String right) { return StringUtils.hasText(left) ? left.trim() + ";" + right : right; }
|
||||
private static String normalize(String value) { return value == null ? "" : value.replaceAll("[\\s()()_-]", "").toUpperCase(Locale.ROOT); }
|
||||
private static String derivedKey(String batchKey, String inboundCode) { try { byte[] bytes = MessageDigest.getInstance("SHA-256").digest((batchKey.trim() + ":" + inboundCode).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception e) { throw unavailable("导入幂等键生成失败"); } }
|
||||
private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); }
|
||||
private static BizException unavailable(String message) { return new BizException(ResultCode.SERVICE_UNAVAILABLE, message); }
|
||||
|
||||
private record Customer(long id, String name) { }
|
||||
private record ParsedRow(int excelRow, String legacyInboundCode, OrderRequests.SaveRequest request, String error) { }
|
||||
private record CargoText(String name, String material) { }
|
||||
private record SkuEntry(String sku, int packages) { }
|
||||
private record Catalog(List<LegacyImportWarehouseClient.Item> domestic, List<LegacyImportWarehouseClient.Item> fba,
|
||||
List<LegacyImportWarehouseClient.Item> overseas,
|
||||
Map<Long, List<LegacyImportPricingClient.Channel>> channelCache) {
|
||||
List<LegacyImportPricingClient.Channel> channels(long warehouseId) {
|
||||
return channelCache.getOrDefault(warehouseId, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -593,6 +593,25 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BatchReviewPreviewEnvelope'}
|
||||
'403': {description: 需要平台身份和 platform:order:approve 权限}
|
||||
/api/platform/v1/orders/first-leg/legacy-import:
|
||||
post:
|
||||
tags: [Platform first-leg orders]
|
||||
summary: 导入旧系统订单
|
||||
description: 固定归属 IT测试公司;保留旧入仓码,按当前仓库和价格簿逐行校验,成功行直接进入待审核。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200': {description: 返回逐行成功、失败及错误原因}
|
||||
'403': {description: 需要平台身份和 platform:order:import 权限}
|
||||
/api/platform/v1/orders/first-leg/batch-approve:
|
||||
post:
|
||||
tags: [Platform first-leg orders]
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ class DomesticWarehouseOrderExportPermissionSqlContractTest {
|
||||
.contains("platform:download:list")
|
||||
.contains("4000003");
|
||||
}
|
||||
assertThat(iamSeed).contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)");
|
||||
assertThat(iamSeed).contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027, 5102028)");
|
||||
assertThat(unified)
|
||||
.contains("VALUES (4000001,5102026,0,CURRENT_TIMESTAMP(3))")
|
||||
.contains("VALUES (4000003,5102026,0,CURRENT_TIMESTAMP(3))")
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
@@ -49,6 +50,8 @@ class OrderControllerPermissionTest {
|
||||
AuthenticatedUser.class, long.class, OrderRequests.BatchReviewRequest.class);
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "batchReject", "hasAuthority('platform:order:reject')",
|
||||
AuthenticatedUser.class, long.class, OrderRequests.BatchRejectRequest.class);
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "importLegacy", "hasAuthority('platform:order:import')",
|
||||
AuthenticatedUser.class, String.class, MultipartFile.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ class OrderDeclarationUpdateContractTest {
|
||||
.contains("values (4000003,5102027,0,current_timestamp(3))");
|
||||
assertThat(iamSource)
|
||||
.contains("platform:order:declaration:edit")
|
||||
.contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)");
|
||||
.contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027, 5102028)");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.service.InboundCodeAssociationService;
|
||||
import com.baiyue.order.service.BatchOrderReviewPreviewService;
|
||||
import com.baiyue.order.service.PlatformLegacyOrderImportService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -32,6 +33,7 @@ class PlatformOrderPageControllerTest {
|
||||
@MockBean private OrderApplicationService service;
|
||||
@MockBean private InboundCodeAssociationService inboundCodeAssociations;
|
||||
@MockBean private BatchOrderReviewPreviewService batchReviewPreview;
|
||||
@MockBean private PlatformLegacyOrderImportService legacyImport;
|
||||
@Resource private PlatformFirstLegOrderController controller;
|
||||
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
|
||||
null, null, null, null, null, null, 1, 20);
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.order.client.LegacyImportPricingClient;
|
||||
import com.baiyue.order.client.LegacyImportWarehouseClient;
|
||||
import com.baiyue.order.client.TenantEligibilityClient;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PlatformLegacyOrderImportServiceTest {
|
||||
private final TenantEligibilityClient tenants = mock(TenantEligibilityClient.class);
|
||||
private final LegacyImportWarehouseClient warehouses = mock(LegacyImportWarehouseClient.class);
|
||||
private final LegacyImportPricingClient pricing = mock(LegacyImportPricingClient.class);
|
||||
private final OrderApplicationService orders = mock(OrderApplicationService.class);
|
||||
private PlatformLegacyOrderImportService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PlatformLegacyOrderImportService();
|
||||
ReflectionTestUtils.setField(service, "tenants", tenants);
|
||||
ReflectionTestUtils.setField(service, "warehouses", warehouses);
|
||||
ReflectionTestUtils.setField(service, "pricing", pricing);
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "token", "internal-token");
|
||||
var customer = new TenantEligibilityClient.CustomerSummary(88L, "IT", "IT测试公司", "ACTIVE", null, null);
|
||||
when(tenants.customerSummaryByKeyword("internal-token", 1, 200, "IT测试公司", "ACTIVE"))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 200, 1, 1)));
|
||||
when(warehouses.catalog("internal-token")).thenReturn(ApiResponse.success(List.of(
|
||||
new LegacyImportWarehouseClient.Item(21L, "CN-YW", "义乌仓", "DOMESTIC", "CN"),
|
||||
new LegacyImportWarehouseClient.Item(22L, "SCK4", "SCK4", "FBA", "US"),
|
||||
new LegacyImportWarehouseClient.Item(23L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US"))));
|
||||
when(pricing.channels("internal-token", 88L, 21L)).thenReturn(ApiResponse.success(List.of(
|
||||
new LegacyImportPricingClient.Channel("MATSON", "美森正班海卡包税"))));
|
||||
when(orders.importLegacySubmittedOrder(anyLong(), anyLong(), anyString(), any(), anyString()))
|
||||
.thenReturn(1001L, 1002L);
|
||||
when(orders.detail(anyLong(), anyLong())).thenAnswer(invocation -> {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setOrderNo("FL-" + invocation.getArgument(1));
|
||||
return new OrderDetailResponse(order, List.of(), List.of(), List.of(), List.of());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void importsFbaAndSplitsDropshipSkuLinesForFixedCustomer() throws Exception {
|
||||
var result = service.importFile(7L, "operator", "batch-key",
|
||||
new MockMultipartFile("file", "legacy.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
workbook()));
|
||||
|
||||
assertThat(result.total()).isEqualTo(2);
|
||||
assertThat(result.succeeded()).isEqualTo(2);
|
||||
assertThat(result.failed()).isZero();
|
||||
ArgumentCaptor<OrderRequests.SaveRequest> captor = ArgumentCaptor.forClass(OrderRequests.SaveRequest.class);
|
||||
verify(orders, times(2)).importLegacySubmittedOrder(anyLong(), anyLong(), anyString(), captor.capture(), anyString());
|
||||
OrderRequests.SaveRequest fba = captor.getAllValues().get(0);
|
||||
assertThat(fba.customerName()).isEqualTo("IT测试公司");
|
||||
assertThat(fba.inboundCode()).isEqualTo("ZBYHK2609200988");
|
||||
assertThat(fba.destinationType()).isEqualTo("FBA");
|
||||
assertThat(fba.cargoes().get(0).skuNo()).isEqualTo("LEGACY-ZBYHK2609200988");
|
||||
OrderRequests.SaveRequest dropship = captor.getAllValues().get(1);
|
||||
assertThat(dropship.fulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP");
|
||||
assertThat(dropship.cargoes()).extracting(OrderRequests.Cargo::skuNo)
|
||||
.containsExactly("SKU-S", "SKU-M", "SKU-L");
|
||||
assertThat(dropship.cargoes()).extracting(OrderRequests.Cargo::packageCount).containsExactly(1, 1, 1);
|
||||
}
|
||||
|
||||
private static byte[] workbook() throws Exception {
|
||||
try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) {
|
||||
var sheet = workbook.createSheet("sheet1");
|
||||
sheet.createRow(0).createCell(0).setCellValue("订单统计导出");
|
||||
sheet.createRow(1).createCell(0).setCellValue("导出人:测试");
|
||||
String[] headers = {"客户公司", "入仓码", "订单状态", "渠道", "品名/材质", "买单/退税", "目的仓", "邮编",
|
||||
"货件编号", "FBA仓追踪编码", "SKU", "总箱数", "预报毛重", "预报体积", "货重比", "交货仓", "备注"};
|
||||
var header = sheet.createRow(2);
|
||||
for (int index = 0; index < headers.length; index++) header.createCell(index).setCellValue(headers[index]);
|
||||
String[] fba = {"旧客户", "ZBYHK2609200988", "审核通过", "美森正班海卡包税", "玩偶-polyester-10件", "退税", "SCK4", "91730",
|
||||
"FBA123-10件", "REF123", "", "10", "102", "0.986", "103.45", "义乌仓", ""};
|
||||
String[] dropship = {"旧客户", "ZBYHK2609200983", "审核通过", "美森正班海卡包税", "内裤-polyester-3件", "买单", "洛杉矶仓一件代发", "91730",
|
||||
"/-3件", "/", "SKU-S-1件\nSKU-M-1件\nSKU-L-1件", "3", "78", "0.360", "216.67", "义乌仓", ""};
|
||||
write(sheet.createRow(3), fba);
|
||||
write(sheet.createRow(4), dropship);
|
||||
workbook.write(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static void write(org.apache.poi.ss.usermodel.Row row, String[] values) {
|
||||
for (int index = 0; index < values.length; index++) row.createCell(index).setCellValue(values[index]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user