416 lines
12 KiB
Go
416 lines
12 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
|
|
}
|
|
|
|
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.
|
|
type Brand struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// ListBrands returns all brands ordered by name.
|
|
func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) {
|
|
rows, err := s.pool.Query(ctx, "SELECT id, name FROM brand ORDER BY 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); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Category is a category option for the edit form.
|
|
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"`
|
|
}
|
|
|
|
// ListCategories returns the full category tree.
|
|
func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
"SELECT id, name_zh, name_en, path::text, level FROM category ORDER BY 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); 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()
|
|
}
|