feat(protese): Fase 1 — clínica de origem na OS + blindagem de CPF (LGPD)

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

- Origem da OS: bancada e detalhe passam a trazer clinica_nome (subquery em clinicas);
  o protético vê a clínica de origem na lista da bancada e no topo do modal. Destrava o
  cenário multi-clínica (Clínica A/B/C → mesmo laboratório).
- Blindagem de CPF (LGPD): confirmado que pacientes.id É o CPF. O provedor de prótese
  (lab que não é da clínica) deixa de receber paciente_id no detalhe e na bancada,
  mantendo paciente_nome; a clínica continua vendo o CPF para operar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 04:20:53 +02:00
parent 9f103be397
commit 7ae6993c4b
3 changed files with 92 additions and 4 deletions
+8 -4
View File
@@ -3957,18 +3957,20 @@ app.get('/api/protese/bancada', authGuard, async (req, res) => {
let where = `( (laboratorio_id IS NOT NULL AND laboratorio_id=$2) OR (laboratorio_id IS NULL AND protetico_id=$1) ) AND deleted_at IS NULL`;
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) { vals.push(req.query.status); where += ` AND status=$${vals.length}`; }
const { rows } = await pool.query(
`SELECT *, (prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado')) AS atrasada
`SELECT *, (SELECT nome_fantasia FROM clinicas WHERE id = protese_os.clinica_id) AS clinica_nome,
(prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado')) AS atrasada
FROM protese_os WHERE ${where}
ORDER BY status='entregue', status='cancelado', prazo_entrega ASC NULLS LAST, created_at DESC LIMIT 300`, vals);
// Bancada é a fila do laboratório: ele vê o próprio custo, não o valor cobrado ao paciente.
res.json(rows.map(r => ({ ...r, valor: 0, custo: parseFloat(r.custo || 0), atrasada: !!r.atrasada })));
// Bancada é a fila do laboratório: vê a clínica de origem e o próprio custo, mas NÃO o
// valor cobrado ao paciente nem o CPF (paciente_id é o CPF).
res.json(rows.map(r => ({ ...r, valor: 0, paciente_id: null, custo: parseFloat(r.custo || 0), atrasada: !!r.atrasada })));
} 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 {
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
const { rows } = await pool.query('SELECT *, (SELECT nome_fantasia FROM clinicas WHERE id = protese_os.clinica_id) AS clinica_nome FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
const os = rows[0];
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
@@ -3983,6 +3985,8 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
const pagsVisiveis = soLab ? pagamentos.filter(p => p.lado !== 'clinica') : pagamentos;
res.json({
...os, valor: soLab ? 0 : parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
// Blindagem LGPD: o id do paciente É o CPF — não expor ao provedor de prótese.
paciente_id: soLab ? null : os.paciente_id,
local_atual: os.local_atual || 'clinica',
eventos,
fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
+76
View File
@@ -0,0 +1,76 @@
# Decisão arquitetural — Provedor de Prótese (módulo de Prótese)
> Decisão de negócio/interface (24/jun/2026). Base: auditoria do código (`AUDITORIA-PROTESE.md`).
> Princípio-guia: **a Clínica controla 100% do fluxo, mesmo que o Protético nunca crie conta.**
> O login do Provedor é **alavanca de crescimento** (marketplace/ecossistema), não pré-requisito.
## Conceito
"**Provedor de Prótese**" é rótulo de **negócio/interface**. Internamente **NÃO se renomeia** o
role `protetico` (aparece 87× no backend + 15 arquivos no front — refatorar = risco alto, ganho
zero). As três formas são variações do **mesmo** workspace `tipo='laboratorio'`, diferenciadas por
metadados (futuros `pessoa_tipo`, `tem_equipe`), não por novos roles:
```
PROVEDOR DE PRÓTESE (role=protetico, workspace tipo='laboratorio')
├── Profissional Individual (PF)
├── Laboratório (PJ)
└── Laboratório com Equipe (PJ + técnicos)
```
## Modos de operação
- **Modo 1 — Protético SEM conta:** a Clínica opera tudo (OS, etapas, envio/recebimento/devolução,
pagamentos, componentes, ocorrências, encerramento). O Protético acompanha por **link seguro**
(`/p/TOKEN` via WhatsApp/e-mail), podendo confirmar recebimento/devolução, anexar fotos, responder
ocorrências. CTA: "crie sua conta ScoreOdonto".
- **Modo 2 — Protético COM conta:** Dashboard, Ordens, Produção, Entregas, Clientes, Financeiro,
Catálogo, Notificações, Perfil.
- **Modo 3 — Laboratório:** + Equipe, Relatórios, Marketplace, Configurações.
## Cadastro/Login
Card próprio **[ PROTÉTICO / LABORATÓRIO ]** com opção PF (Protético Individual) / PJ (Laboratório).
Campos futuros (não obrigatórios no MVP): Nome, Nome Fantasia, CPF, CNPJ, CROT, Responsável Técnico,
Cidade, Estado, Especialidades, Telefone, E-mail. (Campo `clinicas.documento` já existe para CPF/CNPJ.)
## Rastreabilidade de componentes (modelo)
Uma tabela (`protese_os_componente`) com eixo de direção/conferência, não duas:
`enviado pela clínica → recebido pelo protético → devolvido pelo protético → conferido pela clínica`.
A **ocorrência referencia o componente** (FK opcional).
## Ocorrências + Contraditório
Tabela própria. Categorias: não devolvido, danificado, incompatível, parafuso substituído/desgastado,
peça incorreta, ajuste inadequado, falha de fabricação, retrabalho, outro. Campos: autor, data,
categoria, descrição, fotos, evidências, anexos, responsável, status.
**Fluxo com contraditório** (proteção dos dois lados): `Aberta → Respondida → Em análise → Resolvida`
ou `Contestada`. **Só ocorrências resolvidas** alimentam indicadores — nunca automático.
## Abas da OS (alvo)
`Dados · Fotos · Componentes · Pagamentos · Custódia · Histórico · Ocorrências` (nova).
## Indicadores (POR ÚLTIMO)
Capturar primeiro (eventos, componentes, ocorrências, evidências, histórico); só depois derivar
Score/ranking/marketplace. Não codar indicador antes de existir dado.
---
## Roadmap (5 fases)
### ✅ FASE 1 — Origem da OS + LGPD (IMPLEMENTADA 24/jun)
- **Clínica de origem** na bancada e no detalhe (`clinica_nome` via subquery) — visível ao protético
(badge na lista e no modal). Destrava o cenário multi-clínica.
- **Blindagem de CPF:** confirmado que `pacientes.id` **É o CPF**. O protético (lab) deixa de receber
`paciente_id` (detalhe e bancada zeram), mantendo `paciente_nome`. A clínica continua vendo o CPF.
- Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo.
### FASE 2 — Rastreabilidade & Ocorrências
Componentes recebidos/devolvidos/conferidos; tabela de ocorrências; evidências; contraditório.
### FASE 3 — Operação multi-clínica
Tela de Clientes (clínicas), Financeiro do laboratório, Histórico completo.
### FASE 4 — Empresa de verdade
`pessoa_tipo` PF/PJ, CNPJ (expor `documento`), Equipe/Técnicos (estende `proteseOsAccesso` por vínculo).
### FASE 5 — Ecossistema
Marketplace transacional, Score, indicadores, ranking (sobre os dados das fases 2-4).
> Acoplamentos: PF/PJ só com a Fase 4 (equipe), senão é campo morto. Score só na Fase 5 (precisa volume).
+8
View File
@@ -296,6 +296,10 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
<div className="flex-1 overflow-y-auto p-5">
{tab === 'etapas' && (
<div className="space-y-5">
{/* Clínica de origem — crítico no multi-clínica (de qual clínica veio a OS). */}
{modo === 'bancada' && os.clinica_nome && (
<div className="bg-blue-50 rounded-xl px-3 py-2 flex items-center gap-2 text-xs font-black text-blue-700 uppercase"><Building2 size={14} /> Clínica: {os.clinica_nome}</div>
)}
<div className="flex items-center gap-2 flex-wrap">
{os.tipo_os === 'garantia' && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 uppercase flex items-center gap-1"><ShieldCheck size={10} /> Garantia</span>}
{os.atrasada && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-red-100 text-red-600 uppercase flex items-center gap-1"><AlertTriangle size={10} /> Atrasada</span>}
@@ -804,6 +808,10 @@ export const ProteseView: React.FC = () => {
{modo === 'bancada' ? (os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
</p>
{/* Clínica de origem: essencial quando o lab atende várias clínicas. */}
{modo === 'bancada' && os.clinica_nome && (
<p className="text-[10px] font-black text-blue-600 uppercase truncate flex items-center gap-1"><Building2 size={10} /> {os.clinica_nome}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{os.atrasada && <AlertTriangle size={14} className="text-red-500" />}