fix(backend): 库存导入改用 shakinm/xlsReader,按唯一编号去重,入库日期取自 Excel

extrame/xls 误读真实 .xls(3264 行只读出 455 行、编号丢失、数字被当
日期序列),换用 shakinm/xlsReader 正确读取全部行。去重改为「有商品编号
时仅按编号匹配」,避免不同批次被 NSS 回退合并;Inventory.ProductCode
存行自身唯一编号。入库时间取 Excel 入库日期列写入 created_at。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-17 08:04:04 +08:00
parent 21700fa64c
commit eca62ba2c3
4 changed files with 37 additions and 140 deletions
+24 -58
View File
@@ -2,15 +2,13 @@ package handler
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/extrame/xls"
"github.com/shakinm/xlsReader/xls"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
@@ -550,10 +548,11 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
invByNSS[nssKey] = inv
}
lookupInv := func(productCode, name, series, spec string, whID uint64) *model.Inventory {
// 有商品编号时,仅按「编号|仓库」唯一匹配,绝不回退到名称匹配:
// 同一款酒的不同批次/年份是不同的商品编号,名称相同但属于独立库存记录,
// 回退名称匹配会把它们错误合并成一条(3264 行被压成 1134 行)。
if productCode != "" {
if inv, ok := invByCode[fmt.Sprintf("%s|%d", productCode, whID)]; ok {
return inv
}
return invByCode[fmt.Sprintf("%s|%d", productCode, whID)]
}
return invByNSS[fmt.Sprintf("%s|%s|%s|%d", name, series, spec, whID)]
}
@@ -782,7 +781,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
ProductID: &productIDCopy,
StockInItemID: nil,
Quantity: qty,
ProductCode: prod.Code,
ProductCode: productCode, // 行自身的商品编号(每条库存记录唯一),而非商品目录编号
ProductName: prod.Name,
Series: prod.Series,
Spec: prod.Spec,
@@ -858,65 +857,38 @@ func parseUploadedExcel(c *gin.Context) ([][]string, error) {
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")
// 老格式 BIFF (.xls)。注意:extrame/xls 对部分真实导出文件会错读单元格
// (字符串整列丢失、数字被当成日期序列号),导致 3000 行只解析出几百行。
// 改用 shakinm/xlsReader,可正确读取共享字符串表与数值。
wb, xlErr := xls.OpenReader(f)
if xlErr != nil {
return nil, fmt.Errorf("invalid xls file: %s", xlErr.Error())
}
sheet := wb.GetSheet(0)
if sheet == nil {
sheet, shErr := wb.GetSheet(0)
if shErr != nil || 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
}
numRows := sheet.GetNumberRows()
if numRows < 1 {
return nil, fmt.Errorf("empty or invalid sheet")
}
// 列数以表头行为准
header, _ := sheet.GetRow(0)
numCols := len(header.GetCols())
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)
for r := 0; r < numRows; r++ {
row, _ := sheet.GetRow(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
for col := 0; col < numCols; col++ {
if cd, cErr := row.GetCol(col); cErr == nil && cd != nil {
cells[col] = strings.TrimSpace(cd.GetString())
}
}
}
if isEmpty {
emptyStreak++
if emptyStreak >= maxEmpty {
break
}
// 保留空行,让调用方自行跳过
rows = append(rows, cells)
continue
}
emptyStreak = 0
// 保留空行,让调用方自行跳过
rows = append(rows, cells)
}
} else {
@@ -1042,9 +1014,3 @@ func cell(row []string, idx int) string {
}
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)
}