feat(F-112): completed feature

This commit is contained in:
chattie
2026-08-21 13:31:28 +02:00
parent da919d705f
commit 77133c0ecf
16 changed files with 207 additions and 46 deletions

View File

@@ -97,6 +97,7 @@ const newProductSchema = z.object({
expirationDate: z.iso.date().optional().nullable(),
unitWeightKg: z.number().positive().max(1000).optional(),
minPurchaseQty: z.number().int().min(1).max(999).optional(),
aiAssisted: z.boolean().optional(),
});
const productPatchSchema = newProductSchema
@@ -287,7 +288,7 @@ export async function registerCatalogRoutes(
};
const promptFor = (template: string | undefined, fallback: string) =>
(template || fallback).replace(/\{\{(name|description|brand)\}\}/g, (_, key: string) => replacements[key] ?? '');
const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
const patch: { description?: string; seoTitle?: string; seoDescription?: string; aiAssisted?: boolean } = {};
if (!product.description?.trim()) {
const raw = await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_product_description_prompt, 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Devuelve SOLO HTML válido y seguro (párrafos <p>, y si procede listas <ul><li> y <strong> para resaltar). No uses títulos ni markdown.'));
patch.description = aiTextToHtml(raw).slice(0, 4_000);
@@ -298,6 +299,10 @@ export async function registerCatalogRoutes(
if (!product.seoDescription?.trim()) {
patch.seoDescription = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_description_prompt, 'Genera una meta descripción SEO en español para {{name}}. Devuelve solo la descripción.'))).replace(/<[^>]*>/g, '').trim().slice(0, 500);
}
if (Object.keys(patch).length > 0) {
// F-112: cualquier generación automática marca la ficha como asistida por IA.
patch.aiAssisted = true;
}
const updated = await repository.update(id, patch);
return reply.send(serializeProduct(updated ?? product));
});
@@ -775,6 +780,7 @@ function serializeProduct(product: Product, images: ProductImage[] = []) {
expirationDate: product.expirationDate,
unitWeightKg: product.unitWeightKg,
minPurchaseQty: product.minPurchaseQty,
aiAssisted: product.aiAssisted,
createdAt: product.createdAt.toISOString(),
updatedAt: product.updatedAt.toISOString(),
};

View File

@@ -55,6 +55,8 @@ export interface Product {
unitWeightKg: number;
/** Cantidad mínima de compra; el frontend bloquea pedidos por debajo. */
minPurchaseQty: number;
/** F-112: marcado cuando la ficha fue asistida por IA y debe mostrar disclaimer. */
aiAssisted: boolean;
createdAt: Date;
updatedAt: Date;
}
@@ -74,6 +76,7 @@ export interface NewProduct {
expirationDate?: string | null;
unitWeightKg?: number;
minPurchaseQty?: number;
aiAssisted?: boolean;
}
/** Fields a product update may set. Undefined = leave unchanged. */

View File

@@ -28,6 +28,7 @@ export interface ProductRow {
expiration_date: string | null;
unit_weight_kg: string | number;
min_purchase_qty: number;
ai_assisted: boolean;
created_at: Date;
updated_at: Date;
}
@@ -58,6 +59,7 @@ const UPDATABLE: ReadonlyArray<[keyof ProductPatch, string]> = [
['brandId', 'brand_id'],
['expirationDate', 'expiration_date'],
['unitWeightKg', 'unit_weight_kg'],
['aiAssisted', 'ai_assisted'],
['minPurchaseQty', 'min_purchase_qty'],
];
@@ -97,8 +99,8 @@ export class PgProductRepository implements ProductRepository {
try {
await client.query('BEGIN');
const result = await client.query<ProductRow>(
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id, expiration_date, unit_weight_kg, min_purchase_qty)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id, expiration_date, unit_weight_kg, min_purchase_qty, ai_assisted)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *, ARRAY[]::uuid[] AS category_ids`,
[
input.name,
@@ -111,6 +113,7 @@ export class PgProductRepository implements ProductRepository {
input.expirationDate ?? null,
input.unitWeightKg ?? 1,
input.minPurchaseQty ?? 1,
input.aiAssisted ?? false,
],
);
const row = result.rows[0];
@@ -313,6 +316,7 @@ export function toProduct(row: ProductRow): Product {
categoryIds: row.category_ids ?? [],
expirationDate: row.expiration_date ?? null,
unitWeightKg: Number(row.unit_weight_kg ?? 1),
aiAssisted: row.ai_assisted ?? false,
minPurchaseQty: row.min_purchase_qty ?? 1,
createdAt: row.created_at,
updatedAt: row.updated_at,

View File

@@ -19,6 +19,7 @@ function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>
expirationDate: null,
unitWeightKg: 1,
minPurchaseQty: 1,
aiAssisted: false,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,

View File

@@ -23,6 +23,7 @@ function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>
expirationDate: null,
unitWeightKg: 1,
minPurchaseQty: 1,
aiAssisted: false,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,