Files
jiu/backend/internal/handler/import.go
T
wangjia c1ed81dfab feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验
后端
- 新增 shop handler:GET/PUT /shop/info(管理员权限)
- 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款
- 新增 inventory UpdateRemark:PUT /inventory/:id/remark
- 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制)
- 种子数据 S001-S003 补充真实门店信息

前端
- 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人
- 入库单列表新增结清按钮(含确认弹窗),出库单同步
- 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填
- 入库/出库列表新增入库时间、出库时间、创建时间列
- 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」
- 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define)
- 库存备注支持点击编辑,超4字截断显示+Hover展示全文
- ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS)

文档
- 新增 docs/user-manual.md 完整用户操作手册(12章)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:05:41 +08:00

896 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
// 仓库缓存,只查找不创建
warehouseCache := map[string]*uint64{}
findWarehouse := func(name string) *uint64 {
if name == "" {
return nil
}
if idPtr, ok := warehouseCache[name]; ok {
return idPtr
}
var wh model.Warehouse
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil {
warehouseCache[name] = nil
return nil
}
id := wh.ID
warehouseCache[name] = &id
return &id
}
// 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
}
}
}
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 h.db.Where("shop_id = ? AND deleted_at IS NULL AND (code = ? OR (name = ? AND series = ? AND spec = ?))",
shopID, productCode, productName, series, spec).First(&prod).Error != 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
}
prod = newProd
}
if unit != "" && prod.Unit == "" {
h.db.Model(&prod).Update("unit", unit)
}
// 解析生产日期
var productionDate *model.Date
if productionDateStr != "" {
d := parseDate(productionDateStr)
productionDate = &d
}
// 查找仓库(只查,不创建)
whIDPtr := findWarehouse(warehouseName)
var unitPricePtr *float64
if price != 0 {
unitPricePtr = &price
}
productIDCopy := prod.ID
// Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建
var existing model.Inventory
q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL",
shopID, prod.Code)
if whIDPtr != nil {
q = q.Where("warehouse_id = ?", *whIDPtr)
} else {
q = q.Where("warehouse_id IS NULL")
}
found := q.First(&existing).Error == nil
if found {
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
}
} else {
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
}
}
// 写流水
warehouseID := uint64(0)
if whIDPtr != nil {
warehouseID = *whIDPtr
}
qtyBefore := 0.0
if found {
qtyBefore = existing.Quantity
res.updated++
} else {
res.imported++
}
log := model.InventoryLog{
ShopID: shopID,
WarehouseID: warehouseID,
ProductID: prod.ID,
Direction: "in",
Quantity: qty,
QtyBefore: qtyBefore,
QtyAfter: qty,
RefType: "import",
RefID: 0,
}
h.db.Create(&log)
}
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)
}