fix
This commit is contained in:
@@ -1,26 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Classic A1 Postman / Elkotex fixture EANs — must never live on Platform Demo.
|
||||
var a1FixtureEANs = []string{
|
||||
"5905575903198",
|
||||
"6970995789942",
|
||||
}
|
||||
|
||||
type categoryBackfillResult struct {
|
||||
ProcessedUpdated int64
|
||||
ProcessedInserted int64
|
||||
@@ -34,204 +23,38 @@ type categoryBackfillResult struct {
|
||||
MappedWithoutCat int
|
||||
}
|
||||
|
||||
// backfillMappedCategoriesFromProcessed copies processed_products.category into
|
||||
// raw_products.mapped_data.category for A1 only. Delegates to processing.
|
||||
// a1FixtureEANs aliases processing.A1FixtureEANs for seed-a1 tests.
|
||||
var a1FixtureEANs = processing.A1FixtureEANs
|
||||
|
||||
func backfillMappedCategoriesFromProcessed(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID) (int64, error) {
|
||||
return processing.BackfillMappedCategoriesFromProcessed(ctx, pg, companyID)
|
||||
}
|
||||
|
||||
// backfillCategoriesFromMySQLDump streams dump processed_products for the A1
|
||||
// legacy company and writes product_id (GTIN) → category onto A1 Postgres
|
||||
// mapped_data.category (and updates any existing processed_products.category).
|
||||
// It does not insert processed rows — A1 demo seed stays at processed=0.
|
||||
func backfillCategoriesFromMySQLDump(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, dumpPath string) (categoryBackfillResult, error) {
|
||||
var out categoryBackfillResult
|
||||
legacyCompany := billing.A1LegacyCompanyID
|
||||
var legacy string
|
||||
_ = pg.QueryRow(ctx, `SELECT COALESCE(legacy_company_id::text, '') FROM companies WHERE id = $1`, companyID).Scan(&legacy)
|
||||
if legacy != "" {
|
||||
legacyCompany = legacy
|
||||
}
|
||||
|
||||
f, err := os.Open(dumpPath)
|
||||
res, err := processing.BackfillCategoriesFromMySQLDump(ctx, pg, companyID, dumpPath)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("open mysql dump: %w", err)
|
||||
return categoryBackfillResult{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
byGTIN, err := scanA1ProcessedCategories(f, legacyCompany)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.DumpPairs = len(byGTIN)
|
||||
if len(byGTIN) == 0 {
|
||||
return out, fmt.Errorf("no A1 processed_products categories for legacy %s in dump", legacyCompany)
|
||||
}
|
||||
log.Printf("dump: %d A1 gtin→category pairs", len(byGTIN))
|
||||
|
||||
gtins := make([]string, 0, len(byGTIN))
|
||||
cats := make([]string, 0, len(byGTIN))
|
||||
for g, c := range byGTIN {
|
||||
gtins = append(gtins, g)
|
||||
cats = append(cats, c)
|
||||
}
|
||||
|
||||
ct, err := pg.Exec(ctx, `
|
||||
UPDATE processed_products p
|
||||
SET category = v.category,
|
||||
updated_at = now()
|
||||
FROM unnest($2::text[], $3::text[]) AS v(gtin, category)
|
||||
WHERE p.company_id = $1
|
||||
AND p.product_id = v.gtin
|
||||
AND COALESCE(NULLIF(trim(v.category), ''), '') <> ''
|
||||
AND (
|
||||
COALESCE(NULLIF(trim(p.category), ''), '') = ''
|
||||
OR lower(trim(p.category)) = 'none'
|
||||
OR p.category IS DISTINCT FROM v.category
|
||||
)`, companyID, gtins, cats)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("update processed category from dump: %w", err)
|
||||
}
|
||||
out.ProcessedUpdated = ct.RowsAffected()
|
||||
|
||||
// A1 demo seed keeps processed=0. Do not INSERT processed rows from the dump —
|
||||
// only refresh mapped_data.category (and any existing processed rows if present).
|
||||
ct, err = pg.Exec(ctx, `
|
||||
UPDATE raw_products r
|
||||
SET mapped_data = jsonb_set(
|
||||
COALESCE(r.mapped_data, '{}'::jsonb),
|
||||
'{category}',
|
||||
to_jsonb(v.category),
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
FROM unnest($2::text[], $3::text[]) AS v(gtin, category)
|
||||
WHERE r.company_id = $1
|
||||
AND r.gtin = v.gtin
|
||||
AND COALESCE(NULLIF(trim(v.category), ''), '') <> ''
|
||||
AND (
|
||||
COALESCE(NULLIF(trim(r.mapped_data->>'category'), ''), '') = ''
|
||||
OR r.mapped_data->>'category' IS DISTINCT FROM v.category
|
||||
)`, companyID, gtins, cats)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("update mapped category from dump: %w", err)
|
||||
}
|
||||
out.MappedUpdated = ct.RowsAffected()
|
||||
|
||||
n, err := backfillMappedCategoriesFromProcessed(ctx, pg, companyID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.MappedUpdated += n
|
||||
if err := fillCategoryCoverage(ctx, pg, companyID, &out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
return categoryBackfillResult{
|
||||
ProcessedUpdated: res.ProcessedUpdated,
|
||||
ProcessedInserted: res.ProcessedInserted,
|
||||
MappedUpdated: res.MappedUpdated,
|
||||
DumpPairs: res.DumpPairs,
|
||||
PurgedOtherRaw: res.PurgedOtherRaw,
|
||||
PurgedOtherPP: res.PurgedOtherPP,
|
||||
ProcessedWithCat: res.ProcessedWithCat,
|
||||
ProcessedWithoutCat: res.ProcessedWithoutCat,
|
||||
MappedWithCat: res.MappedWithCat,
|
||||
MappedWithoutCat: res.MappedWithoutCat,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fillCategoryCoverage(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, out *categoryBackfillResult) error {
|
||||
err := pg.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(NULLIF(trim(category), ''), '') <> ''
|
||||
AND lower(trim(category)) <> 'none'
|
||||
),
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(NULLIF(trim(category), ''), '') = ''
|
||||
OR lower(trim(category)) = 'none'
|
||||
)
|
||||
FROM processed_products
|
||||
WHERE company_id = $1`, companyID).Scan(&out.ProcessedWithCat, &out.ProcessedWithoutCat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count processed categories: %w", err)
|
||||
}
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') <> ''
|
||||
),
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') = ''
|
||||
)
|
||||
FROM raw_products
|
||||
WHERE company_id = $1`, companyID).Scan(&out.MappedWithCat, &out.MappedWithoutCat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count mapped categories: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgeA1FixtureEANsFromOtherTenants deletes the Postman Elkotex fixture EANs
|
||||
// from every company except A1 (Platform Demo must not mirror A1 fixtures).
|
||||
func purgeA1FixtureEANsFromOtherTenants(ctx context.Context, pg *pgxpool.Pool, a1CompanyID uuid.UUID) (rawN, ppN int64, err error) {
|
||||
ct, err := pg.Exec(ctx, `
|
||||
DELETE FROM processing_job_products pjp
|
||||
WHERE pjp.raw_product_id IN (
|
||||
SELECT id FROM raw_products
|
||||
WHERE company_id <> $1 AND gtin = ANY($2::text[])
|
||||
)
|
||||
OR pjp.processed_product_id IN (
|
||||
SELECT id FROM processed_products
|
||||
WHERE company_id <> $1 AND product_id = ANY($2::text[])
|
||||
)`, a1CompanyID, a1FixtureEANs)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("purge fixture job products: %w", err)
|
||||
}
|
||||
_ = ct
|
||||
|
||||
ct, err = pg.Exec(ctx, `
|
||||
DELETE FROM processed_products
|
||||
WHERE company_id <> $1 AND product_id = ANY($2::text[])`, a1CompanyID, a1FixtureEANs)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("purge fixture processed: %w", err)
|
||||
}
|
||||
ppN = ct.RowsAffected()
|
||||
|
||||
ct, err = pg.Exec(ctx, `
|
||||
DELETE FROM raw_products
|
||||
WHERE company_id <> $1 AND gtin = ANY($2::text[])`, a1CompanyID, a1FixtureEANs)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("purge fixture raw: %w", err)
|
||||
}
|
||||
rawN = ct.RowsAffected()
|
||||
return rawN, ppN, nil
|
||||
return processing.PurgeA1FixtureEANsFromOtherTenants(ctx, pg, a1CompanyID)
|
||||
}
|
||||
|
||||
func scanA1ProcessedCategories(r io.Reader, legacyCompany string) (map[string]string, error) {
|
||||
br := bufio.NewReaderSize(r, 1<<20)
|
||||
inTable := false
|
||||
out := make(map[string]string, 4096)
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if len(line) > 0 {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "INSERT INTO `processed_products`") ||
|
||||
strings.HasPrefix(trimmed, "INSERT INTO processed_products") {
|
||||
inTable = true
|
||||
} else if inTable && strings.HasPrefix(trimmed, "CREATE TABLE") {
|
||||
break
|
||||
} else if inTable && strings.HasPrefix(trimmed, "INSERT INTO `") &&
|
||||
!strings.Contains(trimmed, "processed_products") {
|
||||
break
|
||||
} else if inTable && looksLikeTupleLine(line) && strings.Contains(line, legacyCompany) {
|
||||
fields := parseMySQLTupleFieldsN(line, 6)
|
||||
if len(fields) >= 5 {
|
||||
gtin := strings.TrimSpace(fields[2])
|
||||
cat := strings.TrimSpace(fields[4])
|
||||
if gtin != "" && cat != "" && !strings.EqualFold(cat, "NULL") {
|
||||
out[gtin] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return processing.ScanA1ProcessedCategories(r, legacyCompany)
|
||||
}
|
||||
|
||||
func logCategoryBackfillResult(res categoryBackfillResult, source string) {
|
||||
|
||||
@@ -4,61 +4,19 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// resolveMySQLDumpPath picks an explicit path, else the first readable candidate
|
||||
// under common local locations documented in scripts/seed/README.txt.
|
||||
// resolveMySQLDumpPath delegates to processing (shared with admin Sync A1).
|
||||
func resolveMySQLDumpPath(explicit string) string {
|
||||
if p := strings.TrimSpace(explicit); p != "" {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
log.Printf("warning: mysql dump not found at %q — trying auto-detect", p)
|
||||
}
|
||||
for _, c := range mysqlDumpCandidates() {
|
||||
if strings.TrimSpace(explicit) != "" && filepath.Clean(c) == filepath.Clean(strings.TrimSpace(explicit)) {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return processing.ResolveMySQLDumpPath(explicit)
|
||||
}
|
||||
|
||||
func mysqlDumpCandidates() []string {
|
||||
var out []string
|
||||
if v := strings.TrimSpace(os.Getenv("SEED_A1_MYSQL_DUMP")); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
names := []string{
|
||||
"descrybe_new (1).sql",
|
||||
"descrybe_new.sql",
|
||||
"descrybe_new(1).sql",
|
||||
}
|
||||
if home != "" {
|
||||
for _, n := range names {
|
||||
out = append(out, filepath.Join(home, "Downloads", n))
|
||||
out = append(out, filepath.Join(home, "downloads", n))
|
||||
}
|
||||
}
|
||||
// Repo-relative guesses (cwd may be apps/api or repo root).
|
||||
for _, n := range names {
|
||||
out = append(out,
|
||||
n,
|
||||
filepath.Join("..", "..", n),
|
||||
filepath.Join("scripts", "seed", n),
|
||||
filepath.Join("..", "..", "scripts", "seed", n),
|
||||
)
|
||||
}
|
||||
return out
|
||||
return processing.MySQLDumpCandidates()
|
||||
}
|
||||
|
||||
type mappedCoverage struct {
|
||||
|
||||
@@ -41,9 +41,10 @@
|
||||
// go run ./cmd/seed-a1 -mode backfill-categories -mysql-dump path/to/descrybe_new.sql
|
||||
// go run ./cmd/seed-a1 -mode backfill-attributes
|
||||
//
|
||||
// DATABASE_URL / -postgres required.
|
||||
// DATABASE_URL / -postgres required (also loaded from monorepo-root .env via config.LoadDotEnv).
|
||||
// Day-to-day: `npm run seed:a1` (reimport + category prompts + mapped category backfill). Maintainer snapshot: `npm run seed:a1:export`.
|
||||
// recover-jobs is opt-in only and will reintroduce job history.
|
||||
// Prefer admin UI Sync A1 on the company row (uses API DATABASE_URL; no CLI env needed).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -61,6 +62,7 @@ import (
|
||||
"compress/gzip"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -82,6 +84,8 @@ type tableSpec struct {
|
||||
}
|
||||
|
||||
func main() {
|
||||
config.LoadDotEnv()
|
||||
|
||||
postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL (DATABASE_URL)")
|
||||
mode := flag.String("mode", "reimport", "export | reimport | recover-jobs | apply-category-prompts | backfill-categories | backfill-attributes")
|
||||
file := flag.String("file", "", "gzipped seed archive path (required for export/reimport)")
|
||||
@@ -93,7 +97,7 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
if strings.TrimSpace(*postgresURL) == "" {
|
||||
log.Fatal("-postgres / DATABASE_URL is required")
|
||||
log.Fatal("-postgres / DATABASE_URL is required (export it, pass -postgres, or put DATABASE_URL in monorepo-root .env — same as the API; prefer admin Sync A1)")
|
||||
}
|
||||
companyID, err := uuid.Parse(strings.TrimSpace(*company))
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -419,65 +420,15 @@ func dumpSectionEnded(line, table string) bool {
|
||||
}
|
||||
|
||||
func looksLikeTupleLine(line string) bool {
|
||||
s := strings.TrimLeft(line, " \t")
|
||||
return strings.HasPrefix(s, "(")
|
||||
return processing.LooksLikeMySQLTupleLine(line)
|
||||
}
|
||||
|
||||
func parseMySQLTupleFields(line string) []string {
|
||||
return parseMySQLTupleFieldsN(line, 0)
|
||||
return processing.ParseMySQLTupleFields(line)
|
||||
}
|
||||
|
||||
// parseMySQLTupleFieldsN parses up to maxFields (0 = all) from the first (...) tuple on the line.
|
||||
func parseMySQLTupleFieldsN(line string, maxFields int) []string {
|
||||
start := strings.Index(line, "(")
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
body := line[start+1:]
|
||||
var out []string
|
||||
for i := 0; i < len(body); {
|
||||
if maxFields > 0 && len(out) >= maxFields {
|
||||
break
|
||||
}
|
||||
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == ',') {
|
||||
i++
|
||||
}
|
||||
if i >= len(body) || body[i] == ')' {
|
||||
break
|
||||
}
|
||||
if body[i] == '\'' {
|
||||
i++
|
||||
var b strings.Builder
|
||||
for i < len(body) {
|
||||
ch := body[i]
|
||||
if ch == '\\' && i+1 < len(body) {
|
||||
b.WriteByte(body[i+1])
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if ch == '\'' {
|
||||
if i+1 < len(body) && body[i+1] == '\'' {
|
||||
b.WriteByte('\'')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
break
|
||||
}
|
||||
b.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
out = append(out, b.String())
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
for j < len(body) && body[j] != ',' && body[j] != ')' {
|
||||
j++
|
||||
}
|
||||
out = append(out, strings.TrimSpace(body[i:j]))
|
||||
i = j
|
||||
}
|
||||
return out
|
||||
return processing.ParseMySQLTupleFieldsN(line, maxFields)
|
||||
}
|
||||
|
||||
func parseDumpJobID(raw string) (uuid.UUID, error) {
|
||||
|
||||
@@ -12,21 +12,20 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// handleAdminFixCompanyCatalog runs in-place Fix A1 / catalog hygiene for one company.
|
||||
// Single route: POST /api/admin/companies/{id}/fix-catalog (no separate fix-a1).
|
||||
// Delegates to processing.FixCompanyCatalog, which:
|
||||
// 1. Ensures category_attributes links (orphan purge only)
|
||||
// 2. RepairCompanyCategoryEnhancePrompts (same map as RepairA1DemoCategoryEnhancePrompts)
|
||||
// 3. Re-applies product_enhance BuiltInDefaults when AIPrompts is set
|
||||
// 4–7. FixCatalogHygieneWithIDs + attribute sanitize + reprocess sample
|
||||
// handleAdminSyncCompanyA1 runs dump category backfill (when dump is on the API
|
||||
// host filesystem) plus FixCompanyCatalog hygiene. Does not wipe/reimport.
|
||||
//
|
||||
// Routes (same handler):
|
||||
//
|
||||
// POST /api/admin/companies/{id}/sync-a1
|
||||
// POST /api/admin/companies/{id}/fix-catalog (compat alias)
|
||||
//
|
||||
// Body: confirm=true required; backfill_categories (default true);
|
||||
// reprocess_sample_limit (default 25, max 200, 0 = counts only).
|
||||
// May target A1 in place with confirm — prefer Platform Demo when unsure.
|
||||
// Refuses system company. Does not use A1 as a clone destination.
|
||||
// reprocess_sample_limit (default 25, max 200); mysql_dump optional path;
|
||||
// skip_dump_backfill (default false).
|
||||
//
|
||||
// Flash (UI): result.prompts / hashes / categories → flash.admin.fixA1Success.
|
||||
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
// Flash (UI): result → flash.admin.syncA1Success.
|
||||
func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Pool == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
@@ -42,6 +41,8 @@ func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Req
|
||||
Confirm bool `json:"confirm"`
|
||||
BackfillCategories *bool `json:"backfill_categories"`
|
||||
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
|
||||
MySQLDump string `json:"mysql_dump"`
|
||||
SkipDumpBackfill bool `json:"skip_dump_backfill"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
@@ -53,7 +54,7 @@ func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
if platformsettings.IsSystemCompany(companyID) {
|
||||
Error(w, http.StatusBadRequest, "cannot fix the platform settings company")
|
||||
Error(w, http.StatusBadRequest, "cannot sync the platform settings company")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,26 +81,40 @@ func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Req
|
||||
sampleLimit = 200
|
||||
}
|
||||
|
||||
result, err := processing.FixCompanyCatalog(
|
||||
result, err := processing.SyncCompanyA1(
|
||||
r.Context(),
|
||||
s.Pool,
|
||||
companyID,
|
||||
name,
|
||||
billing.IsA1CohortCompany(legacy, name),
|
||||
processing.FixCompanyCatalogOpts{
|
||||
processing.SyncCompanyA1Opts{
|
||||
FixCompanyCatalogOpts: processing.FixCompanyCatalogOpts{
|
||||
BackfillCategories: backfill,
|
||||
ReprocessSampleLimit: sampleLimit,
|
||||
AIPrompts: s.AIPrompts,
|
||||
},
|
||||
MySQLDumpPath: strings.TrimSpace(body.MySQLDump),
|
||||
SkipDumpBackfill: body.SkipDumpBackfill,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not fix catalog", err, catalog.ClientError)
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not sync A1 catalog", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
|
||||
note := "Synced in place (dump categories when available + hygiene); reprocess recommended products manually (no mass reprocess)."
|
||||
if !result.DumpFound {
|
||||
note = "Hygiene completed without dump backfill. Place descrybe_new.sql on the API host (scripts/seed/ or SEED_A1_MYSQL_DUMP) for mapped category coverage from the legacy dump."
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"result": result,
|
||||
"note": "Catalog was repaired in place; reprocess recommended products manually (no mass reprocess).",
|
||||
"note": note,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminFixCompanyCatalog is a compat alias of Sync A1 (same behavior).
|
||||
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleAdminSyncCompanyA1(w, r)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleAdminSyncCompanyA1NilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/"+uuid.NewString()+"/sync-a1", strings.NewReader(`{"confirm":true}`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminSyncCompanyA1(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminFixCompanyCatalogNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
@@ -26,8 +37,6 @@ func TestHandleAdminFixCompanyCatalogNilPool(t *testing.T) {
|
||||
|
||||
func TestHandleAdminFixCompanyCatalogRequiresConfirm(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Pool nil short-circuits before confirm — use non-nil Pool stub via missing company path is harder.
|
||||
// Confirm gate is covered when Pool is set; here we only assert invalid uuid.
|
||||
s := &Server{Pool: nil}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/not-a-uuid/fix-catalog", strings.NewReader(`{"confirm":false}`))
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -37,8 +46,58 @@ func TestHandleAdminFixCompanyCatalogRequiresConfirm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterAdminFixCatalogMounted locks POST /api/admin/companies/{id}/fix-catalog
|
||||
// after session + CSRF + platform-admin (503 with nil Pool), not chi 404.
|
||||
func TestRouterAdminSyncA1Mounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := s.Router()
|
||||
path := "/api/admin/companies/" + uuid.NewString() + "/sync-a1"
|
||||
csrf := csrfCookieForSession(t, h, sm, token)
|
||||
|
||||
mounted := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"confirm":true}`))
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
req.AddCookie(csrf)
|
||||
req.Header.Set("X-CSRF-Token", csrf.Value)
|
||||
h.ServeHTTP(mounted, req)
|
||||
if mounted.Code == http.StatusNotFound {
|
||||
t.Fatalf("route not mounted: status=404 body=%s", mounted.Body.String())
|
||||
}
|
||||
if mounted.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 (nil pool) body=%s", mounted.Code, mounted.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterAdminFixCatalogMounted locks POST …/fix-catalog (compat alias).
|
||||
func TestRouterAdminFixCatalogMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
|
||||
@@ -391,7 +391,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
|
||||
r.Get("/companies", s.handleAdminListCompanies)
|
||||
r.Post("/companies/{id}/clone-catalog", s.handleAdminCloneCompanyCatalog)
|
||||
r.Post("/companies/{id}/fix-catalog", s.handleAdminFixCompanyCatalog)
|
||||
r.Post("/companies/{id}/sync-a1", s.handleAdminSyncCompanyA1)
|
||||
r.Post("/companies/{id}/fix-catalog", s.handleAdminFixCompanyCatalog) // compat alias → Sync A1
|
||||
r.Get("/readiness", s.handleAdminReadiness)
|
||||
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
||||
r.Get("/analytics", s.handleAdminAnalytics)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package processing
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Classic A1 Postman / Elkotex fixture EANs — must never live on Platform Demo.
|
||||
var A1FixtureEANs = []string{
|
||||
"5905575903198",
|
||||
"6970995789942",
|
||||
}
|
||||
|
||||
// DumpCategoryBackfillResult is the outcome of BackfillCategoriesFromMySQLDump.
|
||||
type DumpCategoryBackfillResult struct {
|
||||
ProcessedUpdated int64 `json:"processed_updated"`
|
||||
ProcessedInserted int64 `json:"processed_inserted"`
|
||||
MappedUpdated int64 `json:"mapped_updated"`
|
||||
DumpPairs int `json:"dump_pairs"`
|
||||
PurgedOtherRaw int64 `json:"purged_other_raw"`
|
||||
PurgedOtherPP int64 `json:"purged_other_pp"`
|
||||
ProcessedWithCat int `json:"processed_with_cat"`
|
||||
ProcessedWithoutCat int `json:"processed_without_cat"`
|
||||
MappedWithCat int `json:"mapped_with_cat"`
|
||||
MappedWithoutCat int `json:"mapped_without_cat"`
|
||||
}
|
||||
|
||||
// BackfillCategoriesFromMySQLDump streams dump processed_products for the company
|
||||
// legacy id and writes product_id (GTIN) → category onto Postgres mapped_data.category
|
||||
// (and updates any existing processed_products.category). It does not insert
|
||||
// processed rows — A1 demo seed stays at processed=0.
|
||||
func BackfillCategoriesFromMySQLDump(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, dumpPath string) (DumpCategoryBackfillResult, error) {
|
||||
var out DumpCategoryBackfillResult
|
||||
if pg == nil {
|
||||
return out, fmt.Errorf("dump category backfill: nil pool")
|
||||
}
|
||||
if companyID == uuid.Nil {
|
||||
return out, fmt.Errorf("dump category backfill: empty company id")
|
||||
}
|
||||
legacyCompany := billing.A1LegacyCompanyID
|
||||
var legacy string
|
||||
_ = pg.QueryRow(ctx, `SELECT COALESCE(legacy_company_id::text, '') FROM companies WHERE id = $1`, companyID).Scan(&legacy)
|
||||
if legacy != "" {
|
||||
legacyCompany = legacy
|
||||
}
|
||||
|
||||
f, err := os.Open(dumpPath)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("open mysql dump: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
byGTIN, err := ScanA1ProcessedCategories(f, legacyCompany)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.DumpPairs = len(byGTIN)
|
||||
if len(byGTIN) == 0 {
|
||||
return out, fmt.Errorf("no A1 processed_products categories for legacy %s in dump", legacyCompany)
|
||||
}
|
||||
log.Printf("dump: %d A1 gtin→category pairs", len(byGTIN))
|
||||
|
||||
gtins := make([]string, 0, len(byGTIN))
|
||||
cats := make([]string, 0, len(byGTIN))
|
||||
for g, c := range byGTIN {
|
||||
gtins = append(gtins, g)
|
||||
cats = append(cats, c)
|
||||
}
|
||||
|
||||
ct, err := pg.Exec(ctx, `
|
||||
UPDATE processed_products p
|
||||
SET category = v.category,
|
||||
updated_at = now()
|
||||
FROM unnest($2::text[], $3::text[]) AS v(gtin, category)
|
||||
WHERE p.company_id = $1
|
||||
AND p.product_id = v.gtin
|
||||
AND COALESCE(NULLIF(trim(v.category), ''), '') <> ''
|
||||
AND (
|
||||
COALESCE(NULLIF(trim(p.category), ''), '') = ''
|
||||
OR lower(trim(p.category)) = 'none'
|
||||
OR p.category IS DISTINCT FROM v.category
|
||||
)`, companyID, gtins, cats)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("update processed category from dump: %w", err)
|
||||
}
|
||||
out.ProcessedUpdated = ct.RowsAffected()
|
||||
|
||||
ct, err = pg.Exec(ctx, `
|
||||
UPDATE raw_products r
|
||||
SET mapped_data = jsonb_set(
|
||||
COALESCE(r.mapped_data, '{}'::jsonb),
|
||||
'{category}',
|
||||
to_jsonb(v.category),
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
FROM unnest($2::text[], $3::text[]) AS v(gtin, category)
|
||||
WHERE r.company_id = $1
|
||||
AND r.gtin = v.gtin
|
||||
AND COALESCE(NULLIF(trim(v.category), ''), '') <> ''
|
||||
AND (
|
||||
COALESCE(NULLIF(trim(r.mapped_data->>'category'), ''), '') = ''
|
||||
OR r.mapped_data->>'category' IS DISTINCT FROM v.category
|
||||
)`, companyID, gtins, cats)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("update mapped category from dump: %w", err)
|
||||
}
|
||||
out.MappedUpdated = ct.RowsAffected()
|
||||
|
||||
n, err := BackfillMappedCategoriesFromProcessed(ctx, pg, companyID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.MappedUpdated += n
|
||||
if err := fillDumpCategoryCoverage(ctx, pg, companyID, &out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fillDumpCategoryCoverage(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, out *DumpCategoryBackfillResult) error {
|
||||
err := pg.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(NULLIF(trim(category), ''), '') <> ''
|
||||
AND lower(trim(category)) <> 'none'
|
||||
),
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(NULLIF(trim(category), ''), '') = ''
|
||||
OR lower(trim(category)) = 'none'
|
||||
)
|
||||
FROM processed_products
|
||||
WHERE company_id = $1`, companyID).Scan(&out.ProcessedWithCat, &out.ProcessedWithoutCat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count processed categories: %w", err)
|
||||
}
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') <> ''
|
||||
),
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') = ''
|
||||
)
|
||||
FROM raw_products
|
||||
WHERE company_id = $1`, companyID).Scan(&out.MappedWithCat, &out.MappedWithoutCat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count mapped categories: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PurgeA1FixtureEANsFromOtherTenants deletes the Postman Elkotex fixture EANs
|
||||
// from every company except A1 (Platform Demo must not mirror them).
|
||||
func PurgeA1FixtureEANsFromOtherTenants(ctx context.Context, pg *pgxpool.Pool, a1CompanyID uuid.UUID) (rawN, ppN int64, err error) {
|
||||
_, err = pg.Exec(ctx, `
|
||||
DELETE FROM processing_job_products pjp
|
||||
WHERE pjp.raw_product_id IN (
|
||||
SELECT id FROM raw_products
|
||||
WHERE company_id <> $1 AND gtin = ANY($2::text[])
|
||||
)
|
||||
OR pjp.processed_product_id IN (
|
||||
SELECT id FROM processed_products
|
||||
WHERE company_id <> $1 AND product_id = ANY($2::text[])
|
||||
)`, a1CompanyID, A1FixtureEANs)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("purge fixture job products: %w", err)
|
||||
}
|
||||
|
||||
ct, err := pg.Exec(ctx, `
|
||||
DELETE FROM processed_products
|
||||
WHERE company_id <> $1 AND product_id = ANY($2::text[])`, a1CompanyID, A1FixtureEANs)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("purge fixture processed: %w", err)
|
||||
}
|
||||
ppN = ct.RowsAffected()
|
||||
|
||||
ct, err = pg.Exec(ctx, `
|
||||
DELETE FROM raw_products
|
||||
WHERE company_id <> $1 AND gtin = ANY($2::text[])`, a1CompanyID, A1FixtureEANs)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("purge fixture raw: %w", err)
|
||||
}
|
||||
rawN = ct.RowsAffected()
|
||||
return rawN, ppN, nil
|
||||
}
|
||||
|
||||
// ScanA1ProcessedCategories reads mysqldump INSERT rows for processed_products
|
||||
// belonging to legacyCompany and returns gtin → category unique_id.
|
||||
func ScanA1ProcessedCategories(r io.Reader, legacyCompany string) (map[string]string, error) {
|
||||
br := bufio.NewReaderSize(r, 1<<20)
|
||||
inTable := false
|
||||
out := make(map[string]string, 4096)
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if len(line) > 0 {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "INSERT INTO `processed_products`") ||
|
||||
strings.HasPrefix(trimmed, "INSERT INTO processed_products") {
|
||||
inTable = true
|
||||
} else if inTable && strings.HasPrefix(trimmed, "CREATE TABLE") {
|
||||
break
|
||||
} else if inTable && strings.HasPrefix(trimmed, "INSERT INTO `") &&
|
||||
!strings.Contains(trimmed, "processed_products") {
|
||||
break
|
||||
} else if inTable && LooksLikeMySQLTupleLine(line) && strings.Contains(line, legacyCompany) {
|
||||
fields := ParseMySQLTupleFieldsN(line, 6)
|
||||
if len(fields) >= 5 {
|
||||
gtin := strings.TrimSpace(fields[2])
|
||||
cat := strings.TrimSpace(fields[4])
|
||||
if gtin != "" && cat != "" && !strings.EqualFold(cat, "NULL") {
|
||||
out[gtin] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package processing
|
||||
|
||||
import "strings"
|
||||
|
||||
// LooksLikeMySQLTupleLine reports whether line starts a mysqldump VALUES tuple.
|
||||
func LooksLikeMySQLTupleLine(line string) bool {
|
||||
s := strings.TrimLeft(line, " \t")
|
||||
return strings.HasPrefix(s, "(")
|
||||
}
|
||||
|
||||
// ParseMySQLTupleFields parses all fields from the first (...) tuple on the line.
|
||||
func ParseMySQLTupleFields(line string) []string {
|
||||
return ParseMySQLTupleFieldsN(line, 0)
|
||||
}
|
||||
|
||||
// ParseMySQLTupleFieldsN parses up to maxFields (0 = all) from the first (...) tuple.
|
||||
func ParseMySQLTupleFieldsN(line string, maxFields int) []string {
|
||||
start := strings.Index(line, "(")
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
body := line[start+1:]
|
||||
var out []string
|
||||
for i := 0; i < len(body); {
|
||||
if maxFields > 0 && len(out) >= maxFields {
|
||||
break
|
||||
}
|
||||
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == ',') {
|
||||
i++
|
||||
}
|
||||
if i >= len(body) || body[i] == ')' {
|
||||
break
|
||||
}
|
||||
if body[i] == '\'' {
|
||||
i++
|
||||
var b strings.Builder
|
||||
for i < len(body) {
|
||||
ch := body[i]
|
||||
if ch == '\\' && i+1 < len(body) {
|
||||
b.WriteByte(body[i+1])
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if ch == '\'' {
|
||||
if i+1 < len(body) && body[i+1] == '\'' {
|
||||
b.WriteByte('\'')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
break
|
||||
}
|
||||
b.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
out = append(out, b.String())
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
for j < len(body) && body[j] != ',' && body[j] != ')' {
|
||||
j++
|
||||
}
|
||||
out = append(out, strings.TrimSpace(body[i:j]))
|
||||
i = j
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package processing
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveMySQLDumpPath picks an explicit path, else the first readable candidate
|
||||
// under common local locations documented in scripts/seed/README.txt.
|
||||
// Used by seed-a1 CLI and admin Sync A1 (API process filesystem).
|
||||
func ResolveMySQLDumpPath(explicit string) string {
|
||||
if p := strings.TrimSpace(explicit); p != "" {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
log.Printf("warning: mysql dump not found at %q — trying auto-detect", p)
|
||||
}
|
||||
for _, c := range MySQLDumpCandidates() {
|
||||
if strings.TrimSpace(explicit) != "" && filepath.Clean(c) == filepath.Clean(strings.TrimSpace(explicit)) {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MySQLDumpCandidates lists paths seed-a1 / Sync A1 try when SEED_A1_MYSQL_DUMP
|
||||
// or -mysql-dump is unset. First readable file wins via ResolveMySQLDumpPath.
|
||||
//
|
||||
// ASSUMPTION: On servers (e.g. Git-Syncer) the dump must live on the API host
|
||||
// filesystem — prefer scripts/seed/descrybe_new.sql under the deploy root, or set
|
||||
// SEED_A1_MYSQL_DUMP in the API/worker environment.
|
||||
func MySQLDumpCandidates() []string {
|
||||
var out []string
|
||||
if v := strings.TrimSpace(os.Getenv("SEED_A1_MYSQL_DUMP")); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
names := []string{
|
||||
"descrybe_new (1).sql",
|
||||
"descrybe_new.sql",
|
||||
"descrybe_new(1).sql",
|
||||
}
|
||||
if home != "" {
|
||||
for _, n := range names {
|
||||
out = append(out, filepath.Join(home, "Downloads", n))
|
||||
out = append(out, filepath.Join(home, "downloads", n))
|
||||
}
|
||||
}
|
||||
// Repo-relative guesses (cwd may be apps/api or repo root).
|
||||
for _, n := range names {
|
||||
out = append(out,
|
||||
n,
|
||||
filepath.Join("..", "..", n),
|
||||
filepath.Join("scripts", "seed", n),
|
||||
filepath.Join("..", "..", "scripts", "seed", n),
|
||||
)
|
||||
}
|
||||
if root, ok := findMonorepoRootFromCwd(); ok {
|
||||
for _, n := range names {
|
||||
out = append(out, filepath.Join(root, "scripts", "seed", n))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findMonorepoRootFromCwd() (string, bool) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
dir := cwd
|
||||
for {
|
||||
api := filepath.Join(dir, "apps", "api")
|
||||
web := filepath.Join(dir, "apps", "web")
|
||||
if st, err := os.Stat(api); err == nil && st.IsDir() {
|
||||
if st, err := os.Stat(web); err == nil && st.IsDir() {
|
||||
return dir, true
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", false
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package processing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveMySQLDumpPathExplicit(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "descrybe_new.sql")
|
||||
if err := os.WriteFile(p, []byte("-- dump\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ResolveMySQLDumpPath(p)
|
||||
if got != p {
|
||||
t.Fatalf("got %q want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMySQLDumpPathMissingExplicitFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := filepath.Join(dir, "descrybe_new (1).sql")
|
||||
if err := os.WriteFile(want, []byte("-- dump\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SEED_A1_MYSQL_DUMP", want)
|
||||
got := ResolveMySQLDumpPath(filepath.Join(dir, "missing.sql"))
|
||||
if got != want {
|
||||
t.Fatalf("got %q want fallback %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLDumpCandidatesIncludeDownloadsName(t *testing.T) {
|
||||
t.Parallel()
|
||||
found := false
|
||||
for _, c := range MySQLDumpCandidates() {
|
||||
if filepath.Base(c) == "descrybe_new (1).sql" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected Downloads/descrybe_new (1).sql among candidates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanA1ProcessedCategoriesKeepsDescriptionNullSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
const legacy = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
|
||||
dump := strings.Join([]string{
|
||||
"INSERT INTO `processed_products` VALUES",
|
||||
"(1,\t'u',\t'790069217715',\t'Name',\t'103',\tNULL,\t'<p>ai</p>',\tNULL,\tNULL,\t'completed',\tNULL,\t0,\t'2026-01-01 00:00:00',\t'2026-01-01 00:00:00',\t1,\t'" + legacy + "',\t1);",
|
||||
"CREATE TABLE `x` (",
|
||||
}, "\n")
|
||||
got, err := ScanA1ProcessedCategories(strings.NewReader(dump), legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["790069217715"] != "103" {
|
||||
t.Fatalf("category=%q want 103", got["790069217715"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package processing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SyncCompanyA1Opts controls admin Sync A1 (dump category backfill + Fix hygiene).
|
||||
// Default is backfill-categories + hygiene — never a full wipe/reimport.
|
||||
type SyncCompanyA1Opts struct {
|
||||
FixCompanyCatalogOpts
|
||||
// MySQLDumpPath is an explicit dump path; empty triggers auto-detect
|
||||
// (SEED_A1_MYSQL_DUMP + MySQLDumpCandidates).
|
||||
MySQLDumpPath string
|
||||
// SkipDumpBackfill skips dump scan even when a dump is found.
|
||||
SkipDumpBackfill bool
|
||||
}
|
||||
|
||||
// SyncCompanyA1Result combines dump category backfill with FixCompanyCatalogResult.
|
||||
type SyncCompanyA1Result struct {
|
||||
FixCompanyCatalogResult
|
||||
|
||||
DumpFound bool `json:"dump_found"`
|
||||
DumpPath string `json:"dump_path,omitempty"`
|
||||
DumpSkippedReason string `json:"dump_skipped_reason,omitempty"`
|
||||
DumpPairs int `json:"dump_pairs"`
|
||||
DumpMappedUpdated int64 `json:"dump_mapped_updated"`
|
||||
DumpProcessedUpdated int64 `json:"dump_processed_updated"`
|
||||
PurgedOtherRaw int64 `json:"purged_other_raw"`
|
||||
PurgedOtherPP int64 `json:"purged_other_pp"`
|
||||
// DumpStatus is a short label for flash UI ({dump_status}).
|
||||
DumpStatus string `json:"dump_status"`
|
||||
}
|
||||
|
||||
// SyncCompanyA1 runs dump→category backfill when a MySQL dump is available, then
|
||||
// FixCompanyCatalog hygiene. Does not wipe or reimport the catalog.
|
||||
func SyncCompanyA1(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID, companyName string, a1Cohort bool, opts SyncCompanyA1Opts) (SyncCompanyA1Result, error) {
|
||||
out := SyncCompanyA1Result{
|
||||
FixCompanyCatalogResult: FixCompanyCatalogResult{
|
||||
CompanyID: companyID,
|
||||
CompanyName: companyName,
|
||||
A1Cohort: a1Cohort,
|
||||
},
|
||||
DumpStatus: "skipped",
|
||||
}
|
||||
if pool == nil {
|
||||
return out, fmt.Errorf("nil pool")
|
||||
}
|
||||
|
||||
dumpPath := strings.TrimSpace(opts.MySQLDumpPath)
|
||||
if !opts.SkipDumpBackfill {
|
||||
resolved := ResolveMySQLDumpPath(dumpPath)
|
||||
if resolved != "" {
|
||||
out.DumpFound = true
|
||||
out.DumpPath = resolved
|
||||
dumpRes, err := BackfillCategoriesFromMySQLDump(ctx, pool, companyID, resolved)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("dump category backfill: %w", err)
|
||||
}
|
||||
out.DumpPairs = dumpRes.DumpPairs
|
||||
out.DumpMappedUpdated = dumpRes.MappedUpdated
|
||||
out.DumpProcessedUpdated = dumpRes.ProcessedUpdated
|
||||
out.DumpStatus = "ok"
|
||||
|
||||
rawN, ppN, err := PurgeA1FixtureEANsFromOtherTenants(ctx, pool, companyID)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("purge fixture eans: %w", err)
|
||||
}
|
||||
out.PurgedOtherRaw = rawN
|
||||
out.PurgedOtherPP = ppN
|
||||
} else if dumpPath != "" {
|
||||
out.DumpSkippedReason = fmt.Sprintf("mysql dump not found at %q and no auto-detect match", dumpPath)
|
||||
out.DumpStatus = "missing"
|
||||
} else {
|
||||
out.DumpSkippedReason = "no MySQL dump (set SEED_A1_MYSQL_DUMP or place descrybe_new.sql in scripts/seed or ~/Downloads); continuing with DB hygiene only"
|
||||
out.DumpStatus = "missing"
|
||||
}
|
||||
} else {
|
||||
out.DumpSkippedReason = "dump backfill skipped by request"
|
||||
out.DumpStatus = "skipped"
|
||||
}
|
||||
|
||||
fixRes, err := FixCompanyCatalog(ctx, pool, companyID, companyName, a1Cohort, opts.FixCompanyCatalogOpts)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.FixCompanyCatalogResult = fixRes
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Admin orgs UI client — users + companies directory, staff roles, plan assign,
|
||||
* clone-catalog, and Fix A1 (`POST …/fix-catalog`).
|
||||
* Flash Fix A1: result.prompts / hashes / categories → flash.admin.fixA1Success.
|
||||
* clone-catalog, and Sync A1 (`POST …/sync-a1`; fix-catalog is a compat alias).
|
||||
* Flash Sync A1: result.prompts / hashes / categories / dump_* → flash.admin.syncA1Success.
|
||||
* Contract: docs/admin-roles-support/04-contract.md · Docs: 10-admin-orgs-ui.md
|
||||
*/
|
||||
import { api, ApiError } from "$lib/api";
|
||||
@@ -22,6 +22,8 @@ export const ADMIN_CLONE_CATALOG_PATH = (companyId: string) =>
|
||||
`${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/clone-catalog`;
|
||||
export const ADMIN_FIX_CATALOG_PATH = (companyId: string) =>
|
||||
`${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/fix-catalog`;
|
||||
export const ADMIN_SYNC_A1_PATH = (companyId: string) =>
|
||||
`${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/sync-a1`;
|
||||
export const ADMIN_STAFF_ROLE_PATH = (userId: string) =>
|
||||
`/api/admin/users/${encodeURIComponent(userId)}/staff-role`;
|
||||
|
||||
@@ -230,13 +232,13 @@ export async function cloneAdminCompanyCatalog(
|
||||
});
|
||||
}
|
||||
|
||||
export type FixCatalogResult = {
|
||||
export type SyncA1Result = {
|
||||
company_id: string;
|
||||
company_name: string;
|
||||
a1_cohort?: boolean;
|
||||
category_attribute_orphans_removed?: number;
|
||||
category_attribute_links?: number;
|
||||
/** Alias of category_prompts_updated for flash.admin.fixA1Success {prompts}. */
|
||||
/** Alias of category_prompts_updated for flash.admin.syncA1Success {prompts}. */
|
||||
prompts?: number;
|
||||
category_prompts_updated?: number;
|
||||
product_enhance_languages?: number;
|
||||
@@ -258,23 +260,36 @@ export type FixCatalogResult = {
|
||||
products_scanned?: number;
|
||||
reprocess_needed_count?: number;
|
||||
reprocess_sample_raw_product_ids?: string[];
|
||||
dump_found?: boolean;
|
||||
dump_path?: string;
|
||||
dump_skipped_reason?: string;
|
||||
dump_pairs?: number;
|
||||
dump_mapped_updated?: number;
|
||||
dump_processed_updated?: number;
|
||||
dump_status?: string;
|
||||
};
|
||||
|
||||
export type FixCatalogResponse = {
|
||||
export type SyncA1Response = {
|
||||
status: string;
|
||||
result: FixCatalogResult;
|
||||
result: SyncA1Result;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/** In-place Fix A1 / catalog hygiene. Never clears catalog; no mass reprocess. */
|
||||
export async function fixAdminCompanyCatalog(
|
||||
/** @deprecated Use SyncA1Result — kept for type aliases during UI rename. */
|
||||
export type FixCatalogResult = SyncA1Result;
|
||||
/** @deprecated Use SyncA1Response */
|
||||
export type FixCatalogResponse = SyncA1Response;
|
||||
|
||||
/** Sync A1: dump category backfill (when dump on API host) + Fix hygiene. No mass reprocess. */
|
||||
export async function syncAdminCompanyA1(
|
||||
companyId: string,
|
||||
opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number }
|
||||
): Promise<FixCatalogResponse> {
|
||||
opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number; skipDumpBackfill?: boolean }
|
||||
): Promise<SyncA1Response> {
|
||||
const body: {
|
||||
confirm: true;
|
||||
backfill_categories?: boolean;
|
||||
reprocess_sample_limit?: number;
|
||||
skip_dump_backfill?: boolean;
|
||||
} = { confirm: true };
|
||||
if (opts?.backfillCategories === false) {
|
||||
body.backfill_categories = false;
|
||||
@@ -282,12 +297,23 @@ export async function fixAdminCompanyCatalog(
|
||||
if (typeof opts?.reprocessSampleLimit === "number") {
|
||||
body.reprocess_sample_limit = opts.reprocessSampleLimit;
|
||||
}
|
||||
return api<FixCatalogResponse>(ADMIN_FIX_CATALOG_PATH(companyId), {
|
||||
if (opts?.skipDumpBackfill) {
|
||||
body.skip_dump_backfill = true;
|
||||
}
|
||||
return api<SyncA1Response>(ADMIN_SYNC_A1_PATH(companyId), {
|
||||
method: "POST",
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
/** Compat alias — same as syncAdminCompanyA1 (fix-catalog route). */
|
||||
export async function fixAdminCompanyCatalog(
|
||||
companyId: string,
|
||||
opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number }
|
||||
): Promise<SyncA1Response> {
|
||||
return syncAdminCompanyA1(companyId, opts);
|
||||
}
|
||||
|
||||
export function isStaffRoleApiUnavailable(err: unknown): boolean {
|
||||
return err instanceof ApiError && (err.status === 404 || err.status === 501);
|
||||
}
|
||||
|
||||
@@ -812,14 +812,14 @@ export const de: MessageDict = {
|
||||
"admin.users.cloneCatalogCancel": "Abbrechen",
|
||||
"admin.users.cloneCatalogConfirm": "Katalog kopieren",
|
||||
"admin.users.cloneDestFallback": "Ihr Sandbox-Unternehmen",
|
||||
"admin.users.fixA1": "A1-Katalog reparieren",
|
||||
"admin.users.fixA1Aria": "KI-Prompts, Kategorien und Enhance-Hashes für {name} reparieren",
|
||||
"admin.users.fixA1Title": "Unternehmenskatalog reparieren",
|
||||
"admin.users.fixA1Desc": "Wendet korrigierte KI-Prompts erneut an, löst schwache Enhance-Hashes und füllt Kategorien aus mapped_data nach.",
|
||||
"admin.users.fixA1Company": "Unternehmen: {name}",
|
||||
"admin.users.fixA1Warning": "Bevorzugen Sie Platform Demo oder ein explizites Unternehmen. Löscht keine Feeds, Mappings oder Rohprodukte. Schützt A1-Kohorten-Überschreibregeln.",
|
||||
"admin.users.fixA1Cancel": "Abbrechen",
|
||||
"admin.users.fixA1Confirm": "Katalog reparieren",
|
||||
"admin.users.syncA1": "A1 synchronisieren",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "A1-Katalog synchronisieren",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "Abbrechen",
|
||||
"admin.users.syncA1Confirm": "A1 synchronisieren",
|
||||
"admin.users.noUsers": "Keine Benutzer entsprechen diesem Filter.",
|
||||
"admin.users.noCompanies": "Keine Unternehmen entsprechen diesem Filter.",
|
||||
"admin.users.assignRoleTitle": "Mitarbeiterrolle zuweisen",
|
||||
@@ -2305,8 +2305,8 @@ export const de: MessageDict = {
|
||||
"flash.admin.staffRoleUnavailable": "Mitarbeiterrollen-Updates sind auf diesem Server noch nicht verfügbar.",
|
||||
"flash.admin.planAssigned": "Plan {name} zugewiesen.",
|
||||
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
|
||||
"flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.fixA1Error": "Katalogreparatur für {name} fehlgeschlagen.",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "A1-Sync für {name} fehlgeschlagen.",
|
||||
"flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
|
||||
"flash.admin.planAssignedShort": "Plan zugewiesen.",
|
||||
"flash.admin.creditsUpdated": "Credits aktualisiert.",
|
||||
|
||||
@@ -839,14 +839,14 @@ export const en: MessageDict = {
|
||||
"admin.users.cloneCatalogCancel": "Cancel",
|
||||
"admin.users.cloneCatalogConfirm": "Copy catalog",
|
||||
"admin.users.cloneDestFallback": "your sandbox company",
|
||||
"admin.users.fixA1": "Fix A1 catalog",
|
||||
"admin.users.fixA1Aria": "Repair AI prompts, categories, and enhance hashes for {name}",
|
||||
"admin.users.fixA1Title": "Fix company catalog",
|
||||
"admin.users.fixA1Desc": "In-place repair: category attribute links, category enhance prompts, weak enhance hashes, bidirectional category backfill (mapped↔processed when data exists), attribute sanitize. No mass reprocess. Does not invent categories for unmapped SKUs.",
|
||||
"admin.users.fixA1Company": "Company: {name}",
|
||||
"admin.users.fixA1Warning": "Repairs existing category data only. Products UI uses mapped_data.category — Fix cannot invent missing feed categories. For full A1 coverage run seed-a1 -mode backfill-categories with the MySQL dump, then clone A1 → sandbox.",
|
||||
"admin.users.fixA1Cancel": "Cancel",
|
||||
"admin.users.fixA1Confirm": "Fix catalog",
|
||||
"admin.users.syncA1": "Sync A1",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "Sync A1 catalog",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "Cancel",
|
||||
"admin.users.syncA1Confirm": "Sync A1",
|
||||
"admin.users.noUsers": "No users match this filter.",
|
||||
"admin.users.noCompanies": "No companies match this filter.",
|
||||
"admin.users.assignRoleTitle": "Assign staff role",
|
||||
@@ -2334,8 +2334,8 @@ export const en: MessageDict = {
|
||||
"flash.admin.staffRoleUnavailable": "Staff role updates are not available on this server yet.",
|
||||
"flash.admin.planAssigned": "Plan assigned to {name}.",
|
||||
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} products with mapped category. Open Products — uncategorized SKUs need dump backfill or categorize, not another Fix.",
|
||||
"flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} raw products have mapped categories ({taxonomy} taxonomy rows).",
|
||||
"flash.admin.fixA1Error": "Catalog repair failed for {name}.",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "Sync A1 failed for {name}.",
|
||||
"flash.admin.cloneDestMissing": "No sandbox destination. Switch active company to Platform Demo (or your staff home), then clone again. Clone refuses to overwrite A1.",
|
||||
"flash.admin.planAssignedShort": "Plan assigned.",
|
||||
"flash.admin.creditsUpdated": "Credits updated.",
|
||||
|
||||
@@ -812,14 +812,14 @@ export const es: MessageDict = {
|
||||
"admin.users.cloneCatalogCancel": "Cancelar",
|
||||
"admin.users.cloneCatalogConfirm": "Copiar catálogo",
|
||||
"admin.users.cloneDestFallback": "tu empresa sandbox",
|
||||
"admin.users.fixA1": "Reparar catálogo A1",
|
||||
"admin.users.fixA1Aria": "Reparar prompts de IA, categorÃas y hashes de enhance para {name}",
|
||||
"admin.users.fixA1Title": "Reparar catálogo de la empresa",
|
||||
"admin.users.fixA1Desc": "Vuelve a aplicar prompts de IA corregidos, limpia hashes de enhance débiles y completa categorÃas desde mapped_data.",
|
||||
"admin.users.fixA1Company": "Empresa: {name}",
|
||||
"admin.users.fixA1Warning": "Prefiera Platform Demo o una empresa explÃcita. No elimina feeds, mapeos ni productos en bruto. Protege las reglas de sobrescritura de la cohorte A1.",
|
||||
"admin.users.fixA1Cancel": "Cancelar",
|
||||
"admin.users.fixA1Confirm": "Reparar catálogo",
|
||||
"admin.users.syncA1": "Sincronizar A1",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "Sincronizar catálogo A1",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "Cancelar",
|
||||
"admin.users.syncA1Confirm": "Sincronizar A1",
|
||||
"admin.users.noUsers": "Ningún usuario coincide con este filtro.",
|
||||
"admin.users.noCompanies": "Ninguna empresa coincide con este filtro.",
|
||||
"admin.users.assignRoleTitle": "Asignar rol de personal",
|
||||
@@ -2304,8 +2304,8 @@ export const es: MessageDict = {
|
||||
"flash.admin.staffRoleUpdated": "Rol de personal actualizado para {email}.",
|
||||
"flash.admin.staffRoleUnavailable": "Las actualizaciones de rol de personal aún no están disponibles en este servidor.",
|
||||
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
|
||||
"flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.fixA1Error": "Falló la reparación del catálogo de {name}.",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "Falló la sincronización A1 de {name}.",
|
||||
"flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
|
||||
"flash.admin.planAssigned": "Plan asignado a {name}.",
|
||||
"flash.admin.planAssignedShort": "Plan asignado.",
|
||||
|
||||
@@ -812,14 +812,14 @@ export const fr: MessageDict = {
|
||||
"admin.users.cloneCatalogCancel": "Annuler",
|
||||
"admin.users.cloneCatalogConfirm": "Copier le catalogue",
|
||||
"admin.users.cloneDestFallback": "votre entreprise sandbox",
|
||||
"admin.users.fixA1": "Réparer le catalogue A1",
|
||||
"admin.users.fixA1Aria": "Réparer les prompts IA, catégories et hashes enhance pour {name}",
|
||||
"admin.users.fixA1Title": "Réparer le catalogue entreprise",
|
||||
"admin.users.fixA1Desc": "Réapplique les prompts IA corrigés, efface les hashes enhance faibles et complète les catégories depuis mapped_data.",
|
||||
"admin.users.fixA1Company": "Entreprise : {name}",
|
||||
"admin.users.fixA1Warning": "Préférer Platform Demo ou une entreprise explicite. Ne supprime ni feeds, ni mappings, ni produits bruts. Protège les règles d'écrasement de la cohorte A1.",
|
||||
"admin.users.fixA1Cancel": "Annuler",
|
||||
"admin.users.fixA1Confirm": "Réparer le catalogue",
|
||||
"admin.users.syncA1": "Synchroniser A1",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "Synchroniser le catalogue A1",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "Annuler",
|
||||
"admin.users.syncA1Confirm": "Synchroniser A1",
|
||||
"admin.users.noUsers": "Aucun utilisateur ne correspond à ce filtre.",
|
||||
"admin.users.noCompanies": "Aucune entreprise ne correspond à ce filtre.",
|
||||
"admin.users.assignRoleTitle": "Assigner un rôle du personnel",
|
||||
@@ -2304,8 +2304,8 @@ export const fr: MessageDict = {
|
||||
"flash.admin.staffRoleUpdated": "Rôle du personnel mis à jour pour {email}.",
|
||||
"flash.admin.staffRoleUnavailable": "Les mises à jour de rôle personnel ne sont pas encore disponibles sur ce serveur.",
|
||||
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
|
||||
"flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.fixA1Error": "Échec de la réparation du catalogue pour {name}.",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "Échec de la synchronisation A1 pour {name}.",
|
||||
"flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
|
||||
"flash.admin.planAssigned": "Offre assignée à {name}.",
|
||||
"flash.admin.planAssignedShort": "Offre assignée.",
|
||||
|
||||
@@ -812,14 +812,14 @@ export const it: MessageDict = {
|
||||
"admin.users.cloneCatalogCancel": "Annulla",
|
||||
"admin.users.cloneCatalogConfirm": "Copia catalogo",
|
||||
"admin.users.cloneDestFallback": "la tua azienda sandbox",
|
||||
"admin.users.fixA1": "Ripara catalogo A1",
|
||||
"admin.users.fixA1Aria": "Ripara prompt IA, categorie e hash enhance per {name}",
|
||||
"admin.users.fixA1Title": "Ripara catalogo azienda",
|
||||
"admin.users.fixA1Desc": "Riapplica i prompt IA corretti, cancella hash enhance deboli e completa le categorie da mapped_data.",
|
||||
"admin.users.fixA1Company": "Azienda: {name}",
|
||||
"admin.users.fixA1Warning": "Preferisci Platform Demo o un'azienda esplicita. Non elimina feed, mapping o prodotti grezzi. Protegge le regole di sovrascrittura della coorte A1.",
|
||||
"admin.users.fixA1Cancel": "Annulla",
|
||||
"admin.users.fixA1Confirm": "Ripara catalogo",
|
||||
"admin.users.syncA1": "Sincronizza A1",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "Sincronizza catalogo A1",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "Annulla",
|
||||
"admin.users.syncA1Confirm": "Sincronizza A1",
|
||||
"admin.users.noUsers": "Nessun utente corrisponde a questo filtro.",
|
||||
"admin.users.noCompanies": "Nessuna azienda corrisponde a questo filtro.",
|
||||
"admin.users.assignRoleTitle": "Assegna ruolo staff",
|
||||
@@ -2304,8 +2304,8 @@ export const it: MessageDict = {
|
||||
"flash.admin.staffRoleUpdated": "Ruolo staff aggiornato per {email}.",
|
||||
"flash.admin.staffRoleUnavailable": "Gli aggiornamenti del ruolo staff non sono ancora disponibili su questo server.",
|
||||
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
|
||||
"flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.fixA1Error": "Riparazione catalogo per {name} non riuscita.",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "Sincronizzazione A1 per {name} non riuscita.",
|
||||
"flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
|
||||
"flash.admin.planAssigned": "Piano assegnato a {name}.",
|
||||
"flash.admin.planAssignedShort": "Piano assegnato.",
|
||||
|
||||
@@ -812,14 +812,14 @@ export const ja: MessageDict = {
|
||||
"admin.users.cloneCatalogCancel": "ã‚ャンセル",
|
||||
"admin.users.cloneCatalogConfirm": "ã‚«ã‚¿ãƒã‚°ã‚’コピー",
|
||||
"admin.users.cloneDestFallback": "サンドボックス会社",
|
||||
"admin.users.fixA1": "A1ã‚«ã‚¿ãƒã‚°ã‚’修復",
|
||||
"admin.users.fixA1Aria": "{name} ã®AIプãƒãƒ³ãƒ—トã€ã‚«ãƒ†ã‚´ãƒªã€enhanceãƒãƒƒã‚·ãƒ¥ã‚’修復",
|
||||
"admin.users.fixA1Title": "会社カタãƒã‚°ã‚’修復",
|
||||
"admin.users.fixA1Desc": "ä¿®æ£æ¸ˆã¿AIプãƒãƒ³ãƒ—トをå†é©ç”¨ã—ã€å¼±ã„enhanceãƒãƒƒã‚·ãƒ¥ã‚’消去ã—ã€mapped_dataã‹ã‚‰ã‚«ãƒ†ã‚´ãƒªã‚’補完ã—ã¾ã™ã€‚",
|
||||
"admin.users.fixA1Company": "会社: {name}",
|
||||
"admin.users.fixA1Warning": "Platform Demoã¾ãŸã¯æ˜Žç¤ºçš„ãªä¼šç¤¾ã‚’優先ã—ã¦ãã ã•ã„。フィードã€ãƒžãƒƒãƒ”ングã€ç”Ÿè£½å“ã¯å‰Šé™¤ã—ã¾ã›ã‚“。A1コホートã®ä¸Šæ›¸ãルールをä¿è·ã—ã¾ã™ã€‚",
|
||||
"admin.users.fixA1Cancel": "ã‚ャンセル",
|
||||
"admin.users.fixA1Confirm": "ã‚«ã‚¿ãƒã‚°ã‚’修復",
|
||||
"admin.users.syncA1": "A1を同期",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "A1カタログを同期",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "キャンセル",
|
||||
"admin.users.syncA1Confirm": "A1を同期",
|
||||
"admin.users.noUsers": "ã“ã®ãƒ•ィルタã«ä¸€è‡´ã™ã‚‹ãƒ¦ãƒ¼ã‚¶ãƒ¼ã¯ã„ã¾ã›ã‚“。",
|
||||
"admin.users.noCompanies": "ã“ã®ãƒ•ィルタã«ä¸€è‡´ã™ã‚‹ä¼šç¤¾ã¯ã‚りã¾ã›ã‚“。",
|
||||
"admin.users.assignRoleTitle": "スタッフãƒãƒ¼ãƒ«ã‚’割り当ã¦",
|
||||
@@ -2304,8 +2304,8 @@ export const ja: MessageDict = {
|
||||
"flash.admin.staffRoleUpdated": "{email} ã®ã‚¹ã‚¿ãƒƒãƒ•ãƒãƒ¼ãƒ«ã‚’æ›´æ–°ã—ã¾ã—ãŸã€‚",
|
||||
"flash.admin.staffRoleUnavailable": "ã“ã®ã‚µãƒ¼ãƒãƒ¼ã§ã¯ã‚¹ã‚¿ãƒƒãƒ•ãƒãƒ¼ãƒ«ã®æ›´æ–°ã¯ã¾ã 利用ã§ãã¾ã›ã‚“。",
|
||||
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
|
||||
"flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.fixA1Error": "{name} ã®ã‚«ã‚¿ãƒã‚°ä¿®å¾©ã«å¤±æ•—ã—ã¾ã—ãŸã€‚",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "{name} のA1同期に失敗しました。",
|
||||
"flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
|
||||
"flash.admin.planAssigned": "{name} ã«ãƒ—ランを割り当ã¦ã¾ã—ãŸã€‚",
|
||||
"flash.admin.planAssignedShort": "プランを割り当ã¦ã¾ã—ãŸã€‚",
|
||||
|
||||
@@ -812,14 +812,14 @@ export const nl: MessageDict = {
|
||||
"admin.users.cloneCatalogCancel": "Annuleren",
|
||||
"admin.users.cloneCatalogConfirm": "Catalogus kopiëren",
|
||||
"admin.users.cloneDestFallback": "je sandboxbedrijf",
|
||||
"admin.users.fixA1": "A1-catalogus repareren",
|
||||
"admin.users.fixA1Aria": "AI-prompts, categorieën en enhance-hashes voor {name} repareren",
|
||||
"admin.users.fixA1Title": "Bedrijfscatalogus repareren",
|
||||
"admin.users.fixA1Desc": "Past gecorrigeerde AI-prompts opnieuw toe, wist zwakke enhance-hashes en vult categorieën bij vanuit mapped_data.",
|
||||
"admin.users.fixA1Company": "Bedrijf: {name}",
|
||||
"admin.users.fixA1Warning": "Geef voorkeur aan Platform Demo of een expliciet bedrijf. Verwijdert geen feeds, mappings of ruwe producten. Beschermt A1-cohort-overschrijfregels.",
|
||||
"admin.users.fixA1Cancel": "Annuleren",
|
||||
"admin.users.fixA1Confirm": "Catalogus repareren",
|
||||
"admin.users.syncA1": "A1 synchroniseren",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "A1-catalogus synchroniseren",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "Annuleren",
|
||||
"admin.users.syncA1Confirm": "A1 synchroniseren",
|
||||
"admin.users.noUsers": "Geen gebruikers komen overeen met dit filter.",
|
||||
"admin.users.noCompanies": "Geen bedrijven komen overeen met dit filter.",
|
||||
"admin.users.assignRoleTitle": "Medewerkerrol toewijzen",
|
||||
@@ -2304,8 +2304,8 @@ export const nl: MessageDict = {
|
||||
"flash.admin.staffRoleUpdated": "Personeelsrol bijgewerkt voor {email}.",
|
||||
"flash.admin.staffRoleUnavailable": "Personeelsrolupdates zijn op deze server nog niet beschikbaar.",
|
||||
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
|
||||
"flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.fixA1Error": "Catalogusreparatie voor {name} mislukt.",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "A1-synchronisatie voor {name} mislukt.",
|
||||
"flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
|
||||
"flash.admin.planAssigned": "Plan toegewezen aan {name}.",
|
||||
"flash.admin.planAssignedShort": "Plan toegewezen.",
|
||||
|
||||
@@ -812,14 +812,14 @@ export const pl: MessageDict = {
|
||||
"admin.users.cloneCatalogCancel": "Anuluj",
|
||||
"admin.users.cloneCatalogConfirm": "Kopiuj katalog",
|
||||
"admin.users.cloneDestFallback": "twoja firma sandbox",
|
||||
"admin.users.fixA1": "Napraw katalog A1",
|
||||
"admin.users.fixA1Aria": "Napraw prompty AI, kategorie i hashe enhance dla {name}",
|
||||
"admin.users.fixA1Title": "Napraw katalog firmy",
|
||||
"admin.users.fixA1Desc": "Ponownie stosuje poprawione prompty AI, czyści słabe hashe enhance i uzupełnia kategorie z mapped_data.",
|
||||
"admin.users.fixA1Company": "Firma: {name}",
|
||||
"admin.users.fixA1Warning": "Preferuj Platform Demo lub wskazaną firmę. Nie usuwa feedów, mapowań ani surowych produktów. Chroni reguły nadpisywania kohorty A1.",
|
||||
"admin.users.fixA1Cancel": "Anuluj",
|
||||
"admin.users.fixA1Confirm": "Napraw katalog",
|
||||
"admin.users.syncA1": "Synchronizuj A1",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "Synchronizuj katalog A1",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "Anuluj",
|
||||
"admin.users.syncA1Confirm": "Synchronizuj A1",
|
||||
"admin.users.noUsers": "Żaden użytkownik nie pasuje do tego filtra.",
|
||||
"admin.users.noCompanies": "Żadna firma nie pasuje do tego filtra.",
|
||||
"admin.users.assignRoleTitle": "Przypisz rolÄ™ personelu",
|
||||
@@ -2304,8 +2304,8 @@ export const pl: MessageDict = {
|
||||
"flash.admin.staffRoleUpdated": "Zaktualizowano rolÄ™ personelu dla {email}.",
|
||||
"flash.admin.staffRoleUnavailable": "Aktualizacje ról personelu nie są jeszcze dostępne na tym serwerze.",
|
||||
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
|
||||
"flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.fixA1Error": "Naprawa katalogu dla {name} nie powiodła się.",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "Synchronizacja A1 dla {name} nie powiodła się.",
|
||||
"flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
|
||||
"flash.admin.planAssigned": "Przypisano plan do {name}.",
|
||||
"flash.admin.planAssignedShort": "Przypisano plan.",
|
||||
|
||||
@@ -812,14 +812,14 @@ export const pt: MessageDict = {
|
||||
"admin.users.cloneCatalogCancel": "Cancelar",
|
||||
"admin.users.cloneCatalogConfirm": "Copiar catálogo",
|
||||
"admin.users.cloneDestFallback": "a sua empresa sandbox",
|
||||
"admin.users.fixA1": "Reparar catálogo A1",
|
||||
"admin.users.fixA1Aria": "Reparar prompts de IA, categorias e hashes de enhance para {name}",
|
||||
"admin.users.fixA1Title": "Reparar catálogo da empresa",
|
||||
"admin.users.fixA1Desc": "Reaplica prompts de IA corrigidos, limpa hashes de enhance fracos e preenche categorias a partir de mapped_data.",
|
||||
"admin.users.fixA1Company": "Empresa: {name}",
|
||||
"admin.users.fixA1Warning": "Prefira Platform Demo ou uma empresa explÃcita. Não elimina feeds, mapeamentos ou produtos brutos. Protege as regras de substituição da coorte A1.",
|
||||
"admin.users.fixA1Cancel": "Cancelar",
|
||||
"admin.users.fixA1Confirm": "Reparar catálogo",
|
||||
"admin.users.syncA1": "Sincronizar A1",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "Sincronizar catálogo A1",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "Cancelar",
|
||||
"admin.users.syncA1Confirm": "Sincronizar A1",
|
||||
"admin.users.noUsers": "Nenhum utilizador corresponde a este filtro.",
|
||||
"admin.users.noCompanies": "Nenhuma empresa corresponde a este filtro.",
|
||||
"admin.users.assignRoleTitle": "Atribuir função de equipa",
|
||||
@@ -2304,8 +2304,8 @@ export const pt: MessageDict = {
|
||||
"flash.admin.staffRoleUpdated": "Função de pessoal atualizada para {email}.",
|
||||
"flash.admin.staffRoleUnavailable": "As atualizações de função de pessoal ainda não estão disponÃveis neste servidor.",
|
||||
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
|
||||
"flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.fixA1Error": "Falha na reparação do catálogo de {name}.",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "Falha na sincronização A1 de {name}.",
|
||||
"flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
|
||||
"flash.admin.planAssigned": "Plano atribuÃdo a {name}.",
|
||||
"flash.admin.planAssignedShort": "Plano atribuÃdo.",
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
/** Slovenian (sl) UI strings for admin Fix A1 — ready pack. Not registered in UI_LOCALES yet (avoid inventing locale switcher). */
|
||||
export const sl: MessageDict = {
|
||||
"admin.users.fixA1": "Popravi katalog A1",
|
||||
"admin.users.fixA1Aria": "Popravi AI pozive, kategorije in zgoÅ¡Äevalne vrednosti za {name}",
|
||||
"admin.users.fixA1Title": "Popravi katalog podjetja",
|
||||
"admin.users.fixA1Desc": "Ponovno uporabi popravljene AI pozive, poÄisti Å¡ibke enhance zgoÅ¡Äevalne vrednosti in dopolni kategorije iz mapped podatkov.",
|
||||
"admin.users.fixA1Company": "Podjetje: {name}",
|
||||
"admin.users.fixA1Warning": "Raje Platform Demo ali izrecno podjetje. Ne briÅ¡e feedov, mapiranj ali surovih izdelkov. Å Äiti pravila prepisovanja A1 kohorte.",
|
||||
"admin.users.fixA1Cancel": "PrekliÄi",
|
||||
"admin.users.fixA1Confirm": "Popravi katalog",
|
||||
"flash.admin.fixA1Success": "Popravilo kataloga za {name}: pozivi {prompts}, zgoščene {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Pokritost: {mapped_with}/{mapped_total} ({taxonomy} taksonomija).",
|
||||
"flash.admin.fixA1Error": "Popravilo kataloga za {name} ni uspelo.",
|
||||
"admin.users.syncA1": "Sinhroniziraj A1",
|
||||
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
|
||||
"admin.users.syncA1Title": "Sinhroniziraj katalog A1",
|
||||
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
|
||||
"admin.users.syncA1Company": "Company: {name}",
|
||||
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
|
||||
"admin.users.syncA1Cancel": "Prekliči",
|
||||
"admin.users.syncA1Confirm": "Sinhroniziraj A1",
|
||||
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
|
||||
"flash.admin.syncA1Error": "Sinhronizacija A1 za {name} ni uspela.",
|
||||
"flash.admin.cloneDestMissing": "Ni ciljnega sandbox podjetja. Preklopite na Platform Demo, nato klonirajte.",
|
||||
};
|
||||
|
||||
+20
-20
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Focused check: admin Fix A1 i18n keys exist in every UI_LOCALES pack.
|
||||
/**
|
||||
* Focused check: admin Sync A1 i18n keys exist in every UI_LOCALES pack.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
@@ -9,32 +9,32 @@ import { en } from "./messages/en.ts";
|
||||
import { loadAllMessages, messagesFor } from "./messages/catalog.ts";
|
||||
import { sl } from "./messages/sl.ts";
|
||||
|
||||
const FIX_A1_KEYS = [
|
||||
"admin.users.fixA1",
|
||||
"admin.users.fixA1Aria",
|
||||
"admin.users.fixA1Title",
|
||||
"admin.users.fixA1Desc",
|
||||
"admin.users.fixA1Company",
|
||||
"admin.users.fixA1Warning",
|
||||
"admin.users.fixA1Cancel",
|
||||
"admin.users.fixA1Confirm",
|
||||
"flash.admin.fixA1Success",
|
||||
"flash.admin.fixA1Error"
|
||||
const SYNC_A1_KEYS = [
|
||||
"admin.users.syncA1",
|
||||
"admin.users.syncA1Aria",
|
||||
"admin.users.syncA1Title",
|
||||
"admin.users.syncA1Desc",
|
||||
"admin.users.syncA1Company",
|
||||
"admin.users.syncA1Warning",
|
||||
"admin.users.syncA1Cancel",
|
||||
"admin.users.syncA1Confirm",
|
||||
"flash.admin.syncA1Success",
|
||||
"flash.admin.syncA1Error"
|
||||
] as const;
|
||||
|
||||
describe("admin Fix A1 i18n keys", () => {
|
||||
it("English defines every Fix A1 key", () => {
|
||||
for (const key of FIX_A1_KEYS) {
|
||||
describe("admin Sync A1 i18n keys", () => {
|
||||
it("English defines every Sync A1 key", () => {
|
||||
for (const key of SYNC_A1_KEYS) {
|
||||
assert.equal(typeof en[key], "string", key);
|
||||
assert.ok(en[key].trim().length > 0, key);
|
||||
}
|
||||
});
|
||||
|
||||
it("every registered UI locale has Fix A1 keys", async () => {
|
||||
it("every registered UI locale has Sync A1 keys", async () => {
|
||||
await loadAllMessages();
|
||||
for (const { code } of UI_LOCALES) {
|
||||
const pack = code === "en" ? en : messagesFor(code);
|
||||
for (const key of FIX_A1_KEYS) {
|
||||
for (const key of SYNC_A1_KEYS) {
|
||||
const value = pack[key];
|
||||
assert.equal(typeof value, "string", `${code}:${key}`);
|
||||
assert.ok(String(value).trim().length > 0, `${code}:${key}`);
|
||||
@@ -42,8 +42,8 @@ describe("admin Fix A1 i18n keys", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Slovenian ready pack has Fix A1 keys (not in UI_LOCALES)", () => {
|
||||
for (const key of FIX_A1_KEYS) {
|
||||
it("Slovenian ready pack has Sync A1 keys (outside UI_LOCALES)", () => {
|
||||
for (const key of SYNC_A1_KEYS) {
|
||||
assert.equal(typeof sl[key], "string", key);
|
||||
assert.ok(sl[key].trim().length > 0, key);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
STAFF_ROLE_OPTIONS,
|
||||
assignAdminPlan,
|
||||
cloneAdminCompanyCatalog,
|
||||
fixAdminCompanyCatalog,
|
||||
syncAdminCompanyA1,
|
||||
companyPlanBadge,
|
||||
isStaffRoleApiUnavailable,
|
||||
listAdminCompanies,
|
||||
@@ -55,7 +55,7 @@
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from "$lib/components/ui";
|
||||
import { Building2, Copy, KeyRound, Search, Shield, UserPlus, Users, Wrench } from "@lucide/svelte";
|
||||
import { Building2, Copy, KeyRound, RefreshCw, Search, Shield, UserPlus, Users } from "@lucide/svelte";
|
||||
|
||||
type TabKey = "users" | "companies";
|
||||
|
||||
@@ -94,8 +94,8 @@
|
||||
let cloneOpen = $state(false);
|
||||
let cloneCompany = $state<AdminOrgCompany | null>(null);
|
||||
|
||||
let fixOpen = $state(false);
|
||||
let fixCompany = $state<AdminOrgCompany | null>(null);
|
||||
let syncOpen = $state(false);
|
||||
let syncCompany = $state<AdminOrgCompany | null>(null);
|
||||
|
||||
const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1);
|
||||
const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE)));
|
||||
@@ -366,28 +366,29 @@
|
||||
success = "";
|
||||
}
|
||||
|
||||
function openFixDialog(company: AdminOrgCompany) {
|
||||
fixCompany = company;
|
||||
fixOpen = true;
|
||||
function openSyncDialog(company: AdminOrgCompany) {
|
||||
syncCompany = company;
|
||||
syncOpen = true;
|
||||
error = "";
|
||||
success = "";
|
||||
}
|
||||
|
||||
async function confirmFixCatalog() {
|
||||
if (!fixCompany) return;
|
||||
async function confirmSyncA1() {
|
||||
if (!syncCompany) return;
|
||||
busy = true;
|
||||
error = "";
|
||||
success = "";
|
||||
const targetName = fixCompany.name;
|
||||
const targetName = syncCompany.name;
|
||||
try {
|
||||
const res = await fixAdminCompanyCatalog(fixCompany.id, {
|
||||
const res = await syncAdminCompanyA1(syncCompany.id, {
|
||||
reprocessSampleLimit: 25
|
||||
});
|
||||
const r = res.result;
|
||||
const mappedWith = Number(r.mapped_with_category ?? 0);
|
||||
const mappedWithout = Number(r.mapped_without_category ?? 0);
|
||||
const mappedTotal = mappedWith + mappedWithout;
|
||||
success = i18n.t("flash.admin.fixA1Success", {
|
||||
const dumpStatus = String(r.dump_status ?? (r.dump_found ? "ok" : "missing"));
|
||||
success = i18n.t("flash.admin.syncA1Success", {
|
||||
name: targetName,
|
||||
prompts: String(r.prompts ?? r.category_prompts_updated ?? 0),
|
||||
hashes: String(r.hashes ?? r.weak_hashes_cleared ?? 0),
|
||||
@@ -395,12 +396,14 @@
|
||||
mapped_backfilled: String(r.mapped_backfilled ?? r.mapped_categories_backfilled ?? 0),
|
||||
mapped_with: String(mappedWith),
|
||||
mapped_total: String(mappedTotal),
|
||||
taxonomy: String(r.taxonomy_categories ?? 0)
|
||||
taxonomy: String(r.taxonomy_categories ?? 0),
|
||||
dump_mapped: String(r.dump_mapped_updated ?? 0),
|
||||
dump_status: dumpStatus
|
||||
});
|
||||
fixOpen = false;
|
||||
fixCompany = null;
|
||||
syncOpen = false;
|
||||
syncCompany = null;
|
||||
} catch (err) {
|
||||
error = failureMessage(err, i18n.t("flash.admin.fixA1Error", { name: targetName }));
|
||||
error = failureMessage(err, i18n.t("flash.admin.syncA1Error", { name: targetName }));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -830,14 +833,14 @@
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => openFixDialog(company)}
|
||||
aria-label={i18n.t("admin.users.fixA1Aria", {
|
||||
onclick={() => openSyncDialog(company)}
|
||||
aria-label={i18n.t("admin.users.syncA1Aria", {
|
||||
name: company.name
|
||||
})}
|
||||
data-testid="admin-fix-catalog"
|
||||
data-testid="admin-sync-a1"
|
||||
>
|
||||
<Wrench class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
|
||||
<span class="hidden lg:inline">{i18n.t("admin.users.fixA1")}</span>
|
||||
<RefreshCw class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
|
||||
<span class="hidden lg:inline">{i18n.t("admin.users.syncA1")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1021,21 +1024,21 @@
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
bind:open={fixOpen}
|
||||
title={i18n.t("admin.users.fixA1Title")}
|
||||
description={i18n.t("admin.users.fixA1Desc")}
|
||||
bind:open={syncOpen}
|
||||
title={i18n.t("admin.users.syncA1Title")}
|
||||
description={i18n.t("admin.users.syncA1Desc")}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive" role="alert">{error}</p>
|
||||
{/if}
|
||||
{#if fixCompany}
|
||||
{#if syncCompany}
|
||||
<p class="text-sm text-foreground">
|
||||
{i18n.t("admin.users.fixA1Company", { name: fixCompany.name })}
|
||||
{i18n.t("admin.users.syncA1Company", { name: syncCompany.name })}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{i18n.t("admin.users.fixA1Warning")}
|
||||
{i18n.t("admin.users.syncA1Warning")}
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
@@ -1043,14 +1046,14 @@
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onclick={() => {
|
||||
fixOpen = false;
|
||||
fixCompany = null;
|
||||
syncOpen = false;
|
||||
syncCompany = null;
|
||||
}}
|
||||
>
|
||||
{i18n.t("admin.users.fixA1Cancel")}
|
||||
{i18n.t("admin.users.syncA1Cancel")}
|
||||
</Button>
|
||||
<Button type="button" loading={busy} onclick={() => confirmFixCatalog()}>
|
||||
{i18n.t("admin.users.fixA1Confirm")}
|
||||
<Button type="button" loading={busy} onclick={() => confirmSyncA1()}>
|
||||
{i18n.t("admin.users.syncA1Confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-6
@@ -31,15 +31,22 @@ Category assignment (dump -> PG):
|
||||
mapped_data.category from a MySQL dump when available.
|
||||
|
||||
Dump path (first match wins):
|
||||
1. -mysql-dump flag
|
||||
2. SEED_A1_MYSQL_DUMP
|
||||
3. Auto-detect: ~/Downloads/descrybe_new (1).sql or descrybe_new.sql
|
||||
1. -mysql-dump flag / admin body mysql_dump
|
||||
2. SEED_A1_MYSQL_DUMP (API process env on Git-Syncer)
|
||||
3. Auto-detect: scripts/seed/descrybe_new.sql (deploy root),
|
||||
~/Downloads/descrybe_new (1).sql or descrybe_new.sql
|
||||
|
||||
One-off without wipe:
|
||||
Git-Syncer / production: copy the dump onto the API host, e.g.
|
||||
<deploy-root>/scripts/seed/descrybe_new.sql
|
||||
or set SEED_A1_MYSQL_DUMP=/absolute/path/descrybe_new.sql in the API env.
|
||||
Then use Admin → Companies → Sync A1 (no CLI DATABASE_URL needed).
|
||||
|
||||
One-off without wipe (CLI; loads monorepo-root .env when unset):
|
||||
go run ./cmd/seed-a1 -mode backfill-categories
|
||||
|
||||
That also deletes Postman Elkotex fixture EANs (5905575903198, 6970995789942)
|
||||
from non-A1 tenants (Platform Demo must not mirror them).
|
||||
Admin Sync A1 = dump category backfill (when dump present) + Fix hygiene
|
||||
(prompts, weak hashes, bidirectional category backfill, attr sanitize).
|
||||
It does NOT wipe/reimport the ~25k catalog.
|
||||
|
||||
Note: MySQL processed description/attributes are usually NULL; feed-origin
|
||||
original description + specs live on raw_products.mapped_data in the archive.
|
||||
|
||||
Reference in New Issue
Block a user