feat(protese): Nota ScoreOdonto pública no marketplace de profissionais

GET /api/profissionais/marketplace passa a trazer a 'nota' dos protéticos (helper
notasScorePorProtetico, mesma fórmula da Fase 5 — só com volume mínimo). Card do protético
no marketplace exibe ★ nota. Ordem mantida por proximidade; a nota é comparação visível.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 07:28:39 +02:00
parent 2e90a2ba5f
commit b07478f0d4
3 changed files with 42 additions and 4 deletions
+27 -1
View File
@@ -4146,6 +4146,29 @@ 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.
async function notasScorePorProtetico(ids) {
const notas = {};
if (!ids || !ids.length) return notas;
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;
}
}
return notas;
}
// MODO 1 — gerar/recuperar o link seguro de acompanhamento da OS (clínica ou lab).
app.post('/api/protese/os/:id/link', authGuard, async (req, res) => {
try {
@@ -7186,7 +7209,10 @@ app.get('/api/profissionais/marketplace', authGuard, async (req, res) => {
const { rows } = await pool.query(
`SELECT id, nome, email, celular, role, estado, cidade, bairro, cep, especialidade_dir AS especialidade, raio_atuacao_km, bio_profissional, ${distSelect}
FROM usuarios ${where} ${order} LIMIT 200`, params);
res.json(rows);
// 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 })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
+13 -3
View File
@@ -106,6 +106,16 @@ Score/ranking/marketplace. Não codar indicador antes de existir dado.
> Acoplamentos respeitados: PF/PJ veio com a equipe (Fase 4); Score só após a captura (Fases 2-4).
### Fora de escopo (futuro)
Marketplace **transacional** (propostas/contratação dentro da plataforma) e o Modo 1 (protético sem
conta via link seguro `/p/TOKEN`) seguem como evolução — a base de dados/score já os suporta.
### Pós-roadmap (entregue depois das Fases 1-5)
- **✅ Modo 1** (protético sem conta via link `/p/TOKEN`) — completo (1a leitura, 1b ação, 1c gestão).
Ver `MODO1-LINK-SEGURO-PROTESE.md`.
- **✅ Higiene** — menu legado do protético removido (Sidebar/App): o protético opera só no workspace
laboratório; fallback mínimo fora dele.
- **✅ Score público no marketplace** — `GET /api/profissionais/marketplace` traz a `nota` ScoreOdonto
dos protéticos (helper `notasScorePorProtetico`, mesma fórmula da Fase 5), exibida como ★ no card.
Ordem mantida por proximidade; a nota é critério de comparação visível.
### Ainda em aberto (futuro)
Marketplace **transacional** (anúncio de serviços + solicitação/contratação/propostas dentro da
plataforma); **agenda de produção/coleta**; **multi-unidade** do laboratório; envio automático do
link (e-mail/WhatsApp API) e herança de cadastro novo com e-mail diferente.
@@ -44,6 +44,7 @@ interface Profissional {
raio_atuacao_km?: number | null;
bio_profissional?: string | null;
dist_km?: number | string | null;
nota?: number | null; // Nota ScoreOdonto (só protéticos com volume mínimo)
}
interface Proposta {
@@ -699,6 +700,7 @@ export const ProfissionaisPlugin: React.FC = () => {
<div className="flex items-center gap-2 flex-wrap">
<span className="font-black text-gray-900 text-sm uppercase truncate">{p.nome}</span>
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${meta.cls}`}>{meta.label}</span>
{p.nota != null && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase" title="Nota ScoreOdonto"> {p.nota}</span>}
</div>
{p.especialidade && <p className="text-xs text-gray-500 font-medium mt-0.5 uppercase">{p.especialidade}</p>}
</div>