Files
scoreodonto.com/doc/FASE2-IMPL-2.0-2.1.md
T
VPS 4 Builder 064f36d614
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m4s
docs(protese): marca Fase 2 como implementada/validada + nota do bug pacientenome
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:35:46 +02:00

11 KiB

Fase 2 — Detalhamento de implementação: passos 2.0 e 2.1

IMPLEMENTADO e validado em DEV (23/jun/2026). Commits feat(protese): Fase 2 backend

  • frontend. Validações: registro de protético nasce laboratorio; backfill preenche laboratorio_id; bancada roteia por lab (fallback por pessoa); entrega gera par espelhado DESPESA(clínica)+RECEITA(lab) R$600, idempotente (409 na re-entrega); menu do lab no bundle.

⚠️ Bug pré-existente achado e corrigido junto: o INSERT da DESPESA de prótese (Fase 1, já em produção v1.0.20) usava paciente_nome — coluna inexistente em financeiropacientenome). A entrega de OS com custo>0 abortava em produção. Corrigido no mesmo commit de backend. Recomenda-se priorizar o deploy por causa disso.

Companheiro de FASE2-PROTETICO-AREA.md (o modelo). Aqui está o como, a nível de arquivo/função, fiel ao código atual de backend/server.js e frontend/. Decisões aplicadas: 1 lab por protético, só terceirizado, receita espelhada na 2.1. Disciplina herdada da Fase 1: migrações aditivas/idempotentes; blocos novos em try/catch que nunca derrubam o fluxo principal; protetico_id sempre preservado (fallback).


PASSO 2.0 — Dados invisíveis (backfill laboratorio_id)

Nenhuma mudança de UI. Objetivo: todo protético tem um workspace tipo='laboratorio' e toda OS/produto carrega laboratorio_id. Validar 100% em DEV (pgdev) antes de seguir.

2.0.a — Migrações (em runMigrations(), junto às da Fase 1, ~linha 5663)

// ── FASE 2.0: Laboratório como workspace de 1ª classe ──────────────────────
`ALTER TABLE protese_os       ADD COLUMN IF NOT EXISTS financeiro_lab_id TEXT`, // RECEITA espelhada (2.1)
`ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT`,
// laboratorio_id em protese_os JÁ EXISTE (coluna reservada na criação da tabela) — não recriar.

// re-tag: o workspace pessoal do protético passa a ser 'laboratorio'
`UPDATE clinicas SET tipo='laboratorio'
   WHERE tipo='pessoal'
     AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico')`,

// backfill: cada OS herda o laboratorio do workspace do seu protético
`UPDATE protese_os o SET laboratorio_id = c.id
   FROM clinicas c
  WHERE o.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = o.protetico_id`,

// backfill: catálogo passa a ser do laboratório (mantém protetico_id p/ compat)
`UPDATE protese_produtos p SET laboratorio_id = c.id
   FROM clinicas c
  WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id`,

// índice por lab (o de protetico_id já existe; adiciona o do lab)
`CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`,

O UPDATE especialidades … area='protese' da Fase 1 já está logo acima — não duplicar.

2.0.b — Helper: resolver o laboratório de um protético

Novo helper perto de proteseOsAccesso (~linha 3772). 1 lab por protético ⇒ LIMIT 1.

// O workspace 'laboratorio' do protético (1 por protético nesta fase).
async function labDoProtetico(proteticoId) {
    if (!proteticoId) return null;
    const { rows } = await pool.query(
        "SELECT id FROM clinicas WHERE owner_id=$1 AND tipo='laboratorio' AND COALESCE(deleted_at IS NULL, true) LIMIT 1",
        [proteticoId]);
    return rows[0]?.id || null;
}

Se clinicas não tiver deleted_at, usar só WHERE owner_id=$1 AND tipo='laboratorio'.

2.0.c — Gravar laboratorio_id na CRIAÇÃO da OS (2 pontos)

(i) POST /api/protese/os (~linha 3893): resolver o lab e incluí-lo no INSERT.

const labId = await labDoProtetico(b.protetico_id);   // antes do INSERT
// no INSERT: acrescentar a coluna laboratorio_id e o valor labId
//   ... protetico_id, laboratorio_id, protetico_nome, ...
//   VALUES ($1,$2,$3,$LAB,$4,...)

(ii) Bloco GTO→protese_os (Fase 1) em PUT /api/gto-builder/:id/finalizar (o INSERT em protese_os que você acabou de escrever): mesma adição.

const labId = await labDoProtetico(it.protetico_id);  // dentro do for, antes do INSERT
// incluir laboratorio_id = labId no INSERT da OS gerada por GTO

Assim, OS novas já nascem com laboratorio_id; as antigas foram cobertas pelo backfill.

2.0.d — Bancada roteia por laboratório (com fallback por pessoa)

GET /api/protese/bancada (~linha 3926). Hoje: WHERE protetico_id=$1. Passa a aceitar OS do(s) laboratório(s) do usuário, mantendo fallback para OS ainda sem laboratorio_id:

const uid = req.authUser.userId;
const labId = await labDoProtetico(uid);              // o lab do protético logado
const vals = [uid, labId];
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}`;
}
// resto idêntico (ORDER BY / LIMIT / map)

proteseOsAccesso não muda na 2.0 (1 lab por protético ⇒ protetico_id===uid ainda identifica o lab). O OR por vínculo no lab entra só na 2.3 (equipe/tecnico_protese).

2.0.e — Novos protéticos já nascem com tipo='laboratorio'

No POST /api/register (~linha 625), o INSERT do workspace usa tipo='pessoal' para todos. Diferenciar o protético:

const wsTipo = userRole === 'protetico' ? 'laboratorio' : 'pessoal';
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1,$2,$3,$4)` // $3 = wsTipo

