From 3664f036ccfa197400a1982b5452db021e2a89db Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 16:33:33 +0800 Subject: [PATCH] feat(order): localize batch import workbook --- .../client/MerchantPricingCatalogClient.java | 19 ++ .../MerchantWarehouseCatalogClient.java | 22 ++ .../MerchantFirstLegOrderController.java | 13 +- .../service/MerchantBatchImportService.java | 229 +++++++++++++++--- .../src/main/resources/openapi/order-v1.yaml | 4 + .../MerchantBatchImportServiceTest.java | 85 ++++++- 6 files changed, 329 insertions(+), 43 deletions(-) create mode 100644 order/src/main/java/com/baiyue/order/client/MerchantPricingCatalogClient.java create mode 100644 order/src/main/java/com/baiyue/order/client/MerchantWarehouseCatalogClient.java diff --git a/order/src/main/java/com/baiyue/order/client/MerchantPricingCatalogClient.java b/order/src/main/java/com/baiyue/order/client/MerchantPricingCatalogClient.java new file mode 100644 index 000000000..9a96c357c --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/MerchantPricingCatalogClient.java @@ -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> channels(@RequestHeader("Authorization") String authorization, + @RequestParam("originWarehouseId") long originWarehouseId); + + record Channel(String channelCode, String channelName) { } +} diff --git a/order/src/main/java/com/baiyue/order/client/MerchantWarehouseCatalogClient.java b/order/src/main/java/com/baiyue/order/client/MerchantWarehouseCatalogClient.java new file mode 100644 index 000000000..b1ddddf26 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/MerchantWarehouseCatalogClient.java @@ -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> 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) { } +} diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index bbc8ad673..bd8756c38 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -117,12 +117,16 @@ public class MerchantFirstLegOrderController { @GetMapping("/batch-imports/template") @PreAuthorize("hasAuthority('merchant:order:create')") - public ResponseEntity batchImportTemplate() { - byte[] content = batchImportService.template(); + @Operation(summary = "下载中文批量下单模板", + description = "模板按当前登录客户生成仓库、渠道和客户地址下拉;导入时会重新校验全部动态选项。") + public ResponseEntity 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 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 / 修改草稿") diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java index e228e39f7..b8cb031e9 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java @@ -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,7 +26,10 @@ 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 { @@ -29,35 +37,91 @@ public class MerchantBatchImportService { 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 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 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> 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 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> 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 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 +129,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 +137,7 @@ public class MerchantBatchImportService { byte[] content; try { content = file.getBytes(); } catch (Exception exception) { throw new BizException(ResultCode.BAD_REQUEST, "无法读取 Excel 文件"); } - List items = parse(content); + List 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 +149,19 @@ public class MerchantBatchImportService { return new MerchantBatchImportDtos.Response(batchNo, mode, sourceFileId, errorFileId, result); } - private List parse(byte[] content) { + List 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 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 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 +170,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 +197,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 +213,101 @@ 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 { + var warehouseResponse = warehouses.page(authorization, 1, 200, "ACTIVE"); + if (warehouseResponse == null || !warehouseResponse.successful() || warehouseResponse.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库选项暂不可用"); + } + List allWarehouses = warehouseResponse.data().records(); + if (warehouseResponse.data().pages() > 1) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "可用仓库超过模板支持范围,请联系平台处理"); + } + 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 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 static Map addressOptions(List values) { + return optionMap(values, value -> String.join("|", value.addressName(), value.contactName(), value.city(), value.addressLine())); + } + + private static Map optionMap(List values, Function labeler) { + Map 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 required(Map 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 pickupAddresses, + Map privateAddresses, + Map receivingWarehouses, + Map destinationWarehouses, + Map 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); diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index f55de4006..1bdf069fc 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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' diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java index f675cd68f..cb5971871 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java @@ -1,22 +1,93 @@ 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.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +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(); + } + } + + private static CustomerAddressDtos.Response address(long id, String name, String type) { + return new CustomerAddressDtos.Response(id, type, name, "测试公司", "张三", "13800000000", null, + "CN", "广东", "深圳", "南山", "科技园1号", "518000", true); + } }