This commit is contained in:
2026-09-20 14:22:06 +08:00
parent 493d62e04c
commit b9e0c8ea2f
29 changed files with 1078 additions and 17 deletions
+1
View File
@@ -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]
@@ -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
@@ -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);
@@ -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]);
}
}