package handler import ( "fmt" "io" "net/http" "os" "strconv" "strings" "time" "github.com/extrame/xls" "github.com/gin-gonic/gin" "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, }) } // 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)}) } // ── 内部辅助函数 ───────────────────────────────────────────── 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 } for r := 0; r <= int(sheet.MaxRow); r++ { row := sheet.Row(r) cells := make([]string, numCols) for c := 0; c < numCols; c++ { cells[c] = strings.TrimSpace(row.Col(c)) } 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, 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 { return p, nil } p = model.Product{ TenantBase: model.TenantBase{ShopID: shopID}, 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]) }