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
+143
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -61,3 +62,145 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
"page_size": q.PageSize,
})
}
// Create POST /api/v1/finance/records — 手动录入付款/收款
func (h *FinanceHandler) Create(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
var req struct {
PartnerID *uint64 `json:"partner_id"`
Type string `json:"type" binding:"required"`
Amount float64 `json:"amount" binding:"required,gt=0"`
RecordDate string `json:"record_date" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Type != "payment" && req.Type != "receipt" {
c.JSON(http.StatusBadRequest, gin.H{"error": "type must be payment or receipt"})
return
}
date, err := time.Parse("2006-01-02", req.RecordDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid record_date, use YYYY-MM-DD"})
return
}
// 付款/收款 均为减少余额(抵消应付/应收)
prevBalance := partnerLastBalance(h.db, shopID, req.PartnerID)
bal := prevBalance - req.Amount
rec := model.FinanceRecord{
ShopID: shopID,
PartnerID: req.PartnerID,
Type: req.Type,
Amount: req.Amount,
Balance: bal,
Status: "closed",
OperatorID: userID,
RecordDate: date,
Remark: req.Remark,
}
if err := h.db.Create(&rec).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rec)
}
// Close PUT /api/v1/finance/records/:id/close — 标记结清
func (h *FinanceHandler) Close(c *gin.Context) {
shopID := middleware.GetShopID(c)
id := c.Param("id")
var rec model.FinanceRecord
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
First(&rec).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "record not found"})
return
}
if rec.Type != "payable" && rec.Type != "receivable" {
c.JSON(http.StatusBadRequest, gin.H{"error": "only payable/receivable can be closed"})
return
}
if rec.Status == "closed" {
c.JSON(http.StatusBadRequest, gin.H{"error": "already closed"})
return
}
if err := h.db.Model(&rec).Update("status", "closed").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// CloseByRef PUT /api/v1/finance/records/close-by-ref?ref_type=stock_in&ref_id=123
func (h *FinanceHandler) CloseByRef(c *gin.Context) {
shopID := middleware.GetShopID(c)
refType := c.Query("ref_type")
refID := c.Query("ref_id")
if refType == "" || refID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ref_type and ref_id are required"})
return
}
var rec model.FinanceRecord
err := h.db.Where("shop_id = ? AND ref_type = ? AND ref_id = ? AND status = 'open' AND deleted_at IS NULL",
shopID, refType, refID).First(&rec).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no open finance record found"})
return
}
if err := h.db.Model(&rec).Update("status", "closed").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Summary GET /api/v1/finance/summary — 按往来单位汇总未结清
func (h *FinanceHandler) Summary(c *gin.Context) {
shopID := middleware.GetShopID(c)
type row struct {
PartnerID *uint64 `json:"partner_id"`
PartnerName string `json:"partner_name"`
Type string `json:"type"`
RecordCount int `json:"record_count"`
TotalAmount float64 `json:"total_amount"`
}
var rows []row
h.db.Raw(`
SELECT f.partner_id,
COALESCE(p.name, '') AS partner_name,
f.type,
COUNT(*) AS record_count,
SUM(f.amount) AS total_amount
FROM finance_records f
LEFT JOIN partners p ON p.id = f.partner_id
WHERE f.shop_id = ? AND f.deleted_at IS NULL
AND f.type IN ('payable','receivable')
AND f.status = 'open'
GROUP BY f.partner_id, f.type
ORDER BY total_amount DESC
`, shopID).Scan(&rows)
c.JSON(http.StatusOK, gin.H{"data": rows})
}
// partnerLastBalance 查询该往来单位最后一条财务记录的余额
func partnerLastBalance(db *gorm.DB, shopID uint64, partnerID *uint64) float64 {
var last model.FinanceRecord
q := db.Where("shop_id = ? AND deleted_at IS NULL", shopID)
if partnerID != nil {
q = q.Where("partner_id = ?", *partnerID)
} else {
q = q.Where("partner_id IS NULL")
}
q.Order("id DESC").First(&last)
return last.Balance
}
+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)
}
+213 -130
View File
@@ -1,9 +1,9 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -20,33 +20,103 @@ func NewInventoryHandler(db *gorm.DB) *InventoryHandler {
return &InventoryHandler{db: db}
}
// inventoryRow is the response shape for GET /api/v1/inventory
type inventoryRow struct {
ID uint64 `json:"id"`
ShopID uint64 `json:"shop_id"`
WarehouseID *uint64 `json:"warehouse_id"`
ProductID *uint64 `json:"product_id"`
StockInItemID *uint64 `json:"stock_in_item_id"`
Quantity float64 `json:"quantity"`
ProductCode string `json:"product_code"`
ProductName string `json:"product_name"`
Series string `json:"series"`
Spec string `json:"spec"`
Unit string `json:"unit"`
WarehouseName string `json:"warehouse_name"`
UnitPrice *float64 `json:"unit_price"`
ProductionDate *string `json:"production_date"`
BatchNo string `json:"batch_no"`
SupplierName string `json:"supplier_name"`
Remark string `json:"remark"`
Brand string `json:"brand"`
MinStock *int `json:"min_stock"`
CreatedAt string `json:"created_at"`
}
// List GET /api/v1/inventory
func (h *InventoryHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 500 {
pageSize = 20
}
query := h.db.Model(&model.Inventory{}).Where("shop_id = ?", shopID)
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
args := []interface{}{shopID}
if warehouseID := c.Query("warehouse_id"); warehouseID != "" {
query = query.Where("warehouse_id = ?", warehouseID)
keyword := c.Query("keyword")
warehouseIDStr := c.Query("warehouse_id")
if keyword != "" {
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like)
}
if productID := c.Query("product_id"); productID != "" {
query = query.Where("product_id = ?", productID)
}
if c.Query("in_stock") == "1" {
query = query.Where("quantity > 0")
if warehouseIDStr != "" {
baseWhere += " AND inv.warehouse_id = ?"
args = append(args, warehouseIDStr)
}
// Count query
countSQL := `
SELECT COUNT(*)
FROM inventories inv
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id
LEFT JOIN products p ON p.id = inv.product_id
LEFT JOIN warehouses w ON w.id = inv.warehouse_id
WHERE ` + baseWhere
var total int64
query.Count(&total)
h.db.Raw(countSQL, args...).Scan(&total)
var inventory []model.Inventory
query.Preload("Product").Preload("Warehouse").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&inventory)
// Data query
dataSQL := `
SELECT
inv.id, inv.shop_id, inv.warehouse_id, inv.product_id, inv.stock_in_item_id,
inv.quantity,
COALESCE(NULLIF(p.code,''), inv.product_code, '') AS product_code,
COALESCE(NULLIF(p.name,''), inv.product_name, '') AS product_name,
COALESCE(NULLIF(p.series,''), inv.series, '') AS series,
COALESCE(NULLIF(p.spec,''), inv.spec, '') AS spec,
COALESCE(NULLIF(p.unit,''), inv.unit, '') AS unit,
COALESCE(NULLIF(w.name,''), inv.warehouse_name, '') AS warehouse_name,
COALESCE(sii.unit_price, inv.unit_price) AS unit_price,
COALESCE(DATE_FORMAT(sii.production_date,'%Y-%m-%d'), DATE_FORMAT(inv.production_date,'%Y-%m-%d')) AS production_date,
COALESCE(NULLIF(sii.batch_no,''), inv.batch_no, '') AS batch_no,
inv.supplier_name,
inv.remark,
COALESCE(p.brand, '') AS brand,
p.min_stock,
DATE_FORMAT(inv.created_at, '%Y-%m-%dT%H:%i:%sZ') AS created_at
FROM inventories inv
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id
LEFT JOIN products p ON p.id = inv.product_id
LEFT JOIN warehouses w ON w.id = inv.warehouse_id
WHERE ` + baseWhere + `
ORDER BY inv.id DESC
LIMIT ? OFFSET ?`
c.JSON(http.StatusOK, gin.H{"data": inventory, "total": total, "page": page, "page_size": pageSize})
dataArgs := append(args, pageSize, (page-1)*pageSize)
var rows []inventoryRow
h.db.Raw(dataSQL, dataArgs...).Scan(&rows)
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
}
// Logs GET /api/v1/inventory/logs
@@ -70,115 +140,6 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
}
// productTrackingItem is the response shape for GET /api/v1/inventory/products
type productTrackingItem struct {
model.StockInItem
CurrentQty float64 `json:"current_qty"`
Status string `json:"status"` // in_stock | sold_out
BuyerName string `json:"buyer_name,omitempty"`
SoldAt string `json:"sold_at,omitempty"`
}
// Products GET /api/v1/inventory/products — 商品追踪:已审核入库单的明细行,附库存状态和买家
func (h *InventoryHandler) Products(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query := h.db.Model(&model.StockInItem{}).
Joins("JOIN stock_in_orders ON stock_in_orders.id = stock_in_items.order_id").
Where("stock_in_orders.shop_id = ? AND stock_in_orders.status = 'approved'", shopID)
if productID := c.Query("product_id"); productID != "" {
query = query.Where("stock_in_items.product_id = ?", productID)
}
if warehouseID := c.Query("warehouse_id"); warehouseID != "" {
query = query.Where("stock_in_orders.warehouse_id = ?", warehouseID)
}
var total int64
query.Count(&total)
var items []model.StockInItem
query.
Preload("Product").
Preload("Order", func(db *gorm.DB) *gorm.DB {
return db.Preload("Warehouse").Preload("Partner")
}).
Select("stock_in_items.*").
Order("stock_in_orders.order_date DESC, stock_in_items.id DESC").
Offset((page-1)*pageSize).Limit(pageSize).
Find(&items)
// 一次查出所有库存,构建 product+warehouse → qty 的 map
var inventories []model.Inventory
h.db.Where("shop_id = ?", shopID).Find(&inventories)
invMap := make(map[string]float64, len(inventories))
for _, inv := range inventories {
key := fmt.Sprintf("%d:%d", inv.ProductID, inv.WarehouseID)
invMap[key] = inv.Quantity
}
// 查询每个 product+warehouse 最新的出库买家信息
type soldRow struct {
ProductID uint64 `gorm:"column:product_id"`
WarehouseID uint64 `gorm:"column:warehouse_id"`
BuyerName string `gorm:"column:buyer_name"`
SoldAt string `gorm:"column:sold_at"`
}
var soldRows []soldRow
h.db.Raw(`
SELECT sooi.product_id, soo.warehouse_id,
COALESCE(p.name, '') AS buyer_name,
CAST(MAX(soo.order_date) AS CHAR) AS sold_at
FROM stock_out_orders soo
JOIN stock_out_items sooi ON sooi.order_id = soo.id
LEFT JOIN partners p ON p.id = soo.partner_id AND p.shop_id = ?
WHERE soo.shop_id = ? AND soo.status = 'approved'
GROUP BY sooi.product_id, soo.warehouse_id
`, shopID, shopID).Scan(&soldRows)
soldMap := make(map[string]soldRow, len(soldRows))
for _, r := range soldRows {
key := fmt.Sprintf("%d:%d", r.ProductID, r.WarehouseID)
soldMap[key] = r
}
result := make([]productTrackingItem, 0, len(items))
for _, item := range items {
var warehouseID uint64
if item.Order != nil {
warehouseID = item.Order.WarehouseID
}
key := fmt.Sprintf("%d:%d", item.ProductID, warehouseID)
currentQty := invMap[key]
status := "in_stock"
buyerName := ""
soldAt := ""
if currentQty <= 0 {
status = "sold_out"
if si, ok := soldMap[key]; ok {
buyerName = si.BuyerName
if len(si.SoldAt) > 10 {
soldAt = si.SoldAt[:10]
} else {
soldAt = si.SoldAt
}
}
}
result = append(result, productTrackingItem{
StockInItem: item,
CurrentQty: currentQty,
Status: status,
BuyerName: buyerName,
SoldAt: soldAt,
})
}
c.JSON(http.StatusOK, gin.H{"data": result, "total": total, "page": page, "page_size": pageSize})
}
// CreateCheck POST /api/v1/inventory/checks
func (h *InventoryHandler) CreateCheck(c *gin.Context) {
shopID := middleware.GetShopID(c)
@@ -194,14 +155,17 @@ func (h *InventoryHandler) CreateCheck(c *gin.Context) {
req.OperatorID = operatorID
req.Status = "draft"
// 自动填入系统库存数量
// 自动填入系统库存数量SUM 聚合)
for i := range req.Items {
req.Items[i].ShopID = shopID
var inv model.Inventory
if err := h.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
shopID, req.WarehouseID, req.Items[i].ProductID).First(&inv).Error; err == nil {
req.Items[i].SystemQty = inv.Quantity
}
warehouseID := req.WarehouseID
productID := req.Items[i].ProductID
var systemQty float64
h.db.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL",
shopID, warehouseID, productID).
Select("COALESCE(SUM(quantity), 0)").Scan(&systemQty)
req.Items[i].SystemQty = systemQty
}
if err := h.db.Create(&req).Error; err != nil {
@@ -223,3 +187,122 @@ func (h *InventoryHandler) GetCheck(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"data": check})
}
// CompleteCheck PUT /api/v1/inventory/checks/:id/complete
func (h *InventoryHandler) CompleteCheck(c *gin.Context) {
shopID := middleware.GetShopID(c)
checkID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var check model.InventoryCheck
if err := h.db.Preload("Items.Product").
Where("id = ? AND shop_id = ?", checkID, shopID).
First(&check).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if check.Status == "completed" {
c.JSON(http.StatusBadRequest, gin.H{"error": "已完成"})
return
}
tx := h.db.Begin()
now := time.Now()
for _, item := range check.Items {
diff := item.ActualQty - item.SystemQty
if diff == 0 {
continue
}
if diff > 0 {
// 盘盈:新增一条库存批次记录
checkIDCopy := checkID
productIDCopy := item.ProductID
warehouseIDCopy := check.WarehouseID
productCode := ""
productName := ""
series := ""
spec := ""
unit := ""
if item.Product != nil {
productCode = item.Product.Code
productName = item.Product.Name
series = item.Product.Series
spec = item.Product.Spec
unit = item.Product.Unit
}
inv := model.Inventory{
ShopID: shopID,
WarehouseID: &warehouseIDCopy,
ProductID: &productIDCopy,
InventoryCheckID: &checkIDCopy,
Quantity: diff,
ProductCode: productCode,
ProductName: productName,
Series: series,
Spec: spec,
Unit: unit,
}
tx.Create(&inv)
} else {
// 盘亏:FIFO 扣减
remaining := -diff
var batches []model.Inventory
tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
shopID, check.WarehouseID, item.ProductID).
Order("created_at ASC").Find(&batches)
for i := range batches {
if remaining <= 0 {
break
}
b := &batches[i]
if b.Quantity <= remaining {
remaining -= b.Quantity
tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
} else {
tx.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
remaining = 0
}
}
}
}
tx.Model(&check).Update("status", "completed")
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "盘点完成"})
}
// UpdateRemark PUT /api/v1/inventory/:id/remark
func (h *InventoryHandler) UpdateRemark(c *gin.Context) {
shopID := middleware.GetShopID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req struct {
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result := h.db.Model(&model.Inventory{}).
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
Update("remark", req.Remark)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "库存记录不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+20
View File
@@ -18,11 +18,31 @@ func NewNumberRuleHandler(db *gorm.DB) *NumberRuleHandler {
return &NumberRuleHandler{db: db}
}
var defaultRules = []struct {
Type string
Prefix string
DateFormat string
}{
{"stock_in", "RK", "YYYYMMDD"},
{"stock_out", "CK", "YYYYMMDD"},
{"inventory_check", "PD", "YYYYMMDD"},
{"product", "SP", "YYYYMMDD"},
}
// List GET /api/v1/number-rules
func (h *NumberRuleHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
var rules []model.NumberRule
h.db.Where("shop_id = ?", shopID).Order("id").Find(&rules)
if len(rules) == 0 {
for _, d := range defaultRules {
r := model.NumberRule{ShopID: shopID, Type: d.Type, Prefix: d.Prefix, DateFormat: d.DateFormat}
h.db.Create(&r)
rules = append(rules, r)
}
}
c.JSON(http.StatusOK, gin.H{"data": rules})
}
+4 -1
View File
@@ -177,7 +177,7 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
var product model.Product
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
Select("id, public_id").First(&product).Error; err != nil {
Select("id, public_id, code").First(&product).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
@@ -188,6 +188,9 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
}
url := config.C.Storage.PublicURL + "/product/" + product.PublicID
if product.Code != "" {
url += "?code=" + product.Code
}
png, err := qrcode.Encode(url, qrcode.Medium, 256)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -51,6 +51,29 @@ func (h *ProductOptionHandler) CreateName(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"data": item})
}
func (h *ProductOptionHandler) UpdateName(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Code string `json:"code"`
Name string `json:"name" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var item model.ProductNameOption
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
}
func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
shopID := middleware.GetShopID(c)
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductNameOption{})
@@ -90,6 +113,29 @@ func (h *ProductOptionHandler) CreateSeries(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"data": item})
}
func (h *ProductOptionHandler) UpdateSeries(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Code string `json:"code"`
Name string `json:"name" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var item model.ProductSeriesOption
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
}
func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
shopID := middleware.GetShopID(c)
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductSeriesOption{})
@@ -131,6 +177,31 @@ func (h *ProductOptionHandler) CreateSpec(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"data": item})
}
func (h *ProductOptionHandler) UpdateSpec(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Code string `json:"code"`
Name string `json:"name" binding:"required"`
Quantity int `json:"quantity"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var item model.ProductSpecOption
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Quantity = req.Quantity
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
}
func (h *ProductOptionHandler) DeleteSpec(c *gin.Context) {
shopID := middleware.GetShopID(c)
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductSpecOption{})
+61
View File
@@ -0,0 +1,61 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
type ShopHandler struct {
db *gorm.DB
}
func NewShopHandler(db *gorm.DB) *ShopHandler {
return &ShopHandler{db: db}
}
// GetInfo GET /api/v1/shop/info
func (h *ShopHandler) GetInfo(c *gin.Context) {
shopID := middleware.GetShopID(c)
var shop model.Shop
if err := h.db.First(&shop, shopID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "shop not found"})
return
}
c.JSON(http.StatusOK, shop)
}
// UpdateInfo PUT /api/v1/shop/info (admin only)
func (h *ShopHandler) UpdateInfo(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Name string `json:"name"`
Address string `json:"address"`
Phone string `json:"phone"`
ManagerName string `json:"manager_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]interface{}{
"name": req.Name,
"address": req.Address,
"phone": req.Phone,
"manager_name": req.ManagerName,
}
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var shop model.Shop
h.db.First(&shop, shopID)
c.JSON(http.StatusOK, shop)
}
+31 -10
View File
@@ -65,25 +65,46 @@ func (h *StockOutHandler) Get(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": order})
}
// checkInventory validates that warehouse has enough stock for each item.
// checkInventory validates that warehouse has enough stock for each item (SUM aggregate).
// warehouseID is the order's warehouse; items are the stock-out line items.
func (h *StockOutHandler) checkInventory(shopID, warehouseID uint64, items []model.StockOutItem) error {
if len(items) == 0 {
return nil
}
// Collect product IDs
productIDs := make([]uint64, 0, len(items))
for _, item := range items {
var inv model.Inventory
err := h.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
shopID, warehouseID, item.ProductID).First(&inv).Error
if err != nil || inv.Quantity < item.Quantity {
// Try to get product name for a clearer error message
productIDs = append(productIDs, item.ProductID)
}
type inventorySum struct {
ProductID uint64
Total float64
}
var sums []inventorySum
h.db.Model(&model.Inventory{}).
Select("product_id, COALESCE(SUM(quantity), 0) AS total").
Where("shop_id = ? AND warehouse_id = ? AND product_id IN ? AND deleted_at IS NULL",
shopID, warehouseID, productIDs).
Group("product_id").Scan(&sums)
// Build map for quick lookup
sumMap := make(map[uint64]float64, len(sums))
for _, s := range sums {
sumMap[s.ProductID] = s.Total
}
for _, item := range items {
have := sumMap[item.ProductID]
if have < item.Quantity {
// Get product name for a clearer error message
var p model.Product
h.db.Where("id = ?", item.ProductID).First(&p)
name := p.Name
if name == "" {
name = fmt.Sprintf("商品ID %d", item.ProductID)
}
have := 0.0
if err == nil {
have = inv.Quantity
}
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
}
}