package handler import ( "fmt" "log" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/shakinm/xlsReader/xls" "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, CostPrice: price, CostAmount: 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, CostTotal: 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, CostPrice: price, CostAmount: 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, // 导入单沿旧行为:合计=源金额(明细 sale_price=0 待定价,确认售价后按差额调整) SaleTotal: 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 skipped 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 { // 有商品编号时,仅按「编号|仓库」唯一匹配,绝不回退到名称匹配: // 同一款酒的不同批次/年份是不同的商品编号,名称相同但属于独立库存记录, // 回退名称匹配会把它们错误合并成一条(3264 行被压成 1134 行)。 if productCode != "" { return invByCode[fmt.Sprintf("%s|%d", productCode, whID)] } 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 colStockInDate := 12 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 "入库日期", "入库时间", "入库日": colStockInDate = 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) stockInDateStr := cell(row, colStockInDate) qty, _ := strconv.ParseFloat(qtyStr, 64) if qty <= 0 { qty = 1 } price, _ := strconv.ParseFloat(priceStr, 64) res.total++ // 有商品名称的行才计入总数 // 从缓存查商品,找不到才创建。 // 有商品编号时:只按编号匹配,绝不回退「名称|系列|规格」合并——同名不同编号 // 是不同的特有产品/序列号,回退名称会把它们错并到同一 product(历史 bug 根因)。 var prod *model.Product if productCode != "" { prod = productByCode[productCode] } else { 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 } // 解析入库日期:用 Excel 里的值作为库存记录的入库时间(CreatedAt), // 解析失败或为空时回退到当前时间。 stockInTime, _ := parseTimeWithFallback(stockInDateStr, time.Now()) 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 { // 去重/增量更新:逐字段对比 Excel 行与库内现有记录, // 仅把「不一致」的字段放进 updates;全部一致则跳过不写库。 updates := map[string]interface{}{} if existing.Quantity != qty { updates["quantity"] = qty } if existing.ProductName != prod.Name { updates["product_name"] = prod.Name } if existing.Series != prod.Series { updates["series"] = prod.Series } if existing.Spec != prod.Spec { updates["spec"] = prod.Spec } if existing.Unit != prod.Unit { updates["unit"] = prod.Unit } if existing.WarehouseName != warehouseName { updates["warehouse_name"] = warehouseName } if existing.SupplierName != supplierName { updates["supplier_name"] = supplierName } if existing.Remark != remark { updates["remark"] = remark } if existing.BatchNo != batchNo { updates["batch_no"] = batchNo } // 单价:仅当 Excel 提供了非零单价且与现有不同才更新 if unitPricePtr != nil { if existing.UnitPrice == nil || *existing.UnitPrice != *unitPricePtr { updates["unit_price"] = *unitPricePtr } } // 生产日期:仅当 Excel 提供了值且与现有不同才更新 if productionDate != nil { if existing.ProductionDate == nil || !existing.ProductionDate.Time.Equal(productionDate.Time) { updates["production_date"] = productionDate } } // 入库时间(created_at):与现有不同才更新 if !existing.CreatedAt.Equal(stockInTime) { updates["created_at"] = stockInTime } if len(updates) == 0 { // 所有字段都一致,跳过(不写库、不写流水) res.skipped++ continue } qtyBefore := existing.Quantity 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 } // 仅在库存数量变化时才记一笔流水(用更新前的数量作为 QtyBefore) if _, ok := updates["quantity"]; ok { logsToCreate = append(logsToCreate, model.InventoryLog{ ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID, Direction: "in", Quantity: qty, QtyBefore: qtyBefore, QtyAfter: qty, RefType: "import", RefID: 0, }) } // 同步缓存,避免同文件后续行重复比对到旧值 existing.Quantity = qty existing.ProductName = prod.Name existing.Series = prod.Series existing.Spec = prod.Spec existing.Unit = prod.Unit existing.WarehouseName = warehouseName existing.SupplierName = supplierName existing.Remark = remark existing.BatchNo = batchNo if _, ok := updates["unit_price"]; ok { existing.UnitPrice = unitPricePtr } if _, ok := updates["production_date"]; ok { existing.ProductionDate = productionDate } if _, ok := updates["created_at"]; ok { existing.CreatedAt = stockInTime } res.updated++ } else { productIDCopy := prod.ID inv := model.Inventory{ ShopID: shopID, WarehouseID: whIDPtr, ProductID: &productIDCopy, StockInItemID: nil, Quantity: qty, ProductCode: productCode, // 行自身的商品编号(每条库存记录唯一),而非商品目录编号 ProductName: prod.Name, Series: prod.Series, Spec: prod.Spec, Unit: prod.Unit, WarehouseName: warehouseName, UnitPrice: unitPricePtr, ProductionDate: productionDate, BatchNo: batchNo, SupplierName: supplierName, Remark: remark, CreatedAt: stockInTime, // 用 Excel「入库日期」作为入库时间 } 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 skipped=%d errors=%d", res.total, res.imported, res.updated, res.skipped, len(res.errors)) c.JSON(http.StatusOK, gin.H{ "total": res.total, "imported": res.imported, "updated": res.updated, "skipped": res.skipped, "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 (.xls)。注意:extrame/xls 对部分真实导出文件会错读单元格 // (字符串整列丢失、数字被当成日期序列号),导致 3000 行只解析出几百行。 // 改用 shakinm/xlsReader,可正确读取共享字符串表与数值。 wb, xlErr := xls.OpenReader(f) if xlErr != nil { return nil, fmt.Errorf("invalid xls file: %s", xlErr.Error()) } sheet, shErr := wb.GetSheet(0) if shErr != nil || sheet == nil { return nil, fmt.Errorf("no sheet found") } numRows := sheet.GetNumberRows() if numRows < 1 { return nil, fmt.Errorf("empty or invalid sheet") } // 列数以表头行为准 header, _ := sheet.GetRow(0) numCols := len(header.GetCols()) if numCols == 0 { numCols = 20 } for r := 0; r < numRows; r++ { row, _ := sheet.GetRow(r) cells := make([]string, numCols) if row != nil { for col := 0; col < numCols; col++ { if cd, cErr := row.GetCol(col); cErr == nil && cd != nil { cells[col] = strings.TrimSpace(cd.GetString()) } } } // 保留空行,让调用方自行跳过 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 code != "" { // 有编号:按「编号」唯一匹配/创建(编号即特有产品序列号), // 绝不按「名称|系列|规格」合并——同名不同编号是不同商品。 if db.Where("shop_id = ? AND code = ? AND deleted_at IS NULL", shopID, code).First(&p).Error == nil { return p, nil } } else { // 无编号:退回「名称|系列|规格」匹配(供 ImportStockIn/Out 等无编号场景)。 if db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL", shopID, name, series, spec).First(&p).Error == nil { 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} } // parseTimeWithFallback 解析「入库日期」等列的时间值。 // 兼容:yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、yyyy/MM/dd、Excel 序列号。 // 解析失败或为空时返回 fallback(通常为 time.Now()),ok=false。 func parseTimeWithFallback(s string, fallback time.Time) (t time.Time, ok bool) { s = strings.TrimSpace(s) if s == "" { return fallback, false } layouts := []string{ "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02", "2006/01/02 15:04:05", "2006/01/02", } for _, layout := range layouts { if parsed, err := time.ParseInLocation(layout, s, time.Local); err == nil { return parsed, true } } // Excel 序列号(自 1899-12-30 起的天数,可带小数表示时间) if serial, err := strconv.ParseFloat(s, 64); err == nil && serial > 0 && serial < 100000 { base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.Local) days := int(serial) frac := serial - float64(days) parsed := base.AddDate(0, 0, days).Add(time.Duration(frac * 24 * float64(time.Hour))) return parsed, true } return fallback, false } 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]) }