feat(F-100): completed feature
This commit is contained in:
@@ -41,6 +41,7 @@ import type { Product } from '../domain/product.js';
|
||||
import { PRODUCT_ATTRIBUTES, PRODUCT_STATES } from '../domain/product.js';
|
||||
import type { ProductRichData, ProductVariant } from '../domain/variant.js';
|
||||
import { NUTRITION_SOURCES } from '../domain/variant.js';
|
||||
import { generateSkuFromTitle, uniqueSku } from '../domain/sku.js';
|
||||
import { LocalProductImageStorage } from '../infrastructure/local-product-image-storage.js';
|
||||
import { PgProductImageRepository } from '../infrastructure/pg-product-image-repository.js';
|
||||
import { PgProductRepository } from '../infrastructure/pg-product-repository.js';
|
||||
@@ -367,6 +368,40 @@ export async function registerCatalogRoutes(
|
||||
return reply.send({ suggestions });
|
||||
});
|
||||
|
||||
// ── Sugerir SKU (F-100) ─────────────────────────────────────────────────
|
||||
const generateSkuSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Suggest a SKU from a product title',
|
||||
description: 'Genera un SKU a partir del título y garantiza que no colisiona con los SKUs existentes.',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['title'],
|
||||
properties: { title: { type: 'string', minLength: 1, maxLength: 200 } },
|
||||
},
|
||||
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema, 422: errorSchema },
|
||||
};
|
||||
app.post('/products/sku:generate', { schema: generateSkuSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { title } = parseJson(
|
||||
z.object({ title: z.string().min(1).max(200) }),
|
||||
request.body,
|
||||
);
|
||||
let base: string;
|
||||
try {
|
||||
base = generateSkuFromTitle(title);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
422,
|
||||
'INVALID_TITLE',
|
||||
error instanceof Error ? error.message : 'Invalid title',
|
||||
);
|
||||
}
|
||||
const taken = new Set(await variants.listAllSkus());
|
||||
const sku = uniqueSku(base, taken);
|
||||
return reply.send({ sku });
|
||||
});
|
||||
|
||||
const createProductSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Create product',
|
||||
@@ -380,10 +415,13 @@ export async function registerCatalogRoutes(
|
||||
const input = parseJson(newProductSchema, request.body);
|
||||
try {
|
||||
const product = await createProduct.execute(input);
|
||||
// Modelo sin variantes visibles: cada producto lleva una única variante
|
||||
// interna autogenerada (SKU interno, nunca editable).
|
||||
// Cada producto lleva una única variante interna autogenerada con un
|
||||
// SKU derivado del título (F-100). El admin puede editarlo después.
|
||||
try {
|
||||
await createVariant.execute(product.id, { sku: `SKU-MV-${product.id}` });
|
||||
const base = generateSkuFromTitle(product.name);
|
||||
const taken = new Set(await variants.listAllSkus());
|
||||
const sku = uniqueSku(base, taken);
|
||||
await createVariant.execute(product.id, { sku });
|
||||
} catch (variantError) {
|
||||
request.log.warn({ err: variantError, productId: product.id }, 'default_variant_create_failed');
|
||||
}
|
||||
@@ -412,8 +450,14 @@ export async function registerCatalogRoutes(
|
||||
try {
|
||||
const user = await deps.authenticate(request);
|
||||
if (user.role === 'admin') {
|
||||
const created = await createVariant.execute(id, { sku: `SKU-MV-${id}` });
|
||||
if (created) items = [created];
|
||||
const product = await repository.findById(id);
|
||||
if (product) {
|
||||
const base = generateSkuFromTitle(product.name);
|
||||
const taken = new Set(await variants.listAllSkus());
|
||||
const sku = uniqueSku(base, taken);
|
||||
const created = await createVariant.execute(id, { sku });
|
||||
if (created) items = [created];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Sin sesión admin: se devuelve la lista vacía sin crear nada.
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface ProductVariantRepository {
|
||||
variantId: string,
|
||||
patch: ProductVariantPatch,
|
||||
): Promise<ProductVariant | undefined>;
|
||||
/** Returns all SKUs (uppercase preserved) currently in use. */
|
||||
listAllSkus(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface ProductRichDataRepository {
|
||||
|
||||
57
project/src/modules/catalog/domain/sku.ts
Normal file
57
project/src/modules/catalog/domain/sku.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* SKU generation helpers (F-100).
|
||||
*
|
||||
* Operators no longer have to type a SKU manually when creating a product.
|
||||
* The SKU is derived from the product title, normalised, and uniqued
|
||||
* against the existing SKU table.
|
||||
*/
|
||||
|
||||
const SKU_PREFIX = 'MV';
|
||||
const MAX_SKU_LENGTH = 100;
|
||||
const SLUG_REGEX = /[^a-z0-9]+/g;
|
||||
|
||||
/**
|
||||
* Builds a SKU from a product title. The result is uppercase, ASCII-only,
|
||||
* uses hyphens as separators and starts with the `MV-` prefix to keep
|
||||
* SKUs aligned with the existing convention.
|
||||
*
|
||||
* Throws if `title` is empty or only punctuation.
|
||||
*/
|
||||
export function generateSkuFromTitle(title: string): string {
|
||||
const slug = title
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(SLUG_REGEX, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-');
|
||||
if (!slug) {
|
||||
throw new Error('Cannot generate a SKU from an empty title');
|
||||
}
|
||||
// Prefix + slug, trimmed to fit within MAX_SKU_LENGTH.
|
||||
const base = `${SKU_PREFIX}-${slug.toUpperCase()}`;
|
||||
return base.slice(0, MAX_SKU_LENGTH).replace(/-+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a SKU that does not collide with `existing` (case-insensitive).
|
||||
* Appends `-2`, `-3`, … until a free one is found, truncating the base
|
||||
* to fit within MAX_SKU_LENGTH.
|
||||
*/
|
||||
export function uniqueSku(base: string, existing: ReadonlySet<string>): string {
|
||||
const lower = new Set([...existing].map((value) => value.toLowerCase()));
|
||||
const baseLower = base.toLowerCase();
|
||||
if (!lower.has(baseLower)) return base;
|
||||
let i = 2;
|
||||
while (true) {
|
||||
const suffix = `-${i}`;
|
||||
const trimmed = `${base}`.slice(0, MAX_SKU_LENGTH - suffix.length).replace(/-+$/, '');
|
||||
const candidate = `${trimmed}${suffix}`;
|
||||
if (!lower.has(candidate.toLowerCase())) return candidate;
|
||||
i += 1;
|
||||
if (i > 9999) {
|
||||
throw new Error('Cannot generate a unique SKU after 9999 attempts');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ const UPDATABLE: ReadonlyArray<[keyof ProductVariantPatch, string]> = [
|
||||
export class PgProductVariantRepository implements ProductVariantRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async listAllSkus(): Promise<string[]> {
|
||||
const result = await this.pool.query<{ sku: string }>(
|
||||
'SELECT sku FROM catalog_product_variants',
|
||||
);
|
||||
return result.rows.map((row) => row.sku);
|
||||
}
|
||||
|
||||
async listByProductId(productId: string): Promise<ProductVariant[]> {
|
||||
const result = await this.pool.query<VariantRow>(
|
||||
'SELECT * FROM catalog_product_variants WHERE product_id = $1 ORDER BY created_at, sku',
|
||||
|
||||
52
project/src/modules/catalog/tests/sku.test.ts
Normal file
52
project/src/modules/catalog/tests/sku.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateSkuFromTitle, uniqueSku } from '../domain/sku.js';
|
||||
|
||||
describe('generateSkuFromTitle', () => {
|
||||
it('uppercases, strips diacritics and replaces separators with hyphens', () => {
|
||||
expect(generateSkuFromTitle('Espelta ecológica')).toBe('MV-ESPELTA-ECOLOGICA');
|
||||
expect(generateSkuFromTitle('Aceite de Oliva Virgen Extra')).toBe('MV-ACEITE-DE-OLIVA-VIRGEN-EXTRA');
|
||||
});
|
||||
|
||||
it('handles & by expanding to "AND"', () => {
|
||||
expect(generateSkuFromTitle('Bread & Pastries')).toBe('MV-BREAD-AND-PASTRIES');
|
||||
});
|
||||
|
||||
it('collapses repeated hyphens and trims edges', () => {
|
||||
expect(generateSkuFromTitle(' Hola --- Mundo ')).toBe('MV-HOLA-MUNDO');
|
||||
});
|
||||
|
||||
it('throws when the title is empty or punctuation only', () => {
|
||||
expect(() => generateSkuFromTitle('')).toThrow();
|
||||
expect(() => generateSkuFromTitle(' ')).toThrow();
|
||||
expect(() => generateSkuFromTitle('!!!')).toThrow();
|
||||
});
|
||||
|
||||
it('trims to MAX_SKU_LENGTH (100 chars) and strips trailing hyphens', () => {
|
||||
const long = 'a'.repeat(200);
|
||||
const sku = generateSkuFromTitle(long);
|
||||
expect(sku.length).toBeLessThanOrEqual(100);
|
||||
expect(sku.endsWith('-')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueSku', () => {
|
||||
it('returns the base when free', () => {
|
||||
expect(uniqueSku('MV-ESPELTA', new Set(['MV-OTHER']))).toBe('MV-ESPELTA');
|
||||
});
|
||||
|
||||
it('appends -2, -3, ... on collision', () => {
|
||||
expect(uniqueSku('MV-ESPELTA', new Set(['MV-ESPELTA']))).toBe('MV-ESPELTA-2');
|
||||
expect(uniqueSku('MV-ESPELTA', new Set(['MV-ESPELTA', 'MV-ESPELTA-2']))).toBe('MV-ESPELTA-3');
|
||||
});
|
||||
|
||||
it('treats existing SKUs case-insensitively', () => {
|
||||
expect(uniqueSku('MV-ESPELTA', new Set(['mv-espelta']))).toBe('MV-ESPELTA-2');
|
||||
});
|
||||
|
||||
it('truncates the base to keep the result <= 100 chars', () => {
|
||||
const base = 'MV-' + 'A'.repeat(120);
|
||||
const result = uniqueSku(base, new Set([base.toLowerCase()]));
|
||||
expect(result.length).toBeLessThanOrEqual(100);
|
||||
expect(result.endsWith('-2')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user