package handler import ( "fmt" "io" "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 { imported int updated int skipped int errors []string } var res importResult // 仓库缓存,只查找不创建 warehouseCache := map[string]*uint64{} findWarehouse := func(name string) *uint64 { if name == "" { return nil } if idPtr, ok := warehouseCache[name]; ok { return idPtr } var wh model.Warehouse if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil { warehouseCache[name] = nil return nil } id := wh.ID warehouseCache[name] = &id return &id } // Dynamic column detection from header row colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15 if len(rows) > 0 { for j, h := range rows[0] { switch strings.TrimSpace(h) { case "库存数量", "数量": colQty = j case "单价": colPrice = j case "生产日期": colProductionDate = j case "批次", "批次号": colBatchNo = j case "所在仓库", "仓库": colWarehouse = j case "供应商": colSupplier = j case "备注": colRemark = j } } } for i, row := range rows[1:] { productName := cell(row, 1) if productName == "" { res.skipped++ continue } productCode := cell(row, 0) series := cell(row, 2) spec := cell(row, 3) unit := cell(row, 4) 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) // 只查找商品,不强制创建 var prod model.Product if h.db.Where("shop_id = ? AND deleted_at IS NULL AND (code = ? OR (name = ? AND series = ? AND spec = ?))", shopID, productCode, productName, series, spec).First(&prod).Error != 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 } prod = newProd } if unit != "" && prod.Unit == "" { h.db.Model(&prod).Update("unit", unit) } // 解析生产日期 var productionDate *model.Date if productionDateStr != "" { d := parseDate(productionDateStr) productionDate = &d } // 查找仓库(只查,不创建) whIDPtr := findWarehouse(warehouseName) var unitPricePtr *float64 if price != 0 { unitPricePtr = &price } productIDCopy := prod.ID // Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建 var existing model.Inventory q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shopID, prod.Code) if whIDPtr != nil { q = q.Where("warehouse_id = ?", *whIDPtr) } else { q = q.Where("warehouse_id IS NULL") } found := q.First(&existing).Error == nil if found { 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 } } else { 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 } } // 写流水 warehouseID := uint64(0) if whIDPtr != nil { warehouseID = *whIDPtr } qtyBefore := 0.0 if found { qtyBefore = existing.Quantity res.updated++ } else { res.imported++ } log := model.InventoryLog{ ShopID: shopID, WarehouseID: warehouseID, ProductID: prod.ID, Direction: "in", Quantity: qty, QtyBefore: qtyBefore, QtyAfter: qty, RefType: "import", RefID: 0, } h.db.Create(&log) } c.JSON(http.StatusOK, gin.H{ "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 — 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") } 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) }