fix
This commit is contained in:
@@ -433,6 +433,25 @@ func cloneCompanyCatalog(ctx context.Context, pool *pgxpool.Pool, src, dest uuid
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ func NormalizeGTIN(ean string) string {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
mapped := map[string]any{
|
||||
"ean": item.EAN,
|
||||
@@ -49,23 +50,15 @@ func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any {
|
||||
if item.Title != "" {
|
||||
mapped["title"] = item.Title
|
||||
mapped["name"] = item.Title
|
||||
} else {
|
||||
mapped["title"] = nil
|
||||
}
|
||||
if item.Description != "" {
|
||||
mapped["description"] = item.Description
|
||||
} else {
|
||||
mapped["description"] = nil
|
||||
}
|
||||
if len(item.Specifications) > 0 {
|
||||
mapped["specifications"] = item.Specifications
|
||||
} else {
|
||||
mapped["specifications"] = []any{}
|
||||
}
|
||||
if item.Search != "" {
|
||||
mapped["search"] = item.Search
|
||||
} else {
|
||||
mapped["search"] = nil
|
||||
}
|
||||
if item.CategoryUniqueID != "" {
|
||||
mapped["category"] = item.CategoryUniqueID
|
||||
@@ -77,6 +70,21 @@ func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any {
|
||||
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 {
|
||||
source := map[string]any{}
|
||||
putIf := func(k, v string) {
|
||||
@@ -279,6 +287,10 @@ type EnsureRawResult struct {
|
||||
|
||||
// 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).
|
||||
//
|
||||
// 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) {
|
||||
if s == nil || s.Pool == nil {
|
||||
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 {
|
||||
merged := map[string]any{}
|
||||
_ = json.Unmarshal(existingMapped, &merged)
|
||||
img := mappedImageFieldsFromV1Item(item)
|
||||
if len(img) > 0 {
|
||||
for k, v := range img {
|
||||
changed := false
|
||||
for k, v := range mapped {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
merged[k] = v
|
||||
changed = true
|
||||
}
|
||||
if item.CategoryUniqueID != "" {
|
||||
merged["category"] = item.CategoryUniqueID
|
||||
merged["category_unique_id"] = item.CategoryUniqueID
|
||||
changed = true
|
||||
}
|
||||
mergedJSON, _ := json.Marshal(merged)
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE raw_products
|
||||
SET mapped_data = $3::jsonb, updated_at = now()
|
||||
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_unique_id"] = item.CategoryUniqueID
|
||||
if changed {
|
||||
mergedJSON, _ := json.Marshal(merged)
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE raw_products
|
||||
@@ -342,6 +353,14 @@ func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uu
|
||||
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
|
||||
insErr := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, is_processed)
|
||||
|
||||
@@ -38,3 +38,19 @@ func TestBuildMappedDataFromV1Item(t *testing.T) {
|
||||
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)
|
||||
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{
|
||||
"status": "ok",
|
||||
"source_company_id": res.SourceCompanyID,
|
||||
"dest_company_id": res.DestCompanyID,
|
||||
"counts": res.Counts,
|
||||
"active_company_id": destID,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ export type CloneCatalogResult = {
|
||||
status: string;
|
||||
source_company_id: string;
|
||||
dest_company_id: string;
|
||||
active_company_id?: string;
|
||||
counts: Record<string, number>;
|
||||
};
|
||||
|
||||
|
||||
@@ -2321,7 +2321,7 @@ export const en: MessageDict = {
|
||||
"flash.admin.staffRoleUpdated": "Staff role updated for {email}.",
|
||||
"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} 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.creditsUpdated": "Credits updated.",
|
||||
"flash.admin.cyclesProcessed": "Billing cycles processed: {count}",
|
||||
|
||||
@@ -378,6 +378,7 @@
|
||||
});
|
||||
const products = Number(res.counts?.raw_products ?? 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", {
|
||||
source: cloneCompany.name,
|
||||
dest: cloneDestLabel,
|
||||
@@ -386,6 +387,18 @@
|
||||
});
|
||||
cloneOpen = false;
|
||||
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) {
|
||||
error = failureMessage(err, "Clone catalog failed");
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user