feat(F-100): completed feature

This commit is contained in:
chattie
2026-08-21 13:28:14 +02:00
parent 7418624df1
commit da919d705f
16 changed files with 413 additions and 49 deletions

View File

@@ -49,6 +49,12 @@ export function PriceStockSection({ productId }: { productId: string }) {
const [savingEan, setSavingEan] = useState(false);
const [eanMsg, setEanMsg] = useState('');
// SKU (F-100)
const [sku, setSku] = useState('');
const [savingSku, setSavingSku] = useState(false);
const [skuMsg, setSkuMsg] = useState('');
const [regeneratingSku, setRegeneratingSku] = useState(false);
// Peso y compra mínima (F-102)
const [unitWeightKg, setUnitWeightKg] = useState('1');
const [minPurchaseQty, setMinPurchaseQty] = useState('1');
@@ -77,6 +83,7 @@ export function PriceStockSection({ productId }: { productId: string }) {
setVariant(first);
setExtraVariants(Math.max(0, (items?.length ?? 0) - 1));
setEan(first?.ean ?? '');
setSku(first?.sku ?? '');
if (!first) return;
// Precio vigente
try {
@@ -172,6 +179,46 @@ export function PriceStockSection({ productId }: { productId: string }) {
}
};
const saveSku = async () => {
if (!variant) return;
const next = sku.trim();
if (!next || next === (variant.sku ?? '')) return;
if (!/^[A-Za-z0-9-]+$/.test(next)) {
setSkuMsg('SKU inválido (solo letras, números y guiones)');
return;
}
setSavingSku(true);
setSkuMsg('');
try {
const updated = await productsApi.updateVariant(productId, variant.id, { sku: next });
setVariant((prev) => (prev ? { ...prev, sku: updated.sku } : prev));
setSku(updated.sku);
setSkuMsg('✓');
setTimeout(() => setSkuMsg(''), 3000);
} catch (error) {
setSkuMsg(error instanceof Error && error.message.includes('409') ? 'SKU duplicado' : 'Error');
} finally {
setSavingSku(false);
}
};
const regenerateSku = async () => {
if (!variant) return;
setRegeneratingSku(true);
setSkuMsg('');
try {
const product = await productsApi.get(productId);
const { sku: suggestion } = await productsApi.generateSku(product.name);
setSku(suggestion);
setSkuMsg('Pulsa intro para guardar');
setTimeout(() => setSkuMsg(''), 3000);
} catch {
setSkuMsg('Error');
} finally {
setRegeneratingSku(false);
}
};
const saveEan = async () => {
if (!variant) return;
const next = ean.trim();
@@ -300,6 +347,34 @@ export function PriceStockSection({ productId }: { productId: string }) {
{stockMsg && <span className={`text-xs shrink-0 ${stockMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{stockMsg}</span>}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">SKU</label>
<div className="flex items-center gap-2">
<input
type="text" value={sku}
onChange={(e) => setSku(e.target.value)}
onBlur={saveSku}
onKeyDown={(e) => { if (e.key === 'Enter') saveSku(); }}
disabled={savingSku}
placeholder="MV-ESPELTA-ECOLOGICA"
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
/>
<button
type="button"
onClick={regenerateSku}
disabled={regeneratingSku}
className="shrink-0 px-3 py-2 text-xs font-semibold text-[#2D6A4F] border border-[#2D6A4F] rounded-xl hover:bg-[#2D6A4F] hover:text-white transition-colors disabled:opacity-50"
title="Regenerar sugerencia desde el nombre del producto"
>
{regeneratingSku ? '...' : '↻'}
</button>
{skuMsg && (
<span className={`text-xs shrink-0 ${skuMsg.startsWith('✓') || skuMsg.startsWith('Pulsa') ? 'text-green-600' : 'text-red-600'}`}>
{skuMsg}
</span>
)}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">EAN</label>
<div className="flex items-center gap-2">

View File

@@ -84,6 +84,8 @@ export const productsApi = {
api.patch<import('@/types').Product>(`/api/products/${id}`, data),
generateSeo: (id: string) =>
api.post<import('@/types').Product>(`/api/products/${id}/generate-seo`),
generateSku: (title: string) =>
api.post<{ sku: string }>(`/api/products/sku:generate`, { title }),
setState: (id: string, state: 'active' | 'archived') =>
api.patch(`/api/products/${id}/state`, { state }),
delete: (id: string) => api.delete(`/api/products/${id}`),

File diff suppressed because one or more lines are too long

View File

@@ -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.

View File

@@ -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 {

View 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');
}
}
}

View File

@@ -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',

View 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);
});
});