@primocaredentgroup/compensi-medici-core
v0.4.0
Published
Componente Convex Compensi Medici — motore compensi strutturato, configurabile e integrato per PrimoUpCore
Downloads
267
Maintainers
Keywords
Readme
@primocaredentgroup/compensi-medici-core
Componente Convex per il motore compensi medici (PrimoUpCore).
API pubblica tipizzata
L'entry principale (.) espone CompensiMediciClient, exposeApi e i validator pubblici. Il riferimento al componente viene passato solo al costruttore/factory: il codice host non deve invocare direttamente le funzioni del raw handle.
import {
CompensiMediciClient,
exposeApi,
} from "@primocaredentgroup/compensi-medici-core";
import { components } from "./_generated/api";
const client = new CompensiMediciClient(components.compensiMedici);
await client.configCommissionPlansListActive(ctx, {});
// Helper host server-side condiviso dai wrapper pubblici.
const getActorId = async (ctx) => {
const actor = await requireAuthenticatedHostActor(ctx);
return actor.id;
};
// Boundary pubblico host con autorizzazione e actor risolto server-side.
const api = exposeApi(components.compensiMedici, {
auth: async (ctx, operation) => {
await requireHostPermission(ctx, operation);
},
getActorId,
});
export const createCommissionPlan = api.createCommissionPlan;
export const createCommissionRule = api.createCommissionRule;getActorId deve derivare l'identità dal contesto autenticato o da una mappatura host attendibile. I campi audit (createdBy, updatedBy, triggeredBy e analoghi) vengono iniettati dal server e non sono argomenti accettati dal frontend. Un userId che identifica il medico o il destinatario del piano resta invece un dato di dominio.
La global setting python_worker_url è riservata al codice server-side: exposeApi ne rifiuta scrittura, rimozione e lettura puntuale e la filtra dagli elenchi, per impedire SSRF indiretta e la divulgazione dell'endpoint interno.
| Subpath | Contenuto |
|---------|-----------|
| . | CompensiMediciClient, exposeApi, validators re-export |
| ./convex.config | registrazione componente |
| ./validators | validators pubblici (status, rule types, …) |
Il client è intenzionalmente limitato alla superficie supportata e il typecheck
verifica ogni argomento e risultato, anche annidato. Non viene generato tramite
script regex: nuove operazioni vanno aggiunte esplicitamente insieme al loro
contratto e ai test.
DSL canonica delle commission rules
Le nuove scritture host-facing devono passare dai validator esportati commissionRuleConditionsValidator e commissionRuleFormulaValidator. La forma canonica delle condizioni è:
const conditions = {
serviceCategoryNames: ["Igiene", "Conservativa"],
listId: ["listino-standard", "listino-premium"],
serviceRegistryId: "prestazione-123",
};serviceCategoryNamesè semprestring[]e viene confrontato con la categoria della prestazione propagata nei metadata di produzione.listIdeserviceRegistryIdaccettanostring | string[].- Sono inoltre supportati i filtri canonici
clinicId,subcategory,conventionId,sourceId,serviceRegistryGroupId,listRowId,hasAgreement,commissionProfileIdeserviceCategoryId. - Gli alias legacy
category,categoryNameeserviceIdsono accettati in ingresso per compatibilità e normalizzati rispettivamente inserviceCategoryNameseserviceRegistryId. Non vanno usati nelle nuove integrazioni.
Le sole formule canoniche eseguibili dal matcher semplice sono:
const percentageFormula = { type: "percentage", rate: 25 }; // 25%
const fixedFormula = { type: "fixed_amount", amount: 40 };Per percentage, rate è espresso in punti percentuali: 25 significa 25%, non 0.25. Il campo legacy percentage viene ancora normalizzato a rate, ma non è la forma canonica.
La corrispondenza tra regola e formula è verificata prima della persistenza: ruleType: "percentage" richiede formula.type: "percentage", mentre ruleType: "fixed" richiede formula.type: "fixed_amount". Il solo outputType attualmente ammesso è "commission", perché il motore genera esclusivamente righe ledger di quel tipo. Una formula fixed_amount non accetta rate/percentage: l'importo conserva il segno della produzione/storno ed è moltiplicato soltanto per CPR e per l'eventuale coefficiente Invisalign.
Il boundary rifiuta condizioni prive di semantica nel matcher semplice (role, progressBased, isReimbursement, reimbursementCategory, tokenType) e formule non supportate (fixed_per_unit, percentage_on_progress, token, fixed_global, oltre alle varianti basate su multipliedByWdays). Questa validazione è intenzionale: le regole non vengono salvate se il motore non può valutarle in modo deterministico.
Anche taxProfiles.additionalRules resta un payload storage legacy privo di semantica nel motore: non è accettato né restituito dalla superficie supportata. Una DSL pubblica richiede prima una specifica di dominio e una migrazione dei record esistenti.
Per compatibilità dati con la versione 0.2.0, nello schema persistito commissionRules.conditions e commissionRules.formula restano temporaneamente wide. La superficie host-facing è invece strict e normalizza ogni nuova scrittura. Restringere anche lo storage richiederà una migrazione separata widen -> migrate -> narrow; questa versione non esegue migrazioni.
seedHelpers.clearAll è disponibile esclusivamente tramite CompensiMediciClient da una funzione server-side controllata, per seed/test. Non è esposto da exposeApi e non deve essere raggiungibile dal frontend.
Scope dei run e pending completions
createRun valida la coppia scopeType/scopePayload: users richiede userIds, clinics richiede clinicIds, la selezione non può essere vuota e global non accetta payload. user_clinic_pairs viene rifiutato fail-closed finché orchestrator e worker non lo applicheranno end-to-end. Anche companyId non è esposto nel payload tipizzato: uno scope company nominale sarebbe fuorviante finché il filtro non viene applicato davvero.
triggerPythonCalculation accetta dal boundary host soltanto runId: l'actor è iniettato server-side e gli eventuali user_ids inviati al worker derivano esclusivamente da run.scopePayload.userIds. Il caller non può sostituire lo scope del run.
Anche i due orchestrator TypeScript rifiutano i run legacy user_clinic_pairs e payload scope malformati prima del fetch, così uno scope nominale non può degradare a fetch globale.
I periodi delle API nuove/toccate usano la forma canonica YYYY-MM, con mese 01–12; valori come 2026-8, 2026-13 o testo arbitrario vengono rifiutati.
La chiusura usa l'operazione dedicata actor-safe api.closeRun; updateRunStatus non può impostare direttamente closed.
L'orchestrator host prepara il batch con workflowPendingCompletionsPrepareBatchForRun({ runId }) prima di accodare il worker. La mutation seleziona soltanto i pending che corrispondono allo scope global, users o clinics, assegna a ogni riga un ownership durevole (claimedRunId, claimedRevision, claimedAt) e registra sul run la prova della preparazione. Un claim appartenente a un altro run o un run preparato concorrente sullo stesso periodo/scope viene rifiutato fail-closed.
Il limite esplicito è MAX_PENDING_COMPLETIONS_PER_RUN = 500 righe nello scope. La mutation legge al massimo la riga 501 e, se il limite è superato, fallisce prima di qualsiasi patch; non esegue mai un batch parziale. Un run scheduled senza pending restituisce pendingCount=0, non lascia una preparazione e non deve essere accodato. Un run manuale o shadow senza pending è invece un ricalcolo legittimo: conserva una preparazione durevole con count zero e può essere accodato.
enqueueRun, claimRun e triggerCalculation verificano la preparazione prima che il worker parta. markCompleted finalizza nella stessa transaction Convex sia lo stato del run sia tutte e sole le revisioni claimed. Se recordCompletion riceve una modifica economica/source durante il worker, incrementa la revisione e libera il claim: il completamento fallisce senza scritture e il calcolo deve essere ritentato. Un retry identico di recordCompletion resta invece un no-op. La vecchia finalizzazione host markPendingCompletionsProcessed non fa più parte della superficie client/exposeApi supportata.
Gate ancora necessari per il rollout
- Lo storage legacy mantiene alcuni campi wide (
conditions,formula, snapshot e metadata). La superficie supportata li normalizza o li omette, ma restringere lo schema richiede una migrazione separatawiden -> migrate -> narrowsui dati reali. - Il worker Python deve adottare il payload economico rigoroso e il lifecycle per-attempt di questa versione prima del rollout:
claim-runassegnaworker_attempt,save-resultsregistra la prova dello stesso tentativo emark-completedla richiede. - Gli scope
user_clinic_pairse company restano intenzionalmente fail-closed finché non esiste una semantica end-to-end approvata. - Matching shadow con il sistema legacy, test multi-clinica/multi-company e verbale E2E restano gate di rilascio operativi, non scorciatoie da risolvere nel client.
Il package non va dichiarato operativo in produzione finché il worker coordinato e i gate E2E non sono completati.
Worker HTTP — adapter host
Il package non esporta route HTTP Convex da montare direttamente nel deploy host. L'host possiede autenticazione, environment e routing e monta il proprio adapter in compensiWorkerRoutes.ts, riusando dal package il parser tipizzato del payload economico. Non importare file interni convex/http/* del package.
L'host monta le route in compensiWorkerRoutes.ts:
import { registerCompensiWorkerRoutes } from "./compensiWorkerRoutes";
registerCompensiWorkerRoutes(http);Worker Integration API
API HTTP worker-only per il Python worker su Railway. Il componente Convex resta source of truth; il worker è solo esecutore esterno.
Autenticazione
Tutte le richieste richiedono:
Authorization: Bearer <WORKER_SECRET>Configura WORKER_SECRET nelle variabili d'ambiente del deployment Convex (PrimoUpCore).
Registrazione route (PrimoUpCore)
Vedi sezione Worker HTTP sopra. Dettagli aggiuntivi in convex/http/INTEGRATION.md.
Base URL: https://<deployment>.convex.site
Endpoint
| Metodo | Path | Descrizione |
|--------|------|-------------|
| POST | /worker/get-next-run | Primo run con workerStatus=queued (FIFO) |
| POST | /worker/claim-run | Claim atomico → processing |
| POST | /worker/update-progress | Aggiorna progress / progressMessage |
| POST | /worker/save-results | Salva ledger, fatture, issues, summary |
| POST | /worker/mark-completed | workerStatus=completed, business status=calculated |
| POST | /worker/mark-failed | workerStatus=failed + errore |
Flow esecuzione
enqueue (UI) → queued
↓
get-next-run → claim-run → processing
↓
update-progress (opzionale, ripetibile)
↓
save-results (solo in processing)
↓
mark-completed → completed + status business "calculated"In caso di errore: mark-failed → failed.
Esempi payload
get-next-run — body vuoto {}
{ "run": { "id": "...", "period": "2025-04", "status": "queued", "created_at": 1710000000 } }oppure { "run": null }.
claim-run
{ "runId": "jh7...", "workerId": "railway-worker-1" }La risposta contiene il token di fencing da riusare in tutte le scritture:
{
"workerAttempt": 3,
"run": { "id": "jh7...", "worker_id": "railway-worker-1", "worker_attempt": 3 }
}update-progress
{
"runId": "jh7...",
"workerId": "railway-worker-1",
"workerAttempt": 3,
"progress": 45,
"message": "Calcolo commissioni..."
}save-results
{
"runId": "jh7...",
"workerId": "railway-worker-1",
"workerAttempt": 3,
"result": {
"summary": {
"totale_medici": 2,
"totale_lordo": 7300,
"totale_netto": 5840,
"valuta": "EUR"
},
"ledger_entries": [
{
"medico_id": "u1",
"clinic_id": "c1",
"company_id": "company-source",
"outside_company_type": "None",
"type": "commission",
"importo_netto": 3360,
"descrizione": "Commissioni aprile"
}
],
"draft_invoices": [
{
"medico_id": "u1",
"clinic_id": "c1",
"company_id": "company-source",
"outside_company_type": "None",
"line_item_count": 1,
"net_price": 3360,
"subtotal": 3360,
"vat_rate": 0,
"vat_amount": 0,
"withholding_rate": 0,
"withholding_on_rate": 0,
"withholding_amount": 0,
"contribution_rate": 0,
"contribution_amount": 0,
"stamp_duty_amount": 0,
"total": 3360,
"net_to_pay": 3360
}
],
"issues": [
{ "code": "MISSING_TIMESHEET", "severity": "warning", "message": "..." }
]
}
}mark-completed
{
"runId": "jh7...",
"workerId": "railway-worker-1",
"workerAttempt": 3
}mark-failed
{
"runId": "jh7...",
"workerId": "railway-worker-1",
"workerAttempt": 3,
"errorMessage": "Timeout calcolo",
"errorStack": "..."
}Accodare un run (wrapper host)
import { CompensiMediciClient } from "@primocaredentgroup/compensi-medici-core";
import { components } from "./_generated/api";
const compensi = new CompensiMediciClient(components.compensiMedici);
// Dentro una mutation host autenticata; `runId` arriva dagli args,
// l'actor no: viene risolto server-side dal contesto.
const actorId = await getActorId(ctx);
await compensi.workflowWorkerApiEnqueueRun(ctx, {
runId,
userId: actorId,
});Il userId richiesto dall'API worker è l'actor audit della transizione, quindi non deve provenire dal payload del caller. Il run deve essere in stato business draft (o non closed). Imposta workerStatus: "queued".
Modello dati worker vs business
| Campo | Significato |
|-------|-------------|
| workerStatus | Coda worker: queued → processing → completed / failed |
| status | Workflow business: draft → calculated → reviewing → approved → closed |
Il worker vede status nel JSON HTTP = workerStatus. Al completamento, Convex imposta anche status: "calculated".
Idempotenza
- claim: stesso
workerIdsu run giàprocessing→ OK (ritorna il run). - claim-run: solo con business
status=draft; assegna/incrementaworker_attempt, lo restituisce comeworkerAttempte un retry dello stesso worker conserva il tentativo attivo. - update-progress / save-results / mark-completed / mark-failed: richiedono sempre il
workerIde ilworkerAttemptrestituiti dal claim corrente. Una coppia obsoleta viene rifiutata prima di qualsiasi scrittura. - save-results: solo con
workerStatus=processinge businessstatus=draft; valida identità, scope e numeri finiti prima di sostituire gli output auto-generati e registra il tentativo salvato. Ogni ledger e fattura deve dichiarare siacompany_idsiaoutside_company_type(None,DS,Est,Est_forfettari); non esiste un default companyless.Estrichiede ancheinvoice_vat. Convex verifica profilo e formule, applica i company mapping attivi e ricostruisce semprecommissionIdsdagli ID Convex appena inseriti: il worker non può inviarli. Le issue che impediscono una fattura per profilo fiscale vengono accettate soltanto se coincidono con la risoluzione del profilo effettuata dal componente. - mark-completed: riesce solo da business
status=drafte sesave-resultsè terminato nello stessoworkerAttempt; non può sovrascriverereviewing,approvedoclosed. Un retry già completato è idempotente soltanto con la stessa coppia di claim. - mark-failed: riesce solo da
processingcon businessstatus=draft; un retry già fallito è idempotente soltanto con la stessa coppia di claim.
Variabili Railway (worker Python)
CONVEX_HTTP_URL=https://<deployment>.convex.site
WORKER_SECRET=<stesso valore di Convex>
WORKER_ID=railway-worker-1
USE_MOCK_API=falseWorker Monitor (DEV example)
UI tecnica per debugging runtime del worker — non produzione.
Vedi example/README.md per setup Vite + mini-host Convex.
API nel componente (convex/workflow/workerMonitor.ts):
- Query:
listWorkerRuns,getWorkerMetrics,getWorkerRunDetails - Dev mutations:
enqueueRunDev,retryRunDev,resetWorkerStatusDev,createFakeFailedRunDev,createFakeProcessingStuckRunDev
Helper: convex/lib/workerMonitorHelpers.ts (duration, stuck detection, metrics).
