471 lines
13 KiB
Go
471 lines
13 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/xuri/excelize/v2"
|
||
"gorm.io/datatypes"
|
||
"gorm.io/gorm"
|
||
|
||
"psi/constant"
|
||
"psi/database"
|
||
"psi/models"
|
||
"psi/models/request"
|
||
"psi/utils"
|
||
)
|
||
|
||
// GoodsImportService 商品导入服务(独立模块,不影响原有代码)
|
||
type GoodsImportService struct{}
|
||
|
||
// EXCEL_COLUMNS 货号批量修改工具导出的17列(前16列为原表,最后一列"货号[新]"为工具追加)
|
||
var EXCEL_COLUMNS = []string{
|
||
"商品编号", "商品名称", "货号", "ISBN", "作者", "出版社",
|
||
"商品定价", "商品售价", "商品分类", "本店分类", "品相", "库存", "上书时间",
|
||
"商品ID", "店铺ID", "商品图片", "货号[新]",
|
||
}
|
||
|
||
// ParseGoodsImportExcel 解析上传的 Excel 文件为数据行
|
||
func (s *GoodsImportService) ParseGoodsImportExcel(fileBytes []byte) ([]request.GoodsImportRow, error) {
|
||
f, err := excelize.OpenReader(strings.NewReader(string(fileBytes)))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取Excel失败: %v", err)
|
||
}
|
||
defer f.Close()
|
||
|
||
sheet := f.GetSheetName(0)
|
||
rows, err := f.GetRows(sheet)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取工作表数据失败: %v", err)
|
||
}
|
||
|
||
if len(rows) < 2 {
|
||
return nil, fmt.Errorf("表格至少需要包含表头和数据行")
|
||
}
|
||
|
||
// 校验表头
|
||
header := rows[0]
|
||
if len(header) < 17 {
|
||
return nil, fmt.Errorf("表格列数不足,期望17列,实际%d列", len(header))
|
||
}
|
||
for i, expected := range EXCEL_COLUMNS {
|
||
actual := strings.TrimSpace(header[i])
|
||
if actual != expected {
|
||
return nil, fmt.Errorf("第%d列表头不匹配:期望'%s',实际'%s'", i+1, expected, actual)
|
||
}
|
||
}
|
||
|
||
// 补齐数据列数到17列
|
||
expectedColCount := len(header)
|
||
if expectedColCount < 17 {
|
||
expectedColCount = 17
|
||
}
|
||
|
||
// getCol 安全获取指定列的值
|
||
getCol := func(rowData []string, idx int) string {
|
||
if idx < len(rowData) {
|
||
return strings.TrimSpace(rowData[idx])
|
||
}
|
||
return ""
|
||
}
|
||
|
||
var result []request.GoodsImportRow
|
||
for _, row := range rows[1:] {
|
||
if len(row) == 0 {
|
||
continue
|
||
}
|
||
|
||
// 补齐列
|
||
for len(row) < expectedColCount {
|
||
row = append(row, "")
|
||
}
|
||
|
||
goodsName := strings.TrimSpace(row[1])
|
||
huohaoNew := getCol(row, 16) // 货号[新] 在第17列(索引16)
|
||
|
||
if goodsName == "" && huohaoNew == "" {
|
||
continue // 空行跳过
|
||
}
|
||
|
||
result = append(result, request.GoodsImportRow{
|
||
GoodsNo: getCol(row, 0),
|
||
GoodsName: goodsName,
|
||
Huohao: getCol(row, 2),
|
||
HuohaoNew: huohaoNew,
|
||
ISBN: getCol(row, 3),
|
||
Author: getCol(row, 4),
|
||
Publisher: getCol(row, 5),
|
||
PriceListing: getCol(row, 6),
|
||
PriceSale: getCol(row, 7),
|
||
Category: getCol(row, 8),
|
||
ShopCategory: getCol(row, 9),
|
||
Appearance: getCol(row, 10),
|
||
Inventory: getCol(row, 11),
|
||
ProductID: getCol(row, 13),
|
||
ShopID: getCol(row, 14),
|
||
LiveImage: getCol(row, 15),
|
||
})
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// ImportGoodsFromExcel 从解析后的数据导入商品
|
||
func (s *GoodsImportService) ImportGoodsFromExcel(userID int64, warehouseID int64, rows []request.GoodsImportRow) (*request.GoodsImportResult, error) {
|
||
databaseConn, err := database.GetTenantDB(userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取数据库连接失败: %v", err)
|
||
}
|
||
|
||
result := &request.GoodsImportResult{
|
||
SuccessCount: 0,
|
||
FailCount: 0,
|
||
FailDetails: make([]string, 0),
|
||
}
|
||
|
||
if len(rows) == 0 {
|
||
return result, nil
|
||
}
|
||
|
||
now := time.Now().Unix()
|
||
|
||
// Step 1: 收集所有货号[新],批量查库位
|
||
huohaoNewSet := make(map[string]bool)
|
||
for _, row := range rows {
|
||
if h := strings.TrimSpace(row.HuohaoNew); h != "" {
|
||
huohaoNewSet[h] = true
|
||
}
|
||
}
|
||
|
||
codes := make([]string, 0, len(huohaoNewSet))
|
||
for code := range huohaoNewSet {
|
||
codes = append(codes, code)
|
||
}
|
||
|
||
var existingLocations []models.Location
|
||
if err := databaseConn.Where("warehouse_id = ? AND code IN ? AND is_del = ?",
|
||
warehouseID, codes, 0).Find(&existingLocations).Error; err != nil {
|
||
return nil, fmt.Errorf("查询库位失败: %v", err)
|
||
}
|
||
|
||
locationMap := make(map[string]models.Location)
|
||
for _, loc := range existingLocations {
|
||
locationMap[loc.Code] = loc
|
||
}
|
||
|
||
// Step 2: 开启事务,逐行处理
|
||
tx := databaseConn.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// Step 2.1: 创建入库单(Excel导入直接完成)
|
||
receivingNo := utils.GenerateReceivingNo()
|
||
batchNo := utils.GenerateExcelImportBatchNo()
|
||
receivingOrder := models.ReceivingOrder{
|
||
ReceivingNo: receivingNo,
|
||
PurchaseOrderID: 0,
|
||
WaveTaskID: 0,
|
||
WarehouseID: warehouseID,
|
||
SupplierID: 0,
|
||
ReceivingDate: now,
|
||
Status: constant.ReceivingStatusCompleted,
|
||
Operator: "",
|
||
OperatorID: userID,
|
||
Remark: "Excel批量导入",
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
IsDel: 0,
|
||
}
|
||
if err := tx.Create(&receivingOrder).Error; err != nil {
|
||
tx.Rollback()
|
||
return nil, fmt.Errorf("创建入库单失败: %v", err)
|
||
}
|
||
|
||
for _, row := range rows {
|
||
huohaoNew := strings.TrimSpace(row.HuohaoNew)
|
||
if huohaoNew == "" {
|
||
result.AddFail(fmt.Sprintf("%s: 货号[新]为空", row.GoodsName))
|
||
continue
|
||
}
|
||
|
||
// 查或建库位
|
||
loc, exists := locationMap[huohaoNew]
|
||
if !exists {
|
||
// 再查一次(避免 map 中已有 is_del=1 的记录被忽略)
|
||
var findLoc models.Location
|
||
findErr := tx.Where("warehouse_id = ? AND code = ? AND is_del = ?",
|
||
warehouseID, huohaoNew, 0).First(&findLoc).Error
|
||
if findErr == nil {
|
||
loc = findLoc
|
||
locationMap[huohaoNew] = loc
|
||
} else if findErr == gorm.ErrRecordNotFound {
|
||
// 不存在则创建
|
||
newLoc := models.Location{
|
||
WarehouseID: warehouseID,
|
||
Code: huohaoNew,
|
||
Type: 1,
|
||
Capacity: 255,
|
||
Status: 1,
|
||
Sort: 0,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
IsDel: 0,
|
||
}
|
||
if createErr := tx.Create(&newLoc).Error; createErr != nil {
|
||
tx.Rollback()
|
||
return nil, fmt.Errorf("创建库位失败(货号=%s): %v", huohaoNew, createErr)
|
||
}
|
||
loc = newLoc
|
||
locationMap[huohaoNew] = loc
|
||
} else {
|
||
tx.Rollback()
|
||
return nil, fmt.Errorf("查询库位异常(货号=%s): %v", huohaoNew, findErr)
|
||
}
|
||
}
|
||
|
||
// Step 3: 创建商品
|
||
product := s.buildProduct(row, now)
|
||
if createErr := tx.Create(&product).Error; createErr != nil {
|
||
tx.Rollback()
|
||
return nil, fmt.Errorf("创建商品失败(%s): %v", row.GoodsName, createErr)
|
||
}
|
||
|
||
// Step 3.1: 写入任务库 t_shop_goods_published(先删后插,库存为0时仅删不插)
|
||
quantity := s.parseIntOrZero(row.Inventory)
|
||
|
||
// 先删除旧记录
|
||
if err := database.TaskDB.Exec(
|
||
`DELETE FROM t_shop_goods_published WHERE erp_shop_id = ? AND product_id = ? AND trilateral_id = ? AND del_flag = 0`,
|
||
row.ShopID, product.ID, row.ProductID,
|
||
).Error; err != nil {
|
||
log.Printf("删除t_shop_goods_published旧记录失败(%s): %v", row.GoodsName, err)
|
||
}
|
||
|
||
// 按库存数量插入(库存为0则跳过)
|
||
for i := int64(0); i < quantity; i++ {
|
||
if err := database.TaskDB.Exec(
|
||
`INSERT INTO t_shop_goods_published (erp_shop_id, product_id, trilateral_id, user_id, is_distribution, del_flag, create_time) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
row.ShopID, product.ID, row.ProductID, userID, 0, 0, now,
|
||
).Error; err != nil {
|
||
log.Printf("写入t_shop_goods_published失败(%s, 第%d条): %v", row.GoodsName, i+1, err)
|
||
}
|
||
}
|
||
|
||
// Step 4: 创建库存(quantity 已在 Step 3.1 计算过)
|
||
if quantity <= 0 {
|
||
quantity = 1 // 库存≥1条记录
|
||
}
|
||
|
||
inventory := models.Inventory{
|
||
WarehouseID: warehouseID,
|
||
ProductID: product.ID,
|
||
BatchNo: batchNo,
|
||
ProductionDate: 0,
|
||
ExpiryDate: 0,
|
||
Quantity: quantity,
|
||
LockedQuantity: 0,
|
||
AvailableQuantity: quantity,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
IsDel: 0,
|
||
}
|
||
if createErr := tx.Create(&inventory).Error; createErr != nil {
|
||
tx.Rollback()
|
||
return nil, fmt.Errorf("创建库存汇总失败(%s): %v", row.GoodsName, createErr)
|
||
}
|
||
|
||
inventoryDetail := models.InventoryDetail{
|
||
WarehouseID: warehouseID,
|
||
LocationID: loc.ID,
|
||
ProductID: product.ID,
|
||
BatchNo: batchNo,
|
||
ProductionDate: 0,
|
||
ExpiryDate: 0,
|
||
Quantity: quantity,
|
||
LockedQuantity: 0,
|
||
AvailableQuantity: quantity,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
IsDel: 0,
|
||
}
|
||
if createErr := tx.Create(&inventoryDetail).Error; createErr != nil {
|
||
tx.Rollback()
|
||
return nil, fmt.Errorf("创建库存明细失败(%s): %v", row.GoodsName, createErr)
|
||
}
|
||
|
||
// Step 4.1: 创建入库单明细
|
||
receivingOrderItem := models.ReceivingOrderItem{
|
||
ReceivingOrderID: receivingOrder.ID,
|
||
ProductID: product.ID,
|
||
LocationID: loc.ID,
|
||
BatchNo: batchNo,
|
||
ProductionDate: 0,
|
||
ExpiryDate: 0,
|
||
Quantity: quantity,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
IsDel: 0,
|
||
}
|
||
if err := tx.Create(&receivingOrderItem).Error; err != nil {
|
||
tx.Rollback()
|
||
return nil, fmt.Errorf("创建入库单明细失败(%s): %v", row.GoodsName, err)
|
||
}
|
||
|
||
// Step 4.2: 创建库存变动日志
|
||
inventoryLog := models.InventoryLog{
|
||
WarehouseID: warehouseID,
|
||
LocationID: loc.ID,
|
||
ProductID: product.ID,
|
||
BatchNo: batchNo,
|
||
ChangeType: constant.InventoryChangeInbound,
|
||
ChangeQuantity: quantity,
|
||
BeforeQuantity: 0,
|
||
AfterQuantity: quantity,
|
||
RelatedOrderType: constant.OrderTypeReceiving,
|
||
RelatedOrderNo: receivingOrder.ReceivingNo,
|
||
Operator: "",
|
||
OperatorID: userID,
|
||
Remark: "Excel批量导入",
|
||
CreatedAt: now,
|
||
IsDel: 0,
|
||
}
|
||
if err := tx.Create(&inventoryLog).Error; err != nil {
|
||
tx.Rollback()
|
||
return nil, fmt.Errorf("创建库存变动日志失败(%s): %v", row.GoodsName, err)
|
||
}
|
||
|
||
result.SuccessCount++
|
||
}
|
||
|
||
// Step 5: 更新统计表入库次数
|
||
var statist models.Statist
|
||
err = tx.Where("create_by = ?", userID).First(&statist).Error
|
||
if err == gorm.ErrRecordNotFound {
|
||
statist = models.Statist{
|
||
CreateBy: userID,
|
||
ReceivingNum: int64(result.SuccessCount),
|
||
StatDate: now,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
IsDel: 0,
|
||
}
|
||
if createErr := tx.Create(&statist).Error; createErr != nil {
|
||
log.Printf("创建统计记录失败: %v", createErr)
|
||
}
|
||
} else if err == nil {
|
||
if updateErr := tx.Model(&models.Statist{}).
|
||
Where("create_by = ?", userID).
|
||
Update("receiving_num", gorm.Expr("receiving_num + ?", result.SuccessCount)).Error; updateErr != nil {
|
||
log.Printf("更新统计记录失败: %v", updateErr)
|
||
}
|
||
}
|
||
|
||
// 提交事务
|
||
if commitErr := tx.Commit().Error; commitErr != nil {
|
||
return nil, fmt.Errorf("提交事务失败: %v", commitErr)
|
||
}
|
||
|
||
result.Message = buildResultMessage(result)
|
||
return result, nil
|
||
}
|
||
|
||
// buildProduct 根据 Excel 行数据构造 Product 模型
|
||
func (s *GoodsImportService) buildProduct(row request.GoodsImportRow, now int64) models.Product {
|
||
var liveImage datatypes.JSON
|
||
liveURL := strings.TrimSpace(row.LiveImage)
|
||
if liveURL != "" {
|
||
// 工具导出后图片已是纯URL,包装为JSON数组
|
||
arr := []string{liveURL}
|
||
b, _ := json.Marshal(arr)
|
||
liveImage = b
|
||
} else {
|
||
liveImage = datatypes.JSON("[]")
|
||
}
|
||
|
||
// 品相:尝试转为数字,不支持则留0
|
||
appearance := s.parseAppearance(row.Appearance)
|
||
|
||
// 售价:元转分(如果包含小数点则为元,直接整数则为分)
|
||
salePrice := s.parsePrice(row.PriceSale)
|
||
|
||
return models.Product{
|
||
CategoryID: 1,
|
||
StandardProductID: 1,
|
||
Name: row.GoodsName,
|
||
Appearance: appearance,
|
||
Barcode: row.ISBN,
|
||
Price: 0,
|
||
SalePrice: salePrice,
|
||
Cost: 0,
|
||
LiveImage: liveImage,
|
||
IsBatchManaged: 0,
|
||
IsShelfLifeManaged: 0,
|
||
Status: 1,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
IsDel: 0,
|
||
}
|
||
}
|
||
|
||
// parseAppearance 解析品相字符串为数字
|
||
func (s *GoodsImportService) parseAppearance(appearance string) int64 {
|
||
val, err := strconv.ParseInt(appearance, 10, 64)
|
||
if err != nil {
|
||
// 中文品相映射
|
||
switch strings.TrimSpace(appearance) {
|
||
case "全新", "十品":
|
||
return 100
|
||
case "九五品", "九五":
|
||
return 95
|
||
case "九品":
|
||
return 90
|
||
case "八五品", "八五":
|
||
return 85
|
||
case "八品":
|
||
return 80
|
||
case "七五品", "七五":
|
||
return 75
|
||
case "七品":
|
||
return 70
|
||
default:
|
||
return int64(s.parsePrice(appearance))
|
||
}
|
||
}
|
||
return val
|
||
}
|
||
|
||
// parsePrice 解析价格(元→分,表格中价格统一为元,统一乘以100转换为分)
|
||
func (s *GoodsImportService) parsePrice(price string) int64 {
|
||
p := strings.TrimSpace(price)
|
||
if p == "" {
|
||
return 0
|
||
}
|
||
if f, err := strconv.ParseFloat(p, 64); err == nil {
|
||
return int64(f * 100)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// parseIntOrZero 安全解析整数
|
||
func (s *GoodsImportService) parseIntOrZero(val string) int64 {
|
||
if v, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64); err == nil {
|
||
return v
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func buildResultMessage(result *request.GoodsImportResult) string {
|
||
msg := fmt.Sprintf("导入完成:成功%d个,失败%d个", result.SuccessCount, result.FailCount)
|
||
if result.FailCount > 0 {
|
||
msg += fmt.Sprintf(",失败详情:%s", strings.Join(result.FailDetails, "; "))
|
||
}
|
||
return msg
|
||
}
|