feat(barcode): 多条码管理(后端+迁移+GTIN校验)WIP
- 迁移 0007: 新增 product_barcode 表(一品多码),回填旧 product.gtin 为主码, 全局唯一索引保证「一码一品」,每品至多一个主码 - internal/gtin: GS1 GTIN-8/12/13/14 校验(校验位 + 拒收店内码/变量重量码/优惠券码) - 公开只读 API: 任一条码命中商品、详情返回 barcodes、搜索匹配条码 - adminstore: 商品详情含 barcodes;新增 AddBarcode/DeleteBarcode/SetPrimaryBarcode, 一码命中其他商品返回 ConflictError 供后台去重 待办(按用户要求暂停): 后台 handler 路由、投稿/审核多条码、前后端 UI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -29,6 +29,15 @@ func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
// Barcode is one GS1 trade item number attached to a product.
|
||||
type Barcode struct {
|
||||
GTIN string `json:"gtin"`
|
||||
GTINType string `json:"gtin_type"`
|
||||
PackLevel string `json:"pack_level"`
|
||||
Region *string `json:"region"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
}
|
||||
|
||||
// Product is the full public view of a product.
|
||||
type Product struct {
|
||||
ID string `json:"id"`
|
||||
@@ -41,6 +50,7 @@ type Product struct {
|
||||
NetContentUnit *string `json:"net_content_unit"`
|
||||
CountryOfOrigin *string `json:"country_of_origin"`
|
||||
QualityScore float64 `json:"quality_score"`
|
||||
Barcodes []Barcode `json:"barcodes"`
|
||||
Nutriments map[string]any `json:"nutriments,omitempty"`
|
||||
NutritionBasis *string `json:"nutrition_basis,omitempty"`
|
||||
NutriScore *string `json:"nutri_score,omitempty"`
|
||||
@@ -49,6 +59,27 @@ type Product struct {
|
||||
Additives []string `json:"additives,omitempty"`
|
||||
}
|
||||
|
||||
// ProductBarcodes returns every barcode attached to a product, primary first.
|
||||
func (s *Store) ProductBarcodes(ctx context.Context, productID string) ([]Barcode, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT gtin, gtin_type, pack_level, region, is_primary
|
||||
FROM product_barcode WHERE product_id = $1
|
||||
ORDER BY is_primary DESC, gtin`, productID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Barcode{}
|
||||
for rows.Next() {
|
||||
var b Barcode
|
||||
if err := rows.Scan(&b.GTIN, &b.GTINType, &b.PackLevel, &b.Region, &b.IsPrimary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ProductSummary is a lightweight row used in search/listing responses.
|
||||
type ProductSummary struct {
|
||||
ID string `json:"id"`
|
||||
@@ -86,16 +117,35 @@ func scanProduct(row pgx.Row) (*Product, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ProductByGTIN looks up an active product by its barcode.
|
||||
// ProductByGTIN looks up an active product by any of its barcodes.
|
||||
func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error) {
|
||||
row := s.pool.QueryRow(ctx, productSelect+" WHERE p.gtin = $1 AND p.status = 'active'", gtin)
|
||||
return scanProduct(row)
|
||||
row := s.pool.QueryRow(ctx, productSelect+`
|
||||
WHERE p.status = 'active'
|
||||
AND (p.gtin = $1 OR EXISTS (
|
||||
SELECT 1 FROM product_barcode pb
|
||||
WHERE pb.product_id = p.id AND pb.gtin = $1))
|
||||
LIMIT 1`, gtin)
|
||||
p, err := scanProduct(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ProductByID looks up a product by its UUID.
|
||||
func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
|
||||
row := s.pool.QueryRow(ctx, productSelect+" WHERE p.id = $1", id)
|
||||
return scanProduct(row)
|
||||
p, err := scanProduct(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SearchProducts performs a fuzzy name search with optional category subtree filter.
|
||||
@@ -104,7 +154,9 @@ func (s *Store) SearchProducts(ctx context.Context, q, category string, limit, o
|
||||
where := "WHERE p.status = 'active'"
|
||||
if q != "" {
|
||||
args = append(args, q)
|
||||
where += " AND p.name ILIKE '%' || $1 || '%'"
|
||||
where += ` AND (p.name ILIKE '%' || $1 || '%'
|
||||
OR EXISTS (SELECT 1 FROM product_barcode pb
|
||||
WHERE pb.product_id = p.id AND pb.gtin ILIKE '%' || $1 || '%'))`
|
||||
}
|
||||
if category != "" {
|
||||
args = append(args, category)
|
||||
|
||||
Reference in New Issue
Block a user