feat(protese): profissionaliza a OS — etapas, fotos, componentes, pagamentos e auditoria

Reformula o modal de detalhe da OS de prótese, fechando os gaps de gestão:

- Etapas: stepper visual do fluxo (solicitado→…→entregue) com a etapa atual e datas.
- Fotos: agora anexáveis TAMBÉM no modo clínica (antes só na bancada — beco sem saída
  para entregar), com autoria (quem anexou), data e legenda; remover foto. Toda foto
  entra no histórico.
- Componentes enviados: nova tabela protese_os_componente — item, qtd, quem enviou e
  quando (ex.: implante, pilar, modelo). Add/remover.
- Valores recebidos (dois lados): nova tabela protese_os_pagamento — lado='clinica'
  (paciente→clínica) e lado='lab' (clínica→lab), com totais, forma, autor e data.
- Auditoria: histórico com data+HORA e autor; foto/componente/pagamento viram eventos
  rastreados. Helpers carregaOSComAcesso/logEventoOS reaproveitados nos sub-recursos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-23 23:41:19 +02:00
parent b80d061a6f
commit 623c9990d6
3 changed files with 366 additions and 39 deletions
+145 -3
View File
@@ -3973,15 +3973,115 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
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]);
const { rows: fotos } = await pool.query('SELECT id, original_name, uploaded_nome, legenda, created_at FROM protese_os_foto WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
const { rows: componentes } = await pool.query('SELECT id, nome, quantidade, observacao, enviado_nome, created_at FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
const { rows: pagamentos } = await pool.query('SELECT id, lado, valor, forma, observacao, data, recebido_nome, created_at FROM protese_os_pagamento WHERE os_id=$1 ORDER BY data, 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)}` })),
componentes,
pagamentos: pagamentos.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Carrega a OS e valida acesso (lab ou clínica de origem). Reuso nos sub-recursos da OS.
async function carregaOSComAcesso(osId, uid) {
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [osId]);
if (!rows.length) return { status: 404, error: 'OS não encontrada.' };
const os = rows[0];
const { isLab, isClinic } = await proteseOsAccesso(os, uid);
if (!isLab && !isClinic) return { status: 403, error: 'Sem acesso a esta OS.' };
return { os, isLab, isClinic };
}
async function logEventoOS(os, uid, observacao, status = 'nota') {
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, status, observacao, uid, await actorNome(uid)]);
}
// Remover foto (rastreado no histórico).
app.delete('/api/protese/os/fotos/:id', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const { rows } = await pool.query('SELECT * FROM protese_os_foto WHERE id=$1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Foto não encontrada.' });
const foto = rows[0];
const acc = await carregaOSComAcesso(foto.os_id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
await pool.query('DELETE FROM protese_os_foto WHERE id=$1', [req.params.id]);
await logEventoOS(acc.os, uid, 'Foto removida', 'foto');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Componentes/insumos enviados ao laboratório.
app.post('/api/protese/os/:id/componentes', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const acc = await carregaOSComAcesso(req.params.id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
const b = req.body || {};
if (!b.nome || !String(b.nome).trim()) return res.status(400).json({ error: 'Nome do componente é obrigatório.' });
const id = `pcomp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const nome = await actorNome(uid);
const qtd = Math.max(1, parseInt(b.quantidade, 10) || 1);
await pool.query(
`INSERT INTO protese_os_componente (id, os_id, clinica_id, nome, quantidade, observacao, enviado_por, enviado_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
[id, acc.os.id, acc.os.clinica_id, String(b.nome).trim(), qtd, b.observacao || null, uid, nome]);
await logEventoOS(acc.os, uid, `Componente enviado: ${qtd}× ${String(b.nome).trim()}`, 'componente');
res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/protese/os/componentes/:id', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const { rows } = await pool.query('SELECT * FROM protese_os_componente WHERE id=$1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Componente não encontrado.' });
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
await pool.query('DELETE FROM protese_os_componente WHERE id=$1', [req.params.id]);
await logEventoOS(acc.os, uid, `Componente removido: ${rows[0].nome}`, 'componente');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Pagamentos/recebimentos da OS (lado='lab' clínica→lab | lado='clinica' paciente→clínica).
app.post('/api/protese/os/:id/pagamentos', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const acc = await carregaOSComAcesso(req.params.id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
const b = req.body || {};
const lado = ['lab', 'clinica'].includes(b.lado) ? b.lado : null;
if (!lado) return res.status(400).json({ error: "lado deve ser 'lab' ou 'clinica'." });
const valor = Number(b.valor);
if (!(valor > 0)) return res.status(400).json({ error: 'Valor deve ser maior que zero.' });
const id = `ppag_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const nome = await actorNome(uid);
await pool.query(
`INSERT INTO protese_os_pagamento (id, os_id, clinica_id, lado, valor, forma, observacao, data, recebido_por, recebido_nome)
VALUES ($1,$2,$3,$4,$5,$6,$7,COALESCE($8::date,CURRENT_DATE),$9,$10)`,
[id, acc.os.id, acc.os.clinica_id, lado, valor, b.forma || null, b.observacao || null, b.data || null, uid, nome]);
const rotulo = lado === 'lab' ? 'pago ao laboratório' : 'recebido do paciente';
await logEventoOS(acc.os, uid, `Pagamento ${rotulo}: ${valor.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}`, 'pagamento');
res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/protese/os/pagamentos/:id', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const { rows } = await pool.query('SELECT * FROM protese_os_pagamento WHERE id=$1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Pagamento não encontrado.' });
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
if (acc.error) return res.status(acc.status).json({ error: acc.error });
await pool.query('DELETE FROM protese_os_pagamento WHERE id=$1', [req.params.id]);
await logEventoOS(acc.os, uid, 'Pagamento removido', 'pagamento');
res.json({ success: true });
} 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;
@@ -4057,8 +4157,15 @@ app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async
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]);
const uid = req.authUser.userId;
const nome = await actorNome(uid);
const legenda = (req.body?.legenda || '').toString().slice(0, 200) || null;
await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome, legenda) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, uid, nome, legenda]);
// Anexar foto entra no histórico (rastreabilidade: quem anexou e quando).
await pool.query(
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'foto',$4,$5,$6)`,
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, legenda ? `Foto anexada: ${legenda}` : 'Foto anexada', uid, nome]);
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 }); }
});
@@ -5732,6 +5839,41 @@ async function runMigrations() {
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)`,
// ── Profissionalização da OS de prótese ────────────────────────────────────
// Fotos com autoria + legenda (quem anexou / o quê).
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS uploaded_by TEXT`,
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS uploaded_nome TEXT`,
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS legenda TEXT`,
// Componentes/insumos enviados ao laboratório (ex.: implante, pilar, modelo).
`CREATE TABLE IF NOT EXISTS protese_os_componente (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL,
clinica_id TEXT,
nome TEXT NOT NULL,
quantidade INTEGER DEFAULT 1,
observacao TEXT,
enviado_por TEXT,
enviado_nome TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_comp ON protese_os_componente (os_id, created_at)`,
// Pagamentos/recebimentos vinculados à OS (dois lados):
// lado='lab' → a clínica paga o laboratório (a-receber do lab)
// lado='clinica' → o paciente paga a clínica pela prótese
`CREATE TABLE IF NOT EXISTS protese_os_pagamento (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL,
clinica_id TEXT,
lado TEXT NOT NULL,
valor NUMERIC DEFAULT 0,
forma TEXT,
observacao TEXT,
data DATE DEFAULT CURRENT_DATE,
recebido_por TEXT,
recebido_nome TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_pag ON protese_os_pagamento (os_id, data)`,
`CREATE TABLE IF NOT EXISTS protese_produtos (
id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético)