Files
goods/api/internal/adminstore/write.go
T
sulaimaannaasif6866 a36700076e
CI / Go (api) (pull_request) Successful in 37s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 16s
feat(admin): 品牌管理 + 新建商品
品牌管理:列出品牌及引用商品数,支持改名、合并重复品牌(把源品牌的商品并入目标后删除源)、删除未被引用的品牌。新建商品:商品列表新增「新建商品」入口,填写名称/条码/品牌/品类后创建并进入详情页继续补全。

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 01:31:07 +00:00

504 lines
15 KiB
Go

package adminstore
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/jackc/pgx/v5"
)
// ProductInput is the editable payload accepted from the admin UI.
type ProductInput struct {
GTIN *string `json:"gtin"`
Name string `json:"name"`
BrandID *string `json:"brand_id"`
BrandName *string `json:"brand_name"`
CategoryID *string `json:"category_id"`
NetContentValue *float64 `json:"net_content_value"`
NetContentUnit *string `json:"net_content_unit"`
CountryOfOrigin *string `json:"country_of_origin"`
Status string `json:"status"`
IngredientsText *string `json:"ingredients_text"`
Allergens []string `json:"allergens"`
Additives []string `json:"additives"`
Nutriments map[string]any `json:"nutriments"`
NutritionBasis *string `json:"nutrition_basis"`
ServingSize *string `json:"serving_size"`
NutriScore *string `json:"nutri_score"`
}
func normBrand(name string) string { return strings.Join(strings.Fields(strings.ToLower(name)), " ") }
func (s *Store) ensureBrand(ctx context.Context, tx pgx.Tx, name string) (string, error) {
var id string
err := tx.QueryRow(ctx, `
INSERT INTO brand (name, normalized_name) VALUES ($1, $2)
ON CONFLICT (normalized_name) DO UPDATE SET name = brand.name
RETURNING id`, name, normBrand(name)).Scan(&id)
return id, err
}
func (s *Store) manualSourceID(ctx context.Context, tx pgx.Tx) (string, error) {
var id string
err := tx.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&id)
return id, err
}
// netCanonical converts value+unit to the canonical base unit via the unit table.
func (s *Store) netCanonical(ctx context.Context, tx pgx.Tx, value *float64, unit *string) (*float64, error) {
if value == nil || unit == nil || *unit == "" {
return nil, nil
}
var factor *float64
err := tx.QueryRow(ctx, "SELECT to_canonical_factor FROM unit WHERE code = $1", *unit).Scan(&factor)
if errors.Is(err, pgx.ErrNoRows) || factor == nil {
return nil, nil
}
if err != nil {
return nil, err
}
c := *value * *factor
return &c, nil
}
// UpdateProduct applies an edit, records provenance + audit, and recomputes quality.
func (s *Store) UpdateProduct(ctx context.Context, id, actor string, in ProductInput) (*ProductDetail, error) {
before, err := s.GetProduct(ctx, id)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Resolve brand (create-by-name takes precedence over id).
brandID := in.BrandID
if in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "" {
bid, err := s.ensureBrand(ctx, tx, strings.TrimSpace(*in.BrandName))
if err != nil {
return nil, err
}
brandID = &bid
}
// Resolve category gpc brick code.
var gpc *string
if in.CategoryID != nil && *in.CategoryID != "" {
if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id = $1", *in.CategoryID).Scan(&gpc); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
}
canonical, err := s.netCanonical(ctx, tx, in.NetContentValue, in.NetContentUnit)
if err != nil {
return nil, err
}
status := in.Status
if status == "" {
status = before.Status
}
_, err = tx.Exec(ctx, `
UPDATE product SET gtin=$1, name=$2, brand_id=$3, category_id=$4, gpc_brick_code=$5,
net_content_value=$6, net_content_unit=$7, net_content_canonical=$8,
country_of_origin=$9, status=$10
WHERE id=$11`,
in.GTIN, in.Name, brandID, in.CategoryID, gpc,
in.NetContentValue, in.NetContentUnit, canonical,
in.CountryOfOrigin, status, id)
if err != nil {
return nil, err
}
var nutriJSON []byte
if in.Nutriments != nil {
nutriJSON, _ = json.Marshal(in.Nutriments)
}
allergens := in.Allergens
if allergens == nil {
allergens = []string{}
}
additives := in.Additives
if additives == nil {
additives = []string{}
}
_, err = tx.Exec(ctx, `
INSERT INTO food_detail (product_id, ingredients_text, allergens, additives,
nutriments, nutrition_basis, serving_size, nutri_score)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (product_id) DO UPDATE SET
ingredients_text=EXCLUDED.ingredients_text,
allergens=EXCLUDED.allergens,
additives=EXCLUDED.additives,
nutriments=EXCLUDED.nutriments,
nutrition_basis=EXCLUDED.nutrition_basis,
serving_size=EXCLUDED.serving_size,
nutri_score=EXCLUDED.nutri_score`,
id, in.IngredientsText, allergens, additives,
nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore)
if err != nil {
return nil, err
}
if _, err := s.recomputeQualityTx(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
after, err := s.GetProduct(ctx, id)
if err != nil {
return nil, err
}
changed := diffFields(before, after)
if len(changed) > 0 {
if err := s.recordProvenance(ctx, id, changed); err != nil {
return nil, err
}
}
if err := s.writeAudit(ctx, actor, "update", "product", &id, changed, before, after); err != nil {
return nil, err
}
return after, nil
}
// ErrDuplicateGTIN is returned when a product GTIN already exists.
var ErrDuplicateGTIN = errors.New("duplicate gtin")
// CreateProduct inserts a new product from the admin UI. Only the core fields
// are required; the operator completes the rest in the detail editor.
func (s *Store) CreateProduct(ctx context.Context, actor string, in ProductInput) (*ProductDetail, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, errors.New("name required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
brandID := in.BrandID
if in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "" {
bid, err := s.ensureBrand(ctx, tx, strings.TrimSpace(*in.BrandName))
if err != nil {
return nil, err
}
brandID = &bid
}
var gpc *string
if in.CategoryID != nil && *in.CategoryID != "" {
if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id = $1", *in.CategoryID).Scan(&gpc); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
}
canonical, err := s.netCanonical(ctx, tx, in.NetContentValue, in.NetContentUnit)
if err != nil {
return nil, err
}
status := in.Status
if status == "" {
status = "active"
}
var id string
err = tx.QueryRow(ctx, `
INSERT INTO product (gtin, name, brand_id, category_id, gpc_brick_code,
net_content_value, net_content_unit, net_content_canonical,
country_of_origin, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING id`,
in.GTIN, in.Name, brandID, in.CategoryID, gpc,
in.NetContentValue, in.NetContentUnit, canonical,
in.CountryOfOrigin, status).Scan(&id)
if isUniqueViolation(err) {
return nil, ErrDuplicateGTIN
}
if err != nil {
return nil, err
}
if _, err := s.recomputeQualityTx(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
after, err := s.GetProduct(ctx, id)
if err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "create", "product", &id, []string{"name"}, nil, after)
return after, nil
}
func strEq(a, b *string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func floatEq(a, b *float64) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func diffFields(a, b *ProductDetail) []string {
changed := []string{}
add := func(name string, eq bool) {
if !eq {
changed = append(changed, name)
}
}
add("gtin", strEq(a.GTIN, b.GTIN))
add("name", a.Name == b.Name)
add("brand", strEq(a.BrandID, b.BrandID))
add("category", strEq(a.CategoryID, b.CategoryID))
add("net_content", floatEq(a.NetContentValue, b.NetContentValue) && strEq(a.NetContentUnit, b.NetContentUnit))
add("country_of_origin", strEq(a.CountryOfOrigin, b.CountryOfOrigin))
add("status", a.Status == b.Status)
add("ingredients", strEq(a.IngredientsText, b.IngredientsText))
ja, _ := json.Marshal(a.Nutriments)
jb, _ := json.Marshal(b.Nutriments)
add("nutriments", string(ja) == string(jb))
add("nutrition_basis", strEq(a.NutritionBasis, b.NutritionBasis))
add("serving_size", strEq(a.ServingSize, b.ServingSize))
add("nutri_score", strEq(a.NutriScore, b.NutriScore))
return changed
}
func (s *Store) recordProvenance(ctx context.Context, productID string, fields []string) error {
var srcID string
if err := s.pool.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&srcID); err != nil {
return err
}
_, err := s.pool.Exec(ctx, `
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
VALUES ($1, $2, NULL, $3, now(), NULL)`, productID, srcID, fields)
return err
}
func (s *Store) writeAudit(ctx context.Context, actor, action, entity string, entityID *string, fields []string, before, after any) error {
bj, _ := json.Marshal(before)
aj, _ := json.Marshal(after)
if fields == nil {
fields = []string{}
}
_, err := s.pool.Exec(ctx, `
INSERT INTO audit_log (actor, action, entity, entity_id, fields, before, after)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, actor, action, entity, entityID, fields, bj, aj)
return err
}
// ---------- images ----------
// AddImage inserts an image URL (manual source) and recomputes quality.
func (s *Store) AddImage(ctx context.Context, productID, actor, url, kind string) (*ProductImage, error) {
if kind == "" {
kind = "other"
}
var srcID string
if err := s.pool.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&srcID); err != nil {
return nil, err
}
var im ProductImage
err := s.pool.QueryRow(ctx, `
INSERT INTO product_image (product_id, url, kind, license, source_id)
VALUES ($1,$2,$3,NULL,$4) RETURNING id, url, kind, license`,
productID, url, kind, srcID).Scan(&im.ID, &im.URL, &im.Kind, &im.License)
if err != nil {
return nil, err
}
if _, err := s.recomputeQuality(ctx, productID); err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "add_image", "product", &productID, []string{"image"}, nil, im)
return &im, nil
}
// DeleteImage removes an image and recomputes quality.
func (s *Store) DeleteImage(ctx context.Context, productID, imageID, actor string) error {
ct, err := s.pool.Exec(ctx, "DELETE FROM product_image WHERE id=$1 AND product_id=$2", imageID, productID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := s.recomputeQuality(ctx, productID); err != nil {
return err
}
_ = s.writeAudit(ctx, actor, "delete_image", "product", &productID, []string{"image"}, map[string]string{"image_id": imageID}, nil)
return nil
}
// ---------- msrp ----------
// MSRPInput is the payload for adding an MSRP snapshot.
type MSRPInput struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Region string `json:"region"`
EffectiveDate *string `json:"effective_date"`
SourceURL *string `json:"source_url"`
Note *string `json:"note"`
}
// AddMSRP inserts a suggested-retail-price snapshot.
func (s *Store) AddMSRP(ctx context.Context, productID, actor string, in MSRPInput) (*MSRP, error) {
if in.Currency == "" {
in.Currency = "CNY"
}
if in.Region == "" {
in.Region = "CN"
}
var srcID string
_ = s.pool.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&srcID)
var m MSRP
err := s.pool.QueryRow(ctx, `
INSERT INTO product_msrp (product_id, amount, currency, region, source_id, source_url, effective_date, note)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, amount, currency, region, effective_date::text, source_url, note`,
productID, in.Amount, in.Currency, in.Region, srcID, in.SourceURL, in.EffectiveDate, in.Note).
Scan(&m.ID, &m.Amount, &m.Currency, &m.Region, &m.EffectiveDate, &m.SourceURL, &m.Note)
if err != nil {
return nil, err
}
_ = s.writeAudit(ctx, actor, "add_msrp", "product", &productID, []string{"msrp"}, nil, m)
return &m, nil
}
// DeleteMSRP removes an MSRP snapshot.
func (s *Store) DeleteMSRP(ctx context.Context, productID, msrpID, actor string) error {
ct, err := s.pool.Exec(ctx, "DELETE FROM product_msrp WHERE id=$1 AND product_id=$2", msrpID, productID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
_ = s.writeAudit(ctx, actor, "delete_msrp", "product", &productID, []string{"msrp"}, map[string]string{"msrp_id": msrpID}, nil)
return nil
}
// ---------- dictionaries ----------
// Brand is a brand option for the edit form and the management view.
type Brand struct {
ID string `json:"id"`
Name string `json:"name"`
ProductCount int `json:"product_count"`
}
// ListBrands returns all brands ordered by name, with the number of products
// referencing each one.
func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) {
rows, err := s.pool.Query(ctx, `
SELECT b.id, b.name,
(SELECT count(*) FROM product p WHERE p.brand_id = b.id) AS product_count
FROM brand b
ORDER BY b.name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Brand{}
for rows.Next() {
var b Brand
if err := rows.Scan(&b.ID, &b.Name, &b.ProductCount); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// Category is a category option for the edit form and the management view.
type Category struct {
ID string `json:"id"`
NameZH string `json:"name_zh"`
NameEN *string `json:"name_en"`
Path string `json:"path"`
Level int `json:"level"`
ParentID *string `json:"parent_id"`
GPCBrickCode *string `json:"gpc_brick_code"`
ProductCount int `json:"product_count"`
}
// ListCategories returns the full category tree (path order) with the number of
// products directly assigned to each node.
func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.name_zh, c.name_en, c.path::text, c.level, c.parent_id::text,
c.gpc_brick_code,
(SELECT count(*) FROM product p WHERE p.category_id = c.id) AS product_count
FROM category c
ORDER BY c.path`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Category{}
for rows.Next() {
var c Category
if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level,
&c.ParentID, &c.GPCBrickCode, &c.ProductCount); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ---------- audit ----------
// AuditEntry is one audit-log row for the history view.
type AuditEntry struct {
ID string `json:"id"`
Actor string `json:"actor"`
Action string `json:"action"`
Fields []string `json:"fields"`
CreatedAt string `json:"created_at"`
}
// ListAudit returns audit history for one product, newest first.
func (s *Store) ListAudit(ctx context.Context, productID string, limit int) ([]AuditEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, actor, action, fields, created_at::text
FROM audit_log WHERE entity='product' AND entity_id=$1
ORDER BY created_at DESC LIMIT $2`, productID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AuditEntry{}
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Fields, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}