feat(protese): fluxo do lado do protético — entrada de OS, baixa/cobrança e importar OS indicada
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s

Fecha o ciclo operacional do laboratório, antes só "receptor passivo" das OS da clínica.

1. Dar entrada em OS pelo lab — POST /api/protese/lab/os: o dono do laboratório registra um
   trabalho recebido fora do app, escolhendo cliente existente OU avulso (clínica/consultório/
   dentista, materializado como registro mínimo) + produto do catálogo ou tipo livre. A OS já
   entra com laboratorio_id e custódia no laboratório. UI: botão "Dar entrada" + modal na LabHome.
2. Dar baixa em pagamento — já existia (lado 'lab'); mantido na PagamentosSecao.
3. Solicitar pagamento — POST /api/protese/os/:id/cobrar: calcula o saldo devedor (custo − pago)
   e notifica o cliente, registrando no histórico. UI: botão "Solicitar pagamento (saldo)" na
   PagamentosSecao do modo bancada.
4. Importar/vincular OS indicada — GET /api/protese/lab/os-indicadas (OS com protetico_id=me e
   laboratorio_id NULL) + POST /api/protese/os/:id/vincular-lab (seta laboratorio_id, status→recebido,
   notifica a clínica). Sem isso a OS só aparecia ao dono via fallback; importando, a equipe/técnicos
   e o financeiro do lab passam a vê-la. UI: seção "OS indicadas a você" na LabHome.

