Files
jiu/backend/internal/handler/product.go
T
wangjia 8385c23c40 feat: 编号规则支持模式/日期格式,商品编码接入统一编号
- number_rules 新增 mode(date_seq 日期+序号3位按周期重置 / seq 连续6位)与 seq_period 列
- 单号生成按 mode + date_format 拼接,跨日期周期自动重置;启动回填 product→seq
- 商品建号接入 number_rules(seq 模式、前缀可配、从现有最大序列号播种,续发连续编号)
- 设置屏:加「模式」列、当前序号按周期显示(未开单显0);编辑弹窗前缀所有类型可改,
  有日期的序号系统自动(只读置灰)、可改日期格式,商品编码序号可调大不可调小
- 桌面原型 settings 补「编号规则」面板+编辑弹窗、m-settings 改准;atoms 去数字框原生箭头
- 同步 CONTRACT / screens.mjs / db-schema.html 与 settings fidelity 基准

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-09-02 21:30:10 +08:00

429 lines
14 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 (
"bytes"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
qrcode "github.com/skip2/go-qrcode"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type ProductHandler struct {
db *gorm.DB
}
func NewProductHandler(db *gorm.DB) *ProductHandler {
return &ProductHandler{db: db}
}
// List GET /api/v1/products
func (h *ProductHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
keyword := c.Query("keyword")
categoryID := c.Query("category_id")
query := h.db.Model(&model.Product{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID)
if keyword != "" {
// 原型搜索口径:商品名 / 拼音(全拼+首字母)/ 编码;barcode 保留兼容
like := "%" + keyword + "%"
query = query.Where(
"name LIKE ? OR code LIKE ? OR barcode LIKE ? OR name_pinyin LIKE ? OR name_initials LIKE ?",
like, like, like, like, like)
}
if categoryID != "" {
query = query.Where("category_id = ?", categoryID)
}
var total int64
query.Count(&total)
products := make([]model.Product, 0)
offset := (page - 1) * pageSize
query.Preload("Category").Offset(offset).Limit(pageSize).
Order("id DESC").Find(&products)
c.JSON(http.StatusOK, gin.H{
"data": products,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// nextProductCode 生成该门店下一个商品编码——走 number_rulestype=product)。
// 商品编码为 seq 模式(连续 6 位、不含日期,如 DS000001);前缀可在「编号规则」页配置。
// 首次使用(current_no==0)按现有商品编码的最大末尾数字播种,使新号延续既有序列、不与
// 已有编码(含导入的 ZXZ 序列号)重号。FOR UPDATE 行锁防并发;调用方仍有 uk_shop_code 撞号重试兜底。
func nextProductCode(tx *gorm.DB, shopID uint64) (string, error) {
var rule model.NumberRule
err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND type = ?", shopID, "product").First(&rule).Error
if err != nil {
// 规则不存在:初始化(seq 模式、默认前缀 SP、播种为现有最大序列号)
rule = model.NumberRule{
ShopID: shopID, Type: "product", Prefix: "SP",
Mode: "seq", DateFormat: "YYYYMMDD", CurrentNo: maxProductSerial(tx, shopID),
}
if e := tx.Create(&rule).Error; e != nil {
return "", e
}
} else if rule.Mode == "seq" && rule.CurrentNo == 0 {
// 已存在但未播种(新增 mode 列回填后 current_no 仍为 0):播种为现有最大序列号
rule.CurrentNo = maxProductSerial(tx, shopID)
}
// 复用统一自增逻辑(按 rule.Mode:seq→前缀+6位;若管理员改成 date_seq 则前缀+日期+3位)
return service.AdvanceNumberRule(tx, &rule), nil
}
// maxProductSerial 该门店所有商品编码「末尾连续数字」的最大值(跨前缀,如 ZXZ031114→31114)。
// 含软删行(不复用已删编号占用过的号)。Go 侧解析,兼容 sqlite 单测(不用 MySQL REGEXP)。
func maxProductSerial(tx *gorm.DB, shopID uint64) int {
var codes []string
tx.Model(&model.Product{}).Where("shop_id = ?", shopID).Pluck("code", &codes)
maxN := 0
for _, c := range codes {
i := len(c)
for i > 0 && c[i-1] >= '0' && c[i-1] <= '9' {
i--
}
if i < len(c) {
if n, e := strconv.Atoi(c[i:]); e == nil && n > maxN {
maxN = n
}
}
}
return maxN
}
// createIndependentProduct 为入库明细新建一个独立产品(特有产品/序列号),返回新 product。
// "入库每行 = 一个特有产品"模型:每条明细建一个独立 product、发新序列号,不按名称复用。
// 含 nextProductCode 自增 + 撞 uk_shop_code 唯一约束时重试。必须在事务内调用。
func createIndependentProduct(tx *gorm.DB, shopID uint64, name, series, spec, batchNo string, prodDate *model.Date, price, salePrice float64) (model.Product, error) {
namePinyin, nameInitials := util.ToPinyin(name)
var prod model.Product
var err error
for attempt := 0; attempt < 5; attempt++ {
code, e := nextProductCode(tx, shopID)
if e != nil {
return model.Product{}, e
}
prod = model.Product{
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Code: code,
Name: name,
Series: series,
Spec: spec,
BatchNo: batchNo,
ProductionDate: prodDate,
PurchasePrice: price,
SalePrice: salePrice,
NamePinyin: namePinyin,
NameInitials: nameInitials,
}
prod.ID = 0
if err = tx.Create(&prod).Error; err == nil || !errors.Is(err, gorm.ErrDuplicatedKey) {
break
}
}
if err != nil {
return model.Product{}, err
}
return prod, nil
}
// Create POST /api/v1/products
func (h *ProductHandler) Create(c *gin.Context) {
shopID := middleware.GetShopID(c)
var product model.Product
if err := c.ShouldBindJSON(&product); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
product.ShopID = shopID
product.PublicID = uuid.New().String()
product.NamePinyin, product.NameInitials = util.ToPinyin(product.Name)
// 未显式指定编码时自动生成(事务内 max+1);撞 uk_shop_code 唯一约束则重算下一号重试(应对并发)。
autoCode := product.Code == ""
var createErr error
for attempt := 0; attempt < 5; attempt++ {
createErr = h.db.Transaction(func(tx *gorm.DB) error {
if autoCode {
code, err := nextProductCode(tx, shopID)
if err != nil {
return err
}
product.Code = code
}
product.ID = 0
return tx.Create(&product).Error
})
if createErr == nil || !autoCode || !errors.Is(createErr, gorm.ErrDuplicatedKey) {
break
}
}
if createErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()})
return
}
util.RespondCreated(c, product)
}
// Update PUT /api/v1/products/:id
func (h *ProductHandler) Update(c *gin.Context) {
shopID := middleware.GetShopID(c)
id := c.Param("id")
var product model.Product
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
First(&product).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var req model.Product
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
namePinyin, nameInitials := util.ToPinyin(req.Name)
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
if err := h.db.Model(&product).Updates(map[string]interface{}{
"code": req.Code,
"barcode": req.Barcode,
"name": req.Name,
"series": req.Series,
"spec": req.Spec,
"unit": req.Unit,
"category_id": req.CategoryID,
"brand": req.Brand,
"purchase_price": req.PurchasePrice,
"sale_price": req.SalePrice,
"min_stock": req.MinStock,
"description": req.Description,
"remark": req.Remark,
"custom_fields": req.CustomFields,
"name_pinyin": namePinyin,
"name_initials": nameInitials,
"origin_id": req.OriginID,
"shelf_life_id": req.ShelfLifeID,
"storage_id": req.StorageID,
"description_doc_id": req.DescriptionDocID,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 重新读取完整数据返回
h.db.Preload("Category").First(&product, product.ID)
util.RespondSuccess(c, product)
}
// Detail GET /api/v1/products/:id/detail
func (h *ProductHandler) Detail(c *gin.Context) {
shopID := middleware.GetShopID(c)
id := c.Param("id")
var product model.Product
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
Preload("Category").Preload("Images").
Preload("Origin").Preload("ShelfLife").Preload("Storage").
First(&product).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
// 老数据可能没有 public_id,按需补生成
if product.PublicID == "" {
product.PublicID = uuid.New().String()
h.db.Model(&product).Update("public_id", product.PublicID)
}
util.RespondSuccess(c, product)
}
// PriceHistory GET /api/v1/products/:id/price-history
// 进价变更历史:从已审核入库单的明细单价按时间派生(零额外表)。
// 返回按日期倒序的 {date, price} 点;前端据相邻点算涨跌。
func (h *ProductHandler) PriceHistory(c *gin.Context) {
shopID := middleware.GetShopID(c)
id := c.Param("id")
type pricePoint struct {
Date string `json:"date"`
Price float64 `json:"price"`
}
points := make([]pricePoint, 0)
// 守多租户:order 的 shop_id 必须等于当前店;只取已审核单的正单价。
h.db.Raw(`
SELECT o.order_date AS date, sii.cost_price AS price
FROM stock_in_items sii
JOIN stock_in_orders o ON o.id = sii.order_id
WHERE sii.product_id = ? AND o.shop_id = ? AND o.status = 'approved'
AND sii.cost_price > 0
ORDER BY o.order_date DESC, sii.id DESC
LIMIT 20`, id, shopID).Scan(&points)
// 去掉相邻重复价(只保留价格发生变化的点)
deduped := make([]pricePoint, 0, len(points))
for i, p := range points {
if i > 0 && points[i-1].Price == p.Price {
continue
}
deduped = append(deduped, p)
}
util.RespondSuccess(c, deduped)
}
// QRCode GET /api/v1/products/:id/qrcode
func (h *ProductHandler) QRCode(c *gin.Context) {
shopID := middleware.GetShopID(c)
id := c.Param("id")
var product model.Product
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
Select("id, public_id, code").First(&product).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if product.PublicID == "" {
product.PublicID = uuid.New().String()
h.db.Model(&product).Update("public_id", product.PublicID)
}
// 统一走 SSR 公开页路由 /product/:public_id(与 router 一致;旧 /app/product/ 前缀已弃)
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()})
return
}
c.DataFromReader(http.StatusOK, int64(len(png)), "image/png", bytes.NewReader(png), nil)
}
// FindOrCreate POST /api/v1/products/find-or-create
func (h *ProductHandler) FindOrCreate(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Name string `json:"name" binding:"required"`
Series string `json:"series"`
Spec string `json:"spec"`
OriginID *uint64 `json:"origin_id"`
ShelfLifeID *uint64 `json:"shelf_life_id"`
StorageID *uint64 `json:"storage_id"`
DescriptionDocID *uint64 `json:"description_doc_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var product model.Product
err := h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
shopID, req.Name, req.Series, req.Spec).First(&product).Error
if err == nil {
// 商品已存在:如果传入了属性 ID,则更新属性关联
attrUpdates := map[string]interface{}{}
if req.OriginID != nil {
attrUpdates["origin_id"] = req.OriginID
}
if req.ShelfLifeID != nil {
attrUpdates["shelf_life_id"] = req.ShelfLifeID
}
if req.StorageID != nil {
attrUpdates["storage_id"] = req.StorageID
}
if req.DescriptionDocID != nil {
attrUpdates["description_doc_id"] = req.DescriptionDocID
}
if len(attrUpdates) > 0 {
h.db.Model(&product).Updates(attrUpdates)
}
util.RespondSuccess(c, product)
return
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
namePinyin, nameInitials := util.ToPinyin(req.Name)
// 事务内 max+1 生成编码;撞 uk_shop_code 唯一约束则重算下一号重试(应对并发)。
var createErr error
for attempt := 0; attempt < 5; attempt++ {
createErr = h.db.Transaction(func(tx *gorm.DB) error {
code, err := nextProductCode(tx, shopID)
if err != nil {
return err
}
product = model.Product{
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Name: req.Name,
Series: req.Series,
Spec: req.Spec,
Code: code,
NamePinyin: namePinyin,
NameInitials: nameInitials,
OriginID: req.OriginID,
ShelfLifeID: req.ShelfLifeID,
StorageID: req.StorageID,
DescriptionDocID: req.DescriptionDocID,
}
return tx.Create(&product).Error
})
if createErr == nil || !errors.Is(createErr, gorm.ErrDuplicatedKey) {
break
}
}
if createErr != nil {
// Race condition: 并发可能已按同 name/series/spec 建好,回查返回既有
if h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
shopID, req.Name, req.Series, req.Spec).First(&product).Error == nil {
util.RespondSuccess(c, product)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()})
return
}
util.RespondCreated(c, product)
}
// Delete DELETE /api/v1/products/:id (软删除)
func (h *ProductHandler) Delete(c *gin.Context) {
shopID := middleware.GetShopID(c)
id := c.Param("id")
now := timeNow()
result := h.db.Model(&model.Product{}).
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
Update("deleted_at", now)
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}