feat(protese): módulo de OS de prótese (clínica ↔ laboratório/protético)
- backend: tabelas protese_os/_evento/_foto/_produtos + endpoints /api/protese/* (laboratorios, os, bancada, status, foto/raw assinado, produtos); catálogo do protético; custo de lab → DESPESA na entrega (idempotente, estorno no delete) + garantia com foto. - Acesso: helper proteticoAcessivelPelaClinica (interno por vínculo OU diretório) valida o POST e o catálogo; catálogos passam por tenantGuard; correção do label da notificação (tipo resolvido). - Frontend: ProteseView (Bancada do protético / Minhas OS da clínica) + rota /proteses + item de menu PRÓTESES no Sidebar (dentista/protético/funcionário). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -452,6 +452,36 @@ async function seedBiomedicina(clinicaId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Catálogo geral de produtos de prótese (preço fixo, SEM especialidade — o protético é geral).
|
||||
// Garantia padrão de 12 meses por produto.
|
||||
const PROTESE_PRODUTOS_PADRAO = [
|
||||
{ nome: 'Coroa de Zircônia', preco: 350 },
|
||||
{ nome: 'Coroa Metalocerâmica', preco: 250 },
|
||||
{ nome: 'Coroa Provisória', preco: 80 },
|
||||
{ nome: 'Faceta / Laminado de Porcelana', preco: 400 },
|
||||
{ nome: 'Onlay / Inlay Cerâmico', preco: 300 },
|
||||
{ nome: 'Núcleo Metálico Fundido', preco: 90 },
|
||||
{ nome: 'Prótese Total (Dentadura)', preco: 600 },
|
||||
{ nome: 'Prótese Parcial Removível (PPR)', preco: 500 },
|
||||
{ nome: 'Coroa sobre Implante', preco: 600 },
|
||||
{ nome: 'Protocolo sobre Implante (arco)', preco: 1800 },
|
||||
{ nome: 'Placa de Bruxismo / Miorrelaxante',preco: 150 },
|
||||
{ nome: 'Provisório em Resina (unidade)', preco: 60 },
|
||||
];
|
||||
// Idempotente: só popula se o protético ainda não tem nenhum produto.
|
||||
async function seedProteseProdutos(proteticoId) {
|
||||
if (!proteticoId) return false;
|
||||
const { rows } = await pool.query('SELECT 1 FROM protese_produtos WHERE protetico_id = $1 LIMIT 1', [proteticoId]);
|
||||
if (rows.length) return false;
|
||||
for (let i = 0; i < PROTESE_PRODUTOS_PADRAO.length; i++) {
|
||||
const p = PROTESE_PRODUTOS_PADRAO[i];
|
||||
await pool.query(
|
||||
"INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,12,true,$5)",
|
||||
[`ppr_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`, proteticoId, p.nome, p.preco, i]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- REGISTRO DE USUÁRIOS ---
|
||||
app.post('/api/register', async (req, res) => {
|
||||
const { nome, email, senha, role, workspaceNome, especialidade } = req.body;
|
||||
@@ -492,6 +522,8 @@ app.post('/api/register', async (req, res) => {
|
||||
workspaces = [{ id: wsId, nome: wsNome, cor: '#2563eb', role: userRole }];
|
||||
// Biomédico já nasce com o catálogo de biomedicina estética no consultório dele.
|
||||
if (userRole === 'biomedico') { try { await seedBiomedicina(wsId); } catch (e) { console.error('[seedBiomedicina]', e.message); } }
|
||||
// Protético já nasce com o catálogo geral de produtos de prótese (preço fixo + garantia).
|
||||
if (userRole === 'protetico') { try { await seedProteseProdutos(userId); } catch (e) { console.error('[seedProteseProdutos]', e.message); } }
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' });
|
||||
@@ -3616,6 +3648,290 @@ app.delete('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) =>
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ═══ PRÓTESES · Ordem de Serviço (OS): clínica/dentista → laboratório/protético ═══
|
||||
// Cross-workspace (padrão das propostas): a clínica cria por tenantGuard (clinica_id origem);
|
||||
// o protético vê na "Bancada" por authGuard (protetico_id = destinatário).
|
||||
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).
|
||||
async function proteseOsAccesso(os, uid) {
|
||||
if (!os) return { isLab: false, isClinic: false };
|
||||
const isLab = !!uid && os.protetico_id === uid;
|
||||
let isClinic = false;
|
||||
if (uid) {
|
||||
const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [uid, os.clinica_id]);
|
||||
isClinic = rows.length > 0;
|
||||
}
|
||||
return { isLab, isClinic };
|
||||
}
|
||||
|
||||
// Um protético é "acessível" por uma clínica se for interno (vínculo) OU estiver no diretório.
|
||||
// Mesma regra do dropdown de Nova OS — usada para travar criação de OS e leitura de catálogo.
|
||||
async function proteticoAcessivelPelaClinica(proteticoId, clinicaId) {
|
||||
if (!proteticoId || !clinicaId) return false;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM usuarios u
|
||||
LEFT JOIN vinculos v ON v.usuario_id = u.id AND v.clinica_id = $2
|
||||
WHERE u.id = $1 AND u.role = 'protetico'
|
||||
AND (v.clinica_id = $2 OR u.disponivel_diretorio = true)
|
||||
LIMIT 1`, [proteticoId, clinicaId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// Laboratórios/protéticos disponíveis para o dropdown da Nova OS:
|
||||
// protéticos com vínculo na clínica (internos) + protéticos no diretório (externos).
|
||||
app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.nome, u.email,
|
||||
BOOL_OR(v.clinica_id = $1) AS interno
|
||||
FROM usuarios u
|
||||
LEFT JOIN vinculos v ON v.usuario_id = u.id
|
||||
WHERE u.role = 'protetico'
|
||||
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true)
|
||||
GROUP BY u.id, u.nome, u.email
|
||||
ORDER BY interno DESC, u.nome`, [clinicaId]);
|
||||
res.json(rows.map(r => ({ ...r, interno: !!r.interno })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ── Catálogo de produtos do protético (preço fixo, geral) ───────────────────
|
||||
// O protético gerencia o próprio catálogo (escopo por usuário). Lazy-seed na 1ª leitura.
|
||||
app.get('/api/protese/produtos', authGuard, async (req, res) => {
|
||||
try {
|
||||
const uid = req.authUser.userId;
|
||||
await seedProteseProdutos(uid); // idempotente — protéticos antigos ganham o catálogo padrão
|
||||
const { rows } = await pool.query('SELECT * FROM protese_produtos WHERE protetico_id=$1 AND deleted_at IS NULL ORDER BY ordem, nome', [uid]);
|
||||
res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Catálogo ATIVO de um laboratório (lido pela clínica ao criar a OS).
|
||||
app.get('/api/protese/laboratorios/:id/produtos', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
if (!await proteticoAcessivelPelaClinica(req.params.id, req.clinicaId)) {
|
||||
return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' });
|
||||
}
|
||||
await seedProteseProdutos(req.params.id);
|
||||
const { rows } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE protetico_id=$1 AND ativo=true AND deleted_at IS NULL ORDER BY ordem, nome', [req.params.id]);
|
||||
res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.post('/api/protese/produtos', authGuard, async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.nome) return res.status(400).json({ error: 'nome obrigatório.' });
|
||||
try {
|
||||
const id = `ppr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query('INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[id, req.authUser.userId, b.nome, Number(b.preco) || 0, Number(b.garantia_meses) || 12, b.ativo !== false, Number(b.ordem) || 0]);
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.put('/api/protese/produtos/:id', authGuard, async (req, res) => {
|
||||
const b = req.body || {};
|
||||
try {
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE protese_produtos SET nome=COALESCE($1,nome), preco=COALESCE($2,preco), garantia_meses=COALESCE($3,garantia_meses), ativo=COALESCE($4,ativo) WHERE id=$5 AND protetico_id=$6 AND deleted_at IS NULL',
|
||||
[b.nome ?? null, b.preco != null ? Number(b.preco) : null, b.garantia_meses != null ? Number(b.garantia_meses) : null, b.ativo ?? null, req.params.id, req.authUser.userId]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' });
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.delete('/api/protese/produtos/:id', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query('UPDATE protese_produtos SET deleted_at=NOW() WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [req.params.id, req.authUser.userId]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' });
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Criar OS (clínica origem).
|
||||
app.post('/api/protese/os', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
const b = req.body || {};
|
||||
if (!b.protetico_id) return res.status(400).json({ error: 'protetico_id (laboratório) obrigatório.' });
|
||||
try {
|
||||
const { rows: prot } = await pool.query("SELECT id, nome FROM usuarios WHERE id=$1 AND role='protetico'", [b.protetico_id]);
|
||||
if (!prot.length) return res.status(404).json({ error: 'Protético não encontrado.' });
|
||||
// Só pode pedir OS a protético interno (vínculo) ou no diretório — mesma regra do dropdown.
|
||||
if (!await proteticoAcessivelPelaClinica(b.protetico_id, req.clinicaId)) {
|
||||
return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' });
|
||||
}
|
||||
// Produto do catálogo do laboratório = preço fixo (custo de lab) + garantia.
|
||||
let tipo = b.tipo || null, custo = Number(b.custo) || 0, garantiaMeses = 12, produtoId = null;
|
||||
if (b.produto_id) {
|
||||
const { rows: pr } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [b.produto_id, b.protetico_id]);
|
||||
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence a este laboratório.' });
|
||||
produtoId = pr[0].id; tipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantiaMeses = Number(pr[0].garantia_meses) || 12;
|
||||
}
|
||||
if (!tipo) return res.status(400).json({ error: 'Selecione um produto/tipo de prótese.' });
|
||||
// OS de garantia: refaz uma OS anterior sem novo custo (dentro da garantia).
|
||||
const ehGarantia = b.tipo_os === 'garantia' && !!b.origem_os_id;
|
||||
if (ehGarantia) custo = 0;
|
||||
const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const uid = req.authUser?.userId;
|
||||
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,
|
||||
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,
|
||||
b.prazo_entrega || null, Number(b.valor) || 0, custo, uid]);
|
||||
await pool.query(
|
||||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'solicitado',$4,$5,$6)`,
|
||||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, id, req.clinicaId, 'OS criada', uid, await actorNome(uid)]);
|
||||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: id, acao: 'criou', actorId: uid, actorNome: await actorNome(uid), detalhes: { protetico_id: b.protetico_id, tipo } });
|
||||
await notificarUsuario(b.protetico_id, 'Nova ordem de prótese', `Você recebeu uma nova OS (${tipo}). Veja na Bancada.`);
|
||||
wsBroadcast(req.clinicaId, 'protese', { entidadeId: id, acao: 'criou' });
|
||||
res.json({ success: true, id });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Lista da clínica origem (dentista/recepção).
|
||||
app.get('/api/protese/os', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const vals = [req.clinicaId];
|
||||
let where = 'clinica_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}`; }
|
||||
if (req.query.paciente_id) { vals.push(req.query.paciente_id); where += ` AND paciente_id=$${vals.length}`; }
|
||||
const { rows } = await pool.query(`SELECT * FROM protese_os WHERE ${where} ORDER BY created_at DESC LIMIT 300`, vals);
|
||||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0), custo: parseFloat(r.custo || 0) })));
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Bancada do laboratório (protético): OS destinadas a mim, com flag de atraso.
|
||||
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';
|
||||
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
|
||||
FROM protese_os WHERE ${where}
|
||||
ORDER BY status='entregue', status='cancelado', prazo_entrega ASC NULLS LAST, created_at DESC LIMIT 300`, vals);
|
||||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0), 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]);
|
||||
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);
|
||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||||
const { rows: eventos } = await pool.query('SELECT status, observacao, actor_nome, created_at FROM protese_os_evento WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
const { rows: fotos } = await pool.query('SELECT id, original_name FROM protese_os_foto WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||||
res.json({
|
||||
...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
|
||||
eventos,
|
||||
fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Mudar status (laboratório avança a produção; clínica entrega/cancela).
|
||||
app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
|
||||
const novo = req.body?.status;
|
||||
if (!PROTESE_STATUS.includes(novo)) return res.status(400).json({ error: 'Status inválido.' });
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * 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 uid = req.authUser.userId;
|
||||
const { isLab, isClinic } = await proteseOsAccesso(os, uid);
|
||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||||
if (os.status === 'cancelado' || os.status === 'entregue') return res.status(409).json({ error: `OS já está "${os.status}".` });
|
||||
// Entregue/cancelado são da clínica; estados de produção são do laboratório.
|
||||
const estadosClinica = ['entregue', 'cancelado'];
|
||||
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;
|
||||
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).' });
|
||||
const meses = Number(os.garantia_meses) || 12;
|
||||
const { rows: gd } = await pool.query(`SELECT (CURRENT_DATE + ($1 || ' months')::interval)::date AS d`, [meses]);
|
||||
garantiaAte = gd[0].d;
|
||||
// Custo de laboratório → DESPESA (contas a pagar ao laboratório), idempotente. OS de garantia não cobra.
|
||||
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)
|
||||
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]);
|
||||
}
|
||||
}
|
||||
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(
|
||||
`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)]);
|
||||
await registrarAudit(pool, { clinicaId: os.clinica_id, entidade: 'protese_os', entidadeId: os.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: { financeiro_id: finId, garantia_ate: garantiaAte } });
|
||||
// Notifica a outra parte.
|
||||
const destino = isLab ? os.created_by : os.protetico_id;
|
||||
if (destino) await notificarUsuario(destino, `Prótese: ${novo}`, `A OS de ${os.paciente_nome || 'paciente'} (${os.tipo}) mudou para "${novo}".`, novo === 'entregue' ? 'sucesso' : 'info');
|
||||
wsBroadcast(os.clinica_id, 'protese', { entidadeId: os.id, acao: novo });
|
||||
res.json({ success: true, status: novo, financeiro_id: finId, garantia_ate: garantiaAte });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Anexar foto do trabalho (laboratório ou clínica).
|
||||
app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' });
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * 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);
|
||||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||||
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
|
||||
const rel = path.join('proteses', os.id, `${Date.now()}.${ext}`);
|
||||
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
|
||||
const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname]);
|
||||
res.json({ success: true, id: fid, url: `/api/protese/os/fotos/${fid}/raw?t=${signMedia(fid)}` });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Stream da imagem (URL assinada — img tag não envia Authorization).
|
||||
app.get('/api/protese/os/fotos/:id/raw', async (req, res) => {
|
||||
try {
|
||||
if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end();
|
||||
const { rows } = await pool.query('SELECT filename, mimetype FROM protese_os_foto WHERE id=$1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).end();
|
||||
res.type(rows[0].mimetype || 'image/jpeg');
|
||||
res.sendFile(path.join(MEDIA_ROOT, rows[0].filename), (err) => { if (err && !res.headersSent) res.status(404).end(); });
|
||||
} catch { res.status(404).end(); }
|
||||
});
|
||||
|
||||
// Excluir OS (só a clínica de origem; soft delete).
|
||||
app.delete('/api/protese/os/:id', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT financeiro_id FROM protese_os WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||||
await pool.query('UPDATE protese_os SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2', [req.params.id, req.clinicaId]);
|
||||
// Estorna a DESPESA de laboratório vinculada (se houver).
|
||||
if (rows[0].financeiro_id) await pool.query('UPDATE financeiro SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [rows[0].financeiro_id, req.clinicaId]);
|
||||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: req.params.id, acao: 'excluiu', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { estornou: rows[0].financeiro_id || null } });
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// --- CONTRATOS ---
|
||||
app.get('/api/orcamentos', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
@@ -5142,6 +5458,75 @@ async function runMigrations() {
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_proc_foto_realizado ON procedimento_realizado_foto (realizado_id, momento)`,
|
||||
// ── PRÓTESES: Ordem de Serviço (clínica → laboratório/protético) ─────
|
||||
`CREATE TABLE IF NOT EXISTS protese_os (
|
||||
id TEXT PRIMARY KEY,
|
||||
clinica_id TEXT NOT NULL, -- clínica origem (quem pediu) — escopo tenantGuard
|
||||
laboratorio_id TEXT, -- workspace destino (futuro; null = roteia por protetico_id)
|
||||
protetico_id TEXT, -- usuário destinatário (a "bancada")
|
||||
protetico_nome TEXT,
|
||||
paciente_id TEXT,
|
||||
paciente_nome TEXT,
|
||||
dentista_id TEXT,
|
||||
dentista_nome TEXT,
|
||||
procedimento_id TEXT,
|
||||
agendamento_id TEXT,
|
||||
tipo TEXT, -- fixa | removível | total | sobre implante | provisório...
|
||||
material TEXT,
|
||||
cor TEXT, -- escala de cor
|
||||
dentes TEXT, -- FDI livre (ex: 11,21)
|
||||
observacoes TEXT,
|
||||
prazo_entrega DATE,
|
||||
valor NUMERIC DEFAULT 0, -- preço cobrado pela OS
|
||||
custo NUMERIC DEFAULT 0, -- custo de laboratório (elo futuro com comissão)
|
||||
status TEXT DEFAULT 'solicitado',-- solicitado|recebido|producao|prova|ajuste|finalizado|entregue|cancelado
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_clinica ON protese_os (clinica_id, status, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_lab ON protese_os (protetico_id, status)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_evento (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
status TEXT,
|
||||
observacao TEXT,
|
||||
actor_id TEXT,
|
||||
actor_nome TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_evento ON protese_os_evento (os_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_os_foto (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
filename TEXT,
|
||||
mimetype TEXT,
|
||||
original_name TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_foto ON protese_os_foto (os_id)`,
|
||||
// Catálogo de produtos do protético (preço fixo, sem especialidade — geral de prótese) + garantia
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS produto_id TEXT`,
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS tipo_os TEXT DEFAULT 'nova'`, // nova | garantia
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS origem_os_id TEXT`, // OS original (em garantia)
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_meses INTEGER DEFAULT 12`,
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_ate DATE`,
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, // DESPESA gerada na entrega
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
nome TEXT NOT NULL,
|
||||
preco NUMERIC DEFAULT 0, -- preço fixo cobrado da clínica (= custo de laboratório)
|
||||
garantia_meses INTEGER DEFAULT 12,
|
||||
ativo BOOLEAN DEFAULT true,
|
||||
ordem INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_produtos_prot ON protese_produtos (protetico_id, ativo)`,
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); }
|
||||
|
||||
Reference in New Issue
Block a user