@@ -12,8 +12,18 @@ 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.CellStyle ;
import org.apache.poi.ss.usermodel.BorderStyle ;
import org.apache.poi.ss.usermodel.DataFormatter ;
import org.apache.poi.ss.usermodel.DataValidationConstraint ;
import org.apache.poi.ss.usermodel.DateUtil ;
import org.apache.poi.ss.usermodel.FillPatternType ;
import org.apache.poi.ss.usermodel.Font ;
import org.apache.poi.ss.usermodel.HorizontalAlignment ;
import org.apache.poi.ss.usermodel.IndexedColors ;
import org.apache.poi.ss.usermodel.Row ;
import org.apache.poi.ss.usermodel.VerticalAlignment ;
import org.apache.poi.ss.util.CellRangeAddress ;
import org.apache.poi.ss.util.CellRangeAddressList ;
import org.apache.poi.xssf.usermodel.XSSFWorkbook ;
import org.springframework.beans.factory.annotation.Value ;
@@ -25,9 +35,15 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream ;
import java.nio.charset.StandardCharsets ;
import java.security.MessageDigest ;
import java.time.LocalDate ;
import java.time.LocalDateTime ;
import java.time.format.DateTimeFormatter ;
import java.time.format.DateTimeParseException ;
import java.time.format.ResolverStyle ;
import java.util.ArrayList ;
import java.util.LinkedHashMap ;
import java.util.List ;
import java.util.Locale ;
import java.util.Map ;
import java.util.function.Function ;
@@ -44,24 +60,48 @@ public class MerchantBatchImportService {
@Resource private ObjectMapper json ;
@Value ( " ${baiyue.security.internal-service-token:} " ) private String token ;
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 ) ) ;
col ( " 订单基础 " , " 客户行号 " , " clientRowId " , null , true , " 每行唯一,用于错误定位 " , 16 ) ,
col ( " 订单基础 " , " 客户名称(企业名称) " , " customerName " , null , true , " 填写当前下单企业的正式名称,系统不会猜测或自动替换 " , 24 ) ,
col ( " 订单基础 " , " 运输方式 " , " transportMode " , " transport " , true , " 始终填写,从下拉选择 " , 14 ) ,
col ( " 订单基础 " , " 贸易术语 " , " tradeTerm " , null , false , " 有约定时填写,如FOB、EXW " , 14 ) ,
col ( " 订单基础 " , " 预计出运日期 " , " expectedShipDate " , null , false , " 有预计日期时填写,格式yyyy-mm-dd " , 18 ) ,
col ( " 订单基础 " , " 客户备注 " , " customerRemark " , null , false , " 有补充说明时填写 " , 24 ) ,
col ( " 目的地与仓库 " , " 目的类型 " , " destinationType " , " destination " , true , " 始终填写,并与对应目的仓字段保持一致 " , 16 ) ,
col ( " 目的地与仓库 " , " FBA目的仓 " , " $fbaWarehouse " , " fbaWarehouses " , false , " 目的类型为亚马逊FBA时必填 " , 22 ) ,
col ( " 目的地与仓库 " , " 海外仓目的仓 " , " $overseasWarehouse " , " overseasWarehouses " , false , " 目的类型为海外仓时必填 " , 24 ) ,
col ( " 目的地与仓库 " , " 私人目的仓地址 " , " $privateAddress " , " privateAddresses " , false , " 目的类型为私人地址时必填 " , 32 ) ,
col ( " 国内提货与运输 " , " 提货方式 " , " pickupMethod " , " pickup " , true , " 始终填写,从下拉选择 " , 18 ) ,
col ( " 国内提货与运输 " , " 预约提货时间 " , " requestedPickupAt " , null , false , " 提货方式为平台上门提货时填写,格式yyyy-mm-dd hh:mm:ss " , 23 ) ,
col ( " 国内提货与运输 " , " 取货地址 " , " $senderAddress " , " pickupAddresses " , false , " 提货方式为平台上门提货时必填 " , 32 ) ,
col ( " 国内提货与运输 " , " 国内收货仓 " , " $receivingWarehouse " , " receivingWarehouses " , true , " 始终填写,从启用的国内仓选择 " , 24 ) ,
col ( " 国内提货与运输 " , " 运输渠道 " , " $channel " , " channels " , true , " 始终填写;导入时校验与国内收货仓的组合 " , 24 ) ,
col ( " 报关与计价 " , " 报关方式 " , " taxMode " , " tax " , true , " 始终填写 " , 16 ) ,
col ( " 报关与计价 " , " 计价税模式 " , " pricingTaxMode " , " pricingTax " , true , " 始终填写 " , 16 ) ,
col ( " 报关与计价 " , " 装箱方式 " , " containerMode " , " container " , false , " 已确定整柜或散货拼箱时填写 " , 16 ) ,
col ( " 报关与计价 " , " 是否需要商检 " , " customsInspection " , " boolean " , false , " 木制品或业务要求商检时填写 " , 18 ) ,
col ( " FBA业务信息 " , " 货件编号 " , " shipmentId " , null , false , " FBA业务有货件编号时填写 " , 18 ) ,
col ( " FBA业务信息 " , " 参考编号 " , " referenceId " , null , false , " FBA业务有参考编号时填写 " , 18 ) ,
col ( " FBA业务信息 " , " 履约类型 " , " fulfillmentType " , " fulfillment " , false , " 未填写时按普通头程处理 " , 16 ) ,
col ( " 货物信息 " , " 货物名称 " , " cargo.cargoName " , null , true , " 填写本行订单的货物名称 " , 22 ) ,
col ( " 货物信息 " , " 包装类型 " , " cargo.packageType " , " package " , true , " 始终填写,从下拉选择 " , 16 ) ,
col ( " 货物信息 " , " 包装数量 " , " cargo.packageCount " , null , true , " 正整数 " , 14 ) ,
col ( " 货物信息 " , " 毛重(千克) " , " cargo.grossWeightKg " , null , true , " 填写大于0的数字 " , 16 ) ,
col ( " 货物信息 " , " 体积(立方米) " , " cargo.volumeCbm " , null , true , " 填写大于0的数字 " , 18 ) ,
col ( " 货物信息 " , " 材质 " , " cargo.material " , null , false , " 按实际材质填写 " , 18 ) ,
col ( " 货物信息 " , " 是否木制品 " , " cargo.woodenProduct " , " boolean " , true , " 始终填写 " , 16 ) ,
col ( " 货物信息 " , " 是否危险品 " , " cargo.dangerousGoods " , " boolean " , true , " 始终填写 " , 16 ) ,
col ( " 货物信息 " , " SKU " , " cargo.skuNo " , null , false , " 有SKU时填写 " , 16 ) ,
col ( " 货物信息 " , " UPC " , " cargo.upcNo " , null , false , " 有UPC时填写 " , 16 ) ,
col ( " 货物信息 " , " 货物货件编号 " , " cargo.shipmentId " , null , false , " 货物级货件编号,有则填写 " , 20 ) ,
col ( " 货物信息 " , " 货物参考编号 " , " cargo.referenceId " , null , false , " 货物级参考编号,有则填写 " , 20 ) ) ;
private static final int DATA_START_ROW = 2 ;
private static final int EXPECTED_SHIP_DATE_COLUMN = 4 ;
private static final int REQUESTED_PICKUP_AT_COLUMN = 11 ;
private static final ThreadLocal < DataFormatter > DATA_FORMATTER =
ThreadLocal . withInitial ( ( ) - > new DataFormatter ( Locale . SIMPLIFIED_CHINESE ) ) ;
private static final DateTimeFormatter DATE = DateTimeFormatter . ISO_LOCAL_DATE ;
private static final DateTimeFormatter DATE_TIME =
DateTimeFormatter . ofPattern ( " uuuu-MM-dd HH:mm:ss " ) . withResolverStyle ( ResolverStyle . STRICT ) ;
private static final Map < String , List < String > > STATIC_OPTIONS = Map . of (
" transport " , List . of ( " 海运 " , " 空运 " , " 铁路 " , " 卡车 " ) ,
" pickup " , List . of ( " 自行快递 " , " 平台上门提货 " , " 自行送仓 " ) ,
@@ -82,17 +122,30 @@ public class MerchantBatchImportService {
public byte [ ] template ( long tenantId , String authorization ) {
Catalog catalog = catalog ( tenantId , authorization ) ;
try ( var workbook = new XSSFWorkbook ( ) ; var out = new ByteArrayOutputStream ( ) ) {
workbook . getFontAt ( 0 ) . setFontName ( " Microsoft YaHei " ) ;
var sheet = workbook . createSheet ( " 订单导入 " ) ;
Row header = sheet . createRow ( 0 ) ;
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 ) ;
createHeaders ( workbook , sheet ) ;
CellStyle baseStyle = workbook . createCellStyle ( ) ;
baseStyle . setVerticalAlignment ( VerticalAlignment . CENTER ) ;
CellStyle dateStyle = workbook . createCellStyle ( ) ; dateStyle . cloneStyleFrom ( baseStyle ) ;
dateStyle . setDataFormat ( workbook . createDataFormat ( ) . getFormat ( " yyyy-mm-dd " ) ) ;
CellStyle dateTimeStyle = workbook . createCellStyle ( ) ; dateTimeStyle . cloneStyleFrom ( baseStyle ) ;
dateTimeStyle . setDataFormat ( workbook . createDataFormat ( ) . getFormat ( " yyyy-mm-dd hh:mm:ss " ) ) ;
for ( int rowNo = DATA_START_ROW ; rowNo < DATA_START_ROW + MAX_ROWS ; rowNo + + ) {
Row row = sheet . createRow ( rowNo ) ; row . setHeightInPoints ( 22 ) ;
for ( int col = 0 ; col < COLUMNS . size ( ) ; col + + ) {
Cell cell = row . createCell ( col ) ;
cell . setCellStyle ( col = = EXPECTED_SHIP_DATE_COLUMN ? dateStyle
: col = = REQUESTED_PICKUP_AT_COLUMN ? dateTimeStyle : baseStyle ) ;
}
}
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 ( " fbaWarehouses " , catalog . fbaWarehouses ( ) . keySet ( ) . stream ( ) . toList ( ) ) ;
optionValues . put ( " overseasWarehouses " , catalog . overseasWarehouses ( ) . keySet ( ) . stream ( ) . toList ( ) ) ;
optionValues . put ( " channels " , catalog . channels ( ) . keySet ( ) . stream ( ) . toList ( ) ) ;
int optionCol = 0 ;
Map < String , String > ranges = new LinkedHashMap < > ( ) ;
@@ -112,17 +165,15 @@ public class MerchantBatchImportService {
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 ) ) ;
var validation = helper . createValidation ( constraint ,
new CellRangeAddressList ( DATA_START_ROW , DATA_START_ROW + MAX_ROWS - 1 , col , col ) ) ;
validation . setShowErrorBox ( true ) ; validation . createErrorBox ( " 选项不正确 " , " 请从下拉列表中选择 " ) ;
sheet . addValidationData ( validation ) ;
}
addDateValidation ( helper , sheet , EXPECTED_SHIP_DATE_COLUMN , " 请输入有效日期(yyyy-mm-dd) " ) ;
addDateValidation ( helper , sheet , REQUESTED_PICKUP_AT_COLUMN , " 请输入有效日期时间(yyyy-mm-dd hh:mm:ss) " ) ;
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 ) ;
createGuide ( workbook ) ;
workbook . write ( out ) ;
return out . toByteArray ( ) ;
} catch ( Exception exception ) {
@@ -130,6 +181,97 @@ public class MerchantBatchImportService {
}
}
private static void createHeaders ( XSSFWorkbook workbook , org . apache . poi . ss . usermodel . Sheet sheet ) {
Font groupFont = workbook . createFont ( ) ; groupFont . setFontName ( " Microsoft YaHei " ) ;
groupFont . setBold ( true ) ; groupFont . setColor ( IndexedColors . WHITE . getIndex ( ) ) ;
CellStyle groupStyle = workbook . createCellStyle ( ) ; groupStyle . setFont ( groupFont ) ;
groupStyle . setFillForegroundColor ( IndexedColors . DARK_BLUE . getIndex ( ) ) ; groupStyle . setFillPattern ( FillPatternType . SOLID_FOREGROUND ) ;
groupStyle . setAlignment ( HorizontalAlignment . CENTER ) ; groupStyle . setVerticalAlignment ( VerticalAlignment . CENTER ) ;
groupStyle . setBorderBottom ( BorderStyle . THIN ) ; groupStyle . setBorderRight ( BorderStyle . THIN ) ;
Font headerFont = workbook . createFont ( ) ; headerFont . setFontName ( " Microsoft YaHei " ) ;
headerFont . setBold ( true ) ; headerFont . setColor ( IndexedColors . DARK_BLUE . getIndex ( ) ) ;
CellStyle headerStyle = workbook . createCellStyle ( ) ; headerStyle . setFont ( headerFont ) ;
headerStyle . setFillForegroundColor ( IndexedColors . PALE_BLUE . getIndex ( ) ) ; headerStyle . setFillPattern ( FillPatternType . SOLID_FOREGROUND ) ;
headerStyle . setAlignment ( HorizontalAlignment . CENTER ) ; headerStyle . setVerticalAlignment ( VerticalAlignment . CENTER ) ;
headerStyle . setWrapText ( true ) ; headerStyle . setBorderBottom ( BorderStyle . THIN ) ; headerStyle . setBorderRight ( BorderStyle . THIN ) ;
Row groupRow = sheet . createRow ( 0 ) ; groupRow . setHeightInPoints ( 26 ) ;
Row headerRow = sheet . createRow ( 1 ) ; headerRow . setHeightInPoints ( 38 ) ;
var drawing = sheet . createDrawingPatriarch ( ) ;
var creation = workbook . getCreationHelper ( ) ;
int start = 0 ;
while ( start < COLUMNS . size ( ) ) {
String group = COLUMNS . get ( start ) . group ( ) ;
int end = start ;
while ( end + 1 < COLUMNS . size ( ) & & group . equals ( COLUMNS . get ( end + 1 ) . group ( ) ) ) end + + ;
if ( end > start ) sheet . addMergedRegion ( new CellRangeAddress ( 0 , 0 , start , end ) ) ;
for ( int col = start ; col < = end ; col + + ) {
Cell cell = groupRow . createCell ( col ) ; cell . setCellStyle ( groupStyle ) ;
if ( col = = start ) cell . setCellValue ( group ) ;
}
start = end + 1 ;
}
for ( int col = 0 ; col < COLUMNS . size ( ) ; col + + ) {
Column column = COLUMNS . get ( col ) ;
Cell cell = headerRow . createCell ( col ) ; cell . setCellValue ( column . displayTitle ( ) ) ; cell . setCellStyle ( headerStyle ) ;
var anchor = creation . createClientAnchor ( ) ;
anchor . setCol1 ( col ) ; anchor . setCol2 ( Math . min ( COLUMNS . size ( ) , col + 3 ) ) ; anchor . setRow1 ( 1 ) ; anchor . setRow2 ( 5 ) ;
var comment = drawing . createCellComment ( anchor ) ;
comment . setString ( creation . createRichTextString ( column . instruction ( ) ) ) ; comment . setAuthor ( " 订单服务 " ) ;
cell . setCellComment ( comment ) ;
sheet . setColumnWidth ( col , Math . min ( 60 , column . width ( ) ) * 256 ) ;
}
sheet . createFreezePane ( 1 , 2 ) ;
sheet . setAutoFilter ( new CellRangeAddress ( 1 , 1 , 0 , COLUMNS . size ( ) - 1 ) ) ;
}
private static void addDateValidation ( org . apache . poi . ss . usermodel . DataValidationHelper helper ,
org . apache . poi . ss . usermodel . Sheet sheet , int column , String message ) {
var constraint = helper . createDateConstraint ( DataValidationConstraint . OperatorType . BETWEEN ,
" DATE(2000,1,1) " , " DATE(2100,12,31) " , " yyyy-mm-dd " ) ;
var validation = helper . createValidation ( constraint ,
new CellRangeAddressList ( DATA_START_ROW , DATA_START_ROW + MAX_ROWS - 1 , column , column ) ) ;
validation . setEmptyCellAllowed ( true ) ; validation . setShowErrorBox ( true ) ;
validation . createErrorBox ( " 日期格式不正确 " , message ) ; sheet . addValidationData ( validation ) ;
}
private static void createGuide ( XSSFWorkbook workbook ) {
var guide = workbook . createSheet ( " 填写说明 " ) ;
Font titleFont = workbook . createFont ( ) ; titleFont . setFontName ( " Microsoft YaHei " ) ;
titleFont . setBold ( true ) ; titleFont . setFontHeightInPoints ( ( short ) 14 ) ;
CellStyle titleStyle = workbook . createCellStyle ( ) ; titleStyle . setFont ( titleFont ) ;
guide . createRow ( 0 ) . createCell ( 0 ) . setCellValue ( " 头程订单批量导入填写说明 " ) ;
guide . getRow ( 0 ) . getCell ( 0 ) . setCellStyle ( titleStyle ) ;
guide . createRow ( 1 ) . createCell ( 0 ) . setCellValue ( " 订单导入页每行代表一张订单,最多50行;示例不会放入导入区域。动态选项在导入时按当前客户重新校验。 " ) ;
Row header = guide . createRow ( 3 ) ;
String [ ] titles = { " 字段 " , " 什么时候填写 / 说明 " , " 格式 " , " 是否必填 " } ;
for ( int col = 0 ; col < titles . length ; col + + ) header . createCell ( col ) . setCellValue ( titles [ col ] ) ;
int rowNo = 4 ;
for ( Column column : COLUMNS ) {
Row row = guide . createRow ( rowNo + + ) ;
row . createCell ( 0 ) . setCellValue ( column . title ( ) ) ; row . createCell ( 1 ) . setCellValue ( column . instruction ( ) ) ;
row . createCell ( 2 ) . setCellValue ( formatDescription ( column ) ) ;
row . createCell ( 3 ) . setCellValue ( column . required ( ) ? " 必填 " : " 按条件/选填 " ) ;
}
guide . setColumnWidth ( 0 , 26 * 256 ) ; guide . setColumnWidth ( 1 , 62 * 256 ) ;
guide . setColumnWidth ( 2 , 28 * 256 ) ; guide . setColumnWidth ( 3 , 16 * 256 ) ;
guide . createFreezePane ( 0 , 4 ) ; guide . setAutoFilter ( new CellRangeAddress ( 3 , 3 , 0 , 3 ) ) ;
}
private static String formatDescription ( Column column ) {
if ( " expectedShipDate " . equals ( column . field ( ) ) ) return " 日期:yyyy-mm-dd " ;
if ( " requestedPickupAt " . equals ( column . field ( ) ) ) return " 日期时间:yyyy-mm-dd hh:mm:ss " ;
if ( column . optionKey ( ) ! = null ) return " 从下拉列表选择 " ;
if ( column . field ( ) . contains ( " Count " ) ) return " 正整数 " ;
if ( column . field ( ) . contains ( " Weight " ) | | column . field ( ) . contains ( " volume " ) ) return " 数字 " ;
return " 文本 " ;
}
private static Column col ( String group , String title , String field , String optionKey , boolean required ,
String instruction , int width ) {
return new Column ( group , title , field , optionKey , required , instruction , width ) ;
}
public MerchantBatchImportDtos . Response importFile ( long tenantId , long userId , String username , String authorization ,
String idempotencyKey , MerchantBatchImportDtos . Mode mode ,
MultipartFile file ) {
@@ -154,16 +296,16 @@ public class MerchantBatchImportService {
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 没有订单数据行 " ) ;
if ( sheet . getLastRowNum ( ) < DATA_START_ROW ) throw new BizException ( ResultCode . VALIDATION_FAILED , " Excel 没有订单数据行 " ) ;
List < MerchantBatchDraftDtos . Item > items = new ArrayList < > ( ) ;
Row header = sheet . getRow ( 0 ) ;
if ( header = = null | | ! COLUMNS . get ( 0 ) . title ( ) . equals ( text ( header . getCell ( 0 ) ) ) ) {
Row header = sheet . getRow ( 1 ) ;
if ( header = = null | | ! COLUMNS . get ( 0 ) . displayTitle ( ) . equals ( text ( header . getCell ( 0 ) ) ) ) {
throw new BizException ( ResultCode . VALIDATION_FAILED , " Excel 模板表头不正确 " ) ;
}
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 ) ) ) )
for ( int col = 0 ; col < COLUMNS . size ( ) ; col + + ) if ( ! COLUMNS . get ( col ) . displayTitle ( ) . equals ( text ( header . getCell ( col ) ) ) )
throw new BizException ( ResultCode . VALIDATION_FAILED , " Excel 模板表头不正确 " ) ;
for ( int rowNo = 1 ; rowNo < = sheet . getLastRowNum ( ) ; rowNo + + ) {
for ( int rowNo = DATA_START_ROW ; rowNo < = sheet . getLastRowNum ( ) ; rowNo + + ) {
Row row = sheet . getRow ( rowNo ) ;
if ( row = = null | | ! StringUtils . hasText ( text ( row . getCell ( 0 ) ) ) ) continue ;
if ( items . size ( ) > = MAX_ROWS ) throw new BizException ( ResultCode . VALIDATION_FAILED , " Excel 最多 50 条订单 " ) ;
@@ -171,14 +313,26 @@ public class MerchantBatchImportService {
try {
var draft = json . createObjectNode ( ) ;
var cargo = json . createObjectNode ( ) ;
boolean fbaSelected = false ;
boolean overseasSelected = false ;
boolean privateSelected = false ;
for ( int col = 1 ; col < COLUMNS . size ( ) ; col + + ) {
String field = COLUMNS . get ( col ) . field ( ) ; String value = text ( row . getCell ( col ) ) ;
String field = COLUMNS . get ( col ) . field ( ) ;
String value = value ( row . getCell ( col ) , field ) ;
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 ( " $ " ) ) {
applySelection ( draft , field , value , catalog ) ;
fbaSelected | = " $fbaWarehouse " . equals ( field ) ;
overseasSelected | = " $overseasWarehouse " . equals ( field ) ;
privateSelected | = " $privateAddress " . equals ( field ) ;
continue ;
}
if ( field . startsWith ( " cargo. " ) ) cargo . put ( field . substring ( " cargo. " . length ( ) ) , value ) ;
else draft . put ( field , value ) ;
}
validateDestination ( draft . path ( " destinationType " ) . asText ( null ) ,
fbaSelected , overseasSelected , privateSelected ) ;
draft . set ( " cargoes " , json . createArrayNode ( ) . add ( cargo ) ) ;
items . add ( new MerchantBatchDraftDtos . Item ( rowId ,
json . treeToValue ( draft , OrderRequests . SaveRequest . class ) ) ) ;
@@ -203,7 +357,7 @@ public class MerchantBatchImportService {
int rowNo = 1 ;
for ( var item : result . items ( ) ) if ( ! item . success ( ) ) {
Row row = sheet . createRow ( rowNo + + ) ;
row . createCell ( 0 ) . setCellValue ( item . index ( ) + 2 ) ;
row . createCell ( 0 ) . setCellValue ( item . index ( ) + DATA_START_ROW + 1 ) ;
row . createCell ( 1 ) . setCellValue ( item . clientRowId ( ) ) ;
row . createCell ( 2 ) . setCellValue ( item . errorCode ( ) ) ;
row . createCell ( 3 ) . setCellValue ( item . errorMessage ( ) ) ;
@@ -221,7 +375,9 @@ public class MerchantBatchImportService {
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 ( ) ,
var fba = optionMap ( allWarehouses . stream ( ) . filter ( value - > " FBA " . equals ( value . warehouseType ( ) ) ) . toList ( ) ,
value - > label ( value . warehouseName ( ) , value . warehouseCode ( ) ) ) ;
var overseas = optionMap ( allWarehouses . stream ( ) . filter ( value - > " OVERSEAS " . equals ( value . warehouseType ( ) ) ) . toList ( ) ,
value - > label ( value . warehouseName ( ) , value . warehouseCode ( ) ) ) ;
Map < String , MerchantPricingCatalogClient . Channel > channels = new LinkedHashMap < > ( ) ;
for ( var warehouse : receiving . values ( ) ) {
@@ -241,7 +397,7 @@ public class MerchantBatchImportService {
return new Catalog (
addressOptions ( addresses . list ( tenantId , " PICKUP " ) ) ,
addressOptions ( addresses . list ( tenantId , " PRIVATE_DESTINATION " ) ) ,
receiving , destination , channels ) ;
receiving , fba , overseas , channels ) ;
} catch ( BizException exception ) {
throw exception ;
} catch ( RuntimeException exception ) {
@@ -319,15 +475,19 @@ public class MerchantBatchImportService {
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 " $privateAddress " - > {
var address = required ( catalog . privateAddresses ( ) , value , " 私人目的仓地址 " ) ;
draft . put ( " privateDestinationAddressId " , address . id ( ) ) ;
draft . put ( " destinationCountryCode " , address . countryCode ( ) ) ;
}
case " $receivingWarehouse " - > {
var warehouse = required ( catalog . receivingWarehouses ( ) , value , " 国内收货仓 " ) ;
draft . put ( " receivingWarehouseId " , warehouse . id ( ) ) ; draft . put ( " receivingWarehouseName " , warehouse . warehouseName ( ) ) ;
}
case " $destination Warehouse " - > {
var warehouse = required ( catalog . destinationWarehouses ( ) , value , " 目的仓库 " ) ;
draft . put ( " destinationWarehouseId " , warehouse . id ( ) ) ; draft . put ( " destinationWarehouseName " , warehouse . warehouseName ( ) ) ;
}
case " $fba Warehouse " - > applyDestinationWarehouse ( draft ,
required ( catalog . fbaWarehouses ( ) , value , " FBA 目的仓" ) , true ) ;
case " $overseasWarehouse " - > applyDestinationWarehouse ( draft ,
required ( catalog . overseasWarehouses ( ) , value , " 海外仓目的仓 " ) , false ) ;
case " $channel " - > {
var channel = required ( catalog . channels ( ) , value , " 运输渠道 " ) ;
draft . put ( " channelCode " , channel . channelCode ( ) ) ; draft . put ( " channelName " , channel . channelName ( ) ) ;
@@ -342,15 +502,44 @@ public class MerchantBatchImportService {
return selected ;
}
private static String label ( String name , String code ) { return name + " | " + code ; }
private static void applyDestinationWarehouse ( com . fasterxml . jackson . databind . node . ObjectNode draft ,
MerchantWarehouseCatalogClient . Warehouse warehouse ,
boolean fba ) {
draft . put ( " destinationWarehouseId " , warehouse . id ( ) ) ;
draft . put ( " destinationWarehouseName " , warehouse . warehouseName ( ) ) ;
draft . put ( " destinationCountryCode " , warehouse . countryCode ( ) ) ;
if ( fba ) draft . put ( " amazonWarehouseCode " , warehouse . warehouseCode ( ) ) ;
}
private static void validateDestination ( String destinationType , boolean fba , boolean overseas , boolean privateAddress ) {
if ( " FBA " . equals ( destinationType ) & & fba & & ! overseas & & ! privateAddress ) return ;
if ( " OVERSEAS " . equals ( destinationType ) & & overseas & & ! fba & & ! privateAddress ) return ;
if ( " PRIVATE " . equals ( destinationType ) & & privateAddress & & ! fba & & ! overseas ) return ;
if ( ! StringUtils . hasText ( destinationType ) ) throw new BizException ( ResultCode . VALIDATION_FAILED , " 请选择目的类型 " ) ;
throw new BizException ( ResultCode . VALIDATION_FAILED , switch ( destinationType ) {
case " FBA " - > " 亚马逊FBA订单必须且只能选择FBA目的仓 " ;
case " OVERSEAS " - > " 海外仓订单必须且只能选择海外仓目的仓 " ;
case " PRIVATE " - > " 私人地址订单必须且只能选择私人目的仓地址 " ;
default - > " 目的类型不正确 " ;
} ) ;
}
private static String label ( String name , String code ) {
if ( ! StringUtils . hasText ( name ) ) return code ;
return name . equalsIgnoreCase ( code ) ? code : 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 Column ( String group , String title , String field , String optionKey , boolean required ,
String instruction , int width ) {
String displayTitle ( ) { return required ? title + " * " : title ; }
}
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 , MerchantWarehouseCatalogClient . Warehouse > fbaWarehouses ,
Map < String , MerchantWarehouseCatalogClient . Warehouse > overseasWarehouses ,
Map < String , MerchantPricingCatalogClient . Channel > channels ) { }
private Long upload ( long tenantId , long userId , String batchNo , String type , MultipartFile file ) {
@@ -373,11 +562,31 @@ public class MerchantBatchImportService {
private static String text ( Cell cell ) {
if ( cell = = null ) return null ;
cell . setCellType ( org . apache . poi . ss . usermodel . CellType . STRING ) ;
String value = cell . getStringCellValue ( ) ;
String value = DATA_FORMATTER . get ( ) . formatCellValue ( cell ) ;
return value = = null ? null : value . trim ( ) ;
}
private static String value ( Cell cell , String field ) {
String formatted = text ( cell ) ;
if ( ! StringUtils . hasText ( formatted ) ) return null ;
boolean date = " expectedShipDate " . equals ( field ) ;
boolean dateTime = " requestedPickupAt " . equals ( field ) ;
if ( ! date & & ! dateTime ) return formatted ;
try {
if ( cell . getCellType ( ) = = org . apache . poi . ss . usermodel . CellType . NUMERIC ) {
if ( ! DateUtil . isValidExcelDate ( cell . getNumericCellValue ( ) ) ) throw new DateTimeParseException ( " invalid " , formatted , 0 ) ;
LocalDateTime excelValue = DateUtil . getLocalDateTime ( cell . getNumericCellValue ( ) ) ;
return date ? DATE . format ( excelValue . toLocalDate ( ) )
: DateTimeFormatter . ISO_LOCAL_DATE_TIME . format ( excelValue . withNano ( 0 ) ) ;
}
if ( date ) return DATE . format ( LocalDate . parse ( formatted , DATE ) ) ;
return DateTimeFormatter . ISO_LOCAL_DATE_TIME . format ( LocalDateTime . parse ( formatted , DATE_TIME ) ) ;
} catch ( RuntimeException exception ) {
throw new BizException ( ResultCode . VALIDATION_FAILED ,
date ? " 预计出运日期格式必须为yyyy-mm-dd " : " 预约提货时间格式必须为yyyy-mm-dd hh:mm:ss " ) ;
}
}
private static String safeName ( String name ) { return StringUtils . hasText ( name ) ? name : " order-batch.xlsx " ; }
private static String batchNo ( long tenantId , long userId , String key ) {
if ( ! StringUtils . hasText ( key ) ) throw new BizException ( ResultCode . BAD_REQUEST , " 必须提供 Idempotency-Key " ) ;