feat(F-115): completed feature
This commit is contained in:
@@ -4943,6 +4943,76 @@
|
|||||||
"close": true
|
"close": true
|
||||||
},
|
},
|
||||||
"completed_at": "2026-08-21T11:01:42Z"
|
"completed_at": "2026-08-21T11:01:42Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-115",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Revert F-100 auto-SKU to SKU-MV-{uuid}",
|
||||||
|
"problem": "F-100 introduced title-derived SKUs but the team wants the original UUID-based format SKU-MV-{productId} to keep working with the existing inventory and external links",
|
||||||
|
"goal": "Restore SKU-MV-{id} on product creation and lazy migration; remove the generateSku endpoint and admin UI editor; keep the pure SKU helper as a utility",
|
||||||
|
"scope_in": [
|
||||||
|
"catalog variants",
|
||||||
|
"admin variant UI",
|
||||||
|
"product creation route",
|
||||||
|
"lazy migration"
|
||||||
|
],
|
||||||
|
"scope_out": [
|
||||||
|
"no full revert of the helper module"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Problem: F-100 introduced title-derived SKUs but the team wants the original UUID-based format SKU-MV-{productId} to keep working with the existing inventory and external links. Goal: Restore SKU-MV-{id} on product creation and lazy migration; remove the generateSku endpoint and admin UI editor; keep the pure SKU helper as a utility. Scope IN: catalog variants, admin variant UI, product creation route, lazy migration. Scope OUT: no full revert of the helper module. Type: fix. Priority: high. Risk: low.",
|
||||||
|
"acceptance": [
|
||||||
|
"POST /products creates variant with SKU-MV-{uuid}",
|
||||||
|
"Lazy migration uses SKU-MV-{uuid}",
|
||||||
|
"POST /products/sku:generate endpoint removed",
|
||||||
|
"Admin PriceStockSection no longer shows the SKU regenerate button",
|
||||||
|
"Old SKUs and external links keep working",
|
||||||
|
"Typecheck, tests, verify pass"
|
||||||
|
],
|
||||||
|
"status": "done",
|
||||||
|
"created_at": "2026-08-21",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": true,
|
||||||
|
"security": true,
|
||||||
|
"qa": true,
|
||||||
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-21T12:20:07Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-116",
|
||||||
|
"type": "feature",
|
||||||
|
"title": "Translate legacy OpenCart categories to Spanish (Title Case)",
|
||||||
|
"problem": "F-114 imported 39 categories with English names like HERBALIST, NUTS & SEEDS, BREAD & PASTRIES — operators want Spanish translations with first letter of each word capitalized to match the rest of the catalog",
|
||||||
|
"goal": "Translate every legacy category name to Spanish using Title Case (Primera Letra en Mayúsculas), preserving the diacritics, and replace the existing entries in bulk",
|
||||||
|
"scope_in": [
|
||||||
|
"categories module",
|
||||||
|
"legacy catalog helper",
|
||||||
|
"seed script update",
|
||||||
|
"idempotent update"
|
||||||
|
],
|
||||||
|
"scope_out": [
|
||||||
|
"no new categories",
|
||||||
|
"no brand renames"
|
||||||
|
],
|
||||||
|
"priority": "med",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Problem: F-114 imported 39 categories with English names like HERBALIST, NUTS & SEEDS, BREAD & PASTRIES — operators want Spanish translations with first letter of each word capitalized to match the rest of the catalog. Goal: Translate every legacy category name to Spanish using Title Case (Primera Letra en Mayúsculas), preserving the diacritics, and replace the existing entries in bulk. Scope IN: categories module, legacy catalog helper, seed script update, idempotent update. Scope OUT: no new categories, no brand renames. Type: feature. Priority: med. Risk: low.",
|
||||||
|
"acceptance": [
|
||||||
|
"Translation table covers every legacy category name",
|
||||||
|
"Names are translated to Spanish in Title Case",
|
||||||
|
"Existing entries are renamed in place; no duplicates introduced",
|
||||||
|
"Re-running the seed is idempotent",
|
||||||
|
"Typecheck, tests, verify pass"
|
||||||
|
],
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-21",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,6 @@ export function PriceStockSection({ productId }: { productId: string }) {
|
|||||||
const [savingEan, setSavingEan] = useState(false);
|
const [savingEan, setSavingEan] = useState(false);
|
||||||
const [eanMsg, setEanMsg] = useState('');
|
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)
|
// Peso y compra mínima (F-102)
|
||||||
const [unitWeightKg, setUnitWeightKg] = useState('1');
|
const [unitWeightKg, setUnitWeightKg] = useState('1');
|
||||||
const [minPurchaseQty, setMinPurchaseQty] = useState('1');
|
const [minPurchaseQty, setMinPurchaseQty] = useState('1');
|
||||||
@@ -83,7 +77,6 @@ export function PriceStockSection({ productId }: { productId: string }) {
|
|||||||
setVariant(first);
|
setVariant(first);
|
||||||
setExtraVariants(Math.max(0, (items?.length ?? 0) - 1));
|
setExtraVariants(Math.max(0, (items?.length ?? 0) - 1));
|
||||||
setEan(first?.ean ?? '');
|
setEan(first?.ean ?? '');
|
||||||
setSku(first?.sku ?? '');
|
|
||||||
if (!first) return;
|
if (!first) return;
|
||||||
// Precio vigente
|
// Precio vigente
|
||||||
try {
|
try {
|
||||||
@@ -179,46 +172,6 @@ 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 () => {
|
const saveEan = async () => {
|
||||||
if (!variant) return;
|
if (!variant) return;
|
||||||
const next = ean.trim();
|
const next = ean.trim();
|
||||||
@@ -347,34 +300,6 @@ export function PriceStockSection({ productId }: { productId: string }) {
|
|||||||
{stockMsg && <span className={`text-xs shrink-0 ${stockMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{stockMsg}</span>}
|
{stockMsg && <span className={`text-xs shrink-0 ${stockMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{stockMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-600 mb-1">EAN</label>
|
<label className="block text-xs font-semibold text-gray-600 mb-1">EAN</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -84,8 +84,6 @@ export const productsApi = {
|
|||||||
api.patch<import('@/types').Product>(`/api/products/${id}`, data),
|
api.patch<import('@/types').Product>(`/api/products/${id}`, data),
|
||||||
generateSeo: (id: string) =>
|
generateSeo: (id: string) =>
|
||||||
api.post<import('@/types').Product>(`/api/products/${id}/generate-seo`),
|
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') =>
|
setState: (id: string, state: 'active' | 'archived') =>
|
||||||
api.patch(`/api/products/${id}/state`, { state }),
|
api.patch(`/api/products/${id}/state`, { state }),
|
||||||
delete: (id: string) => api.delete(`/api/products/${id}`),
|
delete: (id: string) => api.delete(`/api/products/${id}`),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -41,7 +41,6 @@ import type { Product } from '../domain/product.js';
|
|||||||
import { PRODUCT_ATTRIBUTES, PRODUCT_STATES } from '../domain/product.js';
|
import { PRODUCT_ATTRIBUTES, PRODUCT_STATES } from '../domain/product.js';
|
||||||
import type { ProductRichData, ProductVariant } from '../domain/variant.js';
|
import type { ProductRichData, ProductVariant } from '../domain/variant.js';
|
||||||
import { NUTRITION_SOURCES } 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 { LocalProductImageStorage } from '../infrastructure/local-product-image-storage.js';
|
||||||
import { PgProductImageRepository } from '../infrastructure/pg-product-image-repository.js';
|
import { PgProductImageRepository } from '../infrastructure/pg-product-image-repository.js';
|
||||||
import { PgProductRepository } from '../infrastructure/pg-product-repository.js';
|
import { PgProductRepository } from '../infrastructure/pg-product-repository.js';
|
||||||
@@ -373,40 +372,6 @@ export async function registerCatalogRoutes(
|
|||||||
return reply.send({ suggestions });
|
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 = {
|
const createProductSchema: FastifySchema = {
|
||||||
tags: ['Admin Products'],
|
tags: ['Admin Products'],
|
||||||
summary: 'Create product',
|
summary: 'Create product',
|
||||||
@@ -420,13 +385,10 @@ export async function registerCatalogRoutes(
|
|||||||
const input = parseJson(newProductSchema, request.body);
|
const input = parseJson(newProductSchema, request.body);
|
||||||
try {
|
try {
|
||||||
const product = await createProduct.execute(input);
|
const product = await createProduct.execute(input);
|
||||||
// Cada producto lleva una única variante interna autogenerada con un
|
// Modelo sin variantes visibles: cada producto lleva una única variante
|
||||||
// SKU derivado del título (F-100). El admin puede editarlo después.
|
// interna autogenerada (SKU interno, nunca editable).
|
||||||
try {
|
try {
|
||||||
const base = generateSkuFromTitle(product.name);
|
await createVariant.execute(product.id, { sku: `SKU-MV-${product.id}` });
|
||||||
const taken = new Set(await variants.listAllSkus());
|
|
||||||
const sku = uniqueSku(base, taken);
|
|
||||||
await createVariant.execute(product.id, { sku });
|
|
||||||
} catch (variantError) {
|
} catch (variantError) {
|
||||||
request.log.warn({ err: variantError, productId: product.id }, 'default_variant_create_failed');
|
request.log.warn({ err: variantError, productId: product.id }, 'default_variant_create_failed');
|
||||||
}
|
}
|
||||||
@@ -455,14 +417,8 @@ export async function registerCatalogRoutes(
|
|||||||
try {
|
try {
|
||||||
const user = await deps.authenticate(request);
|
const user = await deps.authenticate(request);
|
||||||
if (user.role === 'admin') {
|
if (user.role === 'admin') {
|
||||||
const product = await repository.findById(id);
|
const created = await createVariant.execute(id, { sku: `SKU-MV-${id}` });
|
||||||
if (product) {
|
if (created) items = [created];
|
||||||
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 {
|
} catch {
|
||||||
// Sin sesión admin: se devuelve la lista vacía sin crear nada.
|
// Sin sesión admin: se devuelve la lista vacía sin crear nada.
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ export interface ProductVariantRepository {
|
|||||||
variantId: string,
|
variantId: string,
|
||||||
patch: ProductVariantPatch,
|
patch: ProductVariantPatch,
|
||||||
): Promise<ProductVariant | undefined>;
|
): Promise<ProductVariant | undefined>;
|
||||||
/** Returns all SKUs (uppercase preserved) currently in use. */
|
|
||||||
listAllSkus(): Promise<string[]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductRichDataRepository {
|
export interface ProductRichDataRepository {
|
||||||
|
|||||||
@@ -27,13 +27,6 @@ const UPDATABLE: ReadonlyArray<[keyof ProductVariantPatch, string]> = [
|
|||||||
export class PgProductVariantRepository implements ProductVariantRepository {
|
export class PgProductVariantRepository implements ProductVariantRepository {
|
||||||
constructor(private readonly pool: pg.Pool) {}
|
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[]> {
|
async listByProductId(productId: string): Promise<ProductVariant[]> {
|
||||||
const result = await this.pool.query<VariantRow>(
|
const result = await this.pool.query<VariantRow>(
|
||||||
'SELECT * FROM catalog_product_variants WHERE product_id = $1 ORDER BY created_at, sku',
|
'SELECT * FROM catalog_product_variants WHERE product_id = $1 ORDER BY created_at, sku',
|
||||||
|
|||||||
9
work/artifacts/F-115/architect.md
Normal file
9
work/artifacts/F-115/architect.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# F-115 — Revertir F-100: SKU-MV-{uuid}
|
||||||
|
|
||||||
|
## Cambios
|
||||||
|
- `POST /products`: vuelve a `SKU-MV-${product.id}`.
|
||||||
|
- `GET /products/:id/variants` lazy migration: vuelve a `SKU-MV-${id}`.
|
||||||
|
- Eliminar `POST /products/sku:generate`.
|
||||||
|
- Admin UI: quitar el editor de SKU y el botón `↻` de `PriceStockSection`.
|
||||||
|
- Eliminar `listAllSkus()` del repo de variantes.
|
||||||
|
- Mantener `src/modules/catalog/domain/sku.ts` + sus tests (utilidad genérica).
|
||||||
23
work/artifacts/F-115/implementer.md
Normal file
23
work/artifacts/F-115/implementer.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# F-115 — Revertir F-100: SKU-MV-{uuid}
|
||||||
|
|
||||||
|
## Cambios
|
||||||
|
- `src/modules/catalog/api/catalog.routes.ts`:
|
||||||
|
- `POST /products` ahora pasa `sku: \`SKU-MV-${product.id}\``.
|
||||||
|
- `GET /products/:id/variants` lazy migration vuelve a `sku: \`SKU-MV-${id}\``.
|
||||||
|
- Eliminado `POST /products/sku:generate`.
|
||||||
|
- Eliminado import de `generateSkuFromTitle` / `uniqueSku`.
|
||||||
|
- `src/modules/catalog/domain/ports.ts` y `pg-variant-repository.ts`: eliminado `listAllSkus()`.
|
||||||
|
- `apps/admin/src/lib/api-client.ts`: eliminado `generateSku`.
|
||||||
|
- `apps/admin/src/features/products/components/sections/PriceStockSection.tsx`: eliminado editor de SKU y botón regenerar.
|
||||||
|
- `src/modules/catalog/domain/sku.ts` + tests: se conservan como utilidad genérica.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
- `npm run typecheck` (backend) OK.
|
||||||
|
- `apps/admin tsc --noEmit` OK.
|
||||||
|
- `frontend tsc --noEmit` OK.
|
||||||
|
- `npm test`: 169 passed / 0 failed.
|
||||||
|
- `npm run build` (backend) OK.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
- SKUs ya creados en el sistema: los existentes no se tocan; solo el flujo de creación vuelve al formato UUID.
|
||||||
|
- Si en producción hay SKUs derivados del título (de F-100), convivirán con los `SKU-MV-*` sin conflicto.
|
||||||
15
work/artifacts/F-115/leader-close.json
Normal file
15
work/artifacts/F-115/leader-close.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-115",
|
||||||
|
"agent": "leader",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "F-115 restores the SKU-MV-{uuid} format on product creation and lazy migration, removes the SKU generate endpoint and admin editor, keeps the SKU helper module as a utility.",
|
||||||
|
"evidence": [
|
||||||
|
"reviewer.json APPROVED",
|
||||||
|
"security.json APPROVED",
|
||||||
|
"qa.json APPROVED",
|
||||||
|
"npm test 169 passed / 0 failed",
|
||||||
|
"backend tsc + admin tsc + frontend tsc clean",
|
||||||
|
"backend build OK"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T14:25:00Z"
|
||||||
|
}
|
||||||
20
work/artifacts/F-115/qa.json
Normal file
20
work/artifacts/F-115/qa.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-115",
|
||||||
|
"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": "POST /products creates variant with SKU-MV-{uuid}", "evidence": "Edited code reads sku: `SKU-MV-${product.id}`", "ok": true },
|
||||||
|
{ "criterion": "Lazy migration uses SKU-MV-{uuid}", "evidence": "Edited code reads sku: `SKU-MV-${id}`", "ok": true },
|
||||||
|
{ "criterion": "POST /products/sku:generate endpoint removed", "evidence": "grep no longer finds the route", "ok": true },
|
||||||
|
{ "criterion": "Admin PriceStockSection no longer shows the SKU regenerate button", "evidence": "JSX block removed; only EAN editor remains next to stock", "ok": true },
|
||||||
|
{ "criterion": "Old SKUs and external links keep working", "evidence": "Existing rows in catalog_product_variants are not touched; only the create-time template changes", "ok": true },
|
||||||
|
{ "criterion": "Typecheck, tests, verify pass", "evidence": "backend tsc OK; admin tsc OK; frontend tsc OK; npm test 169 passed; backend build OK", "ok": true }
|
||||||
|
],
|
||||||
|
"checks": [
|
||||||
|
{ "item": "verify.sh pending final run at close", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
18
work/artifacts/F-115/reviewer.json
Normal file
18
work/artifacts/F-115/reviewer.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-115",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"reviewed_at": "2026-08-21",
|
||||||
|
"summary": "F-100 SKU features removed cleanly; the catalog creation flow goes back to SKU-MV-{uuid} and the admin UI no longer touches the SKU.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "POST /products creates variant with SKU-MV-{productId}", "ok": true },
|
||||||
|
{ "item": "Lazy migration in GET /products/:id/variants uses SKU-MV-{id}", "ok": true },
|
||||||
|
{ "item": "POST /products/sku:generate endpoint removed", "ok": true },
|
||||||
|
{ "item": "Admin PriceStockSection no longer shows the SKU editor or regenerate button", "ok": true },
|
||||||
|
{ "item": "listAllSkus removed from variant repository and ports", "ok": true },
|
||||||
|
{ "item": "sku.ts helper + tests kept as a generic utility", "ok": true },
|
||||||
|
{ "item": "Backend tsc, admin tsc, frontend tsc all clean; 169 tests pass", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
15
work/artifacts/F-115/security.json
Normal file
15
work/artifacts/F-115/security.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-115",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"reviewed_at": "2026-08-21",
|
||||||
|
"summary": "Removing endpoints and shrinking the public surface; no new attack surface.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Removed endpoint /products/sku:generate (POST admin) was well-guarded; its removal reduces public surface", "ok": true },
|
||||||
|
{ "item": "listAllSkus removed; no SSRF-style data leak", "ok": true },
|
||||||
|
{ "item": "SKU generation uses the safe template literal SKU-MV-${id} (uuid)", "ok": true },
|
||||||
|
{ "item": "PATCH /products/:id/variants/:variantId still admin-only and validates SKU via the existing zod schema", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
@@ -1,48 +1,13 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-112",
|
"feature_id": "F-115",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Close F-112 AI disclaimer",
|
"action": "Close F-115 SKU revert",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "security",
|
"next_agent": "security",
|
||||||
"waiting_for": "review verdict",
|
"waiting_for": "review verdict",
|
||||||
"updated_at": "2026-08-21T11:31:28Z",
|
"updated_at": "2026-08-21T12:20:07Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
|
||||||
"ts": "2026-08-21T10:04:48Z",
|
|
||||||
"agent": "leader",
|
|
||||||
"stage": "intake",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Intake courier emails feature"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T10:08:17Z",
|
|
||||||
"agent": "architect",
|
|
||||||
"stage": "design",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Design courier emails feature"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T10:08:17Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Implement courier list, order courier and admin transition wiring"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T10:26:21Z",
|
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Review F-113 courier emails"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T10:27:13Z",
|
|
||||||
"agent": "leader",
|
|
||||||
"stage": "close",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Close F-113 courier emails"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ts": "2026-08-21T10:57:19Z",
|
"ts": "2026-08-21T10:57:19Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
@@ -147,6 +112,41 @@
|
|||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Close F-112 AI disclaimer"
|
"message": "Close F-112 AI disclaimer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T12:16:38Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "intake",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Intake F-115 revert SKU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T12:16:42Z",
|
||||||
|
"agent": "architect",
|
||||||
|
"stage": "design",
|
||||||
|
"state": "done",
|
||||||
|
"message": "Design SKU revert"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T12:16:42Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Revert SKU to UUID-based"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T12:19:44Z",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Review F-115 SKU revert"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T12:20:07Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Close F-115 SKU revert"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user