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())
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
merged[k] = v
|
merged[k] = v
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
if item.CategoryUniqueID != "" {
|
if item.CategoryUniqueID != "" {
|
||||||
merged["category"] = item.CategoryUniqueID
|
merged["category"] = item.CategoryUniqueID
|
||||||
merged["category_unique_id"] = item.CategoryUniqueID
|
merged["category_unique_id"] = item.CategoryUniqueID
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
mergedJSON, _ := json.Marshal(merged)
|
if changed {
|
||||||
_, _ = 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
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user