feat(protese): etapas livres, ajustes do trabalho e privacidade de valores do paciente

- Etapas repetíveis/puláveis: cada etapa tem ação Ir/Voltar/Repetir (não só "avançar
  para a próxima"). 'entregue' segue na Linha do tempo.
- Ajustes do trabalho (aba Etapas): editar cor/material (PATCH .../os/:id); trocar o
  trabalho por outro + MOTIVO obrigatório (POST .../trocar); acréscimo/redução do valor
  cobrado do paciente com observação (POST .../ajuste-valor) — tudo auditado no histórico.
- Privacidade: o protético (lab que não é da clínica) NÃO vê o que o paciente paga/deve —
  GET detalhe omite pagamentos do lado clínica e zera o valor cobrado; a bancada também
  zera o valor. O ajuste de valor do paciente é exclusivo da clínica (403 para o lab). No
  front, o modo bancada esconde "Recebido do paciente".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 02:32:21 +02:00
parent f4f433b14c
commit 9f103be397
3 changed files with 173 additions and 13 deletions
+69 -3
View File
@@ -3960,7 +3960,8 @@ app.get('/api/protese/bancada', authGuard, async (req, res) => {
`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 })));
// Bancada é a fila do laboratório: ele vê o próprio custo, não o valor cobrado ao paciente.
res.json(rows.map(r => ({ ...r, valor: 0, custo: parseFloat(r.custo || 0), atrasada: !!r.atrasada })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -3977,13 +3978,16 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
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]);
const { rows: custodia } = await pool.query('SELECT id, de_local, para_local, etapa, observacao, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
// Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve.
const soLab = isLab && !isClinic;
const pagsVisiveis = soLab ? pagamentos.filter(p => p.lado !== 'clinica') : pagamentos;
res.json({
...os, valor: parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
...os, valor: soLab ? 0 : parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
local_atual: os.local_atual || 'clinica',
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) })),
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
custodia,
});
} catch (err) { res.status(500).json({ error: err.message }); }
@@ -4107,6 +4111,68 @@ app.post('/api/protese/os/:id/custodia', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Editar campos do trabalho (cor, material, dentes, observações) — lab ou clínica. Auditado.
app.patch('/api/protese/os/:id', 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 });
if (acc.os.status === 'entregue' || acc.os.status === 'cancelado') return res.status(409).json({ error: 'OS finalizada não pode ser editada.' });
const b = req.body || {};
const muda = [];
for (const campo of ['cor', 'material', 'dentes', 'observacoes']) {
if (b[campo] !== undefined) {
const novo = b[campo] === null || b[campo] === '' ? null : String(b[campo]);
if ((acc.os[campo] || null) !== novo) { await pool.query(`UPDATE protese_os SET ${campo}=$1 WHERE id=$2`, [novo, acc.os.id]); muda.push(`${campo}: ${acc.os[campo] || '—'}${novo || '—'}`); }
}
}
if (muda.length) await logEventoOS(acc.os, uid, `Trabalho editado (${muda.join('; ')})`, 'edicao');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Ajuste do valor cobrado do paciente (acréscimo/redução) — SOMENTE clínica. Auditado.
app.post('/api/protese/os/:id/ajuste-valor', 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 });
if (!acc.isClinic) return res.status(403).json({ error: 'Apenas a clínica ajusta o valor do paciente.' });
const delta = Number(req.body?.delta);
if (!delta || isNaN(delta)) return res.status(400).json({ error: 'Informe um valor de ajuste (+ ou ).' });
const atual = parseFloat(acc.os.valor || 0);
const novo = Math.max(0, atual + delta);
await pool.query('UPDATE protese_os SET valor=$1 WHERE id=$2', [novo, acc.os.id]);
const sinal = delta >= 0 ? '+' : '';
const obs = req.body?.observacao ? ` · ${String(req.body.observacao).slice(0, 200)}` : '';
await logEventoOS(acc.os, uid, `Valor ao paciente ${sinal}${Math.abs(delta).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}${obs} (novo total ${novo.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })})`, 'valor');
res.json({ success: true, valor: novo });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Trocar o trabalho por outro (produto do catálogo do lab OU tipo livre) + motivo. Auditado.
app.post('/api/protese/os/:id/trocar', 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 });
if (acc.os.status === 'entregue' || acc.os.status === 'cancelado') return res.status(409).json({ error: 'OS finalizada não pode ser trocada.' });
const b = req.body || {};
if (!b.motivo || !String(b.motivo).trim()) return res.status(400).json({ error: 'Informe o motivo da troca.' });
const de = acc.os.tipo;
let novoTipo = b.tipo ? String(b.tipo).trim() : null, custo = parseFloat(acc.os.custo || 0), garantia = acc.os.garantia_meses, produtoId = acc.os.produto_id;
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, acc.os.protetico_id]);
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence a este laboratório.' });
produtoId = pr[0].id; novoTipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantia = Number(pr[0].garantia_meses) || 12;
}
if (!novoTipo) return res.status(400).json({ error: 'Selecione o novo trabalho.' });
await pool.query('UPDATE protese_os SET tipo=$1, produto_id=$2, custo=$3, garantia_meses=$4 WHERE id=$5', [novoTipo, produtoId, custo, garantia, acc.os.id]);
await logEventoOS(acc.os, uid, `Trabalho trocado: ${de}${novoTipo} · Motivo: ${String(b.motivo).trim()}`, 'troca');
res.json({ success: true, tipo: novoTipo });
} 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;