feat(F-114): completed feature

This commit is contained in:
chattie
2026-08-21 13:01:42 +02:00
parent c05c0b0582
commit 21aeac307e
12 changed files with 663 additions and 51 deletions

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* seed-legacy-categories — F-114
*
* Imports the legacy OpenCart `oc_category_description` rows into the
* current catalog and brand tables. Reads the legacy list from the
* pure helper at `src/modules/categories/legacy/legacy-catalog` so the
* mapping stays in sync with the tests.
*
* Idempotent: skips entries whose name (case-insensitive) already exists in
* the target table, and uniquifies slugs against existing ones.
*
* Usage:
* npm run seed:legacy-categories # apply
* DATABASE_URL=... node scripts/seed-legacy-categories.mjs --dry-run
*/
import pg from 'pg';
import { buildImportPlan } from '../dist/modules/categories/legacy/legacy-catalog.js';
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error('DATABASE_URL is required');
process.exit(2);
}
const args = new Set(process.argv.slice(2));
const dryRun = args.has('--dry-run');
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 1 });
try {
const categories = await pool.query('SELECT id, name, slug FROM categories_categories');
const brands = await pool.query('SELECT id, name, slug FROM brands_brands');
const categoryNameIndex = new Set(categories.rows.map((r) => r.name.toLowerCase()));
const brandNameIndex = new Set(brands.rows.map((r) => r.name.toLowerCase()));
const slugIndex = new Set(
[...categories.rows, ...brands.rows].map((r) => r.slug.toLowerCase()),
);
const parentBySlug = new Map(categories.rows.map((r) => [r.slug, r.id]));
const plan = buildImportPlan(categoryNameIndex, brandNameIndex, slugIndex);
// Validate that every parentSlug resolves to an existing category.
const missingParents = new Set();
for (const cat of plan.categories) {
if (cat.parentSlug && !parentBySlug.has(cat.parentSlug)) missingParents.add(cat.parentSlug);
}
if (missingParents.size > 0) {
console.error(`Missing parent categories: ${[...missingParents].join(', ')}`);
process.exit(1);
}
console.log(`Planned: ${plan.categories.length} categories, ${plan.brands.length} brands`);
if (dryRun) {
console.log('DRY RUN — nothing will be written.');
for (const cat of plan.categories) {
console.log(` + category ${cat.name} (slug=${cat.slug}, parent=${cat.parentSlug ?? 'ROOT'})`);
}
for (const brand of plan.brands) {
console.log(` + brand ${brand.name} (slug=${brand.slug})`);
}
process.exit(0);
}
for (const cat of plan.categories) {
const parentId = cat.parentSlug ? parentBySlug.get(cat.parentSlug) : null;
await pool.query(
`INSERT INTO categories_categories (parent_id, name, slug, is_parent)
VALUES ($1, $2, $3, false)
ON CONFLICT (slug) DO NOTHING`,
[parentId, cat.name, cat.slug],
);
console.log(` + category ${cat.name} (slug=${cat.slug}, parent=${cat.parentSlug ?? 'ROOT'})`);
}
for (const brand of plan.brands) {
await pool.query(
`INSERT INTO brands_brands (name, slug) VALUES ($1, $2)
ON CONFLICT (slug) DO NOTHING`,
[brand.name, brand.slug],
);
console.log(` + brand ${brand.name} (slug=${brand.slug})`);
}
console.log(`\nDone. Inserted ${plan.categories.length} categories and ${plan.brands.length} brands.`);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
} finally {
await pool.end();
}

View File

