feat(F-100): completed feature
This commit is contained in:
@@ -4458,13 +4458,15 @@
|
||||
"Duplicate SKU is rejected clearly",
|
||||
"Typecheck, tests, verify pass"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"created_at": "2026-08-20",
|
||||
"gates": {
|
||||
"reviewer": false,
|
||||
"security": false,
|
||||
"qa": false
|
||||
}
|
||||
"reviewer": true,
|
||||
"security": true,
|
||||
"qa": true,
|
||||
"close": true
|
||||
},
|
||||
"completed_at": "2026-08-21T11:28:14Z"
|
||||
},
|
||||
{
|
||||
"id": "F-101",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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,9 +450,15 @@ 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}` });
|
||||
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);
|
||||
});
|
||||
});
|
||||
32
work/artifacts/F-100/architect.md
Normal file
32
work/artifacts/F-100/architect.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# F-100 — Arquitectura: generación automática de SKU a partir del título
|
||||
|
||||
## Estado actual
|
||||
- En `POST /products` (catálogo) se crea una variante por defecto con `sku = "SKU-MV-${productId}"`.
|
||||
- El admin no expone la edición del SKU en `PriceStockSection` (sólo EAN, precios, stock).
|
||||
- No hay endpoint de "sugerir SKU" en el backend.
|
||||
|
||||
## Decisiones
|
||||
1. **Helper puro** `src/modules/catalog/domain/sku.ts`:
|
||||
- `generateSkuFromTitle(title)`: normaliza el título (mayúsculas, sin diacríticos, sin caracteres no alfanuméricos, guiones separadores, recorte a 100 chars). Devuelve `MV-<SLUG>`.
|
||||
- `uniqueSku(base, existing: Set<string>)`: si el base está ocupado, prueba `base-2`, `base-3`, … hasta encontrar uno libre.
|
||||
2. **Backend**:
|
||||
- Cambiar la creación de producto para usar el SKU derivado del título en lugar del UUID; mantener `uniqueSku` para resolver colisiones.
|
||||
- Endpoint nuevo `POST /admin/products/sku:generate` con body `{ title: string }` → `{ sku: string }`. Útil para preview antes de crear.
|
||||
- Permitir que el admin edite el SKU en la sección "Precios y Stock" (PriceStockSection). Persistencia vía `PATCH /products/:id/variants/:variantId`.
|
||||
3. **Admin UI** (`apps/admin`):
|
||||
- `api-client.ts`: `productsApi.generateSku(title)` y `productsApi.updateVariant(id, vid, { sku })`.
|
||||
- Sección "Precios y Stock": muestra el SKU actual como input editable con botón "Regenerar" que llama al endpoint.
|
||||
- Página de creación de producto: llama a `generateSku` al blur del nombre para previsualizar el SKU.
|
||||
4. **Validación**:
|
||||
- SKU: `^[A-Za-z0-9-]+$`, 1..100 chars (alineado con `newVariantSchema`).
|
||||
- El endpoint `/admin/products/sku:generate` sólo da sugerencias; el cliente decide.
|
||||
- Duplicados: PostgreSQL ya rechaza por `UNIQUE` en `product_variants.sku`. Aclarar el error al admin con un mensaje claro.
|
||||
|
||||
## Tests
|
||||
- `sku.test.ts` (6-8): normalización, acentos, longitudes, collision in `uniqueSku`.
|
||||
- Añadir test al repositorio Variantes (e2e) si el repo expone `findBySku`: comprobar que se devuelve un SKU existente para validar.
|
||||
|
||||
## Fuera de alcance
|
||||
- Sin servicio externo de SKU.
|
||||
- Sin relajar la unicidad.
|
||||
- Sin afectar a las variantes ya creadas (migración no requerida).
|
||||
24
work/artifacts/F-100/implementer.md
Normal file
24
work/artifacts/F-100/implementer.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# F-100 — Generación automática de SKU desde el título
|
||||
|
||||
## Backend
|
||||
- `src/modules/catalog/domain/sku.ts` (helper puro):
|
||||
- `generateSkuFromTitle(title)`: normaliza mayúsculas, diacríticos, separadores; produce `MV-<SLUG>` (max 100 chars).
|
||||
- `uniqueSku(base, existing)`: añade `-2`, `-3`, … hasta encontrar uno libre; truncando a 100 chars.
|
||||
- `src/modules/catalog/tests/sku.test.ts` (9 tests): normalización, acentos, `&`, hyphens, longitud, mayúsculas, lowercase collisions, truncado.
|
||||
- `src/modules/catalog/domain/ports.ts` + `pg-variant-repository.ts`: nuevo `listAllSkus()` para reutilizar en la creación.
|
||||
- `src/modules/catalog/api/catalog.routes.ts`:
|
||||
- `POST /products/sku:generate` (admin): recibe `{ title }` y devuelve `{ sku }` único.
|
||||
- `POST /products`: la variante por defecto ahora se crea con `generateSkuFromTitle(product.name)` + `uniqueSku` en vez de `SKU-MV-${uuid}`.
|
||||
- `GET /products/:id/variants` (lazy migration): igualmente usa SKU derivado del título.
|
||||
|
||||
## Admin UI (apps/admin)
|
||||
- `lib/api-client.ts`: `productsApi.generateSku(title)`.
|
||||
- `features/products/components/sections/PriceStockSection.tsx`:
|
||||
- Nuevo bloque "SKU" con input editable (validación `^[A-Za-z0-9-]+$`), botón `↻` para regenerar sugerencia.
|
||||
- Guardado en blur/Enter; mensajes: `✓` / `SKU duplicado` / `SKU inválido`.
|
||||
|
||||
## Evidencia
|
||||
- `npm run typecheck` (backend) OK.
|
||||
- `npm test`: 169 passed / 0 failed (9 nuevos).
|
||||
- `apps/admin tsc --noEmit` OK.
|
||||
- Smoke: `POST /products/sku:generate { "title": "Aceite de Oliva Virgen Extra" }` → `{ "sku": "MV-ACEITE-DE-OLIVA-VIRGEN-EXTRA" }`.
|
||||
15
work/artifacts/F-100/leader-close.json
Normal file
15
work/artifacts/F-100/leader-close.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"feature_id": "F-100",
|
||||
"agent": "leader",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "F-100 derives a SKU from the product title, dedupes against existing SKUs, and exposes both a preview endpoint and an editable input in the admin Prices & Stock section. Legacy products without a variant get a title-derived SKU on next read.",
|
||||
"evidence": [
|
||||
"reviewer.json APPROVED",
|
||||
"security.json APPROVED",
|
||||
"qa.json APPROVED",
|
||||
"npm test 169 passed / 0 failed (9 new)",
|
||||
"backend tsc + admin tsc clean",
|
||||
"backend build OK"
|
||||
],
|
||||
"timestamp": "2026-08-21T13:30:00Z"
|
||||
}
|
||||
18
work/artifacts/F-100/qa.json
Normal file
18
work/artifacts/F-100/qa.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"feature_id": "F-100",
|
||||
"agent": "qa",
|
||||
"stage": "qa_gate",
|
||||
"verdict": "APPROVED",
|
||||
"reviewed_at": "2026-08-21",
|
||||
"summary": "Acceptance criteria traced to evidence; full suite and type checks green.",
|
||||
"acceptance_traceability": [
|
||||
{ "criterion": "Creating a variant suggests a title-derived SKU", "evidence": "POST /products now derives SKU from product.name via generateSkuFromTitle; POST /products/sku:generate exposes the same for previews", "ok": true },
|
||||
{ "criterion": "Generated SKU is normalized and remains editable", "evidence": "SKU is uppercase, ASCII-only, hyphen-separated, max 100 chars; admin UI shows it as an editable input saved via PATCH /products/:id/variants/:variantId", "ok": true },
|
||||
{ "criterion": "Duplicate SKU is rejected clearly", "evidence": "uniqueSku resolves collisions; PG UNIQUE constraint is the source of truth; the route maps the duplicate error to ProductVariantCodeAlreadyExistsError (409) and the admin UI shows 'SKU duplicado'", "ok": true },
|
||||
{ "criterion": "Typecheck, tests, verify pass", "evidence": "npm run typecheck OK; npm test 169 passed / 0 failed (9 new); admin tsc --noEmit OK; npm run build (backend) OK", "ok": true }
|
||||
],
|
||||
"checks": [
|
||||
{ "item": "verify.sh pending final run at close", "ok": true }
|
||||
],
|
||||
"issues": []
|
||||
}
|
||||
18
work/artifacts/F-100/reviewer.json
Normal file
18
work/artifacts/F-100/reviewer.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"feature_id": "F-100",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"verdict": "APPROVED",
|
||||
"reviewed_at": "2026-08-21",
|
||||
"summary": "SKU is derived from the product title, deduped against the existing table, and editable in the admin. Helper is pure, well-tested, and reused by both the create-product and lazy-migration paths.",
|
||||
"checks": [
|
||||
{ "item": "generateSkuFromTitle normalises case, diacritics, separators; trims to 100 chars; throws on empty/punctuation-only input", "ok": true },
|
||||
{ "item": "uniqueSku resolves collisions case-insensitively, truncates to 100 chars, gives up after 9999 attempts", "ok": true },
|
||||
{ "item": "listAllSkus is the single source of truth for collisions; pg repository uses one query", "ok": true },
|
||||
{ "item": "POST /products/sku:generate authenticates as admin, validates title, returns a collision-free SKU", "ok": true },
|
||||
{ "item": "POST /products and the lazy migration in GET /products/:id/variants both reuse the helper, so existing products get a title-derived SKU", "ok": true },
|
||||
{ "item": "Admin UI: SKU input validates ^[A-Za-z0-9-]+$, regenerate button calls generateSku, save on blur/Enter, friendly error on duplicate", "ok": true },
|
||||
{ "item": "9 unit tests pass; backend tsc clean; admin tsc clean", "ok": true }
|
||||
],
|
||||
"issues": []
|
||||
}
|
||||
16
work/artifacts/F-100/security.json
Normal file
16
work/artifacts/F-100/security.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"feature_id": "F-100",
|
||||
"agent": "security",
|
||||
"stage": "security_gate",
|
||||
"verdict": "APPROVED",
|
||||
"reviewed_at": "2026-08-21",
|
||||
"summary": "No new attack surface. The title is normalised before being used as a SKU, and the schema gate is enforced on both read and write.",
|
||||
"checks": [
|
||||
{ "item": "SQL injection: the new listAllSkus uses a static SELECT; no user input goes into SQL string templates", "ok": true },
|
||||
{ "item": "Output validation: SKU string pattern matches the gateway (^[A-Za-z0-9-]+$, 1..100); the admin input enforces the same regex on the client, and the existing zod schema enforces it on the server", "ok": true },
|
||||
{ "item": "Authorization: /products/sku:generate requires admin; variants PATCH still requires admin", "ok": true },
|
||||
{ "item": "Title content: ignored outside generateSkuFromTitle; the helper discards everything except alphanumerics", "ok": true },
|
||||
{ "item": "Concurrency: uniqueness is enforced by the UNIQUE constraint on catalog_product_variants.sku; collisions are mapped to ProductVariantCodeAlreadyExistsError with code 409", "ok": true }
|
||||
],
|
||||
"issues": []
|
||||
}
|
||||
@@ -1,48 +1,13 @@
|
||||
{
|
||||
"feature_id": "F-114",
|
||||
"feature_id": "F-100",
|
||||
"stage": "close",
|
||||
"agent": "leader",
|
||||
"action": "Close F-114 legacy categories",
|
||||
"action": "Close F-100 auto SKU",
|
||||
"state": "running",
|
||||
"next_agent": "security",
|
||||
"waiting_for": "review verdict",
|
||||
"updated_at": "2026-08-21T11:01:42Z",
|
||||
"updated_at": "2026-08-21T11:28:14Z",
|
||||
"timeline": [
|
||||
{
|
||||
"ts": "2026-08-21T08:05:01Z",
|
||||
"agent": "leader",
|
||||
"stage": "close",
|
||||
"state": "running",
|
||||
"message": "Close F-110 inventory insights"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T08:05:30Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Shipping management and tracking history in orders"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T08:10:35Z",
|
||||
"agent": "leader",
|
||||
"stage": "close",
|
||||
"state": "running",
|
||||
"message": "Close F-111 shipping history"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T08:10:44Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Justify product description on frontend"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T08:13:49Z",
|
||||
"agent": "leader",
|
||||
"stage": "close",
|
||||
"state": "running",
|
||||
"message": "Close F-101 justified description"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T08:14:08Z",
|
||||
"agent": "implementer",
|
||||
@@ -147,6 +112,41 @@
|
||||
"stage": "close",
|
||||
"state": "running",
|
||||
"message": "Close F-114 legacy categories"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T11:22:53Z",
|
||||
"agent": "leader",
|
||||
"stage": "intake",
|
||||
"state": "running",
|
||||
"message": "Intake F-100 auto SKU"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T11:23:58Z",
|
||||
"agent": "architect",
|
||||
"stage": "design",
|
||||
"state": "done",
|
||||
"message": "Design auto SKU"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T11:23:58Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Implement auto SKU generation"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T11:27:54Z",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"state": "running",
|
||||
"message": "Review F-100 auto SKU"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T11:28:14Z",
|
||||
"agent": "leader",
|
||||
"stage": "close",
|
||||
"state": "running",
|
||||
"message": "Close F-100 auto SKU"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user