- FASE2-PROTETICO-AREA: Laboratório como workspace de 1ª classe (tipo='laboratorio'), roteamento por laboratorio_id, área de gestão própria, financeiro espelhado. - FASE2-IMPL-2.0-2.1: detalhamento de implementação (migrações, backfill, bancada, receita espelhada, menu da área). - ANALISE-AREA-RECEPCAO-TC: gap de permissões da recepção fundamentado em modelos validados (Treatment Coordinator / RBAC / Lab Case Manager). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
10 KiB
Fase 2 — Detalhamento de implementação: passos 2.0 e 2.1
Companheiro de
FASE2-PROTETICO-AREA.md(o modelo). Aqui está o como, a nível de arquivo/função, fiel ao código atual debackend/server.jsefrontend/. 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 emtry/catchque nunca derrubam o fluxo principal;protetico_idsempre 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
clinicasnão tiverdeleted_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)
proteseOsAccessonão muda na 2.0 (1 lab por protético ⇒protetico_id===uidainda identifica o lab). OOR por vínculo no labentra 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 porprotetico_id(owner) — sem mudança. (Olaboratorio_iddo 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 NULL→ 0 (após backfill).- Toda
clinicasde protético temtipo='laboratorio'. - Criar OS nova (via Nova OS e via finalizar GTO) →
laboratorio_idpreenchido. - 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 usaros.clinica_id). Estorno (cancelar entrega) é tarefa à parte: reverter os doisfinanceiro— fica para a sub-fase de cancelamento; por ora a entrega é terminal (já é hoje: 409 seentregue).
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 filtrocurrentRole==='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)
ProteseViewjá decidemodo='bancada'porgetCurrentRole()==='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á retornaatrasada). - Faturamento do mês: somar
financeiroRECEITAorigem='protese'do workspace lab (rota de financeiro existente, escopada porclinicaId= 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 valorcusto); reentregar/reprocessar não duplica (COALESCE). - OS de garantia entregue → nenhum lançamento (nos dois lados).
LabHomeViewmostra fila + faturamento do mês do laboratório.
Ordem de execução e deploy
- Branch a partir da
main(que já tem a Fase 1 não-commitada — commitar a Fase 1 antes). - 2.0 (backend só) → subir em DEV (
dev.scoreodonto.com/pgdev), rodar os SELECTs de aceite. - 2.1 (backend receita + front) → validar aceite em DEV.
- Commit por sub-fase; push → CI builda por commit;
git tag vX.Y.Zpromove à produção.
Migrações aditivas ⇒ rollback de código é seguro (padrão já validado no projeto).