feat(protese): marketplace transacional fatia 3 — cotação (RFQ) multi-lab
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user