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) rows, err := parseUploadedExcel(c) 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 / xlsm(ZIP 格式) 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") } log.Printf("[import] parseUploadedExcel: file=%s rows=%d (incl. header)", file.Filename, len(rows)) 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) }