From a1c203ced558bf3f224f8bc5cae50fde82b7e6fd Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Tue, 23 Jun 2026 22:17:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20Fase=202=20backend=20=E2=80=94?= =?UTF-8?q?=20Laborat=C3=B3rio=20como=20workspace=20de=201=C2=AA=20classe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Workspace do protético passa a ser tipo='laboratorio' (re-tag dos existentes + novos nascem assim no /api/register). Área de gestão própria, como a clínica. - Roteamento por laboratorio_id: OS nascem com o lab (POST /os e GTO→OS); a Bancada filtra por laboratorio_id com fallback por protetico_id (OS legadas). Helper labDoProtetico(). Backfill aditivo + índice. - Receita espelhada (2.1): a entrega lança RECEITA no workspace do lab (clinica_id=laboratorio_id, valor=os.custo), espelho da DESPESA da clínica — idempotente (financeiro_lab_id), isolado em try/catch. fix: corrige coluna `paciente_nome`→`pacientenome` no INSERT da DESPESA de prótese (bug pré-existente da Fase 1, em produção): a entrega de OS com custo>0 abortava com "column paciente_nome does not exist". A mesma correção vale para a RECEITA nova. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 72 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/backend/server.js b/backend/server.js index cfaeffd..8b4d308 100644 --- a/backend/server.js +++ b/backend/server.js @@ -634,9 +634,11 @@ app.post('/api/register', async (req, res) => { : userRole === 'protetico' ? `LABORATÓRIO DE ${nome.trim()}` : nome.trim(); const wsNome = (workspaceNome && workspaceNome.trim() ? workspaceNome.trim() : defaultNome).toUpperCase(); + // Fase 2: o workspace do protético é um LABORATÓRIO (área de gestão de 1ª classe). + const wsTipo = userRole === 'protetico' ? 'laboratorio' : 'pessoal'; await pool.query( - `INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1, $2, 'pessoal', $3)`, - [wsId, wsNome, userId] + `INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1, $2, $3, $4)`, + [wsId, wsNome, wsTipo, userId] ); await pool.query( 'INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1, $2, $3, $4)', @@ -3782,6 +3784,15 @@ app.delete('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) => const PROTESE_STATUS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue', 'cancelado']; // Verifica o acesso de um usuário a uma OS: laboratório (destinatário) ou clínica de origem (com vínculo). +// O workspace 'laboratorio' do protético (1 por protético nesta fase). Usado para escopar a +// bancada e as OS por laboratório (empresa), além do protetico_id (pessoa) que segue como fallback. +async function labDoProtetico(proteticoId) { + if (!proteticoId) return null; + const { rows } = await pool.query( + "SELECT id FROM clinicas WHERE owner_id=$1 AND tipo='laboratorio' LIMIT 1", [proteticoId]); + return rows[0]?.id || null; +} + async function proteseOsAccesso(os, uid) { if (!os) return { isLab: false, isClinic: false }; const isLab = !!uid && os.protetico_id === uid; @@ -3904,10 +3915,11 @@ app.post('/api/protese/os', tenantGuard, async (req, res) => { if (ehGarantia) custo = 0; const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const uid = req.authUser?.userId; + const labId = await labDoProtetico(b.protetico_id); // Fase 2: escopo por laboratório (empresa) await pool.query( - `INSERT INTO protese_os (id, clinica_id, protetico_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, procedimento_id, agendamento_id, tipo, produto_id, tipo_os, origem_os_id, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, created_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,'solicitado',$23)`, - [id, req.clinicaId, b.protetico_id, prot[0].nome || null, b.paciente_id || null, b.paciente_nome || null, + `INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, procedimento_id, agendamento_id, tipo, produto_id, tipo_os, origem_os_id, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,'solicitado',$24)`, + [id, req.clinicaId, b.protetico_id, labId, prot[0].nome || null, b.paciente_id || null, b.paciente_nome || null, b.dentista_id || null, b.dentista_nome || null, b.procedimento_id || null, b.agendamento_id || null, tipo, produtoId, ehGarantia ? 'garantia' : 'nova', ehGarantia ? b.origem_os_id : null, garantiaMeses, b.material || null, b.cor || null, b.dentes || null, b.observacoes || null, @@ -3939,8 +3951,10 @@ app.get('/api/protese/os', tenantGuard, async (req, res) => { app.get('/api/protese/bancada', authGuard, async (req, res) => { try { const uid = req.authUser.userId; - const vals = [uid]; - let where = 'protetico_id=$1 AND deleted_at IS NULL'; + const labId = await labDoProtetico(uid); // Fase 2: a bancada é do LABORATÓRIO (empresa) + const vals = [uid, labId]; + // Roteia por laboratorio_id; fallback por pessoa cobre OS legadas sem laboratorio_id. + 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 @@ -3985,7 +3999,7 @@ app.post('/api/protese/os/:id/status', authGuard, async (req, res) => { if (estadosClinica.includes(novo) && !isClinic) return res.status(403).json({ error: 'Só a clínica de origem marca entregue/cancelado.' }); if (!estadosClinica.includes(novo) && !isLab) return res.status(403).json({ error: 'Só o laboratório avança a produção.' }); // Garantia com fotos: para ENTREGAR é obrigatório ter ao menos 1 foto do trabalho (evidência da garantia). - let garantiaAte = null, finId = os.financeiro_id || null; + let garantiaAte = null, finId = os.financeiro_id || null, finLabId = os.financeiro_lab_id || null; if (novo === 'entregue') { const { rows: fc } = await pool.query('SELECT COUNT(*)::int AS n FROM protese_os_foto WHERE os_id=$1', [os.id]); if (!fc[0].n) return res.status(422).json({ error: 'Anexe ao menos 1 foto do trabalho antes de entregar (evidência da garantia).' }); @@ -3996,13 +4010,27 @@ app.post('/api/protese/os/:id/status', authGuard, async (req, res) => { if (!finId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') { finId = `fin_pos_${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, profissional_id, paciente_id, paciente_nome, origem, centro_custo, fornecedor, sem_comissao) + `INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, profissional_id, paciente_id, pacientenome, origem, centro_custo, fornecedor, sem_comissao) VALUES ($1,$2,$3,$4,'DESPESA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,$7,'protese','LABORATÓRIO',$8,true)`, [finId, os.clinica_id, `PRÓTESE: ${os.tipo} (lab ${os.protetico_nome || ''})`.trim(), Number(os.custo), os.protetico_id, os.paciente_id || null, os.paciente_nome || null, os.protetico_nome || null]); } + // Fase 2.1: espelho — RECEITA no workspace do laboratório (clinica_id = laboratorio_id). + // Mesmo valor/condição da DESPESA; isolado para nunca derrubar a transição de status. + try { + const labId = os.laboratorio_id || await labDoProtetico(os.protetico_id); + if (!finLabId && labId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') { + const { rows: cl } = await pool.query('SELECT nome_fantasia FROM clinicas WHERE id=$1', [os.clinica_id]); + 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, pacientenome, 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 ${cl[0]?.nome_fantasia || ''})`.trim(), Number(os.custo), + os.paciente_id || null, os.paciente_nome || null]); + } + } catch (e) { console.error('[protese entrega→receita lab]', e.message); } } - await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW(), garantia_ate=COALESCE($2,garantia_ate), financeiro_id=COALESCE($3,financeiro_id) WHERE id=$4', [novo, garantiaAte, finId, os.id]); + await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW(), garantia_ate=COALESCE($2,garantia_ate), financeiro_id=COALESCE($3,financeiro_id), financeiro_lab_id=COALESCE($4,financeiro_lab_id) WHERE id=$5', [novo, garantiaAte, finId, finLabId, os.id]); await pool.query( `INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)`, [`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, novo, req.body?.observacao || null, uid, await actorNome(uid)]); @@ -4476,10 +4504,11 @@ app.put('/api/gto-builder/:id/finalizar', authGuard, async (req, res) => { if (!pr.length) continue; const { rows: prot } = await pool.query("SELECT nome FROM usuarios WHERE id=$1 AND role='protetico'", [it.protetico_id]); const osId = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + const labId = await labDoProtetico(it.protetico_id); // Fase 2: escopo por laboratório await pool.query( - `INSERT INTO protese_os (id, clinica_id, protetico_id, protetico_nome, paciente_id, paciente_nome, dentista_id, procedimento_id, tipo, produto_id, tipo_os, garantia_meses, dentes, observacoes, valor, custo, status, created_by, gto_item_id) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'nova',$11,$12,$13,$14,$15,'solicitado',$16,$17)`, - [osId, b.clinica_id, it.protetico_id, prot[0]?.nome || null, b.pacienteid, b.paciente_nome, + `INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, procedimento_id, tipo, produto_id, tipo_os, garantia_meses, dentes, observacoes, valor, custo, status, created_by, gto_item_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'nova',$12,$13,$14,$15,$16,'solicitado',$17,$18)`, + [osId, b.clinica_id, it.protetico_id, labId, prot[0]?.nome || null, b.pacienteid, b.paciente_nome, b.dentistaid, it.procedimentoid || null, pr[0].nome, pr[0].id, Number(pr[0].garantia_meses) || 12, it.dente || null, it.observacao || null, Number(it.valortotal) || 0, parseFloat(pr[0].preco || 0), uid, it.id]); await pool.query( @@ -5686,6 +5715,23 @@ async function runMigrations() { // Especialidade de prótese vinha marcada como 'odontologia' (legado) → re-tag para 'protese' // permite o front detectar a especialidade e puxar o catálogo do protético. Idempotente. `UPDATE especialidades SET area='protese' WHERE area IS DISTINCT FROM 'protese' AND (nome ILIKE '%protese%' OR nome ILIKE '%prótese%')`, + // ── FASE 2.0: Laboratório como workspace de 1ª classe ────────────────────── + // RECEITA espelhada no lab (preenchida na entrega; idempotência) e catálogo por lab. + `ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_lab_id TEXT`, + `ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT`, + // (protese_os.laboratorio_id já existe — coluna reservada na criação da tabela.) + // 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`, + `CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`, `CREATE TABLE IF NOT EXISTS protese_produtos ( id TEXT PRIMARY KEY, protetico_id TEXT NOT NULL, -- dono (usuário protético)