feat(protese): marketplace transacional fatia 4 — avaliação, satisfação na nota e selo verificado
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m5s

- A clínica avalia o laboratório (1-5★ + comentário) após a entrega (protese_avaliacao, 1 por OS,
  reavaliável). POST /protese/os/:id/avaliar; avaliação volta no GET detalhe.
- Nota ScoreOdonto passa a combinar 0.6·operacional + 0.4·satisfação (estrelas) quando há avaliações.
  Selo "Verificado" (≥5 entregas). Exposto no marketplace (★nota · nº avaliações · Verificado) e nos
  indicadores do lab (KPI Satisfação). Helper notasScorePorProtetico unificado (dropdown + marketplace).
- UI: seção "Avaliar o laboratório" na OS entregue (lado clínica); selo/avaliações no card do marketplace.
- Correção de passagem: FileText/Star importados em ProteseView (FileText faltava desde a fatia 3).

Validado em DEV: 6 entregas + 5★/3★ → nota 9.2, satisfação 4★, verificado (marketplace e indicadores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 17:56:35 +02:00
parent c084ad553b
commit 6cd8c1f9e1
6 changed files with 118 additions and 38 deletions
+64 -30
View File
@@ -3848,27 +3848,8 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
GROUP BY u.id, u.nome, u.email
ORDER BY interno DESC, u.nome`, [clinicaId]);
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
const ids = rows.map(r => r.id);
const notas = {};
if (ids.length) {
const { rows: ag } = await pool.query(
`SELECT protetico_id,
COUNT(*) FILTER (WHERE status='entregue')::int AS entregues,
COUNT(*) FILTER (WHERE status='entregue' AND tipo_os='garantia')::int AS garantia,
COUNT(*) FILTER (WHERE status='entregue' AND prazo_entrega IS NOT NULL AND updated_at::date > prazo_entrega)::int AS atrasados
FROM protese_os WHERE protetico_id = ANY($1) AND deleted_at IS NULL GROUP BY protetico_id`, [ids]);
const { rows: ocs } = await pool.query(
`SELECT o.protetico_id, COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND o.protetico_id = ANY($1) GROUP BY o.protetico_id`, [ids]);
const ocMap = Object.fromEntries(ocs.map(o => [o.protetico_id, o.n]));
for (const g of ag) {
if (g.entregues >= SCORE_MIN_OS) {
const pct = n => g.entregues > 0 ? n / g.entregues * 100 : 0;
const nota = 10 - (pct(g.atrasados) / 100 * 3 + pct(g.garantia) / 100 * 4 + pct(ocMap[g.protetico_id] || 0) / 100 * 3);
notas[g.protetico_id] = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
}
}
}
const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id] ?? null }));
const notas = await notasScorePorProtetico(rows.map(r => r.id));
const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
// Ranking: internos primeiro, depois maior nota, depois nome.
lista.sort((a, b) => (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
res.json(lista);
@@ -4122,18 +4103,23 @@ async function calcularIndicadoresLab(uid, labId) {
`SELECT COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND ${esc}`, [uid, labId]);
const { rows: cp } = await pool.query(
`SELECT COUNT(*)::int AS n FROM protese_os_componente c JOIN protese_os o ON o.id = c.os_id WHERE c.status_conferencia='ausente' AND ${esc}`, [uid, labId]);
const { rows: av } = await pool.query(
`SELECT AVG(estrelas)::numeric AS media, COUNT(*)::int AS n FROM protese_avaliacao WHERE protetico_id = (SELECT owner_id FROM clinicas WHERE id=$1) OR protetico_id = $2`, [labId, uid]);
const r = a[0], entregues = r.entregues, pct = n => entregues > 0 ? (n / entregues * 100) : 0;
const atraso_pct = pct(r.atrasados), retrab_pct = pct(r.garantia), ocor_pct = pct(oc[0].n);
const satisf = av[0].n > 0 ? parseFloat(av[0].media) : null;
let nota = null;
if (entregues >= SCORE_MIN_OS) {
nota = Math.max(0, Math.min(10, 10 - (atraso_pct / 100 * 3 + retrab_pct / 100 * 4 + ocor_pct / 100 * 3)));
nota = Math.round(nota * 10) / 10;
const operacional = Math.max(0, Math.min(10, 10 - (atraso_pct / 100 * 3 + retrab_pct / 100 * 4 + ocor_pct / 100 * 3)));
nota = satisf != null ? 0.6 * operacional + 0.4 * (satisf / 5 * 10) : operacional;
nota = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
}
const r1 = n => Math.round(Number(n) * 10) / 10;
return {
total: r.total, entregues, garantia: r.garantia, atrasados: r.atrasados,
ocorrencias_resolvidas: oc[0].n, componentes_ausentes: cp[0].n, dias_medio: r1(r.dias_medio),
atraso_pct: r1(atraso_pct), retrabalho_pct: r1(retrab_pct), ocorrencia_pct: r1(ocor_pct),
satisfacao: satisf != null ? r1(satisf) : null, avaliacoes: av[0].n, verificado: entregues >= SCORE_VERIF_MIN,
nota, min_os: SCORE_MIN_OS,
};
}
@@ -4146,10 +4132,12 @@ app.get('/api/protese/lab/indicadores', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Nota ScoreOdonto por protético (mapa id→nota), p/ ranking no dropdown e no marketplace.
// Nota ScoreOdonto por protético (mapa id→{nota, avaliacoes, satisfacao, verificado}).
// A nota combina o operacional (atraso/retrabalho/ocorrências) com a satisfação da clínica (estrelas).
const SCORE_VERIF_MIN = 5; // entregas p/ selo "verificado"
async function notasScorePorProtetico(ids) {
const notas = {};
if (!ids || !ids.length) return notas;
const out = {};
if (!ids || !ids.length) return out;
const { rows: ag } = await pool.query(
`SELECT protetico_id,
COUNT(*) FILTER (WHERE status='entregue')::int AS entregues,
@@ -4158,15 +4146,23 @@ async function notasScorePorProtetico(ids) {
FROM protese_os WHERE protetico_id = ANY($1) AND deleted_at IS NULL GROUP BY protetico_id`, [ids]);
const { rows: ocs } = await pool.query(
`SELECT o.protetico_id, COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND o.protetico_id = ANY($1) GROUP BY o.protetico_id`, [ids]);
const { rows: avs } = await pool.query(
`SELECT protetico_id, AVG(estrelas)::numeric AS media, COUNT(*)::int AS n FROM protese_avaliacao WHERE protetico_id = ANY($1) GROUP BY protetico_id`, [ids]);
const ocMap = Object.fromEntries(ocs.map(o => [o.protetico_id, o.n]));
const avMap = Object.fromEntries(avs.map(a => [a.protetico_id, { media: parseFloat(a.media), n: a.n }]));
for (const g of ag) {
const av = avMap[g.protetico_id];
const info = { nota: null, avaliacoes: av?.n || 0, satisfacao: av ? Math.round(av.media * 10) / 10 : null, verificado: g.entregues >= SCORE_VERIF_MIN };
if (g.entregues >= SCORE_MIN_OS) {
const pct = n => g.entregues > 0 ? n / g.entregues * 100 : 0;
const nota = 10 - (pct(g.atrasados) / 100 * 3 + pct(g.garantia) / 100 * 4 + pct(ocMap[g.protetico_id] || 0) / 100 * 3);
notas[g.protetico_id] = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
const operacional = Math.max(0, Math.min(10, 10 - (pct(g.atrasados) / 100 * 3 + pct(g.garantia) / 100 * 4 + pct(ocMap[g.protetico_id] || 0) / 100 * 3)));
// Com avaliações: 60% operacional + 40% satisfação (estrelas→0-10). Sem: só operacional.
const nota = av && av.n > 0 ? 0.6 * operacional + 0.4 * (av.media / 5 * 10) : operacional;
info.nota = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
}
out[g.protetico_id] = info;
}
return notas;
return out;
}
// MODO 1 — gerar/recuperar o link seguro de acompanhamento da OS (clínica ou lab).
@@ -4298,6 +4294,27 @@ app.post('/api/protese/rfq/:id/escolher', tenantGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Fatia 4 — a clínica avalia o laboratório ao final da OS (1-5 estrelas + comentário).
app.post('/api/protese/os/:id/avaliar', 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 avalia.' });
if (acc.os.status !== 'entregue') return res.status(409).json({ error: 'Avalie após a entrega.' });
const estrelas = parseInt(req.body?.estrelas, 10);
if (!(estrelas >= 1 && estrelas <= 5)) return res.status(400).json({ error: 'Estrelas de 1 a 5.' });
const nome = await actorNome(uid);
await pool.query(
`INSERT INTO protese_avaliacao (id, os_id, clinica_id, protetico_id, estrelas, comentario, autor_id, autor_nome)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (os_id) DO UPDATE SET estrelas=$5, comentario=$6, autor_id=$7, autor_nome=$8, created_at=NOW()`,
[`pav_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, acc.os.id, acc.os.clinica_id, acc.os.protetico_id, estrelas, req.body?.comentario || null, uid, nome]);
await logEventoOS(acc.os, uid, `Laboratório avaliado: ${estrelas}`, 'avaliacao');
res.json({ success: true });
} 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 {
@@ -4409,6 +4426,7 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
const { rows: ocorFotos } = await pool.query("SELECT id, ocorrencia_id, original_name FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NOT NULL", [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]);
const { rows: aval } = await pool.query('SELECT estrelas, comentario, autor_nome FROM protese_avaliacao WHERE os_id=$1', [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;
@@ -4422,6 +4440,7 @@ app.get('/api/protese/os/:id', authGuard, async (req, res) => {
componentes,
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
custodia,
avaliacao: aval[0] || null,
ocorrencias: ocorrencias.map(o => ({
...o,
fotos: ocorFotos.filter(f => f.ocorrencia_id === o.id).map(f => ({ id: f.id, original_name: f.original_name, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
@@ -6569,6 +6588,19 @@ async function runMigrations() {
)`,
`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)`,
// Fatia 4 — Avaliação da clínica ao final da OS (satisfação → alimenta a Nota; selo verificado).
`CREATE TABLE IF NOT EXISTS protese_avaliacao (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL UNIQUE,
clinica_id TEXT,
protetico_id TEXT NOT NULL,
estrelas INTEGER NOT NULL,
comentario TEXT,
autor_id TEXT,
autor_nome TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_protese_aval_prot ON protese_avaliacao (protetico_id)`,
`CREATE TABLE IF NOT EXISTS protese_produtos (
id TEXT PRIMARY KEY,
protetico_id TEXT NOT NULL, -- dono (usuário protético)
@@ -7331,7 +7363,9 @@ app.get('/api/profissionais/marketplace', authGuard, async (req, res) => {
// Nota ScoreOdonto nos protéticos (score público no marketplace). Mantém a ordem por
// proximidade; a nota é exibida no card para a clínica comparar.
const notas = await notasScorePorProtetico(rows.filter(r => r.role === 'protetico').map(r => r.id));
res.json(rows.map(r => ({ ...r, nota: r.role === 'protetico' ? (notas[r.id] ?? null) : undefined })));
res.json(rows.map(r => r.role === 'protetico'
? { ...r, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }
: r));
} catch (err) { res.status(500).json({ error: err.message }); }
});