This commit is contained in:
2026-08-16 12:00:28 +02:00
parent 52f27e30fc
commit 52fb129957
7 changed files with 105 additions and 23 deletions
+19
View File
@@ -433,6 +433,25 @@ func cloneCompanyCatalog(ctx context.Context, pool *pgxpool.Pool, src, dest uuid
} }
add("company_language", ct.RowsAffected()) add("company_language", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO company_settings (company_id, settings, updated_at)
SELECT $2, settings, now()
FROM company_settings WHERE company_id = $1
ON CONFLICT (company_id) DO UPDATE
SET settings = EXCLUDED.settings, updated_at = now()`, src, dest)
if err != nil {
return nil, fmt.Errorf("company_settings: %w", err)
}
add("company_settings", ct.RowsAffected())
// Cloned raws are ready to process on the destination (source flags may say processed).
if _, err := tx.Exec(ctx, `
UPDATE raw_products
SET is_processed = false, processing_status = 'unprocessed', updated_at = now()
WHERE company_id = $1`, dest); err != nil {
return nil, fmt.Errorf("reset raw processing flags: %w", err)
}
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return nil, err return nil, err
} }
+41 -22
View File
@@ -42,6 +42,7 @@ func NormalizeGTIN(ean string) string {
} }
// BuildMappedDataFromV1Item mirrors legacy buildMappedDataFromItem + image field storage. // BuildMappedDataFromV1Item mirrors legacy buildMappedDataFromItem + image field storage.
// Empty optional fields are omitted (not null) so jsonb || merges cannot wipe catalog titles.
func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any { func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any {
mapped := map[string]any{ mapped := map[string]any{
"ean": item.EAN, "ean": item.EAN,
@@ -49,23 +50,15 @@ func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any {
if item.Title != "" { if item.Title != "" {
mapped["title"] = item.Title mapped["title"] = item.Title
mapped["name"] = item.Title mapped["name"] = item.Title
} else {
mapped["title"] = nil
} }
if item.Description != "" { if item.Description != "" {
mapped["description"] = item.Description mapped["description"] = item.Description
} else {
mapped["description"] = nil
} }
if len(item.Specifications) > 0 { if len(item.Specifications) > 0 {
mapped["specifications"] = item.Specifications mapped["specifications"] = item.Specifications
} else {
mapped["specifications"] = []any{}
} }
if item.Search != "" { if item.Search != "" {
mapped["search"] = item.Search mapped["search"] = item.Search
} else {
mapped["search"] = nil
} }
if item.CategoryUniqueID != "" { if item.CategoryUniqueID != "" {
mapped["category"] = item.CategoryUniqueID mapped["category"] = item.CategoryUniqueID
@@ -77,6 +70,21 @@ func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any {
return mapped return mapped
} }
// v1ItemHasContent reports whether the request carries feed/product fields beyond EAN.
// EAN-only requests must resolve an existing catalog row (e.g. after admin clone).
func v1ItemHasContent(item V1ProcessItem) bool {
if strings.TrimSpace(item.Title) != "" || strings.TrimSpace(item.Description) != "" {
return true
}
if strings.TrimSpace(item.CategoryUniqueID) != "" || strings.TrimSpace(item.Search) != "" {
return true
}
if len(item.Specifications) > 0 {
return true
}
return len(mappedImageFieldsFromV1Item(item)) > 0
}
func mappedImageFieldsFromV1Item(item V1ProcessItem) map[string]any { func mappedImageFieldsFromV1Item(item V1ProcessItem) map[string]any {
source := map[string]any{} source := map[string]any{}
putIf := func(k, v string) { putIf := func(k, v string) {
@@ -279,6 +287,10 @@ type EnsureRawResult struct {
// EnsureRawProductsFromV1Items finds or creates/updates raw_products by EAN for the company. // EnsureRawProductsFromV1Items finds or creates/updates raw_products by EAN for the company.
// Returns successfully resolved IDs and per-item error messages (non-fatal for partial batches). // Returns successfully resolved IDs and per-item error messages (non-fatal for partial batches).
//
// EAN-only items reuse existing catalog mapped_data (e.g. after admin clone-from-company).
// Creating a brand-new row requires at least one content field; otherwise callers get empty
// "Product {ean}" stubs with no feed data.
func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uuid.UUID, items []V1ProcessItem) (ids []uuid.UUID, results []EnsureRawResult, errs []string, err error) { func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uuid.UUID, items []V1ProcessItem) (ids []uuid.UUID, results []EnsureRawResult, errs []string, err error) {
if s == nil || s.Pool == nil { if s == nil || s.Pool == nil {
return nil, nil, nil, fmt.Errorf("catalog not configured") return nil, nil, nil, fmt.Errorf("catalog not configured")
@@ -309,24 +321,23 @@ func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uu
if qErr == nil { if qErr == nil {
merged := map[string]any{} merged := map[string]any{}
_ = json.Unmarshal(existingMapped, &merged) _ = json.Unmarshal(existingMapped, &merged)
img := mappedImageFieldsFromV1Item(item) changed := false
if len(img) > 0 { for k, v := range mapped {
for k, v := range img { if v == nil {
merged[k] = v continue
} }
if item.CategoryUniqueID != "" { if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
merged["category"] = item.CategoryUniqueID continue
merged["category_unique_id"] = item.CategoryUniqueID
} }
mergedJSON, _ := json.Marshal(merged) merged[k] = v
_, _ = s.Pool.Exec(ctx, ` changed = true
UPDATE raw_products }
SET mapped_data = $3::jsonb, updated_at = now() if item.CategoryUniqueID != "" {
WHERE id = $1 AND company_id = $2`, existingID, companyID, string(mergedJSON))
} else if item.CategoryUniqueID != "" {
_ = json.Unmarshal(existingMapped, &merged)
merged["category"] = item.CategoryUniqueID merged["category"] = item.CategoryUniqueID
merged["category_unique_id"] = item.CategoryUniqueID merged["category_unique_id"] = item.CategoryUniqueID
changed = true
}
if changed {
mergedJSON, _ := json.Marshal(merged) mergedJSON, _ := json.Marshal(merged)
_, _ = s.Pool.Exec(ctx, ` _, _ = s.Pool.Exec(ctx, `
UPDATE raw_products UPDATE raw_products
@@ -342,6 +353,14 @@ func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uu
continue continue
} }
if !v1ItemHasContent(item) {
errs = append(errs, fmt.Sprintf(
"No catalog product for EAN %s. Use a GTIN from the cloned/synced feed, or include title/description/images in the request.",
item.EAN,
))
continue
}
var newID uuid.UUID var newID uuid.UUID
insErr := s.Pool.QueryRow(ctx, ` insErr := s.Pool.QueryRow(ctx, `
INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, is_processed) INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, is_processed)
+16
View File
@@ -38,3 +38,19 @@ func TestBuildMappedDataFromV1Item(t *testing.T) {
t.Fatalf("more=%s", b) t.Fatalf("more=%s", b)
} }
} }
func TestBuildMappedDataFromV1ItemOmitsEmptyNulls(t *testing.T) {
mapped := BuildMappedDataFromV1Item(V1ProcessItem{EAN: "123"})
if _, ok := mapped["title"]; ok {
t.Fatalf("empty title must be omitted, got %v", mapped)
}
if _, ok := mapped["description"]; ok {
t.Fatalf("empty description must be omitted, got %v", mapped)
}
if !v1ItemHasContent(V1ProcessItem{EAN: "1", Title: "x"}) {
t.Fatal("title should count as content")
}
if v1ItemHasContent(V1ProcessItem{EAN: "1"}) {
t.Fatal("EAN-only must not count as content")
}
}
@@ -69,11 +69,25 @@ func (s *Server) handleAdminCloneCompanyCatalog(w http.ResponseWriter, r *http.R
ClientOrLog(w, http.StatusBadRequest, "could not clone catalog", err, catalog.ClientError) ClientOrLog(w, http.StatusBadRequest, "could not clone catalog", err, catalog.ClientError)
return return
} }
// Point the admin session at the destination so Products / process use the cloned catalog.
if s.Sessions != nil {
s.Sessions.Put(r.Context(), auth.SessionCompanyIDKey, destID.String())
if s.Auth != nil {
if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil {
if _, memErr := s.Auth.EnsureMembership(r.Context(), uid, destID); memErr == nil {
s.Sessions.Remove(r.Context(), auth.SessionStaffHomeCompanyKey)
}
}
}
}
JSON(w, http.StatusOK, map[string]any{ JSON(w, http.StatusOK, map[string]any{
"status": "ok", "status": "ok",
"source_company_id": res.SourceCompanyID, "source_company_id": res.SourceCompanyID,
"dest_company_id": res.DestCompanyID, "dest_company_id": res.DestCompanyID,
"counts": res.Counts, "counts": res.Counts,
"active_company_id": destID,
}) })
} }
+1
View File
@@ -207,6 +207,7 @@ export type CloneCatalogResult = {
status: string; status: string;
source_company_id: string; source_company_id: string;
dest_company_id: string; dest_company_id: string;
active_company_id?: string;
counts: Record<string, number>; counts: Record<string, number>;
}; };
+1 -1
View File
@@ -2321,7 +2321,7 @@ export const en: MessageDict = {
"flash.admin.staffRoleUpdated": "Staff role updated for {email}.", "flash.admin.staffRoleUpdated": "Staff role updated for {email}.",
"flash.admin.staffRoleUnavailable": "Staff role updates are not available on this server yet.", "flash.admin.staffRoleUnavailable": "Staff role updates are not available on this server yet.",
"flash.admin.planAssigned": "Plan assigned to {name}.", "flash.admin.planAssigned": "Plan assigned to {name}.",
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} categories.", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} categories. Open Products and process catalog GTINs (EAN-only API calls need a matching feed product).",
"flash.admin.planAssignedShort": "Plan assigned.", "flash.admin.planAssignedShort": "Plan assigned.",
"flash.admin.creditsUpdated": "Credits updated.", "flash.admin.creditsUpdated": "Credits updated.",
"flash.admin.cyclesProcessed": "Billing cycles processed: {count}", "flash.admin.cyclesProcessed": "Billing cycles processed: {count}",
@@ -378,6 +378,7 @@
}); });
const products = Number(res.counts?.raw_products ?? 0); const products = Number(res.counts?.raw_products ?? 0);
const categories = Number(res.counts?.categories ?? 0); const categories = Number(res.counts?.categories ?? 0);
const activeDest = (res.active_company_id || res.dest_company_id || "").trim();
success = i18n.t("flash.admin.catalogCloned", { success = i18n.t("flash.admin.catalogCloned", {
source: cloneCompany.name, source: cloneCompany.name,
dest: cloneDestLabel, dest: cloneDestLabel,
@@ -386,6 +387,18 @@
}); });
cloneOpen = false; cloneOpen = false;
cloneCompany = null; cloneCompany = null;
if (activeDest) {
try {
await api("/api/auth/select-company", {
method: "POST",
body: { company_id: activeDest }
});
} catch {
/* clone handler may already have switched the session */
}
window.location.assign("/products");
return;
}
} catch (err) { } catch (err) {
error = failureMessage(err, "Clone catalog failed"); error = failureMessage(err, "Clone catalog failed");
} finally { } finally {