Files
jiu/backend/internal/handler/import.go
T
wangjia a05f9bd4ec
Deploy / deploy (push) Failing after 8s
feat(shop): 门店 logo 上传 + 侧边栏品牌替换
- shops 表新增 logo_url 字段,AutoMigrate 自动建列
- 后端新增 POST /api/v1/shop/logo 接口(管理员),图片裁剪为 256×256 JPEG 存储
- UpdateInfo 支持传入 logo_url
- 侧边栏「岩美」替换为门店真实名称 + 自定义 logo
- 无 logo 时显示店名首字文字头像(深蓝底白字)
- 设置页「酒行信息」展示 logo 预览,编辑弹窗新增「更换 Logo」上传按钮
- 修复库存导入计数逻辑:total/imported/updated/errors 四项分别统计
- 库存导入去重改为双路索引(编号 + 名称|系列|规格),避免 key 不一致误判新增
- settings 页导入结果统一显示「重复 X 条」,兼容 skipped 和 updated 两个字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:03:43 +08:00

969 lines
27 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 handler
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/extrame/xls"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
type ImportHandler struct {
db *gorm.DB
}
func NewImportHandler(db *gorm.DB) *ImportHandler {
return &ImportHandler{db: db}
}
// ImportProducts POST /api/v1/import/products
// 支持 .xlsx / .csv,列顺序:名称,系列,规格,单位,品牌,最低库存,备注
func (h *ImportHandler) ImportProducts(c *gin.Context) {
shopID := middleware.GetShopID(c)
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
f, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer f.Close()
xl, err := excelize.OpenReader(f)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid excel file: " + err.Error()})
return
}
sheetName := xl.GetSheetName(0)
rows, err := xl.GetRows(sheetName)
if err != nil || len(rows) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "empty or invalid sheet"})
return
}
var products []model.Product
var errRows []map[string]interface{}
for i, row := range rows[1:] { // 跳过表头
if len(row) < 1 || strings.TrimSpace(row[0]) == "" {
continue
}
p := model.Product{
TenantBase: model.TenantBase{ShopID: shopID},
}
p.Name = cell(row, 0)
p.Series = cell(row, 1)
p.Spec = cell(row, 2)
p.Unit = cell(row, 3)
p.Brand = cell(row, 4)
p.Remark = cell(row, 6)
if p.Name == "" {
errRows = append(errRows, map[string]interface{}{"row": i + 2, "error": "name is empty"})
continue
}
products = append(products, p)
}
if len(products) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rows", "errors": errRows})
return
}
if err := h.db.CreateInBatches(&products, 100).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"imported": len(products),
"errors": errRows,
})
}
// ImportProductCodes POST /api/v1/import/product-codes
// 列顺序:商品名称,商品编码(按名称匹配商品并更新编码)
func (h *ImportHandler) ImportProductCodes(c *gin.Context) {
shopID := middleware.GetShopID(c)
rows, err := parseUploadedExcel(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
total, imported, skipped := 0, 0, 0
for _, row := range rows[1:] {
name := cell(row, 0)
code := cell(row, 1)
if name == "" || code == "" {
continue
}
total++
var p model.Product
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&p).Error != nil {
skipped++
continue
}
if h.db.Model(&p).Update("code", code).Error == nil {
imported++
}
}
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
}
// ImportPartners POST /api/v1/import/partners
// 列顺序(来往单位.xls):编号,类型,状态,名称,电话,卡号,初始金额,单位,地址,...,备注
func (h *ImportHandler) ImportPartners(c *gin.Context) {
shopID := middleware.GetShopID(c)
rows, err := parseUploadedExcel(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
total, imported, skipped := 0, 0, 0
for _, row := range rows[1:] {
name := cell(row, 3)
if name == "" {
continue
}
total++
var existing model.Partner
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil {
skipped++
continue
}
balance, _ := strconv.ParseFloat(cell(row, 6), 64)
status := "enabled"
if cell(row, 2) == "禁用" {
status = "disabled"
}
p := model.Partner{
TenantBase: model.TenantBase{ShopID: shopID},
Code: cell(row, 0),
Type: parsePartnerType(cell(row, 1)),
Status: status,
Name: name,
Phone: cell(row, 4),
BankAccount: cell(row, 5),
Balance: balance,
Address: cell(row, 8),
Remark: cell(row, 11),
}
if h.db.Create(&p).Error == nil {
imported++
}
}
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
}
// ImportProductNames POST /api/v1/import/product-names
// 列顺序(商品名称.xls):选项编号,选项名称,备注
func (h *ImportHandler) ImportProductNames(c *gin.Context) {
shopID := middleware.GetShopID(c)
rows, err := parseUploadedExcel(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
total, imported, skipped := 0, 0, 0
for _, row := range rows[1:] {
name := cell(row, 1)
if name == "" {
continue
}
total++
var existing model.ProductNameOption
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil {
skipped++
continue
}
opt := model.ProductNameOption{
TenantBase: model.TenantBase{ShopID: shopID},
Code: cell(row, 0),
Name: name,
Remark: cell(row, 2),
}
if h.db.Create(&opt).Error == nil {
imported++
}
}
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
}
// ImportProductSeries POST /api/v1/import/product-series
// 列顺序(商品系列.xls):选项编号,选项名称,备注
func (h *ImportHandler) ImportProductSeries(c *gin.Context) {
shopID := middleware.GetShopID(c)
rows, err := parseUploadedExcel(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
total, imported, skipped := 0, 0, 0
for _, row := range rows[1:] {
name := cell(row, 1)
if name == "" {
continue
}
total++
var existing model.ProductSeriesOption
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil {
skipped++
continue
}
opt := model.ProductSeriesOption{
TenantBase: model.TenantBase{ShopID: shopID},
Code: cell(row, 0),
Name: name,
Remark: cell(row, 2),
}
if h.db.Create(&opt).Error == nil {
imported++
}
}
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
}
// ImportProductSpecs POST /api/v1/import/product-specs
// 列顺序(商品规格.xls):选项编号,选项名称,单品数量,备注
func (h *ImportHandler) ImportProductSpecs(c *gin.Context) {
shopID := middleware.GetShopID(c)
rows, err := parseUploadedExcel(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
total, imported, skipped := 0, 0, 0
for _, row := range rows[1:] {
name := cell(row, 1)
if name == "" {
continue
}
total++
var existing model.ProductSpecOption
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil {
skipped++
continue
}
qty, _ := strconv.Atoi(strings.TrimSuffix(cell(row, 2), ".0")) // "12" 或 "12.0"
if qty == 0 {
qtyF, _ := strconv.ParseFloat(cell(row, 2), 64)
qty = int(qtyF)
}
opt := model.ProductSpecOption{
TenantBase: model.TenantBase{ShopID: shopID},
Code: cell(row, 0),
Name: name,
Quantity: qty,
Remark: cell(row, 3),
}
if h.db.Create(&opt).Error == nil {
imported++
}
}
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
}
// ImportStockIn POST /api/v1/import/stock-in
// 支持老系统打印格式(每文件一张入库单)
func (h *ImportHandler) ImportStockIn(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
rows, err := parseUploadedExcel(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(rows) < 7 {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件行数不足,请检查格式"})
return
}
// 解析单据头
partnerName := strings.TrimPrefix(cell(rows[3], 0), "来往单位名称:")
dateStr := strings.TrimPrefix(cell(rows[3], 7), "单据日期:")
orderNo := strings.TrimPrefix(cell(rows[3], 18), "NO.")
if orderNo == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到单据号,请检查文件格式"})
return
}
// 检查重复
var existing model.StockInOrder
if h.db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&existing).Error == nil {
c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "skipped": true, "message": "单据已存在,已跳过"})
return
}
// 解析日期
orderDate := parseDate(dateStr)
// 往来单位
partnerID := findOrCreatePartner(h.db, shopID, partnerName, "supplier")
// 默认仓库
var wh model.Warehouse
if h.db.Where("shop_id = ? AND is_default = 1", shopID).First(&wh).Error != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先在系统设置中设置默认仓库"})
return
}
// 解析明细
var items []model.StockInItem
var totalAmount float64
for _, row := range rows[6:] {
if cell(row, 0) == "" || strings.HasPrefix(cell(row, 0), "单据总计") {
break
}
productName := cell(row, 1)
if productName == "" {
continue
}
series := cell(row, 5)
spec := cell(row, 6)
qty, _ := strconv.ParseFloat(cell(row, 9), 64)
price, _ := strconv.ParseFloat(cell(row, 11), 64)
batchNo := cell(row, 16)
prod, err := findOrCreateProductFn(h.db, shopID, "", productName, series, spec)
if err != nil {
continue
}
total := qty * price
totalAmount += total
items = append(items, model.StockInItem{
ShopID: shopID,
ProductID: prod.ID,
Quantity: qty,
UnitPrice: price,
TotalPrice: total,
BatchNo: batchNo,
})
}
if len(items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "未解析到有效明细行"})
return
}
order := model.StockInOrder{
TenantBase: model.TenantBase{ShopID: shopID},
OrderNo: orderNo,
Type: "purchase",
WarehouseID: wh.ID,
PartnerID: partnerID,
OperatorID: userID,
Status: "draft",
OrderDate: orderDate,
TotalAmount: totalAmount,
}
if err := h.db.Create(&order).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range items {
items[i].OrderID = order.ID
}
if err := h.db.CreateInBatches(&items, 50).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "items": len(items)})
}
// ImportStockOut POST /api/v1/import/stock-out
// 支持老系统打印格式(每文件一张出库单)
func (h *ImportHandler) ImportStockOut(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
rows, err := parseUploadedExcel(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(rows) < 7 {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件行数不足,请检查格式"})
return
}
// 出库单日期在 col8(比入库单多一个空列)
partnerName := strings.TrimPrefix(cell(rows[3], 0), "来往单位名称:")
dateStr := strings.TrimPrefix(cell(rows[3], 8), "单据日期:")
orderNo := strings.TrimPrefix(cell(rows[3], 18), "NO.")
if orderNo == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到单据号,请检查文件格式"})
return
}
var existing model.StockOutOrder
if h.db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&existing).Error == nil {
c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "skipped": true, "message": "单据已存在,已跳过"})
return
}
orderDate := parseDate(dateStr)
partnerID := findOrCreatePartner(h.db, shopID, partnerName, "customer")
var wh model.Warehouse
if h.db.Where("shop_id = ? AND is_default = 1", shopID).First(&wh).Error != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先在系统设置中设置默认仓库"})
return
}
var items []model.StockOutItem
var totalAmount float64
for _, row := range rows[6:] {
if cell(row, 0) == "" || strings.HasPrefix(cell(row, 0), "单据总计") {
break
}
productName := cell(row, 1)
if productName == "" {
continue
}
series := cell(row, 5)
spec := cell(row, 6)
qty, _ := strconv.ParseFloat(cell(row, 9), 64)
price, _ := strconv.ParseFloat(cell(row, 11), 64)
prod, err := findOrCreateProductFn(h.db, shopID, "", productName, series, spec)
if err != nil {
continue
}
total := qty * price
totalAmount += total
items = append(items, model.StockOutItem{
ShopID: shopID,
ProductID: prod.ID,
Quantity: qty,
UnitPrice: price,
TotalPrice: total,
})
}
if len(items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "未解析到有效明细行"})
return
}
order := model.StockOutOrder{
TenantBase: model.TenantBase{ShopID: shopID},
OrderNo: orderNo,
Type: "sale",
WarehouseID: wh.ID,
PartnerID: partnerID,
OperatorID: userID,
Status: "draft",
OrderDate: orderDate,
TotalAmount: totalAmount,
}
if err := h.db.Create(&order).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range items {
items[i].OrderID = order.ID
}
if err := h.db.CreateInBatches(&items, 50).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "items": len(items)})
}
// ImportInventory POST /api/v1/import/inventory
// 列顺序:商品编号,商品名称,系列,规格,单位,库存数量,单价,金额,生产日期,批次,分类,所在仓库,入库日期,供应商,上次盘点,备注
func (h *ImportHandler) ImportInventory(c *gin.Context) {
shopID := middleware.GetShopID(c)
rows, err := parseUploadedExcel(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
type importResult struct {
total int
imported int
updated int
errors []string
}
var res importResult
// 预加载仓库(1 次查询)
var warehouses []model.Warehouse
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
warehouseByName := make(map[string]uint64, len(warehouses))
for _, wh := range warehouses {
warehouseByName[wh.Name] = wh.ID
}
findWarehouse := func(name string) *uint64 {
if name == "" {
return nil
}
if id, ok := warehouseByName[name]; ok {
return &id
}
return nil
}
// 预加载商品(1 次查询)
var allProducts []model.Product
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&allProducts)
productByCode := make(map[string]*model.Product, len(allProducts))
productByNSS := make(map[string]*model.Product, len(allProducts))
for i := range allProducts {
p := &allProducts[i]
if p.Code != "" {
productByCode[p.Code] = p
}
productByNSS[p.Name+"|"+p.Series+"|"+p.Spec] = p
}
// 预加载已有导入库存(1 次查询)
// 同时建两套索引:编号索引 + 名称|系列|规格索引,供后续双路查找
var allInvs []model.Inventory
h.db.Where("shop_id = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shopID).Find(&allInvs)
invByCode := make(map[string]*model.Inventory, len(allInvs)) // key: productCode|warehouseID
invByNSS := make(map[string]*model.Inventory, len(allInvs)) // key: name|series|spec|warehouseID
for i := range allInvs {
inv := &allInvs[i]
whID := uint64(0)
if inv.WarehouseID != nil {
whID = *inv.WarehouseID
}
if inv.ProductCode != "" {
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv
}
nssKey := fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whID)
invByNSS[nssKey] = inv
}
lookupInv := func(productCode, name, series, spec string, whID uint64) *model.Inventory {
if productCode != "" {
if inv, ok := invByCode[fmt.Sprintf("%s|%d", productCode, whID)]; ok {
return inv
}
}
return invByNSS[fmt.Sprintf("%s|%s|%s|%d", name, series, spec, whID)]
}
// Dynamic column detection from header row
colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
if len(rows) > 0 {
log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0])
for j, h := range rows[0] {
switch strings.TrimSpace(h) {
case "商品编号", "商品编码", "编号", "编码", "商品条码":
colProductCode = j
case "商品名称", "品名", "名称", "货品名称":
colProductName = j
case "系列", "品牌系列":
colSeries = j
case "规格", "规格型号":
colSpec = j
case "单位":
colUnit = j
case "库存数量", "数量", "库存":
colQty = j
case "单价", "进价", "采购单价":
colPrice = j
case "生产日期", "生产年月":
colProductionDate = j
case "批次", "批次号":
colBatchNo = j
case "所在仓库", "仓库", "库位":
colWarehouse = j
case "供应商", "供应商名称":
colSupplier = j
case "备注":
colRemark = j
}
}
}
log.Printf("[import-inv] total rows=%d, colProductName=%d, colProductCode=%d, colQty=%d",
len(rows), colProductName, colProductCode, colQty)
// 如果没有匹配到任何列头,返回诊断信息
detectedHeader := strings.Join(rows[0], " | ")
var logsToCreate []model.InventoryLog
for i, row := range rows[1:] {
productName := cell(row, colProductName)
if i < 5 {
log.Printf("[import-inv] row[%d] len=%d | productName=%q productCode=%q qty=%q",
i+2, len(row), productName, cell(row, colProductCode), cell(row, colQty))
}
if productName == "" {
continue // 空行(文件末尾填充行),不计入 total
}
productCode := cell(row, colProductCode)
series := cell(row, colSeries)
spec := cell(row, colSpec)
unit := cell(row, colUnit)
qtyStr := cell(row, colQty)
priceStr := cell(row, colPrice)
productionDateStr := cell(row, colProductionDate)
batchNo := cell(row, colBatchNo)
warehouseName := cell(row, colWarehouse)
supplierName := cell(row, colSupplier)
remark := cell(row, colRemark)
qty, _ := strconv.ParseFloat(qtyStr, 64)
if qty <= 0 {
qty = 1
}
price, _ := strconv.ParseFloat(priceStr, 64)
res.total++ // 有商品名称的行才计入总数
// 从缓存查商品,找不到才创建
var prod *model.Product
if productCode != "" {
prod = productByCode[productCode]
}
if prod == nil {
prod = productByNSS[productName+"|"+series+"|"+spec]
}
if prod == nil {
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
if createErr != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
continue
}
allProducts = append(allProducts, newProd)
prod = &allProducts[len(allProducts)-1]
if prod.Code != "" {
productByCode[prod.Code] = prod
}
productByNSS[prod.Name+"|"+prod.Series+"|"+prod.Spec] = prod
}
if unit != "" && prod.Unit == "" {
h.db.Model(prod).Update("unit", unit)
prod.Unit = unit
}
// 解析生产日期
var productionDate *model.Date
if productionDateStr != "" {
d := parseDate(productionDateStr)
productionDate = &d
}
whIDPtr := findWarehouse(warehouseName)
var unitPricePtr *float64
if price != 0 {
unitPricePtr = &price
}
whIDVal := uint64(0)
if whIDPtr != nil {
whIDVal = *whIDPtr
}
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
if existing != nil {
updates := map[string]interface{}{
"quantity": qty,
"product_name": prod.Name,
"series": prod.Series,
"spec": prod.Spec,
"unit": prod.Unit,
"warehouse_name": warehouseName,
"supplier_name": supplierName,
"remark": remark,
"deleted_at": nil,
}
if unitPricePtr != nil {
updates["unit_price"] = *unitPricePtr
}
if productionDate != nil {
updates["production_date"] = productionDate
}
if batchNo != "" {
updates["batch_no"] = batchNo
}
if err := h.db.Model(existing).Updates(updates).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
continue
}
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty,
RefType: "import", RefID: 0,
})
res.updated++
} else {
productIDCopy := prod.ID
inv := model.Inventory{
ShopID: shopID,
WarehouseID: whIDPtr,
ProductID: &productIDCopy,
StockInItemID: nil,
Quantity: qty,
ProductCode: prod.Code,
ProductName: prod.Name,
Series: prod.Series,
Spec: prod.Spec,
Unit: prod.Unit,
WarehouseName: warehouseName,
UnitPrice: unitPricePtr,
ProductionDate: productionDate,
BatchNo: batchNo,
SupplierName: supplierName,
Remark: remark,
}
if err := h.db.Create(&inv).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
continue
}
// 写入两套缓存,防止同文件后续行重复插入
if inv.ProductCode != "" {
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whIDVal)] = &inv
}
invByNSS[fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whIDVal)] = &inv
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: 0, QtyAfter: qty,
RefType: "import", RefID: 0,
})
res.imported++
}
}
// 批量写流水
if len(logsToCreate) > 0 {
h.db.CreateInBatches(&logsToCreate, 100)
}
// total=0 说明列格式不匹配,没有解析到任何有效行
if res.total == 0 {
res.errors = append(res.errors,
fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader))
}
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d",
res.total, res.imported, res.updated, len(res.errors))
c.JSON(http.StatusOK, gin.H{
"total": res.total,
"imported": res.imported,
"updated": res.updated,
"errors": res.errors,
})
}
// ── 内部辅助函数 ─────────────────────────────────────────────
func parseUploadedExcel(c *gin.Context) ([][]string, error) {
file, err := c.FormFile("file")
if err != nil {
return nil, fmt.Errorf("file required")
}
f, err := file.Open()
if err != nil {
return nil, err
}
defer f.Close()
// 读取前 4 字节检测 OLE2 格式(D0 CF 11 E0 = 老式 .xls BIFF
var magic [4]byte
f.Read(magic[:])
f.Seek(0, 0)
isOLE := magic[0] == 0xD0 && magic[1] == 0xCF && magic[2] == 0x11 && magic[3] == 0xE0
var rows [][]string
if isOLE {
// 老格式 BIFF — extrame/xls 需要文件路径,写入临时文件
tmp, tmpErr := os.CreateTemp("", "import_*.xls")
if tmpErr != nil {
return nil, fmt.Errorf("cannot create temp file: %s", tmpErr.Error())
}
defer os.Remove(tmp.Name())
if _, cpErr := io.Copy(tmp, f); cpErr != nil {
tmp.Close()
return nil, fmt.Errorf("cannot write temp file: %s", cpErr.Error())
}
tmp.Close()
wb, xlErr := xls.Open(tmp.Name(), "utf-8")
if xlErr != nil {
return nil, fmt.Errorf("invalid xls file: %s", xlErr.Error())
}
sheet := wb.GetSheet(0)
if sheet == nil {
return nil, fmt.Errorf("no sheet found")
}
// LastCol() returns 0 for many data rows in extrame/xls; derive column
// count from the header row instead.
numCols := 0
headerRow := sheet.Row(0)
for c := 0; c < headerRow.LastCol(); c++ {
if strings.TrimSpace(headerRow.Col(c)) != "" {
numCols = c + 1
}
}
if numCols == 0 {
numCols = 20
}
// sheet.MaxRow 依赖 DIMENSIONS 记录,旧软件导出的 XLS 该值可能偏小。
// 改为读到连续 5 行全空为止,最多 100000 行防止死循环。
const maxRows = 100000
const maxEmpty = 5
emptyStreak := 0
for r := 0; r < maxRows; r++ {
row := safeXlsRow(sheet, r)
cells := make([]string, numCols)
isEmpty := true
if row != nil {
for c := 0; c < numCols; c++ {
cells[c] = strings.TrimSpace(row.Col(c))
if cells[c] != "" {
isEmpty = false
}
}
}
if isEmpty {
emptyStreak++
if emptyStreak >= maxEmpty {
break
}
// 保留空行,让调用方自行跳过
rows = append(rows, cells)
continue
}
emptyStreak = 0
rows = append(rows, cells)
}
} else {
// xlsx / xlsmZIP 格式)
xl, xlErr := excelize.OpenReader(f)
if xlErr != nil {
return nil, fmt.Errorf("invalid xlsx file: %s", xlErr.Error())
}
rows, err = xl.GetRows(xl.GetSheetName(0))
if err != nil {
return nil, fmt.Errorf("cannot read sheet: %s", err.Error())
}
}
if len(rows) < 2 {
return nil, fmt.Errorf("empty or invalid sheet")
}
return rows, nil
}
func findOrCreateProductFn(db *gorm.DB, shopID uint64, code, name, series, spec string) (model.Product, error) {
var p model.Product
if db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
shopID, name, series, spec).First(&p).Error == nil {
// 若现有商品没有编码,补填
if p.Code == "" && code != "" {
db.Model(&p).Update("code", code)
p.Code = code
}
return p, nil
}
p = model.Product{
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Code: code,
Name: name,
Series: series,
Spec: spec,
Unit: "瓶",
}
return p, db.Create(&p).Error
}
func findOrCreatePartner(db *gorm.DB, shopID uint64, name, ptype string) *uint64 {
if name == "" {
return nil
}
var p model.Partner
if db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&p).Error == nil {
id := p.ID
return &id
}
p = model.Partner{
TenantBase: model.TenantBase{ShopID: shopID},
Name: name,
Type: ptype,
Status: "enabled",
}
if db.Create(&p).Error != nil {
return nil
}
id := p.ID
return &id
}
func parseDate(s string) model.Date {
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return model.Date{Time: time.Now()}
}
return model.Date{Time: t}
}
func parsePartnerType(raw string) string {
hasCust := strings.Contains(raw, "客户")
hasSupp := strings.Contains(raw, "供应商")
switch {
case hasCust && hasSupp:
return "supplier,customer"
case hasCust:
return "customer"
default:
return "supplier"
}
}
func cell(row []string, idx int) string {
if idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}
// safeXlsRow 安全读取 XLS 行,捕获 extrame/xls 在超出行数时的 panic。
func safeXlsRow(sheet *xls.WorkSheet, r int) (row *xls.Row) {
defer func() { recover() }() //nolint:errcheck
return sheet.Row(r)
}