Files
jiu/backend/internal/handler/import.go
T
wangjia d5dc499c6b
Deploy / deploy (push) Successful in 1m15s
fix(import): 修复导入 504 超时 + 添加上传进度显示
nginx:
- 新增 /api/v1/import/ 专属 location,proxy_read_timeout 延长至 300s

后端:
- ImportInventory 预加载商品/仓库/库存(3 次 bulk query 替代 N+1)
- 批量写 inventory_log(CreateInBatches 替代逐行 Create)

前端:
- settings 导入行:显示"上传 XX%"进度条和"导入数据(Ns)"计时
- Dio 超时设置为 send=120s / receive=300s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:12:59 +08:00

920 lines
24 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/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
// 预加载仓库(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)
invByKey := make(map[string]*model.Inventory, len(allInvs))
for i := range allInvs {
inv := &allInvs[i]
whID := uint64(0)
if inv.WarehouseID != nil {
whID = *inv.WarehouseID
}
invByKey[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv
}
// 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
}
}
}
var logsToCreate []model.InventoryLog
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 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
}
invKey := fmt.Sprintf("%s|%d", prod.Code, whIDVal)
existing := invByKey[invKey]
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
}
invByKey[invKey] = &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)
}
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 / 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, 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)
}