@@ -0,0 +1,235 @@
/**
* Legacy OpenCart catalog import (F-114).
*
* This module is a pure helper that:
* - declares the legacy categories from `oc_category_description`
* - normalises names (HTML entities, whitespace, case)
* - classifies each entry as a real category, a brand, or excluded
* - picks a parent category for each new category
*
* The seed script (`scripts/seed-legacy-categories.mjs`) consumes this helper
* to import the entries idempotently. Domain logic is kept here so it can be
* unit tested without touching the database.
*/
export type LegacyKind = 'category' | 'brand' | 'exclude';
/**
* A parent assignment. We resolve by `slug` against the existing categories
* at runtime so the seed script can look up the UUID once, and so the
* mapping is easy to read in tests.
*/
export interface LegacyEntry {
/** Original OpenCart category_id. */
ocId: number;
/** Decoded, trimmed name. */
name: string;
/** Classification (category / brand / exclude). */
kind: LegacyKind;
/** Parent slug for new categories. undefined = root. */
parentSlug?: string;
}
/** Lowercases + collapses whitespace + decodes HTML entities. */
export function normalizeName(raw: string): string {
const decoded = decodeHtmlEntities(raw)
.replace(/\s+/g, ' ')
.trim();
return decoded;
}
/** Case-insensitive comparison for "already exists" checks. */
export function isSameName(left: string, right: string): boolean {
return normalizeName(left).toLowerCase() === normalizeName(right).toLowerCase();
}
/** Minimal HTML entity decoder for the entities we know appear in the legacy table. */
export function decodeHtmlEntities(raw: string): string {
const named: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
};
return raw.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (entity, body) => {
if (body in named) return named[body] ?? entity;
if (body.startsWith('#x') || body.startsWith('#X')) {
const code = parseInt(body.slice(2), 16);
if (Number.isFinite(code)) return String.fromCodePoint(code);
} else if (body.startsWith('#')) {
const code = parseInt(body.slice(1), 10);
if (Number.isFinite(code)) return String.fromCodePoint(code);
}
return entity;
});
}
/**
* Converts a name into a URL slug matching the categories/brands validator:
* `^[a-z0-9]+(?:-[a-z0-9]+)*$`. Strips diacritics, drops non-alphanumerics.
*/
export function slugify(name: string): string {
const stripped = normalizeName(name)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return stripped || 'entry';
}
/**
* Returns a slug that does not collide with `existing` (case-insensitive).
* Appends `-2`, `-3`, … until a free one is found.
*/
export function uniqueSlug(base: string, existing: ReadonlySet<string>): string {
const lower = base.toLowerCase();
if (!existing.has(lower)) return base;
let i = 2;
while (existing.has(`${lower}-${i}`)) i += 1;
return `${base}-${i}`;
}
/**
* The legacy OpenCart rows. Each entry is classified and (for categories)
* pinned to a parent by slug so the import is deterministic.
* Excluded entries are legacy markers (`00 - SIN CODIGO`, etc.) that we
* do not want to surface in the new catalog.
*/
export const LEGACY_ENTRIES: ReadonlyArray<LegacyEntry> = [
{ ocId: 66, name: '00 - SIN CODIGO', kind: 'exclude' },
{ ocId: 67, name: 'HERBALIST', kind: 'category', parentSlug: 'hierbas-infusiones' },
{ ocId: 68, name: 'FOOD', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 69, name: 'COSMETICS', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
{ ocId: 70, name: 'DIET AND NUTRITION', kind: 'category', parentSlug: 'suplementos' },
{ ocId: 71, name: 'NUTS & SEEDS', kind: 'category', parentSlug: 'frutos-secos' },
{ ocId: 72, name: 'BREAD & PASTRIES', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 75, name: 'LEGUMES', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 76, name: 'FLOUR & CEREALS', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 77, name: 'PASTA & RICE', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 78, name: 'CREAMS & JAMS', kind: 'category', parentSlug: 'cremas' },
{ ocId: 79, name: 'FRUTAS Y VERDURAS', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 80, name: 'SUGAR & SWEETENERS', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 81, name: 'BEVERAGES', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 82, name: 'OIL AND VINEGAR', kind: 'category', parentSlug: 'aceites' },
{ ocId: 83, name: 'RAW FOOD', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 84, name: 'HIERBAS MEDICINALES', kind: 'category', parentSlug: 'hierbas-infusiones' },
{ ocId: 85, name: 'SUPLEMENTS', kind: 'category', parentSlug: 'suplementos' },
{ ocId: 86, name: 'OILS & EXTRACTS', kind: 'category', parentSlug: 'aceites' },
{ ocId: 87, name: 'FACIAL', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
{ ocId: 88, name: 'CORPORAL', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
{ ocId: 89, name: 'ASEO PERSONAL', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
{ ocId: 90, name: 'HOME', kind: 'category', parentSlug: 'hogar-y-mascotas' },
{ ocId: 91, name: 'MACROBIOTIC', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 93, name: 'SNACKS', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 95, name: 'BOOKS', kind: 'category', parentSlug: 'hogar-y-mascotas' },
{ ocId: 97, name: 'FRESH PRODUCTS', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 102, name: 'WINE', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 103, name: 'BEER', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 104, name: 'VEGETAL MILKS', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 105, name: 'JUICES', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 106, name: 'SODAS', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 107, name: 'CHILDREN', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 108, name: 'BABYS & KIDS', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
{ ocId: 115, name: 'TEA & INFUSIONS', kind: 'category', parentSlug: 'hierbas-infusiones' },
{ ocId: 116, name: 'SPICE & CONDIMENTS', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 117, name: 'CHOCOLATE & SWEETS', kind: 'category', parentSlug: 'alimentacion' },
{ ocId: 129, name: 'CLEANING', kind: 'category', parentSlug: 'limpieza-ecologica' },
{ ocId: 127, name: 'PROVEEDORES', kind: 'category' }, // root
// ── Marcas ───────────────────────────────────────────────────────────────
{ ocId: 98, name: 'PROVEEDORES', kind: 'brand' },
{ ocId: 99, name: 'BIOSPIRIT', kind: 'brand' },
{ ocId: 100, name: 'SALUD VIVA', kind: 'brand' },
{ ocId: 101, name: 'GLOBO NATURA', kind: 'brand' },
{ ocId: 102, name: 'SOLGAR', kind: 'brand' },
{ ocId: 103, name: 'VITAFOOD', kind: 'brand' },
{ ocId: 104, name: 'EL GRANERO INTEGRAL', kind: 'brand' },
{ ocId: 105, name: 'A.VOGEL', kind: 'brand' },
{ ocId: 106, name: 'BIOCOP', kind: 'brand' },
{ ocId: 107, name: 'QBIO', kind: 'brand' },
{ ocId: 108, name: 'LA FINESTRA SUL CIELO', kind: 'brand' },
{ ocId: 109, name: 'CADIDIET', kind: 'brand' },
{ ocId: 110, name: 'COMPLEMENTOS Y NUTRICIÓN', kind: 'brand' },
{ ocId: 111, name: 'CHISVERT', kind: 'brand' },
{ ocId: 112, name: 'SUPERFOOD', kind: 'brand' },
{ ocId: 113, name: 'DIETISUR', kind: 'brand' },
{ ocId: 114, name: 'GOURMET BIO', kind: 'brand' },
{ ocId: 116, name: 'NATURALMENTE MEDITERRANEO', kind: 'brand' },
{ ocId: 117, name: 'NATURAL', kind: 'brand' },
{ ocId: 126, name: 'GOURMET CASH', kind: 'brand' },
{ ocId: 127, name: 'NUTRINAT', kind: 'brand' },
{ ocId: 131, name: 'COMERCIAL GARZA', kind: 'brand' },
{ ocId: 132, name: 'ARTESANÍA AGRÍCOLA', kind: 'brand' },
{ ocId: 133, name: 'DAS BROT', kind: 'brand' },
{ ocId: 134, name: 'DULCES LISSEN', kind: 'brand' },
{ ocId: 135, name: 'LEMONPHARMA', kind: 'brand' },
{ ocId: 136, name: 'VEKINE', kind: 'brand' },
{ ocId: 137, name: 'NAAY BOTANICALS', kind: 'brand' },
{ ocId: 138, name: 'LAMBERTS', kind: 'brand' },
{ ocId: 139, name: 'YODETIENDAS', kind: 'brand' },
{ ocId: 140, name: 'NATURCOSMETIKA', kind: 'brand' },
{ ocId: 142, name: 'TONGIL', kind: 'brand' },
{ ocId: 96, name: 'SUPLEMENTS', kind: 'exclude' }, // duplicate of 85 (we keep 85 as category)
{ ocId: 141, name: '01 - PRODUCTOS DESCATALOGADOS', kind: 'exclude' },
{ ocId: 143, name: '02 - PRODUCTOS RAPIDOS', kind: 'exclude' },
{ ocId: 145, name: 'GRANOLA', kind: 'category', parentSlug: 'alimentacion' },
];
export interface ImportPlanCategory {
ocId: number;
name: string;
slug: string;
parentSlug?: string;
}
export interface ImportPlanBrand {
ocId: number;
name: string;
slug: string;
}
export interface ImportPlan {
categories: ImportPlanCategory[];
brands: ImportPlanBrand[];
}
/**
* Builds the import plan from `LEGACY_ENTRIES`, skipping entries whose
* name (case-insensitive) is already present in `existingCategoryNames` or
* `existingBrandNames`. Slugs are uniqued against `existingSlugs`.
*/
export function buildImportPlan(
existingCategoryNames: ReadonlySet<string>,
existingBrandNames: ReadonlySet<string>,
existingSlugs: ReadonlySet<string>,
): ImportPlan {
const categories: ImportPlanCategory[] = [];
const brands: ImportPlanBrand[] = [];
const usedSlugs = new Set<string>(existingSlugs);
for (const entry of LEGACY_ENTRIES) {
if (entry.kind === 'exclude') continue;
if (entry.kind === 'category') {
if (existingCategoryNames.has(entry.name.toLowerCase())) continue;
const base = slugify(entry.name);
const slug = uniqueSlug(base, usedSlugs);
usedSlugs.add(slug.toLowerCase());
categories.push({
ocId: entry.ocId,
name: entry.name,
slug,
parentSlug: entry.parentSlug,
});
} else if (entry.kind === 'brand') {
if (existingBrandNames.has(entry.name.toLowerCase())) continue;
const base = slugify(entry.name);
const slug = uniqueSlug(base, usedSlugs);
usedSlugs.add(slug.toLowerCase());
brands.push({ ocId: entry.ocId, name: entry.name, slug });
}
}
return { categories, brands };
}

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import {
buildImportPlan,
decodeHtmlEntities,
isSameName,
LEGACY_ENTRIES,
normalizeName,
slugify,
uniqueSlug,
} from '../legacy/legacy-catalog.js';
describe('normalizeName', () => {
it('trims and collapses whitespace', () => {
expect(normalizeName(' hello world ')).toBe('hello world');
});
it('decodes HTML entities', () => {
expect(normalizeName('NUTS &amp; SEEDS')).toBe('NUTS & SEEDS');
expect(normalizeName('A &amp; B &nbsp; C')).toBe('A & B C');
});
it('keeps unknown entities as-is', () => {
expect(normalizeName('foo &bogus; bar')).toBe('foo &bogus; bar');
});
});
describe('decodeHtmlEntities', () => {
it('decodes numeric entities', () => {
expect(decodeHtmlEntities('&#38;')).toBe('&');
expect(decodeHtmlEntities('&#x26;')).toBe('&');
});
});
describe('isSameName', () => {
it('compares case-insensitively after normalization', () => {
expect(isSameName('nuez', 'NUEZ')).toBe(true);
expect(isSameName('NUTS & SEEDS', 'nuts & seeds')).toBe(true);
expect(isSameName('Hierbas', 'Hierba')).toBe(false);
});
});
describe('slugify', () => {
it('lowercases, strips diacritics and replaces non-alphanumerics', () => {
expect(slugify('Frutas y Verduras')).toBe('frutas-y-verduras');
expect(slugify('NUTS & SEEDS')).toBe('nuts-and-seeds');
expect(slugify('A. Vogel')).toBe('a-vogel');
expect(slugify('La Finestra Sul Cielo')).toBe('la-finestra-sul-cielo');
});
it('falls back to "entry" for empty input', () => {
expect(slugify('---')).toBe('entry');
});
});
describe('uniqueSlug', () => {
it('returns the base when free', () => {
expect(uniqueSlug('aceites', new Set(['frutos-secos']))).toBe('aceites');
});
it('appends a numeric suffix on collision', () => {
expect(uniqueSlug('aceites', new Set(['aceites', 'aceites-2']))).toBe('aceites-3');
});
});
describe('buildImportPlan', () => {
it('classifies every legacy entry as category, brand, or exclude', () => {
const kinds = new Set(LEGACY_ENTRIES.map((e) => e.kind));
expect(kinds.has('category')).toBe(true);
expect(kinds.has('brand')).toBe(true);
expect(kinds.has('exclude')).toBe(true);
});
it('skips entries whose name already exists in the catalog (case-insensitive)', () => {
const existingCategories = new Set(['nuts & seeds']);
const existingBrands = new Set<string>();
const existingSlugs = new Set<string>();
const plan = buildImportPlan(existingCategories, existingBrands, existingSlugs);
expect(plan.categories.find((c) => c.name === 'NUTS & SEEDS')).toBeUndefined();
});
it('skips excluded legacy markers', () => {
const plan = buildImportPlan(new Set(), new Set(), new Set());
expect(plan.categories.find((c) => c.name.startsWith('00 -'))).toBeUndefined();
expect(plan.categories.find((c) => c.name.startsWith('01 -'))).toBeUndefined();
expect(plan.categories.find((c) => c.name.startsWith('02 -'))).toBeUndefined();
});
it('produces unique slugs across categories and brands', () => {
const plan = buildImportPlan(new Set(), new Set(), new Set());
const slugs = [...plan.categories.map((c) => c.slug), ...plan.brands.map((b) => b.slug)];
expect(new Set(slugs).size).toBe(slugs.length);
});
it('attaches the declared parent to each category', () => {
const plan = buildImportPlan(new Set(), new Set(), new Set());
const nuts = plan.categories.find((c) => c.name === 'NUTS & SEEDS');
expect(nuts?.parentSlug).toBe('frutos-secos');
});
it('does not include brands as categories', () => {
const plan = buildImportPlan(new Set(), new Set(), new Set());
expect(plan.categories.find((c) => c.name === 'SOLGAR')).toBeUndefined();
expect(plan.brands.find((b) => b.name === 'SOLGAR')).toBeDefined();
});
});