feat(F-182): completed feature

This commit is contained in:
chattie
2026-08-22 19:25:12 +02:00
parent 2985d7274a
commit b860e8d1df
14 changed files with 361 additions and 25 deletions

View File

@@ -197,6 +197,94 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
app.get(
'/pos/admin/catalog-products',
{
schema: {
tags: ['POS Admin'],
summary: 'List products available for TPV touch configuration',
response: { 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const result = await pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE p.state = 'active'
ORDER BY p.name, v.sku`,
);
return reply.send({ items: result.rows });
},
);
app.patch<{ Params: { id: string } }>(
'/pos/admin/terminals/:id/touch-config',
{
schema: {
tags: ['POS Admin'],
summary: 'Configure terminal touch navigation and quick products',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['touchNavigationEnabled', 'quickProductVariantIds'],
properties: {
touchNavigationEnabled: { type: 'boolean' },
quickProductVariantIds: {
type: 'array', minItems: 4, maxItems: 4,
items: { type: ['string', 'null'], format: 'uuid' },
},
},
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const body = parseJson(
z.object({
touchNavigationEnabled: z.boolean(),
quickProductVariantIds: z.array(z.string().uuid().nullable()).length(4),
}),
request.body ?? {},
);
const selected = body.quickProductVariantIds.filter((value): value is string => value !== null);
if (new Set(selected).size !== selected.length) {
throw new AppError(400, 'DUPLICATE_QUICK_PRODUCT', 'Cada acceso rápido debe usar un producto diferente');
}
if (selected.length > 0) {
const valid = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM catalog_product_variants v JOIN catalog_products p ON p.id = v.product_id
WHERE v.id = ANY($1::uuid[]) AND p.state = 'active'`,
[selected],
);
if (Number(valid.rows[0]?.count ?? 0) !== selected.length) {
throw new AppError(400, 'INVALID_QUICK_PRODUCT', 'Algún producto rápido no está activo');
}
}
const result = await pool.query(
`UPDATE pos_terminals
SET interface_mode = CASE WHEN $2 THEN 'touch' ELSE 'auto' END,
settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object(
'touchNavigationEnabled', $2::boolean,
'quickProductVariantIds', $3::jsonb
),
updated_at = now()
WHERE id = $1 RETURNING *`,
[id, body.touchNavigationEnabled, JSON.stringify(body.quickProductVariantIds)],
);
if (!result.rows[0]) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
return reply.send({ ok: true });
},
);
app.get<{ Params: { id: string } }>(
'/pos/admin/terminals/:id',
{
@@ -424,6 +512,71 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
app.get(
'/pos/catalog/touch',
{
schema: {
tags: ['POS Terminal'],
summary: 'Get category navigation and quick products for the bound terminal',
headers: {
type: 'object', required: ['x-terminal-id'],
properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const terminalId = request.headers['x-terminal-id'] as string | undefined;
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
const terminalResult = await pool.query<{ store_id: string; settings: Record<string, unknown> }>(
`SELECT store_id, settings FROM pos_terminals WHERE id = $1 AND status = 'active'`,
[terminalId],
);
const terminal = terminalResult.rows[0];
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
const [categoryResult, productResult] = await Promise.all([
pool.query(
`SELECT id, parent_id AS "parentId", name, emoji, bg_color AS "bgColor", text_color AS "textColor"
FROM categories_categories ORDER BY name`,
),
pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
pc.category_id AS "categoryId", COALESCE(stock.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(available), 0)::int AS quantity
FROM inventory_stock WHERE variant_id = v.id AND store_id = $1
) stock ON true
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE p.state = 'active'
ORDER BY p.name, v.sku`,
[terminal.store_id],
),
]);
const configured = Array.isArray(terminal.settings?.quickProductVariantIds)
? terminal.settings.quickProductVariantIds
: [null, null, null, null];
const uniqueProducts = new Map<string, Record<string, unknown>>();
for (const row of productResult.rows as Array<Record<string, unknown>>) {
if (!uniqueProducts.has(String(row.variantId))) uniqueProducts.set(String(row.variantId), row);
}
return reply.send({
enabled: terminal.settings?.touchNavigationEnabled !== false,
categories: categoryResult.rows,
products: productResult.rows,
quickProducts: [0, 1, 2, 3].map((slot) => {
const variantId = configured[slot];
return typeof variantId === 'string' ? uniqueProducts.get(variantId) ?? null : null;
}),
});
},
);
// ── POS-005: Product search ───────────────────────────────────────────────
app.get(