feat(protese): Fase 3 — Clientes e Financeiro do laboratório (operação multi-clínica)

Provedor de Prótese, Fase 3 (doc/PROVEDOR-PROTESE-DECISAO.md):

- Clientes (lab-clientes): clínicas que enviam OS, com OS totais/ativas/atrasadas/entregues,
  faturado e a-receber por cliente. GET /protese/lab/clientes (agrega por clinica_id no escopo
  do laboratório).
- Financeiro do lab (lab-financeiro): KPIs faturado/recebido/a-receber + extrato de recebimentos
  (pagamentos lado lab) com clínica/paciente/forma/data. GET /protese/lab/pagamentos.
  Regra: faturado = custo das OS entregues (≠ garantia); a-receber = faturado − recebido.
- Menu do laboratório ganha CLIENTES e FINANCEIRO (Sidebar + App rotas/contexto + nova
  LabClientesView com abas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 05:09:25 +02:00
parent 3ffaff9d7d
commit c8d679eb70
6 changed files with 200 additions and 6 deletions
+43
View File
@@ -3967,6 +3967,49 @@ app.get('/api/protese/bancada', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// FASE 3 — Clientes do laboratório: clínicas que enviam OS, com métricas operacionais e financeiras.
app.get('/api/protese/lab/clientes', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const labId = await labDoProtetico(uid);
const escopo = `(laboratorio_id = $2 OR (laboratorio_id IS NULL AND protetico_id = $1)) AND deleted_at IS NULL`;
const { rows } = await pool.query(
`SELECT clinica_id, (SELECT nome_fantasia FROM clinicas WHERE id = o.clinica_id) AS clinica_nome,
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status NOT IN ('entregue','cancelado'))::int AS ativas,
COUNT(*) FILTER (WHERE prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado'))::int AS atrasadas,
COUNT(*) FILTER (WHERE status = 'entregue')::int AS entregues,
COALESCE(SUM(custo) FILTER (WHERE status = 'entregue' AND tipo_os <> 'garantia'), 0) AS faturado
FROM protese_os o WHERE ${escopo}
GROUP BY clinica_id ORDER BY total DESC`, [uid, labId]);
const { rows: pagos } = await pool.query(
`SELECT o.clinica_id, COALESCE(SUM(p.valor), 0) AS recebido
FROM protese_os_pagamento p JOIN protese_os o ON o.id = p.os_id
WHERE p.lado = 'lab' AND (o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))
GROUP BY o.clinica_id`, [uid, labId]);
const recPorClinica = Object.fromEntries(pagos.map(p => [p.clinica_id, parseFloat(p.recebido || 0)]));
res.json(rows.map(r => {
const faturado = parseFloat(r.faturado || 0), recebido = recPorClinica[r.clinica_id] || 0;
return { ...r, faturado, recebido, a_receber: Math.max(0, faturado - recebido) };
}));
} catch (err) { res.status(500).json({ error: err.message }); }
});
// FASE 3 — Extrato de recebimentos do laboratório (pagamentos lado 'lab').
app.get('/api/protese/lab/pagamentos', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const labId = await labDoProtetico(uid);
const { rows } = await pool.query(
`SELECT p.id, p.valor, p.forma, p.data, p.recebido_nome, o.tipo, o.paciente_nome,
(SELECT nome_fantasia FROM clinicas WHERE id = o.clinica_id) AS clinica_nome
FROM protese_os_pagamento p JOIN protese_os o ON o.id = p.os_id
WHERE p.lado = 'lab' AND (o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))
ORDER BY p.data DESC, p.created_at DESC LIMIT 200`, [uid, labId]);
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Detalhe + histórico de eventos + fotos.
app.get('/api/protese/os/:id', authGuard, async (req, res) => {
try {