Files
jiu/backend/internal/handler/import.go
T
wangjia eac3e8825c feat(client): 库存查询新增规格/系列/入库时间;筛选组件支持搜索+滚动+标签
- 库存查询加规格、系列服务端筛选(后端 IN 条件,前端从 product-options API 加载全量选项)
- 库存查询新增「入库时间」列,导出 Excel 同步带上
- 入库单商品明细:隐藏商品编码列,生产日期从选填折叠区移至主行常显
- 筛选弹窗重构:顶部搜索框实时过滤 + 滚动列表(ConstrainedBox maxHeight)根治溢出
- 已选项以 Chip 标签置顶展示,每个标签带 × 可单独取消
- FilterableColumnHeader 筛选图标改为常驻,不再需要 hover 才显示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:15:43 +08:00

950 lines
26 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"
"log"
"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)
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,
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 {
total int
imported int
updated 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 {
if productCode != "" {
if inv, ok := invByCode[fmt.Sprintf("%s|%d", productCode, whID)]; ok {
return inv
}
}
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
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 "所在仓库", "仓库", "库位":
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)
qty, _ := strconv.ParseFloat(qtyStr, 64)
if qty <= 0 {
qty = 1
}
price, _ := strconv.ParseFloat(priceStr, 64)
res.total++ // 有商品名称的行才计入总数
// 从缓存查商品,找不到才创建
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
}
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
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
}
// 写入两套缓存,防止同文件后续行重复插入
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 errors=%d",
res.total, res.imported, res.updated, len(res.errors))
c.JSON(http.StatusOK, gin.H{
"total": res.total,
"imported": res.imported,
"updated": res.updated,
"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")
}
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 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)
}