origemDe agora reconhece cliente avulso tipo 'dentista'. Validado em DEV (todos os 4 endpoints).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-25 04:43:35 +02:00
parent 7566c01159
commit 7c8c8fc0d2
4 changed files with 276 additions and 4 deletions
+102 -2
View File
@@ -4032,8 +4032,9 @@ app.get('/api/protese/lab/clientes', authGuard, async (req, res) => {
GROUP BY o.clinica_id`, [uid, labId]);
const recPorClinica = Object.fromEntries(pagos.map(p => [p.clinica_id, parseFloat(p.recebido || 0)]));
const origemDe = (tipo, role) => tipo === 'consultorio' ? 'Consultório'
: tipo === 'pessoal' ? (role === 'dentista' ? 'Dentista' : role === 'biomedico' ? 'Biomédico' : 'Profissional')
: 'Clínica';
: tipo === 'dentista' ? 'Dentista'
: tipo === 'pessoal' ? (role === 'dentista' ? 'Dentista' : role === 'biomedico' ? 'Biomédico' : 'Profissional')
: 'Clínica';
res.json(rows.map(r => {
const faturado = parseFloat(r.faturado || 0), recebido = recPorClinica[r.clinica_id] || 0;
return {
@@ -4063,6 +4064,105 @@ app.get('/api/protese/lab/fechamento', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// FASE 6 — OS indicadas a mim ainda SEM laboratório vinculado. Só o dono as vê (fallback);
// ao importar, entram no lab e a equipe/técnicos passam a vê-las.
app.get('/api/protese/lab/os-indicadas', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const { rows } = await pool.query(
`SELECT o.id, o.tipo, o.status, o.custo, o.prazo_entrega, o.created_at, o.dentista_nome, o.paciente_nome,
c.nome_fantasia AS cliente_nome, c.tipo AS cliente_tipo
FROM protese_os o LEFT JOIN clinicas c ON c.id = o.clinica_id
WHERE o.protetico_id=$1 AND o.laboratorio_id IS NULL AND o.deleted_at IS NULL
AND o.status NOT IN ('entregue','cancelado')
ORDER BY o.created_at DESC LIMIT 100`, [uid]);
res.json(rows.map(r => ({ ...r, custo: parseFloat(r.custo || 0) })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
// FASE 6 — Importar/vincular a OS ao MEU laboratório (formaliza a entrada).
app.post('/api/protese/os/:id/vincular-lab', 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.isLab) return res.status(403).json({ error: 'Apenas o protético destinatário importa a OS.' });
if (acc.os.laboratorio_id) return res.status(409).json({ error: 'Esta OS já está vinculada a um laboratório.' });
const labId = await labDoUsuario(uid);
if (!labId) return res.status(400).json({ error: 'Você não possui laboratório.' });
const novoStatus = acc.os.status === 'solicitado' ? 'recebido' : acc.os.status;
await pool.query('UPDATE protese_os SET laboratorio_id=$1, status=$2 WHERE id=$3', [labId, novoStatus, acc.os.id]);
await logEventoOS(acc.os, uid, 'Trabalho importado para o laboratório', 'vinculo');
if (acc.os.created_by) await notificarUsuario(acc.os.created_by, 'Trabalho aceito pelo laboratório', `A OS (${acc.os.tipo}) foi importada pelo laboratório.`);
wsBroadcast(acc.os.clinica_id, 'protese', { entidadeId: acc.os.id, acao: 'vinculou' });
res.json({ success: true, laboratorio_id: labId, status: novoStatus });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// FASE 6 — Solicitar pagamento (cobrar saldo): notifica o cliente e registra no histórico da OS.
app.post('/api/protese/os/:id/cobrar', 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.isLab) return res.status(403).json({ error: 'Apenas o laboratório solicita pagamento.' });
const { rows: pg } = await pool.query("SELECT COALESCE(SUM(valor),0) AS pago FROM protese_os_pagamento WHERE os_id=$1 AND lado='lab'", [acc.os.id]);
const pago = parseFloat(pg[0].pago || 0);
const saldo = Math.max(0, parseFloat(acc.os.custo || 0) - pago);
const valor = req.body?.valor != null ? Number(req.body.valor) : saldo;
if (!(valor > 0)) return res.status(400).json({ error: 'Nada a cobrar: o saldo já está quitado.' });
const brl = valor.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
const obs = (req.body?.observacao || '').trim();
await logEventoOS(acc.os, uid, `Pagamento solicitado ao cliente: ${brl}${obs ? `${obs}` : ''}`, 'cobranca');
if (acc.os.created_by) await notificarUsuario(acc.os.created_by, 'Solicitação de pagamento', `O laboratório solicitou ${brl} referente à OS (${acc.os.tipo}).${obs ? ` ${obs}` : ''}`);
wsBroadcast(acc.os.clinica_id, 'protese', { entidadeId: acc.os.id, acao: 'cobranca' });
res.json({ success: true, valor, saldo });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// FASE 6 — Dar entrada em OS pelo lado do LABORATÓRIO (trabalho recebido fora do app).
// Cliente: clínica existente (cliente_id) OU cliente avulso (cliente_nome + cliente_tipo).
app.post('/api/protese/lab/os', authGuard, async (req, res) => {
try {
const uid = req.authUser.userId;
const labId = await labDoProtetico(uid);
if (!labId) return res.status(403).json({ error: 'Apenas o dono do laboratório dá entrada em OS.' });
const b = req.body || {};
// Produto do catálogo (preço fixo) ou tipo livre + custo.
let tipo = (b.tipo || '').trim() || 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, uid]);
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence ao seu 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: 'Informe o produto ou o tipo de prótese.' });
// Cliente: existente (valida acesso ao lab) ou avulso (materializa registro mínimo, owner nulo).
let clienteId = b.cliente_id || null;
if (clienteId) {
const { rows: cl } = await pool.query('SELECT id FROM clinicas WHERE id=$1', [clienteId]);
if (!cl.length) return res.status(404).json({ error: 'Cliente não encontrado.' });
} else {
const nome = (b.cliente_nome || '').trim();
if (!nome) return res.status(400).json({ error: 'Informe o cliente (existente ou avulso).' });
const tipoCli = ['clinica', 'consultorio', 'dentista'].includes(b.cliente_tipo) ? b.cliente_tipo : 'clinica';
clienteId = `cliext_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query('INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1,$2,$3,NULL)', [clienteId, nome.toUpperCase(), tipoCli]);
}
const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const status = b.local_atual === 'laboratorio' ? 'recebido' : 'recebido'; // já está com o lab
await pool.query(
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_nome, dentista_nome, tipo, produto_id, tipo_os, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, local_atual, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'nova',$10,$11,$12,$13,$14,$15,0,$16,$17,'laboratorio',$18)`,
[id, clienteId, uid, labId, (await actorNome(uid)) || null, b.paciente_nome || null, b.dentista_nome || null,
tipo, produtoId, garantiaMeses, b.material || null, b.cor || null, b.dentes || null, b.observacoes || null,
b.prazo_entrega || null, custo, status, uid]);
await pool.query(
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'recebido',$4,$5,$6)`,
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, id, clienteId, 'Entrada registrada pelo laboratório', uid, await actorNome(uid)]);
res.json({ success: true, id, cliente_id: clienteId });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// FASE 3 — Extrato de recebimentos do laboratório (pagamentos lado 'lab').
app.get('/api/protese/lab/pagamentos', authGuard, async (req, res) => {
try {