feat(F-115): completed feature
This commit is contained in:
@@ -49,12 +49,6 @@ 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');
|
||||
@@ -83,7 +77,6 @@ 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 {
|
||||
@@ -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 () => {
|
||||
if (!variant) return;
|
||||
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>}
|
||||
</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,8 +84,6 @@ 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,7 +41,6 @@ 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';
|
||||
@@ -373,40 +372,6 @@ 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',
|
||||
@@ -420,13 +385,10 @@ export async function registerCatalogRoutes(
|
||||
const input = parseJson(newProductSchema, request.body);
|
||||
try {
|
||||
const product = await createProduct.execute(input);
|
||||
// Cada producto lleva una única variante interna autogenerada con un
|
||||
// SKU derivado del título (F-100). El admin puede editarlo después.
|
||||
// Modelo sin variantes visibles: cada producto lleva una única variante
|
||||
// interna autogenerada (SKU interno, nunca editable).
|
||||
try {
|
||||
const base = generateSkuFromTitle(product.name);
|
||||
const taken = new Set(await variants.listAllSkus());
|
||||
const sku = uniqueSku(base, taken);
|
||||
await createVariant.execute(product.id, { sku });
|
||||
await createVariant.execute(product.id, { sku: `SKU-MV-${product.id}` });
|
||||
} catch (variantError) {
|
||||
request.log.warn({ err: variantError, productId: product.id }, 'default_variant_create_failed');
|
||||
}
|
||||
@@ -455,14 +417,8 @@ export async function registerCatalogRoutes(
|
||||
try {
|
||||
const user = await deps.authenticate(request);
|
||||
if (user.role === 'admin') {
|
||||
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];
|
||||
}
|
||||
const created = await createVariant.execute(id, { sku: `SKU-MV-${id}` });
|
||||
if (created) items = [created];
|
||||
}
|
||||
} catch {
|
||||
// Sin sesión admin: se devuelve la lista vacía sin crear nada.
|
||||
|
||||
@@ -40,8 +40,6 @@ export interface ProductVariantRepository {
|
||||
variantId: string,
|
||||
patch: ProductVariantPatch,
|
||||
): Promise<ProductVariant | undefined>;
|
||||
/** Returns all SKUs (uppercase preserved) currently in use. */
|
||||
listAllSkus(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface ProductRichDataRepository {
|
||||
|
||||
@@ -27,13 +27,6 @@ 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',
|
||||
|
||||
Reference in New Issue
Block a user