daShangDao_psiServer/service/goods_import.go
2026-06-15 13:47:39 +08:00

471 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}