Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -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) { }
|
||||
}
|
||||
+9
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user