feat(protese): marketplace transacional fatia 3 — cotação (RFQ) multi-lab
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s

Entidades protese_rfq + protese_rfq_cotacao. A clínica descreve o trabalho e convida N labs;
cada lab cota (valor/prazo/obs); a clínica compara (ordenado por preço) e escolhe → vira OS
(custo = valor da cotação) e a RFQ fecha (demais recusadas).

- Backend: POST/GET /protese/rfq, GET /protese/rfq/recebidas, POST /protese/rfq/cotacao/:id,
  POST /protese/rfq/:id/escolher (cria a OS).
- Lado lab: tela Cotações (lab-cotacoes) — RFQs recebidas + responder.
- Lado clínica: botão Cotações na tela de Prótese — pedir multi-lab + comparar + escolher.

Validado em DEV ponta a ponta: pedido → cotação R$420 → escolha → OS criada → RFQ fechada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 14:43:56 +02:00
parent 07d798c370
commit cd89ca245c
7 changed files with 352 additions and 11 deletions
+119
View File
@@ -4211,6 +4211,93 @@ app.get('/api/protese/os/:id/link/status', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ── Fatia 3: Cotação (RFQ) — clínica pede, N labs cotam, clínica escolhe → vira OS ──
app.post('/api/protese/rfq', tenantGuard, async (req, res) => {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const b = req.body || {};
const proteticos = Array.isArray(b.proteticos) ? b.proteticos.filter(Boolean) : [];
if (!b.descricao || !String(b.descricao).trim()) return res.status(400).json({ error: 'Descreva o trabalho.' });
if (!proteticos.length) return res.status(400).json({ error: 'Convide ao menos um laboratório.' });
try {
const uid = req.authUser?.userId;
const rfqId = `rfq_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query(
`INSERT INTO protese_rfq (id, clinica_id, descricao, dentes, prazo_desejado, paciente_id, paciente_nome, dentista_id, dentista_nome, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[rfqId, req.clinicaId, String(b.descricao).trim(), b.dentes || null, b.prazo_desejado || null, b.paciente_id || null, b.paciente_nome || null, b.dentista_id || null, b.dentista_nome || null, uid]);
let convidados = 0;
for (const pid of proteticos) {
if (!await proteticoAcessivelPelaClinica(pid, req.clinicaId)) continue;
const { rows: pr } = await pool.query("SELECT nome FROM usuarios WHERE id=$1 AND role='protetico'", [pid]);
if (!pr.length) continue;
await pool.query(`INSERT INTO protese_rfq_cotacao (id, rfq_id, protetico_id, protetico_nome) VALUES ($1,$2,$3,$4)`,
[`cot_${Date.now()}_${Math.random().toString(36).slice(2, 6)}_${convidados}`, rfqId, pid, pr[0].nome]);
await notificarUsuario(pid, 'Novo pedido de cotação', 'Você recebeu um pedido de cotação de prótese. Responda em Cotações.');
convidados++;
}
res.json({ success: true, id: rfqId, convidados });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/protese/rfq', tenantGuard, async (req, res) => {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try {
const { rows: rfqs } = await pool.query("SELECT * FROM protese_rfq WHERE clinica_id=$1 ORDER BY created_at DESC LIMIT 100", [req.clinicaId]);
const ids = rfqs.map(r => r.id);
let cots = [];
if (ids.length) { const { rows } = await pool.query("SELECT * FROM protese_rfq_cotacao WHERE rfq_id = ANY($1) ORDER BY (valor IS NULL), valor ASC", [ids]); cots = rows; }
res.json(rfqs.map(r => ({ ...r, cotacoes: cots.filter(c => c.rfq_id === r.id).map(c => ({ ...c, valor: c.valor != null ? parseFloat(c.valor) : null })) })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/protese/rfq/recebidas', authGuard, async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT c.id AS cotacao_id, c.valor, c.prazo_dias, c.observacao, c.status AS cotacao_status,
r.id AS rfq_id, r.descricao, r.dentes, r.prazo_desejado, r.paciente_nome, r.dentista_nome, r.status AS rfq_status, r.created_at,
(SELECT nome_fantasia FROM clinicas WHERE id=r.clinica_id) AS clinica_nome
FROM protese_rfq_cotacao c JOIN protese_rfq r ON r.id = c.rfq_id
WHERE c.protetico_id=$1 ORDER BY r.created_at DESC LIMIT 100`, [req.authUser.userId]);
res.json(rows.map(r => ({ ...r, valor: r.valor != null ? parseFloat(r.valor) : null })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/protese/rfq/cotacao/:id', authGuard, async (req, res) => {
try {
const { rows } = await pool.query("SELECT c.*, r.status AS rfq_status FROM protese_rfq_cotacao c JOIN protese_rfq r ON r.id=c.rfq_id WHERE c.id=$1", [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Cotação não encontrada.' });
if (rows[0].protetico_id !== req.authUser.userId) return res.status(403).json({ error: 'Não é sua cotação.' });
if (rows[0].rfq_status !== 'aberta') return res.status(409).json({ error: 'Pedido de cotação encerrado.' });
const valor = Number(req.body?.valor);
if (!(valor > 0)) return res.status(400).json({ error: 'Informe o valor da cotação.' });
await pool.query("UPDATE protese_rfq_cotacao SET valor=$1, prazo_dias=$2, observacao=$3, status='enviada', respondida_em=NOW() WHERE id=$4",
[valor, parseInt(req.body?.prazo_dias, 10) || null, req.body?.observacao || null, req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/protese/rfq/:id/escolher', tenantGuard, async (req, res) => {
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
try {
const { rows: rr } = await pool.query("SELECT * FROM protese_rfq WHERE id=$1 AND clinica_id=$2", [req.params.id, req.clinicaId]);
if (!rr.length) return res.status(404).json({ error: 'Cotação não encontrada.' });
const rfq = rr[0];
if (rfq.status !== 'aberta') return res.status(409).json({ error: 'Pedido já encerrado.' });
const { rows: cc } = await pool.query("SELECT * FROM protese_rfq_cotacao WHERE id=$1 AND rfq_id=$2 AND status='enviada'", [req.body?.cotacao_id, rfq.id]);
if (!cc.length) return res.status(400).json({ error: 'Cotação inválida.' });
const cot = cc[0];
const uid = req.authUser?.userId;
const osId = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const labId = await labDoProtetico(cot.protetico_id);
await pool.query(
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, tipo, dentes, observacoes, prazo_entrega, valor, custo, status, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,0,$14,'solicitado',$15)`,
[osId, req.clinicaId, cot.protetico_id, labId, cot.protetico_nome, rfq.paciente_id, rfq.paciente_nome, rfq.dentista_id, rfq.dentista_nome, rfq.descricao, rfq.dentes, rfq.descricao, rfq.prazo_desejado, parseFloat(cot.valor || 0), 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)}`, osId, req.clinicaId, 'OS criada a partir de cotação', uid, await actorNome(uid)]);
await pool.query("UPDATE protese_rfq SET status='fechada', os_id=$1 WHERE id=$2", [osId, rfq.id]);
await pool.query("UPDATE protese_rfq_cotacao SET status=CASE WHEN id=$1 THEN 'escolhida' ELSE 'recusada' END WHERE rfq_id=$2", [cot.id, rfq.id]);
await notificarUsuario(cot.protetico_id, 'Cotação aceita!', 'Sua cotação foi escolhida — uma OS entrou na sua Bancada.');
res.json({ success: true, os_id: osId });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// MODO 1 — leitura PÚBLICA da OS pelo token (sem login). Blindagem: sem CPF, sem valor do paciente.
app.get('/api/p/:token', async (req, res) => {
try {
@@ -6450,6 +6537,38 @@ async function runMigrations() {
acessos INTEGER DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id)`,
// Marketplace transacional Fatia 3 — Cotação (RFQ): a clínica descreve um trabalho e N
// laboratórios cotam (preço/prazo); a clínica escolhe e vira OS.
`CREATE TABLE IF NOT EXISTS protese_rfq (
id TEXT PRIMARY KEY,
clinica_id TEXT NOT NULL,
descricao TEXT NOT NULL,
dentes TEXT,
prazo_desejado DATE,
paciente_id TEXT,
paciente_nome TEXT,
dentista_id TEXT,
dentista_nome TEXT,
status TEXT DEFAULT 'aberta',
os_id TEXT,
created_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_clinica ON protese_rfq (clinica_id, created_at)`,
`CREATE TABLE IF NOT EXISTS protese_rfq_cotacao (
id TEXT PRIMARY KEY,
rfq_id TEXT NOT NULL,
protetico_id TEXT NOT NULL,
protetico_nome TEXT,
valor NUMERIC,
prazo_dias INTEGER,
observacao TEXT,
status TEXT DEFAULT 'convidada',
created_at TIMESTAMPTZ DEFAULT NOW(),
respondida_em TIMESTAMPTZ
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_rfq ON protese_rfq_cotacao (rfq_id)`,
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_prot ON protese_rfq_cotacao (protetico_id, status)`,
`CREATE TABLE IF NOT EXISTS protese_produtos (
id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético)