From 7ae6993c4bdb79e09c5a1d0dd7875a6cf1abef0b Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 04:20:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20Fase=201=20=E2=80=94=20cl?= =?UTF-8?q?=C3=ADnica=20de=20origem=20na=20OS=20+=20blindagem=20de=20CPF?= =?UTF-8?q?=20(LGPD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/server.js | 12 ++++-- doc/PROVEDOR-PROTESE-DECISAO.md | 76 +++++++++++++++++++++++++++++++++ frontend/views/ProteseView.tsx | 8 ++++ 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 doc/PROVEDOR-PROTESE-DECISAO.md diff --git a/backend/server.js b/backend/server.js index d17d24d..69186c5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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)}` })), diff --git a/doc/PROVEDOR-PROTESE-DECISAO.md b/doc/PROVEDOR-PROTESE-DECISAO.md new file mode 100644 index 0000000..05026d9 --- /dev/null +++ b/doc/PROVEDOR-PROTESE-DECISAO.md @@ -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). diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index 0242731..b01a12a 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -296,6 +296,10 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
{tab === 'etapas' && (
+ {/* Clínica de origem — crítico no multi-clínica (de qual clínica veio a OS). */} + {modo === 'bancada' && os.clinica_nome && ( +
Clínica: {os.clinica_nome}
+ )}
{os.tipo_os === 'garantia' && Garantia} {os.atrasada && Atrasada} @@ -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)}` : ''}

+ {/* Clínica de origem: essencial quando o lab atende várias clínicas. */} + {modo === 'bancada' && os.clinica_nome && ( +

{os.clinica_nome}

+ )}
{os.atrasada && }