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:
+145
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user