feat(barcode): admin barcode CRUD endpoints + UI; show extra barcodes publicly
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Go (api) (pull_request) Failing after 15s
CI / Migrations (postgres) (pull_request) Failing after 11m35s

Wire the multi-barcode store layer to HTTP and the operator console:

- adminhandler: add POST /products/{id}/barcodes, DELETE
  /products/{id}/barcodes/{barcodeID}, and POST .../primary. A barcode
  owned by another product returns 409 with the conflicting product
  (gtin/product_id/product_name); an invalid GTIN returns 400.
- admin-frontend: BarcodesCard on the product detail page lists all
  barcodes (primary starred), adds with type/pack-level/region, sets
  primary, and deletes; audit labels for the new actions.
- public-frontend: product detail surfaces non-primary barcodes so a
  case/region code resolves and is visible to consumers.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
novaalphastrikeomegaz663
2026-06-20 07:06:07 +00:00
parent 1d5f775d33
commit 98b5f1575d
6 changed files with 286 additions and 0 deletions
+66
View File
@@ -15,6 +15,7 @@ import (
"github.com/baicai2026-baicai/goods/api/internal/adminstore"
"github.com/baicai2026-baicai/goods/api/internal/auth"
"github.com/baicai2026-baicai/goods/api/internal/gtin"
)
// Handler holds the admin dependencies.
@@ -62,6 +63,9 @@ func (h *Handler) Router() http.Handler {
r.Delete("/api/products/{id}/images/{imageID}", h.DeleteImage)
r.Post("/api/products/{id}/msrp", h.AddMSRP)
r.Delete("/api/products/{id}/msrp/{msrpID}", h.DeleteMSRP)
r.Post("/api/products/{id}/barcodes", h.AddBarcode)
r.Delete("/api/products/{id}/barcodes/{barcodeID}", h.DeleteBarcode)
r.Post("/api/products/{id}/barcodes/{barcodeID}/primary", h.SetPrimaryBarcode)
r.Get("/api/brands", h.ListBrands)
r.Get("/api/categories", h.ListCategories)
@@ -231,6 +235,68 @@ func (h *Handler) DeleteMSRP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ---------- barcodes ----------
// AddBarcode validates and attaches a barcode to a product. A code already
// owned by another product yields 409 with the conflicting product so the
// operator can de-duplicate; an invalid GTIN yields 400.
func (h *Handler) AddBarcode(w http.ResponseWriter, r *http.Request) {
var in adminstore.BarcodeInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
b, err := h.store.AddBarcode(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in)
if h.handleBarcodeErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, b)
}
// DeleteBarcode removes a barcode; a primary one is replaced automatically.
func (h *Handler) DeleteBarcode(w http.ResponseWriter, r *http.Request) {
err := h.store.DeleteBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context()))
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// SetPrimaryBarcode marks one barcode primary and mirrors it to product.gtin.
func (h *Handler) SetPrimaryBarcode(w http.ResponseWriter, r *http.Request) {
b, err := h.store.SetPrimaryBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context()))
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, b)
}
// handleBarcodeErr maps barcode-specific errors (GTIN validation, ownership
// conflict) to client-facing statuses, falling back to handleErr otherwise.
func (h *Handler) handleBarcodeErr(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
var conflict *adminstore.ConflictError
if errors.As(err, &conflict) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": map[string]string{"code": "barcode_conflict", "message": err.Error()},
"conflict": map[string]string{
"gtin": conflict.GTIN,
"product_id": conflict.ProductID,
"product_name": conflict.ProductName,
},
})
return true
}
if errors.Is(err, gtin.ErrEmpty) || errors.Is(err, gtin.ErrFormat) ||
errors.Is(err, gtin.ErrCheck) || errors.Is(err, gtin.ErrRestricted) {
writeError(w, http.StatusBadRequest, "invalid_gtin", err.Error())
return true
}
return h.handleErr(w, err)
}
// ListBrands returns brand options.
func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) {
items, err := h.store.ListBrands(r.Context())