package adminstore import ( "context" "errors" "github.com/jackc/pgx/v5" "github.com/baicai2026-baicai/goods/api/internal/gtin" ) // Barcode is one GS1 trade item number attached to a product. type Barcode struct { ID string `json:"id"` GTIN string `json:"gtin"` GTINType string `json:"gtin_type"` PackLevel string `json:"pack_level"` Region *string `json:"region"` IsPrimary bool `json:"is_primary"` } // BarcodeInput is the payload for attaching a barcode to a product. type BarcodeInput struct { GTIN string `json:"gtin"` GTINType string `json:"gtin_type"` PackLevel string `json:"pack_level"` Region *string `json:"region"` IsPrimary bool `json:"is_primary"` } // ConflictError signals that a barcode is already attached to another product, // so the operator must de-duplicate instead of creating a clash. type ConflictError struct { GTIN string ProductID string ProductName string } func (e *ConflictError) Error() string { return "条码已被其他商品占用:" + e.GTIN } func validPackLevel(p string) string { switch p { case "each", "case", "pallet": return p default: return "each" } } func validGTINType(t, normalized string) string { switch t { case "EAN8", "UPC", "EAN13", "ITF14", "GTIN14": return t default: return gtin.InferType(normalized) } } func (s *Store) listBarcodes(ctx context.Context, productID string) ([]Barcode, error) { rows, err := s.pool.Query(ctx, `SELECT id, 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.ID, &b.GTIN, &b.GTINType, &b.PackLevel, &b.Region, &b.IsPrimary); err != nil { return nil, err } out = append(out, b) } return out, rows.Err() } // barcodeOwner returns the product currently owning a barcode, if any. func barcodeOwner(ctx context.Context, q pgx.Tx, code string) (productID, productName string, found bool, err error) { err = q.QueryRow(ctx, `SELECT pb.product_id, p.name FROM product_barcode pb JOIN product p ON p.id = pb.product_id WHERE pb.gtin = $1`, code). Scan(&productID, &productName) if errors.Is(err, pgx.ErrNoRows) { return "", "", false, nil } if err != nil { return "", "", false, err } return productID, productName, true, nil } // AddBarcode validates and attaches a barcode to a product, recording audit. // A barcode already owned by another product yields a *ConflictError. func (s *Store) AddBarcode(ctx context.Context, productID, actor string, in BarcodeInput) (*Barcode, error) { code, err := gtin.Normalize(in.GTIN) if err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) // Product must exist. var exists bool if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM product WHERE id=$1)", productID).Scan(&exists); err != nil { return nil, err } if !exists { return nil, ErrNotFound } // Globally unique: a barcode owned by any product (this one included) // is a conflict the operator must resolve by de-duplicating. if owner, name, found, err := barcodeOwner(ctx, tx, code); err != nil { return nil, err } else if found { return nil, &ConflictError{GTIN: code, ProductID: owner, ProductName: name} } // Make this the primary barcode when requested or when none exists yet. makePrimary := in.IsPrimary if !makePrimary { var hasPrimary bool if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM product_barcode WHERE product_id=$1 AND is_primary)", productID). Scan(&hasPrimary); err != nil { return nil, err } makePrimary = !hasPrimary } if makePrimary { if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=false WHERE product_id=$1 AND is_primary", productID); err != nil { return nil, err } } srcID, _ := s.manualSourceID(ctx, tx) var srcArg any if srcID != "" { srcArg = srcID } var b Barcode err = tx.QueryRow(ctx, ` INSERT INTO product_barcode (product_id, gtin, gtin_type, pack_level, region, is_primary, source_id) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, gtin, gtin_type, pack_level, region, is_primary`, productID, code, validGTINType(in.GTINType, code), validPackLevel(in.PackLevel), in.Region, makePrimary, srcArg). Scan(&b.ID, &b.GTIN, &b.GTINType, &b.PackLevel, &b.Region, &b.IsPrimary) if err != nil { return nil, err } if makePrimary { if _, err := tx.Exec(ctx, "UPDATE product SET gtin=$2 WHERE id=$1", productID, code); err != nil { return nil, err } } if _, err := s.recomputeQualityTx(ctx, tx, productID); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } _ = s.writeAudit(ctx, actor, "add_barcode", "product", &productID, []string{"gtin"}, nil, b) return &b, nil } // DeleteBarcode removes a barcode; if it was primary, another is promoted. func (s *Store) DeleteBarcode(ctx context.Context, productID, barcodeID, actor string) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var code string var wasPrimary bool err = tx.QueryRow(ctx, "DELETE FROM product_barcode WHERE id=$1 AND product_id=$2 RETURNING gtin, is_primary", barcodeID, productID).Scan(&code, &wasPrimary) if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } if err != nil { return err } if wasPrimary { var newID, newGTIN string e := tx.QueryRow(ctx, "SELECT id, gtin FROM product_barcode WHERE product_id=$1 ORDER BY gtin LIMIT 1", productID). Scan(&newID, &newGTIN) if e == nil { if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=true WHERE id=$1", newID); err != nil { return err } if _, err := tx.Exec(ctx, "UPDATE product SET gtin=$2 WHERE id=$1", productID, newGTIN); err != nil { return err } } else if errors.Is(e, pgx.ErrNoRows) { if _, err := tx.Exec(ctx, "UPDATE product SET gtin=NULL WHERE id=$1", productID); err != nil { return err } } else { return e } } if _, err := s.recomputeQualityTx(ctx, tx, productID); err != nil { return err } if err := tx.Commit(ctx); err != nil { return err } _ = s.writeAudit(ctx, actor, "delete_barcode", "product", &productID, []string{"gtin"}, map[string]string{"gtin": code}, nil) return nil } // SetPrimaryBarcode marks one barcode primary and mirrors it to product.gtin. func (s *Store) SetPrimaryBarcode(ctx context.Context, productID, barcodeID, actor string) (*Barcode, error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var code string err = tx.QueryRow(ctx, "SELECT gtin FROM product_barcode WHERE id=$1 AND product_id=$2", barcodeID, productID).Scan(&code) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=false WHERE product_id=$1 AND is_primary", productID); err != nil { return nil, err } if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=true WHERE id=$1", barcodeID); err != nil { return nil, err } if _, err := tx.Exec(ctx, "UPDATE product SET gtin=$2 WHERE id=$1", productID, code); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } _ = s.writeAudit(ctx, actor, "set_primary_barcode", "product", &productID, []string{"gtin"}, nil, map[string]string{"gtin": code}) bcs, err := s.listBarcodes(ctx, productID) if err != nil { return nil, err } for i := range bcs { if bcs[i].ID == barcodeID { return &bcs[i], nil } } return nil, ErrNotFound }