feat(protese): marketplace transacional fatia 4 — avaliação, satisfação na nota e selo verificado
- 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:
+64
-30
@@ -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 }); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user