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:
+8
-4
@@ -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)}` })),
|
||||
|
||||
@@ -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).
|
||||
@@ -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" />}
|
||||
|
||||
Reference in New Issue
Block a user