Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
6 changed files with 423 additions and 43 deletions
Showing only changes of commit 997419ea6b - Show all commits
@@ -0,0 +1,19 @@
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 = "orderMerchantPricingCatalogClient",
url = "${baiyue.clients.firstleg.url:}", path = "/api/merchant/v1/first-leg/pricing")
public interface MerchantPricingCatalogClient {
@GetMapping("/channels")
ApiResponse<List<Channel>> channels(@RequestHeader("Authorization") String authorization,
@RequestParam("originWarehouseId") long originWarehouseId);
record Channel(String channelCode, String channelName) { }
}
@@ -0,0 +1,22 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
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;
@FeignClient(name = "warehouse-service", contextId = "orderMerchantWarehouseCatalogClient",
url = "${baiyue.clients.warehouse.url:}", path = "/api/merchant/v1/warehouses")
public interface MerchantWarehouseCatalogClient {
@GetMapping
ApiResponse<PageResult<Warehouse>> page(
@RequestHeader("Authorization") String authorization,
@RequestParam("page") long page,
@RequestParam("size") long size,
@RequestParam("status") String status);
record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType,
String countryCode, String status) { }
}
@@ -117,12 +117,16 @@ public class MerchantFirstLegOrderController {
@GetMapping("/batch-imports/template")
@PreAuthorize("hasAuthority('merchant:order:create')")
public ResponseEntity<byte[]> batchImportTemplate() {
byte[] content = batchImportService.template();
@Operation(summary = "下载中文批量下单模板",
description = "模板按当前登录客户生成仓库、渠道和客户地址下拉;导入时会重新校验全部动态选项。")
public ResponseEntity<byte[]> batchImportTemplate(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) {
byte[] content = batchImportService.template(OrderScopeGuard.merchant(user), authorization);
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())
ContentDisposition.attachment().filename("头程订单批量导入模板.xlsx", StandardCharsets.UTF_8).build().toString())
.contentLength(content.length).body(content);
}
@@ -131,11 +135,12 @@ public class MerchantFirstLegOrderController {
@PreAuthorize("hasAuthority('merchant:order:create')")
public ApiResponse<MerchantBatchImportDtos.Response> batchImport(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization,
@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));
user.username(), authorization, idempotencyKey, mode, file));
}
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿")
@@ -3,13 +3,18 @@ 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.client.MerchantPricingCatalogClient;
import com.baiyue.order.client.MerchantWarehouseCatalogClient;
import com.baiyue.order.dto.CustomerAddressDtos;
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.DataValidationConstraint;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -21,43 +26,103 @@ import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@Service
public class MerchantBatchImportService {
private static final int MAX_ROWS = 50;
private static final int WAREHOUSE_PAGE_SIZE = 200;
private static final long MAX_BYTES = 5L * 1024 * 1024;
@Resource private MerchantBatchDraftService drafts;
@Resource private OrderBatchFileClient files;
@Resource private CustomerAddressService addresses;
@Resource private MerchantWarehouseCatalogClient warehouses;
@Resource private MerchantPricingCatalogClient pricing;
@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");
private static final List<Column> COLUMNS = List.of(
new Column("客户行号", "clientRowId", null), new Column("客户名称", "customerName", null),
new Column("运输方式", "transportMode", "transport"), new Column("贸易术语", "tradeTerm", null),
new Column("目的国家代码", "destinationCountryCode", null), new Column("目的仓库", "$destinationWarehouse", "destinationWarehouses"),
new Column("预计出运日期", "expectedShipDate", null), new Column("客户备注", "customerRemark", null),
new Column("提货方式", "pickupMethod", "pickup"), new Column("预约提货时间", "requestedPickupAt", null),
new Column("取货地址", "$senderAddress", "pickupAddresses"), new Column("国内收货仓", "$receivingWarehouse", "receivingWarehouses"),
new Column("运输渠道", "$channel", "channels"), new Column("报关方式", "taxMode", "tax"),
new Column("计价税模式", "pricingTaxMode", "pricingTax"), new Column("装箱方式", "containerMode", "container"),
new Column("是否需要商检", "customsInspection", "boolean"), new Column("目的类型", "destinationType", "destination"),
new Column("私人目的仓地址", "$privateAddress", "privateAddresses"), new Column("亚马逊仓库代码", "amazonWarehouseCode", null),
new Column("货件编号", "shipmentId", null), new Column("参考编号", "referenceId", null),
new Column("履约类型", "fulfillmentType", "fulfillment"), new Column("货物名称", "cargo.cargoName", null),
new Column("包装类型", "cargo.packageType", "package"), new Column("包装数量", "cargo.packageCount", null),
new Column("毛重(千克)", "cargo.grossWeightKg", null), new Column("体积(立方米)", "cargo.volumeCbm", null),
new Column("材质", "cargo.material", null), new Column("是否木制品", "cargo.woodenProduct", "boolean"),
new Column("是否危险品", "cargo.dangerousGoods", "boolean"), new Column("SKU", "cargo.skuNo", null),
new Column("UPC", "cargo.upcNo", null), new Column("货物货件编号", "cargo.shipmentId", null),
new Column("货物参考编号", "cargo.referenceId", null));
private static final Map<String, List<String>> STATIC_OPTIONS = Map.of(
"transport", List.of("海运", "空运", "铁路", "卡车"),
"pickup", List.of("自行快递", "平台上门提货", "自行送仓"),
"tax", List.of("买单报关", "退税报关"), "pricingTax", List.of("包税", "不包税"),
"container", List.of("整柜", "散货拼箱"), "boolean", List.of("是", "否"),
"destination", List.of("亚马逊FBA", "海外仓", "私人地址"),
"fulfillment", List.of("普通头程", "一件代发"), "package", List.of("纸箱", "托盘", "木箱"));
private static final Map<String, String> VALUE_CODES = Map.ofEntries(
Map.entry("海运", "SEA"), Map.entry("空运", "AIR"), Map.entry("铁路", "RAIL"), Map.entry("卡车", "TRUCK"),
Map.entry("自行快递", "SELF_EXPRESS"), Map.entry("平台上门提货", "PICKUP"), Map.entry("自行送仓", "SELF_TRUCK"),
Map.entry("买单报关", "BUY_DECLARATION"), Map.entry("退税报关", "TAX_REFUND"),
Map.entry("包税", "TAX_INCLUDED"), Map.entry("不包税", "TAX_EXCLUDED"),
Map.entry("整柜", "FULL_CONTAINER"), Map.entry("散货拼箱", "LCL"), Map.entry("是", "true"), Map.entry("否", "false"),
Map.entry("亚马逊FBA", "FBA"), Map.entry("海外仓", "OVERSEAS"), Map.entry("私人地址", "PRIVATE"),
Map.entry("普通头程", "STANDARD"), Map.entry("一件代发", "ONE_CLICK_DROPSHIP"),
Map.entry("纸箱", "CARTON"), Map.entry("托盘", "PALLET"), Map.entry("木箱", "WOODEN_CASE"));
public byte[] template() {
public byte[] template(long tenantId, String authorization) {
Catalog catalog = catalog(tenantId, authorization);
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);
for (int i = 0; i < COLUMNS.size(); i++) header.createCell(i).setCellValue(COLUMNS.get(i).title());
sheet.createFreezePane(0, 1);
for (int i = 0; i < COLUMNS.size(); i++) sheet.setColumnWidth(i, 20 * 256);
var options = workbook.createSheet("选项数据");
Map<String, List<String>> optionValues = new LinkedHashMap<>(STATIC_OPTIONS);
optionValues.put("pickupAddresses", catalog.pickupAddresses().keySet().stream().toList());
optionValues.put("privateAddresses", catalog.privateAddresses().keySet().stream().toList());
optionValues.put("receivingWarehouses", catalog.receivingWarehouses().keySet().stream().toList());
optionValues.put("destinationWarehouses", catalog.destinationWarehouses().keySet().stream().toList());
optionValues.put("channels", catalog.channels().keySet().stream().toList());
int optionCol = 0;
Map<String, String> ranges = new LinkedHashMap<>();
for (var entry : optionValues.entrySet()) {
if (entry.getValue().isEmpty()) continue;
for (int row = 0; row < entry.getValue().size(); row++) {
var target = options.getRow(row) == null ? options.createRow(row) : options.getRow(row);
target.createCell(optionCol).setCellValue(entry.getValue().get(row));
}
String rangeName = "option_" + optionCol;
var name = workbook.createName(); name.setNameName(rangeName);
name.setRefersToFormula("'选项数据'!$" + columnLetter(optionCol) + "$1:$" + columnLetter(optionCol) + "$" + entry.getValue().size());
ranges.put(entry.getKey(), rangeName); optionCol++;
}
var helper = sheet.getDataValidationHelper();
for (int col = 0; col < COLUMNS.size(); col++) {
String range = ranges.get(COLUMNS.get(col).optionKey());
if (range == null) continue;
DataValidationConstraint constraint = helper.createFormulaListConstraint(range);
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, MAX_ROWS, col, col));
validation.setShowErrorBox(true); validation.createErrorBox("选项不正确", "请从下拉列表中选择");
sheet.addValidationData(validation);
}
workbook.setSheetHidden(workbook.getSheetIndex(options), true);
var guide = workbook.createSheet("填写说明");
String[] guides = {"每一行代表一张订单,最多填写50行。", "客户行号由您自定义且在文件内唯一,用于定位错误行。",
"带下拉的字段必须从列表选择;系统导入时会重新核验仓库、渠道和地址的状态及客户归属。",
"日期格式:yyyy-MM-dd;预约提货时间格式:yyyy-MM-dd HH:mm:ss。", "本说明页不会被导入,请勿在订单导入页放置示例行。"};
for (int i = 0; i < guides.length; i++) guide.createRow(i).createCell(0).setCellValue(guides[i]);
guide.setColumnWidth(0, 100 * 256);
workbook.write(out);
return out.toByteArray();
} catch (Exception exception) {
@@ -65,7 +130,7 @@ public class MerchantBatchImportService {
}
}
public MerchantBatchImportDtos.Response importFile(long tenantId, long userId, String username,
public MerchantBatchImportDtos.Response importFile(long tenantId, long userId, String username, String authorization,
String idempotencyKey, MerchantBatchImportDtos.Mode mode,
MultipartFile file) {
requireFile(file);
@@ -73,7 +138,7 @@ public class MerchantBatchImportService {
byte[] content;
try { content = file.getBytes(); }
catch (Exception exception) { throw new BizException(ResultCode.BAD_REQUEST, "无法读取 Excel 文件"); }
List<MerchantBatchDraftDtos.Item> items = parse(content);
List<MerchantBatchDraftDtos.Item> items = parse(tenantId, authorization, content);
Long sourceFileId = upload(tenantId, userId, batchNo, "ORDER_BATCH_IMPORT_SOURCE",
new BytesMultipartFile("file", safeName(file.getOriginalFilename()),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content));
@@ -85,17 +150,19 @@ public class MerchantBatchImportService {
return new MerchantBatchImportDtos.Response(batchNo, mode, sourceFileId, errorFileId, result);
}
private List<MerchantBatchDraftDtos.Item> parse(byte[] content) {
List<MerchantBatchDraftDtos.Item> parse(long tenantId, String authorization, byte[] content) {
Catalog catalog = catalog(tenantId, authorization);
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)))) {
if (header == null || !COLUMNS.get(0).title().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)));
if (header.getLastCellNum() != COLUMNS.size()) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
for (int col = 0; col < COLUMNS.size(); col++) if (!COLUMNS.get(col).title().equals(text(header.getCell(col))))
throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
for (int rowNo = 1; rowNo <= sheet.getLastRowNum(); rowNo++) {
Row row = sheet.getRow(rowNo);
if (row == null || !StringUtils.hasText(text(row.getCell(0)))) continue;
@@ -104,15 +171,19 @@ public class MerchantBatchImportService {
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));
for (int col = 1; col < COLUMNS.size(); col++) {
String field = COLUMNS.get(col).field(); String value = text(row.getCell(col));
if (!StringUtils.hasText(field) || !StringUtils.hasText(value)) continue;
value = VALUE_CODES.getOrDefault(value, value);
if (field.startsWith("$")) { applySelection(draft, field, value, catalog); 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 (BizException exception) {
items.add(new MerchantBatchDraftDtos.Item(rowId, null, exception.getMessage()));
} catch (Exception exception) {
items.add(new MerchantBatchDraftDtos.Item(rowId, null, "Excel 行字段格式不正确"));
}
@@ -127,7 +198,7 @@ public class MerchantBatchImportService {
try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("错误报告");
Row header = sheet.createRow(0);
String[] headers = {"rowIndex", "clientRowId", "errorCode", "errorMessage"};
String[] headers = {"Excel行号", "客户行号", "错误代码", "错误说明"};
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()) {
@@ -143,6 +214,145 @@ public class MerchantBatchImportService {
} catch (Exception exception) { throw new BizException(ResultCode.INTERNAL_ERROR, "错误报告生成失败"); }
}
private Catalog catalog(long tenantId, String authorization) {
if (!StringUtils.hasText(authorization)) throw new BizException(ResultCode.UNAUTHORIZED, "登录凭证缺失");
try {
List<MerchantWarehouseCatalogClient.Warehouse> allWarehouses = activeWarehouses(authorization);
var receiving = optionMap(allWarehouses.stream().filter(value -> "DOMESTIC".equals(value.warehouseType())).toList(),
value -> label(value.warehouseName(), value.warehouseCode()));
if (receiving.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "暂无可用国内收货仓");
var destination = optionMap(allWarehouses.stream().filter(value -> !"DOMESTIC".equals(value.warehouseType())).toList(),
value -> label(value.warehouseName(), value.warehouseCode()));
Map<String, MerchantPricingCatalogClient.Channel> channels = new LinkedHashMap<>();
for (var warehouse : receiving.values()) {
var response = pricing.channels(authorization, warehouse.id());
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "渠道选项暂不可用");
}
for (var channel : response.data()) {
String display = label(channel.channelName(), channel.channelCode());
var previous = channels.putIfAbsent(display, channel);
if (previous != null && !previous.channelCode().equals(channel.channelCode())) {
throw new BizException(ResultCode.CONFLICT, "渠道显示名称重复,请联系平台处理");
}
}
}
if (channels.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "暂无可用运输渠道");
return new Catalog(
addressOptions(addresses.list(tenantId, "PICKUP")),
addressOptions(addresses.list(tenantId, "PRIVATE_DESTINATION")),
receiving, destination, channels);
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单模板选项暂不可用");
}
}
private List<MerchantWarehouseCatalogClient.Warehouse> activeWarehouses(String authorization) {
Map<Long, MerchantWarehouseCatalogClient.Warehouse> unique = new LinkedHashMap<>();
Long expectedTotal = null;
Long expectedPages = null;
long fetchedRows = 0;
for (long requestedPage = 1; ; requestedPage++) {
var response = warehouses.page(authorization, requestedPage, WAREHOUSE_PAGE_SIZE, "ACTIVE");
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项第" + requestedPage + "页暂不可用");
}
var page = response.data();
if (page.page() != requestedPage || page.size() != WAREHOUSE_PAGE_SIZE) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项分页响应异常");
}
long calculatedPages = page.total() == 0 ? 0 : ((page.total() - 1) / page.size()) + 1;
if (page.pages() != calculatedPages) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项分页统计异常");
}
if (expectedTotal == null) {
expectedTotal = page.total();
expectedPages = page.pages();
} else if (expectedTotal != page.total() || expectedPages != page.pages()) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项分页数据发生变化,请重试");
}
if (requestedPage > Math.max(1L, expectedPages)) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项分页响应异常");
}
List<MerchantWarehouseCatalogClient.Warehouse> records = page.records();
if (expectedPages > 0 && records.isEmpty()) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项分页数据不完整");
}
fetchedRows += records.size();
for (var warehouse : records) {
if (warehouse == null || warehouse.id() == null || warehouse.id() <= 0
|| !"ACTIVE".equals(warehouse.status())) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项数据异常");
}
var previous = unique.putIfAbsent(warehouse.id(), warehouse);
if (previous != null && !previous.equals(warehouse)) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项分页数据发生变化,请重试");
}
}
if (requestedPage >= expectedPages) {
if (fetchedRows != expectedTotal) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项分页数据不完整");
}
return List.copyOf(unique.values());
}
}
}
private static Map<String, CustomerAddressDtos.Response> addressOptions(List<CustomerAddressDtos.Response> values) {
return optionMap(values, value -> String.join("|", value.addressName(), value.contactName(), value.city(), value.addressLine()));
}
private static <T> Map<String, T> optionMap(List<T> values, Function<T, String> labeler) {
Map<String, T> result = new LinkedHashMap<>();
for (T value : values) {
String label = labeler.apply(value);
if (result.putIfAbsent(label, value) != null) {
throw new BizException(ResultCode.CONFLICT, "模板选项显示名称重复,请联系平台处理");
}
}
return result;
}
private static void applySelection(com.fasterxml.jackson.databind.node.ObjectNode draft, String field,
String value, Catalog catalog) {
switch (field) {
case "$senderAddress" -> draft.put("senderAddressId", required(catalog.pickupAddresses(), value, "取货地址").id());
case "$privateAddress" -> draft.put("privateDestinationAddressId", required(catalog.privateAddresses(), value, "私人目的仓地址").id());
case "$receivingWarehouse" -> {
var warehouse = required(catalog.receivingWarehouses(), value, "国内收货仓");
draft.put("receivingWarehouseId", warehouse.id()); draft.put("receivingWarehouseName", warehouse.warehouseName());
}
case "$destinationWarehouse" -> {
var warehouse = required(catalog.destinationWarehouses(), value, "目的仓库");
draft.put("destinationWarehouseId", warehouse.id()); draft.put("destinationWarehouseName", warehouse.warehouseName());
}
case "$channel" -> {
var channel = required(catalog.channels(), value, "运输渠道");
draft.put("channelCode", channel.channelCode()); draft.put("channelName", channel.channelName());
}
default -> throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板字段不正确");
}
}
private static <T> T required(Map<String, T> options, String value, String name) {
T selected = options.get(value);
if (selected == null) throw new BizException(ResultCode.VALIDATION_FAILED, name + "不存在、已停用或不属于当前客户");
return selected;
}
private static String label(String name, String code) { return name + "|" + code; }
private static String columnLetter(int column) { return String.valueOf((char) ('A' + column)); }
private record Column(String title, String field, String optionKey) { }
private record Catalog(
Map<String, CustomerAddressDtos.Response> pickupAddresses,
Map<String, CustomerAddressDtos.Response> privateAddresses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> receivingWarehouses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> destinationWarehouses,
Map<String, MerchantPricingCatalogClient.Channel> channels) { }
private Long upload(long tenantId, long userId, String batchNo, String type, MultipartFile file) {
try {
var response = files.upload(token, userId, tenantId, type, batchNo, file);
@@ -70,6 +70,9 @@ paths:
/api/merchant/v1/orders/first-leg/batch-imports/template:
get:
summary: 下载 Excel 批量下单模板
description: >-
返回仅含中文业务列名的 xlsx。静态业务枚举使用中文下拉;仓库、渠道和客户地址按当前登录客户及正式主数据实时生成,
工作表不暴露内部 ID。示例与填写规则位于独立说明页,不占用可导入数据行。导入时重新校验动态选项的状态、归属和组合有效性。
responses:
'200': {description: xlsx 模板}
'403': {description: 需要 merchant:order:create 权限}
@@ -79,6 +82,7 @@ paths:
description: >-
ATOMIC 在任一行失败时整批回滚;PARTIAL 每行独立事务,错误行不影响成功行。
同一操作者重试必须复用 Idempotency-Key,每行再以 clientRowId 派生稳定幂等键。
仅接受当前中文模板,中文下拉值由服务端映射为权威代码,不兼容历史英文技术表头。
原文件和错误报告分别通过 File 的 ORDER_BATCH_IMPORT_SOURCE、ORDER_BATCH_IMPORT_ERROR_REPORT 契约保存。
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
@@ -1,22 +1,142 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.order.client.MerchantPricingCatalogClient;
import com.baiyue.order.client.MerchantWarehouseCatalogClient;
import com.baiyue.order.dto.CustomerAddressDtos;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MerchantBatchImportServiceTest {
private final CustomerAddressService addresses = mock(CustomerAddressService.class);
private final MerchantWarehouseCatalogClient warehouses = mock(MerchantWarehouseCatalogClient.class);
private final MerchantPricingCatalogClient pricing = mock(MerchantPricingCatalogClient.class);
private MerchantBatchImportService service;
@BeforeEach
void setUp() {
service = new MerchantBatchImportService();
ReflectionTestUtils.setField(service, "addresses", addresses);
ReflectionTestUtils.setField(service, "warehouses", warehouses);
ReflectionTestUtils.setField(service, "pricing", pricing);
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
when(addresses.list(88L, "PICKUP")).thenReturn(List.of(address(11L, "工厂", "PICKUP")));
when(addresses.list(88L, "PRIVATE_DESTINATION")).thenReturn(List.of(address(12L, "美国门店", "PRIVATE_DESTINATION")));
var domestic = new MerchantWarehouseCatalogClient.Warehouse(21L, "CN-YW", "义乌收货仓", "DOMESTIC", "CN", "ACTIVE");
var overseas = new MerchantWarehouseCatalogClient.Warehouse(22L, "US-LAX", "洛杉矶海外仓", "OVERSEAS", "US", "ACTIVE");
when(warehouses.page("Bearer test", 1, 200, "ACTIVE"))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(domestic, overseas), 1, 200, 2, 1)));
when(pricing.channels("Bearer test", 21L)).thenReturn(ApiResponse.success(List.of(
new MerchantPricingCatalogClient.Channel("US-SEA", "美森海运"))));
}
@Test
void templateUsesStableRowIdentityAndAuthoritativeDraftSchema() throws Exception {
byte[] content = new MerchantBatchImportService().template();
void templateUsesChineseHeadersDropdownsAndSeparateGuideWithoutExampleRow() throws Exception {
byte[] content = service.template(88L, "Bearer test");
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");
var sheet = workbook.getSheet("订单导入");
assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isEqualTo("客户行号");
assertThat(sheet.getRow(0).getCell(10).getStringCellValue()).isEqualTo("取货地址");
assertThat(sheet.getRow(0).getCell(11).getStringCellValue()).isEqualTo("国内收货仓");
assertThat(sheet.getRow(0).getCell(29).getStringCellValue()).isEqualTo("是否木制品");
assertThat((Object) sheet.getRow(1)).isNull();
assertThat(sheet.getDataValidations()).hasSizeGreaterThanOrEqualTo(10);
assertThat(workbook.getSheet("填写说明")).isNotNull();
assertThat(workbook.isSheetHidden(workbook.getSheetIndex("选项数据"))).isTrue();
}
}
@Test
void importMapsChineseAndBusinessLabelsToCodesAndAuthoritativeIds() throws Exception {
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) {
var row = workbook.getSheet("订单导入").createRow(1);
row.createCell(0).setCellValue("第1行"); row.createCell(1).setCellValue("测试客户");
row.createCell(2).setCellValue("海运"); row.createCell(8).setCellValue("平台上门提货");
row.createCell(10).setCellValue("工厂|张三|深圳|科技园1号");
row.createCell(11).setCellValue("义乌收货仓|CN-YW"); row.createCell(12).setCellValue("美森海运|US-SEA");
row.createCell(13).setCellValue("买单报关"); row.createCell(14).setCellValue("不包税");
row.createCell(17).setCellValue("私人地址"); row.createCell(18).setCellValue("美国门店|张三|深圳|科技园1号");
row.createCell(23).setCellValue("蓝牙音箱"); row.createCell(24).setCellValue("纸箱");
row.createCell(25).setCellValue("1"); row.createCell(26).setCellValue("1.2"); row.createCell(27).setCellValue("0.1");
row.createCell(29).setCellValue("否"); row.createCell(30).setCellValue("是");
workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.parseError()).isNull();
assertThat(item.draft().transportMode()).isEqualTo("SEA");
assertThat(item.draft().pickupMethod()).isEqualTo("PICKUP");
assertThat(item.draft().senderAddressId()).isEqualTo(11L);
assertThat(item.draft().receivingWarehouseId()).isEqualTo(21L);
assertThat(item.draft().channelCode()).isEqualTo("US-SEA");
assertThat(item.draft().taxMode()).isEqualTo("BUY_DECLARATION");
assertThat(item.draft().cargoes().get(0).packageType()).isEqualTo("CARTON");
assertThat(item.draft().cargoes().get(0).dangerousGoods()).isTrue();
}
}
@Test
void templateReadsEveryActiveWarehousePageWithoutApplyingBusinessLimit() throws Exception {
List<MerchantWarehouseCatalogClient.Warehouse> firstPage = new ArrayList<>();
for (long id = 1000; id < 1200; id++) {
firstPage.add(new MerchantWarehouseCatalogClient.Warehouse(id, "US-" + id, "海外仓" + id,
"OVERSEAS", "US", "ACTIVE"));
}
var lastWarehouse = new MerchantWarehouseCatalogClient.Warehouse(21L, "CN-YW", "义乌收货仓",
"DOMESTIC", "CN", "ACTIVE");
when(warehouses.page("Bearer test", 1, 200, "ACTIVE"))
.thenReturn(ApiResponse.success(new PageResult<>(firstPage, 1, 200, 201, 2)));
when(warehouses.page("Bearer test", 2, 200, "ACTIVE"))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(lastWarehouse), 2, 200, 201, 2)));
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
var options = workbook.getSheet("选项数据");
assertThat(hasCellValue(options, "海外仓1000|US-1000")).isTrue();
assertThat(hasCellValue(options, "海外仓1199|US-1199")).isTrue();
assertThat(hasCellValue(options, "义乌收货仓|CN-YW")).isTrue();
}
verify(warehouses).page("Bearer test", 2, 200, "ACTIVE");
}
@Test
void templateFailsClosedWhenLaterWarehousePageCannotBeRead() {
List<MerchantWarehouseCatalogClient.Warehouse> firstPage = new ArrayList<>();
for (long id = 1000; id < 1200; id++) {
firstPage.add(new MerchantWarehouseCatalogClient.Warehouse(id, "US-" + id, "海外仓" + id,
"OVERSEAS", "US", "ACTIVE"));
}
when(warehouses.page("Bearer test", 1, 200, "ACTIVE"))
.thenReturn(ApiResponse.success(new PageResult<>(firstPage, 1, 200, 201, 2)));
when(warehouses.page("Bearer test", 2, 200, "ACTIVE")).thenReturn(null);
assertThatThrownBy(() -> service.template(88L, "Bearer test"))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessage("仓库选项第2页暂不可用");
}
private static boolean hasCellValue(org.apache.poi.ss.usermodel.Sheet sheet, String expected) {
for (var row : sheet) for (var cell : row) if (expected.equals(cell.getStringCellValue())) return true;
return false;
}
private static CustomerAddressDtos.Response address(long id, String name, String type) {
return new CustomerAddressDtos.Response(id, type, name, "测试公司", "张三", "13800000000", null,
"CN", "广东", "深圳", "南山", "科技园1号", "518000", true);
}
}