feat(F-182): completed feature
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user