# Pedido: enviar as marcações do Medicore para o ADMin (página KPI's) Para quem vai fazer o programa que lê o Medicore. O ADMin precisa de receber, numa tabela da sua base de dados (Supabase/Postgres), **uma linha por appointment do Medicore**. É só isso: o programa lê o Medicore e escreve nessa tabela. O resto (comparações, KPI's, gráficos) é feito pelo site. ## O que o ADMin faz com os dados | Tipo de profissional | Para que serve | |---|---| | **Psicólogas** | Cruzar o que está no Medicore com a Weekly Agenda do ADMin (separador "Controlo e Medicore": total direto + indireto do ADMin vs minutos no Medicore, com tolerância em %). | | **Psiquiatras e GP (médicos)** | Não têm Weekly Agenda no ADMin, por isso as linhas do Medicore **são** a agenda deles: entram no "Agora", "Previsto vs realizado" e "Painel de KPI" tal como as psicólogas. | ## A tabela Nome: `public.medicore_appointments` (já criada pela migração `shared/migration_kpis.sql`). | Coluna | Tipo | Obrigatória | O que é | |---|---|---|---| | `medicore_id` | texto | **sim, única** | O ID do appointment no Medicore. É a chave: se o mesmo ID for enviado outra vez, a linha é **atualizada** em vez de duplicada (upsert). | | `profissional` | texto | sim | Nome do profissional. Para psicólogas tem de ser **exatamente** o nome que está no ADMin (users.Nome, ex: `Joana Marrazes`) — senão o cruzamento não encontra a pessoa. Para os médicos usar **exatamente** estes nomes: `Dra. Karimi`, `Dr. Nandoe` (psiquiatras) e `Dr. Pedro Silva` (GP). | | `tipo_profissional` | texto | sim | Um de: `psicologa`, `psiquiatra`, `gp`, `enfermeira`. | | `empresa` | texto | sim | `VR` (Villa Ramadas) ou `BiY`. Cada agenda do Medicore é de uma empresa. | | `data` | date | sim | Dia do appointment, `YYYY-MM-DD`. | | `hora_inicio` | time | recomendada | Hora de início, `HH:MM`. Sem isto o "Agora" não sabe quando a consulta decorre. | | `duracao_minutos` | inteiro | sim | Duração em minutos (ex: 90, 60, 15). | | `appointment_type` | texto | sim | Tal como está no Medicore: `1 on 1`, `Intake`, `General indirect`, `MDO`, `Group therapy 60`, `Group therapy 90`, etc. Tudo o que contiver "indirect" ou "MDO" é tratado como tempo indireto; o resto como tempo direto. | | `paciente` | texto | **enviar sempre** | Número do paciente tal como está no Medicore (ex.: `3785`, `100296HU-565`). Hoje a página só usa os minutos, mas o número fica guardado para, a seguir, cruzar sessão a sessão com a Weekly Agenda. | | `status` | texto | sim | Tradução dos estados do Medicore (ver tabela abaixo): `scheduled` · `done` · `no-show` · `cancelled`. A distinção fina (no-show vs falha de link vs cancelou em cima da hora vs cancelado pela psicóloga) é feita no ADMin pela própria psicóloga, não é preciso vir do Medicore. Os valores `no-link`, `cancelled-late` e `cancelled-by-clinic` também são aceites, mas só se um dia o Medicore os tiver. | ### Tradução dos estados do Medicore | No Medicore | Tem paciente? | Enviar como | Porquê | |---|---|---|---| | `verwerkt` | — | `done` | processado pela psicóloga = realizado | | `Cancelled < 24 uur (no-show)` | — | `no-show` | | | `Cancelled > 24 uur` | — | `cancelled` | | | `niet aangemeld` | sim | `scheduled` | ainda não aconteceu, **ou** já passou mas a psicóloga ainda não processou — nos dois casos ainda não conta como realizado (o ADMin mostra "terminou · por confirmar") | | `niet aangemeld` | não (General indirect, MDO, etc.) | `done` se a hora de início já passou, senão `scheduled` | os appointments sem paciente nunca mudam de estado no Medicore (só ficam verdes quando passa a hora), por isso é a hora que decide | | `local` | texto | opcional | `Online` ou `Presential `. Sem valor = Online. | | `nota` | texto | **enviar sempre** | O texto livre (Note) do appointment no Medicore, tal e qual. É onde estão os códigos dos documentos (Docs) e os lembretes BiY→VR — fica guardado para verificações futuras. | | `relatorio_escrito` | true/false | **sim, nas sessões com paciente** | Se o relatório dessa sessão já está escrito no Medicore. Enviar `null` quando não se aplica (General indirect, MDO, etc.). Serve para o indicador "Relatórios em dia" da avaliação das psicólogas. | | `relatorio_em` | timestamp | quando `relatorio_escrito` = true | Data/hora em que o relatório ficou escrito (`YYYY-MM-DDTHH:MM:SSZ`). A regra da avaliação é "entregue até 24 h depois da sessão", por isso a hora interessa. Se o Medicore só disser que está escrito mas não quando, enviar a hora em que o programa o viu escrito pela primeira vez (e nunca a mudar depois). | | `atualizado_em` | timestamp | automática | Data/hora do envio. Enviar sempre (`now()`) para o ADMin mostrar "última atualização". | **Appointments apagados no Medicore** (como detetar isso exige ir ao log do Medicore, fica a forma simples): em cada envio, depois de ler o Medicore para um profissional e intervalo de datas, pedir ao ADMin o que já lá está para o mesmo intervalo (`GET /rest/v1/medicore_appointments?select=medicore_id&profissional=eq.&data=gte.&data=lte.`), e apagar os IDs que já não vieram do Medicore (`DELETE /rest/v1/medicore_appointments?medicore_id=eq.`). O script Python abaixo tem a função `apagar` para isso. **Agenda portuguesa (VR-PT / Blu):** se existir uma agenda separada para a equipa portuguesa, enviar com `empresa = VR` (o ADMin trata VR-PT como VR). **Atenção ao batch:** se uma linha do array tiver um valor inválido (ex: `status` fora da lista, `empresa` diferente de VR/BiY, `data` mal formatada), o PostgREST rejeita o **envio inteiro** (HTTP 400) — nenhuma linha fica gravada. Convém validar antes de enviar e, em caso de erro, ler a resposta (vem a dizer qual a restrição falhou) em vez de ignorar. ## Como escrever na tabela É uma API REST normal (PostgREST). Endereço e chave são os mesmos que o site usa: - URL: `https://fggfvtyidtzzbymbhads.supabase.co/rest/v1/medicore_appointments` - Cabeçalhos: `apikey` e `Authorization: Bearer`, as duas com esta chave (é a chave pública "anon" do site, a mesma que está em `shared/supabase-client.js`):
`eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZnZ2Z2dHlpZHR6emJ5bWJoYWRzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODQ3OTMwNTYsImV4cCI6MjEwMDM2OTA1Nn0.ybrG1k80jP7kW2X3TroRS4RDWc-rYjFAfo860WbP7O8` - Upsert (inserir ou atualizar pelo `medicore_id`), vários de uma vez num array JSON: ```http POST /rest/v1/medicore_appointments?on_conflict=medicore_id Content-Type: application/json Prefer: resolution=merge-duplicates,return=minimal apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZnZ2Z2dHlpZHR6emJ5bWJoYWRzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODQ3OTMwNTYsImV4cCI6MjEwMDM2OTA1Nn0.ybrG1k80jP7kW2X3TroRS4RDWc-rYjFAfo860WbP7O8 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZnZ2Z2dHlpZHR6emJ5bWJoYWRzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODQ3OTMwNTYsImV4cCI6MjEwMDM2OTA1Nn0.ybrG1k80jP7kW2X3TroRS4RDWc-rYjFAfo860WbP7O8 [ { "medicore_id": "", "profissional": "Catarina Serra", "tipo_profissional": "psicologa", "empresa": "BiY", "data": "2026-10-22", "hora_inicio": "08:45", "duracao_minutos": 90, "appointment_type": "1 on 1", "paciente": "100296HU-565", "status": "done", "local": "Online", "relatorio_escrito": true, "relatorio_em": "2026-10-22T12:40:00Z", "atualizado_em": "2026-10-22T18:05:00Z" }, { "medicore_id": "", "profissional": "Beatriz Caldeira", "tipo_profissional": "psicologa", "empresa": "VR", "data": "2026-10-22", "hora_inicio": "08:30", "duracao_minutos": 150, "appointment_type": "Intake", "paciente": "3785", "status": "done", "local": "Online", "atualizado_em": "2026-10-22T18:05:00Z" }, { "medicore_id": "", "profissional": "Inês Moleiro", "tipo_profissional": "psicologa", "empresa": "VR", "data": "2026-10-23", "hora_inicio": "09:30", "duracao_minutos": 90, "appointment_type": "1 on 1", "paciente": "300584JA-1913", "status": "scheduled", "local": "Online", "atualizado_em": "2026-10-22T18:05:00Z" } ] ``` Estes três são sessões reais que estão na Weekly Agenda do ADMin (22 e 23 de outubro de 2026) — servem para o primeiro teste: se o programa as enviar com o ID do Medicore, o separador "Controlo e Medicore" tem de mostrar a Catarina Serra e a Beatriz Caldeira com minutos no Medicore nesse dia. Nota: no Medicore uma sessão de 75 min é registada como `1 on 1` de 90 min — enviar a duração tal como está no Medicore. ### Exemplo completo em Python (só precisa de `pip install requests`) ```python import requests SUPABASE_URL = "https://fggfvtyidtzzbymbhads.supabase.co" SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZnZ2Z2dHlpZHR6emJ5bWJoYWRzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODQ3OTMwNTYsImV4cCI6MjEwMDM2OTA1Nn0.ybrG1k80jP7kW2X3TroRS4RDWc-rYjFAfo860WbP7O8" HEADERS = { "apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}", "Content-Type": "application/json", "Prefer": "resolution=merge-duplicates,return=minimal", } TABELA = f"{SUPABASE_URL}/rest/v1/medicore_appointments" STATUS_OK = {"scheduled", "done", "no-show", "no-link", "cancelled-late", "cancelled", "cancelled-by-clinic"} def traduzir_status(status_medicore: str, tem_paciente: bool, inicio: "datetime") -> str: """Estado do Medicore → estado do ADMin (ver tabela no documento). inicio = data+hora de início do appointment, hora da Holanda.""" from datetime import datetime s = status_medicore.strip().lower() if s == "verwerkt": return "done" if s.startswith("cancelled < 24"): return "no-show" if s.startswith("cancelled > 24"): return "cancelled" # "niet aangemeld" (ou qualquer outro): com paciente ainda não conta como realizado; # sem paciente (General indirect, MDO...) nunca muda no Medicore, decide a hora if tem_paciente: return "scheduled" return "done" if inicio < datetime.now() else "scheduled" TIPOS_OK = {"psicologa", "psiquiatra", "gp", "enfermeira"} def validar(a: dict) -> None: """Uma linha inválida rejeita o envio inteiro — validar antes.""" assert a["medicore_id"], "medicore_id vazio" assert a["profissional"], "profissional vazio" assert a["tipo_profissional"] in TIPOS_OK, f"tipo_profissional inválido: {a['tipo_profissional']}" assert a["empresa"] in {"VR", "BiY"}, f"empresa inválida: {a['empresa']}" assert len(a["data"]) == 10, "data tem de ser YYYY-MM-DD" assert a["status"] in STATUS_OK, f"status inválido: {a['status']}" assert isinstance(a["duracao_minutos"], int), "duracao_minutos tem de ser inteiro" def enviar(appointments: list[dict]) -> None: """Upsert pelo medicore_id (reenviar o mesmo ID atualiza em vez de duplicar).""" for a in appointments: validar(a) # em lotes de 500 para não fazer pedidos gigantes for i in range(0, len(appointments), 500): lote = appointments[i:i + 500] r = requests.post(TABELA, params={"on_conflict": "medicore_id"}, headers=HEADERS, json=lote, timeout=60) if r.status_code >= 300: # a resposta diz qual a restrição que falhou (ex.: status fora da lista) raise RuntimeError(f"Supabase {r.status_code}: {r.text}") print(f"enviados {len(lote)}") def apagar(medicore_id: str) -> None: """Appointment apagado no Medicore → apagar também aqui.""" r = requests.delete(TABELA, params={"medicore_id": f"eq.{medicore_id}"}, headers=HEADERS, timeout=30) r.raise_for_status() if __name__ == "__main__": # sessões reais da Weekly Agenda do ADMin (22-23 out 2026), para o primeiro teste — # só falta o programa pôr o medicore_id verdadeiro de cada uma enviar([ { "medicore_id": "", "profissional": "Catarina Serra", # EXATAMENTE como no ADMin "tipo_profissional": "psicologa", "empresa": "BiY", "data": "2026-10-22", "hora_inicio": "08:45", "duracao_minutos": 90, # no Medicore a sessão de 75 min fica 1 on 1 de 90 "appointment_type": "1 on 1", "paciente": "100296HU-565", "status": "done", "local": "Online", "relatorio_escrito": True, # relatório já escrito no Medicore "relatorio_em": "2026-10-22T12:40:00Z", # quando ficou escrito "atualizado_em": "2026-10-22T18:05:00Z", }, { "medicore_id": "", "profissional": "Beatriz Caldeira", "tipo_profissional": "psicologa", "empresa": "VR", "data": "2026-10-22", "hora_inicio": "08:30", "duracao_minutos": 150, "appointment_type": "Intake", "paciente": "3785", "status": "done", "local": "Online", "atualizado_em": "2026-10-22T18:05:00Z", }, { "medicore_id": "", "profissional": "Inês Moleiro", "tipo_profissional": "psicologa", "empresa": "VR", "data": "2026-10-23", "hora_inicio": "09:30", "duracao_minutos": 90, "appointment_type": "1 on 1", "paciente": "300584JA-1913", "status": "scheduled", # ainda não aconteceu "local": "Online", "atualizado_em": "2026-10-22T18:05:00Z", }, { "medicore_id": "", "profissional": "Dr. Nandoe", # psiquiatra: nome fixo combinado com o ADMin "tipo_profissional": "psiquiatra", "empresa": "BiY", "data": "2026-10-23", "hora_inicio": "14:30", "duracao_minutos": 60, "appointment_type": "Intake", "status": "scheduled", "atualizado_em": "2026-10-22T18:05:00Z", }, ]) ``` Se preferir a biblioteca oficial (`pip install supabase`), é o mesmo: `supabase.table("medicore_appointments").upsert(lista, on_conflict="medicore_id").execute()`. ## Com que frequência - **Passado (realizadas / falhadas):** todos os dias ao fim do dia, os appointments dos últimos 7 dias (reenviar os mesmos IDs não faz mal — é upsert). É nesta passagem que se atualiza o `relatorio_escrito`/`relatorio_em` das sessões dos dias anteriores, à medida que as psicólogas vão escrevendo os relatórios. - **Futuro (scheduled):** todos os dias de manhã, os appointments dos próximos 30 dias. O "Agora" do ADMin usa os de hoje com `hora_inicio`. - Carga inicial: se der, os últimos 3 meses, para o histórico (taxa de falha) ficar logo preenchido. ## Checklist para o primeiro envio 1. A tabela já existe no Supabase (migração corrida a 22/10/2026) — não há nada a preparar do lado do ADMin. 2. Enviar as 3 sessões de teste do exemplo acima (Catarina Serra, Beatriz Caldeira, Inês Moleiro) com o ID verdadeiro do Medicore e abrir no ADMin: KPI's → Controlo e Medicore (tem de aparecer "Medicore: 3 marcações carregadas" e a Catarina/Beatriz com minutos no dia 22). 3. Confirmar que o nome da psicóloga bate certo com o ADMin (senão a linha dela na tabela mostra Medicore 0h00). 4. Enviar um psiquiatra e um GP: têm de aparecer como grupos "Psiquiatras" / "Médicos de Clínica Geral (GP)" no Painel de KPI e no Agora.