Files
jiu/backend/internal/handler/import.go
T

763 lines
20 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"
"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)})
}
// 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 result struct {
imported int
skipped int
errors []string
}
var res result
// 仓库缓存,避免重复查询
warehouseCache := map[string]uint64{}
findOrCreateWarehouse := func(name string) (uint64, error) {
if name == "" {
name = "默认仓库"
}
if id, ok := warehouseCache[name]; ok {
return id, nil
}
var wh model.Warehouse
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil {
wh = model.Warehouse{
TenantBase: model.TenantBase{ShopID: shopID},
Name: name,
}
if err := h.db.Create(&wh).Error; err != nil {
return 0, err
}
}
warehouseCache[name] = wh.ID
return wh.ID, nil
}
for i, row := range rows[1:] {
productName := cell(row, 1)
if productName == "" {
res.skipped++
continue
}
series := cell(row, 2)
spec := cell(row, 3)
unit := cell(row, 4)
qtyStr := cell(row, 5)
priceStr := cell(row, 6)
warehouseName := cell(row, 11)
qty, _ := strconv.ParseFloat(qtyStr, 64)
price, _ := strconv.ParseFloat(priceStr, 64)
// 找或创建商品
prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec)
if err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, err.Error()))
continue
}
if unit != "" && prod.Unit == "" {
h.db.Model(&prod).Update("unit", unit)
}
if price > 0 && prod.PurchasePrice == 0 {
h.db.Model(&prod).Update("purchase_price", price)
}
// 找或创建仓库
whID, err := findOrCreateWarehouse(warehouseName)
if err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 仓库创建失败: %s", i+2, err.Error()))
continue
}
// upsert 库存数量
err = h.db.Transaction(func(tx *gorm.DB) error {
var inv model.Inventory
isNew := false
if tx.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
shopID, whID, prod.ID).First(&inv).Error != nil {
inv = model.Inventory{
ShopID: shopID,
WarehouseID: whID,
ProductID: prod.ID,
}
isNew = true
}
qtyBefore := inv.Quantity
inv.Quantity = qty
if isNew {
if err := tx.Create(&inv).Error; err != nil {
return err
}
} else {
if err := tx.Save(&inv).Error; err != nil {
return err
}
}
// 写流水
log := model.InventoryLog{
ShopID: shopID,
WarehouseID: whID,
ProductID: prod.ID,
Direction: "in",
Quantity: qty,
QtyBefore: qtyBefore,
QtyAfter: qty,
RefType: "import",
}
return tx.Create(&log).Error
})
if err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
continue
}
res.imported++
}
c.JSON(http.StatusOK, gin.H{
"imported": res.imported,
"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 := sheet.Row(r)
cells := make([]string, numCols)
isEmpty := true
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, 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])
}