O seed de catálogo (seedProteseProdutos(userId)) continua por protetico_id (owner) — sem mudança. (O laboratorio_id do catálogo é resolvido pelo backfill / por leitura.)

Critérios de aceite 2.0 (validar em DEV)

  • SELECT count(*) FROM protese_os WHERE laboratorio_id IS NULL0 (após backfill).
  • Toda clinicas de protético tem tipo='laboratorio'.
  • Criar OS nova (via Nova OS e via finalizar GTO) → laboratorio_id preenchido.
  • Bancada continua mostrando exatamente as mesmas OS de antes (fallback cobre o legado).
  • Zero mudança visível na UI.

PASSO 2.1 — Casa própria + receita espelhada

Agora o protético entra numa área de Laboratório (menu próprio) e a entrega passa a lançar a receita no workspace do lab.

2.1.a — BACKEND: receita espelhada na entrega

Em POST /api/protese/os/:id/status, dentro do if (novo === 'entregue') { ... }, logo após o bloco que cria a DESPESA (finId). Mesma condição (custo>0 && tipo_os≠'garantia'):

// Espelho: RECEITA no workspace do laboratório (clinica_id = laboratorio_id). Idempotente.
let finLabId = os.financeiro_lab_id || null;
try {
    const labId = os.laboratorio_id || await labDoProtetico(os.protetico_id);
    if (!finLabId && labId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
        finLabId = `fin_lab_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
        await pool.query(
            `INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, paciente_id, paciente_nome, origem, centro_custo, sem_comissao)
             VALUES ($1,$2,$3,$4,'RECEITA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,'protese','PRÓTESE',true)`,
            [finLabId, labId, `PRÓTESE: ${os.tipo} (cliente ${clinicaNome || ''})`.trim(), Number(os.custo),
             os.paciente_id || null, os.paciente_nome || null]);
    }
} catch (e) { console.error('[protese entrega→receita lab]', e.message); }

E no UPDATE protese_os da entrega, persistir o id:

// ...financeiro_id=COALESCE($3,financeiro_id), financeiro_lab_id=COALESCE($N,financeiro_lab_id)...

clinicaNome = nome da clínica de origem (1 query opcional, ou usar os.clinica_id). Estorno (cancelar entrega) é tarefa à parte: reverter os dois financeiro — fica para a sub-fase de cancelamento; por ora a entrega é terminal (já é hoje: 409 se entregue).

2.1.b — FRONTEND: menu da área de Laboratório (Sidebar.tsx)

(i) Acrescentar os itens ao array menuItems (~linha 53):

{ id: 'lab-home',      label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada',   label: 'BANCADA',       icon: FlaskConical },

(catálogo/clientes/agenda/financeiro entram na 2.2/2.3 — não criar itens mortos agora.)

(ii) Isolar o menu quando o workspace ativo é laboratório — espelhar o bloco que já existe para tipo==='sala' (~linha 98). Inserir antes dele:

// Contexto de LABORATÓRIO: área própria do protético (não é clínica).
if ((activeWorkspace as any)?.tipo === 'laboratorio')
    return ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id);

Como o protético tem 1 workspace e ele é tipo='laboratorio', esse branch governa o menu dele. O antigo filtro currentRole==='protetico' (linha ~131) deixa de ser alcançado para o caso comum, mas mantê-lo como rede de segurança (protético sem workspace lab).

2.1.c — FRONTEND: roteamento das telas novas (App.tsx)

No switch/router de view do App.tsx, mapear:

case 'lab-bancada': return <ProteseView />;   // já renderiza modo 'bancada' p/ role protetico
case 'lab-home':    return <LabHomeView />;    // novo painel (abaixo)

ProteseView já decide modo='bancada' por getCurrentRole()==='protetico' — reaproveita 100%. Só muda o id de menu que a abre.

2.1.d — FRONTEND: LabHomeView (painel mínimo)

Nova view frontend/views/LabHomeView.tsx. Cards a partir de dados que já existem:

  • Fila por status e atrasadas: derivar de getProteseBancada() (já retorna atrasada).
  • Faturamento do mês: somar financeiro RECEITA origem='protese' do workspace lab (rota de financeiro existente, escopada por clinicaId = id do lab).
  • Prazo médio / entregues no mês: agregação simples sobre a bancada.

Sem rota nova obrigatória na 2.1 (reusa bancada + financeiro); se quiser um resumo de 1 chamada, criar GET /api/protese/lab/resumo depois.

Critérios de aceite 2.1

  • Logado como protético, a Sidebar mostra PAINEL DO LAB + BANCADA (+ notificações/config).
  • A clínica continua com a tela PRÓTESES (modo clinica) intacta.
  • Entregar uma OS com custo>0 (não-garantia) cria 2 lançamentos: DESPESA na clínica e RECEITA no lab (mesmo valor custo); reentregar/reprocessar não duplica (COALESCE).
  • OS de garantia entregue → nenhum lançamento (nos dois lados).
  • LabHomeView mostra fila + faturamento do mês do laboratório.

Ordem de execução e deploy

  1. Branch a partir da main (que já tem a Fase 1 não-commitada — commitar a Fase 1 antes).
  2. 2.0 (backend só) → subir em DEV (dev.scoreodonto.com/pgdev), rodar os SELECTs de aceite.
  3. 2.1 (backend receita + front) → validar aceite em DEV.
  4. Commit por sub-fase; push → CI builda por commit; git tag vX.Y.Z promove à produção.

Migrações aditivas ⇒ rollback de código é seguro (padrão já validado no projeto).