feat(POS-005): completed feature

This commit is contained in:
chattie
2026-08-22 13:36:26 +02:00
parent 955c25d77b
commit 915fbe0ba9
12 changed files with 294 additions and 22 deletions

File diff suppressed because one or more lines are too long

View File

@@ -324,5 +324,190 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
throw new AppError(409, 'CLOSE_ERROR', String(err));
}
});
}
// ── POS-005: Product search ───────────────────────────────────────────────
app.get('/pos/products/search', {
schema: {
tags: ['POS Terminal'],
summary: 'Search products for POS',
querystring: {
type: 'object',
properties: {
q: { type: 'string', minLength: 1 },
storeId: { type: 'string', format: 'uuid' },
limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 },
},
},
response: { 401: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { q, storeId, limit = 20 } = request.query as { q?: string; storeId?: string; limit?: number };
if (!q || q.trim().length < 2) throw new AppError(400, 'INVALID_QUERY', 'Query must be at least 2 characters');
const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, v.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock, pp.price_cents, c.name AS category, b.name AS brand
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (SELECT quantity FROM inventory_stock WHERE variant_id = v.id AND store_id = $3 LIMIT 1) s ON true
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
LEFT JOIN catalog_product_categories pc ON pc.variant_id = v.id AND pc.is_primary = true
LEFT JOIN categories_categories c ON c.id = pc.category_id
LEFT JOIN brands_brands b ON b.id = p.brand_id
WHERE (v.name ILIKE $1 OR v.sku ILIKE $1 OR v.ean = $1) AND v.active = true AND p.active = true
ORDER BY v.name LIMIT $2`,
[`%${q.trim()}%`, limit, storeId ?? null],
);
return reply.send({ items: result.rows.map(r => ({
variantId: r.variant_id, productId: r.product_id, name: r.name, sku: r.sku, ean: r.ean,
stock: r.stock, priceCents: r.price_cents, category: r.category, brand: r.brand,
})) });
});
app.get<{ Params: { ean: string } }>('/pos/products/by-ean/:ean', {
schema: {
tags: ['POS Terminal'],
summary: 'Get product by EAN',
params: { type: 'object', properties: { ean: { type: 'string' } } },
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 { ean } = request.params;
const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, v.name, v.sku, v.ean, COALESCE(s.quantity, 0) AS stock, pp.price_cents
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (SELECT quantity FROM inventory_stock WHERE variant_id = v.id LIMIT 1) s ON true
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE v.ean = $1 AND v.active = true AND p.active = true LIMIT 1`,
[ean],
);
if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Product not found');
const r = result.rows[0];
return reply.send({ variantId: r.variant_id, productId: r.product_id, name: r.name, sku: r.sku, ean: r.ean, stock: r.stock, priceCents: r.price_cents });
});
app.get<{ Params: { sku: string } }>('/pos/products/by-sku/:sku', {
schema: {
tags: ['POS Terminal'],
summary: 'Get product by SKU',
params: { type: 'object', properties: { sku: { type: 'string' } } },
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 { sku } = request.params;
const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, v.name, v.sku, v.ean, COALESCE(s.quantity, 0) AS stock, pp.price_cents
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (SELECT quantity FROM inventory_stock WHERE variant_id = v.id LIMIT 1) s ON true
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE v.sku = $1 AND v.active = true AND p.active = true LIMIT 1`,
[sku],
);
if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Product not found');
const r = result.rows[0];
return reply.send({ variantId: r.variant_id, productId: r.product_id, name: r.name, sku: r.sku, ean: r.ean, stock: r.stock, priceCents: r.price_cents });
});
// ── POS-005: Admin payment methods ───────────────────────────────────────
app.get<{ Params: { storeId: string } }>('/pos/admin/payment-methods', {
schema: {
tags: ['POS Admin'],
summary: 'List payment methods',
querystring: { type: 'object', properties: { storeId: { type: 'string', format: 'uuid' } } },
response: { 401: errorSchema, 403: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { storeId } = request.query as { storeId: string };
const methods = await paymentMethodRepo.listByStore(storeId);
return reply.send({ items: methods });
});
app.post('/pos/admin/payment-methods', {
schema: {
tags: ['POS Admin'],
summary: 'Create payment method',
body: {
type: 'object',
required: ['storeId', 'code', 'label', 'kind'],
properties: {
storeId: { type: 'string', format: 'uuid' },
code: { type: 'string', minLength: 1, maxLength: 32 },
label: { type: 'string', minLength: 1, maxLength: 64 },
kind: { type: 'string', enum: ['cash', 'card', 'other'] },
active: { type: 'boolean', default: true },
sortOrder: { type: 'integer', default: 0 },
},
},
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema, 409: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const body = parseJson(
z.object({
storeId: z.string().uuid(),
code: z.string().min(1).max(32),
label: z.string().min(1).max(64),
kind: z.enum(['cash', 'card', 'other']),
active: z.boolean().default(true),
sortOrder: z.number().int().default(0),
}),
request.body ?? {},
);
try {
const result = await pool.query(
`INSERT INTO pos_payment_methods (store_id, code, label, kind, active, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, store_id AS "storeId", code, label, kind, active, sort_order AS "sortOrder"`,
[body.storeId, body.code, body.label, body.kind, body.active, body.sortOrder],
);
return reply.code(201).send(result.rows[0]);
} catch (err: unknown) {
if ((err as Record<string, unknown>).code === '23505') throw new AppError(409, 'DUPLICATE', 'Code already exists');
throw err;
}
});
app.patch<{ Params: { id: string } }>('/pos/admin/payment-methods/:id', {
schema: {
tags: ['POS Admin'],
summary: 'Update payment method',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
properties: { label: { type: 'string' }, active: { type: 'boolean' }, sortOrder: { type: 'integer' } },
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { id } = request.params;
const body = (request.body ?? {}) as Record<string, unknown>;
const sets: string[] = [];
const vals: unknown[] = [];
if (body.label !== undefined) { vals.push(body.label); sets.push(`label = $${vals.length}`); }
if (body.active !== undefined) { vals.push(body.active); sets.push(`active = $${vals.length}`); }
if (body.sortOrder !== undefined) { vals.push(body.sortOrder); sets.push(`sort_order = $${vals.length}`); }
if (sets.length === 0) return reply.send({ ok: true });
vals.push(id);
const result = await pool.query(
`UPDATE pos_payment_methods SET ${sets.join(', ')}, updated_at = now() WHERE id = $${vals.length} RETURNING *`,
vals,
);
if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Payment method not found');
return reply.send(result.rows[0]);
});
}

File diff suppressed because one or more lines are too long