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>
This commit is contained in:
wangjia
2026-05-23 14:05:41 +08:00
parent c77e1c1490
commit c1ed81dfab
60 changed files with 4664 additions and 1572 deletions
+211 -80
View File
@@ -98,6 +98,37 @@ func (h *ImportHandler) ImportProducts(c *gin.Context) {
})
}
// 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) {
@@ -319,7 +350,7 @@ func (h *ImportHandler) ImportStockIn(c *gin.Context) {
price, _ := strconv.ParseFloat(cell(row, 11), 64)
batchNo := cell(row, 16)
prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec)
prod, err := findOrCreateProductFn(h.db, shopID, "", productName, series, spec)
if err != nil {
continue
}
@@ -422,7 +453,7 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) {
qty, _ := strconv.ParseFloat(cell(row, 9), 64)
price, _ := strconv.ParseFloat(cell(row, 11), 64)
prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec)
prod, err := findOrCreateProductFn(h.db, shopID, "", productName, series, spec)
if err != nil {
continue
}
@@ -480,34 +511,54 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
return
}
type result struct {
type importResult struct {
imported int
updated int
skipped int
errors []string
}
var res result
var res importResult
// 仓库缓存,避免重复查询
warehouseCache := map[string]uint64{}
findOrCreateWarehouse := func(name string) (uint64, error) {
// 仓库缓存,只查找不创建
warehouseCache := map[string]*uint64{}
findWarehouse := func(name string) *uint64 {
if name == "" {
name = "默认仓库"
return nil
}
if id, ok := warehouseCache[name]; ok {
return id, 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 {
wh = model.Warehouse{
TenantBase: model.TenantBase{ShopID: shopID},
Name: name,
}
if err := h.db.Create(&wh).Error; err != nil {
return 0, err
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
}
}
warehouseCache[name] = wh.ID
return wh.ID, nil
}
for i, row := range rows[1:] {
@@ -516,82 +567,148 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
res.skipped++
continue
}
productCode := cell(row, 0)
series := cell(row, 2)
spec := cell(row, 3)
unit := cell(row, 4)
qtyStr := cell(row, 5)
priceStr := cell(row, 6)
warehouseName := cell(row, 11)
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)
// 找或创建商品
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
// 只查找商品,不强制创建
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)
}
if price > 0 && prod.PurchasePrice == 0 {
h.db.Model(&prod).Update("purchase_price", price)
// 解析生产日期
var productionDate *model.Date
if productionDateStr != "" {
d := parseDate(productionDateStr)
productionDate = &d
}
// 找或创建仓库
whID, err := findOrCreateWarehouse(warehouseName)
if err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 仓库创建失败: %s", i+2, err.Error()))
continue
// 查找仓库(只查,不创建)
whIDPtr := findWarehouse(warehouseName)
var unitPricePtr *float64
if price != 0 {
unitPricePtr = &price
}
// 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
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")
}
res.imported++
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,
})
@@ -657,13 +774,15 @@ func parseUploadedExcel(c *gin.Context) ([][]string, error) {
const maxEmpty = 5
emptyStreak := 0
for r := 0; r < maxRows; r++ {
row := sheet.Row(r)
row := safeXlsRow(sheet, 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 row != nil {
for c := 0; c < numCols; c++ {
cells[c] = strings.TrimSpace(row.Col(c))
if cells[c] != "" {
isEmpty = false
}
}
}
if isEmpty {
@@ -696,15 +815,21 @@ func parseUploadedExcel(c *gin.Context) ([][]string, error) {
return rows, nil
}
func findOrCreateProductFn(db *gorm.DB, shopID uint64, name, series, spec string) (model.Product, error) {
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,
@@ -762,3 +887,9 @@ 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)
}