@primocaredentgroup/compensi-medici-core 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -0
- package/convex/http/INTEGRATION.md +19 -0
- package/convex/http/auth.ts +5 -0
- package/convex/http/worker.ts +181 -0
- package/convex/http.ts +6 -0
- package/convex/lib/types.ts +3 -0
- package/convex/schema.ts +22 -1
- package/convex/workflow/workerApi.ts +578 -0
- package/convex/workflow/workerAuth.ts +32 -0
- package/package.json +5 -1
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# @primocaredentgroup/compensi-medici-core
|
|
2
|
+
|
|
3
|
+
Componente Convex per il motore compensi medici (PrimoUpCore).
|
|
4
|
+
|
|
5
|
+
## Worker Integration API
|
|
6
|
+
|
|
7
|
+
API HTTP **worker-only** per il Python worker su Railway. Il componente Convex resta **source of truth**; il worker è solo esecutore esterno.
|
|
8
|
+
|
|
9
|
+
### Autenticazione
|
|
10
|
+
|
|
11
|
+
Tutte le richieste richiedono:
|
|
12
|
+
|
|
13
|
+
```http
|
|
14
|
+
Authorization: Bearer <WORKER_SECRET>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Configura `WORKER_SECRET` nelle variabili d'ambiente del deployment Convex (PrimoUpCore).
|
|
18
|
+
|
|
19
|
+
### Registrazione route (PrimoUpCore)
|
|
20
|
+
|
|
21
|
+
In `PrimoUpCore/convex/http.ts`:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { registerWorkerRoutes } from "compensi-medici-core/convex/http";
|
|
25
|
+
import { httpAction } from "./_generated/server";
|
|
26
|
+
import { components } from "./_generated/api";
|
|
27
|
+
|
|
28
|
+
registerWorkerRoutes(http, httpAction, components.compensiMedici);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Base URL: `https://<deployment>.convex.site`
|
|
32
|
+
|
|
33
|
+
### Endpoint
|
|
34
|
+
|
|
35
|
+
| Metodo | Path | Descrizione |
|
|
36
|
+
|--------|------|-------------|
|
|
37
|
+
| POST | `/worker/get-next-run` | Primo run con `workerStatus=queued` (FIFO) |
|
|
38
|
+
| POST | `/worker/claim-run` | Claim atomico → `processing` |
|
|
39
|
+
| POST | `/worker/update-progress` | Aggiorna `progress` / `progressMessage` |
|
|
40
|
+
| POST | `/worker/save-results` | Salva ledger, fatture, issues, summary |
|
|
41
|
+
| POST | `/worker/mark-completed` | `workerStatus=completed`, business `status=calculated` |
|
|
42
|
+
| POST | `/worker/mark-failed` | `workerStatus=failed` + errore |
|
|
43
|
+
|
|
44
|
+
### Flow esecuzione
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
enqueue (UI) → queued
|
|
48
|
+
↓
|
|
49
|
+
get-next-run → claim-run → processing
|
|
50
|
+
↓
|
|
51
|
+
update-progress (opzionale, ripetibile)
|
|
52
|
+
↓
|
|
53
|
+
save-results (solo in processing)
|
|
54
|
+
↓
|
|
55
|
+
mark-completed → completed + status business "calculated"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
In caso di errore: `mark-failed` → `failed`.
|
|
59
|
+
|
|
60
|
+
### Esempi payload
|
|
61
|
+
|
|
62
|
+
**get-next-run** — body vuoto `{}`
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{ "run": { "id": "...", "period": "2025-04", "status": "queued", "created_at": 1710000000 } }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
oppure `{ "run": null }`.
|
|
69
|
+
|
|
70
|
+
**claim-run**
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{ "runId": "jh7...", "workerId": "railway-worker-1" }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**update-progress**
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{ "runId": "jh7...", "progress": 45, "message": "Calcolo commissioni..." }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**save-results**
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"runId": "jh7...",
|
|
87
|
+
"result": {
|
|
88
|
+
"summary": { "totale_medici": 2, "totale_lordo": 7300, "totale_netto": 5840 },
|
|
89
|
+
"ledger_entries": [
|
|
90
|
+
{ "medico_id": "u1", "clinic_id": "c1", "importo_netto": 3360, "descrizione": "..." }
|
|
91
|
+
],
|
|
92
|
+
"draft_invoices": [
|
|
93
|
+
{ "medico_id": "u1", "clinic_id": "c1", "importo": 3360 }
|
|
94
|
+
],
|
|
95
|
+
"issues": [
|
|
96
|
+
{ "code": "MISSING_TIMESHEET", "severity": "warning", "message": "..." }
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**mark-completed**
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{ "runId": "jh7..." }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**mark-failed**
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"runId": "jh7...",
|
|
113
|
+
"errorMessage": "Timeout calcolo",
|
|
114
|
+
"errorStack": "..."
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Accodare un run (mutation UI)
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
await ctx.runMutation(components.compensiMedici.workflow.workerApi.enqueueRun, {
|
|
122
|
+
runId,
|
|
123
|
+
userId: "...",
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Il run deve essere in stato business `draft` (o non `closed`). Imposta `workerStatus: "queued"`.
|
|
128
|
+
|
|
129
|
+
### Modello dati worker vs business
|
|
130
|
+
|
|
131
|
+
| Campo | Significato |
|
|
132
|
+
|-------|-------------|
|
|
133
|
+
| `workerStatus` | Coda worker: `queued` → `processing` → `completed` / `failed` |
|
|
134
|
+
| `status` | Workflow business: `draft` → `calculated` → `reviewing` → `approved` → `closed` |
|
|
135
|
+
|
|
136
|
+
Il worker vede `status` nel JSON HTTP = `workerStatus`. Al completamento, Convex imposta anche `status: "calculated"`.
|
|
137
|
+
|
|
138
|
+
### Idempotenza
|
|
139
|
+
|
|
140
|
+
- **claim**: stesso `workerId` su run già `processing` → OK (ritorna il run).
|
|
141
|
+
- **mark-completed** / **mark-failed**: se già nello stato finale → OK.
|
|
142
|
+
- **save-results**: solo con `workerStatus=processing`; sostituisce dati auto-generati precedenti.
|
|
143
|
+
|
|
144
|
+
### Variabili Railway (worker Python)
|
|
145
|
+
|
|
146
|
+
```env
|
|
147
|
+
CONVEX_HTTP_URL=https://<deployment>.convex.site
|
|
148
|
+
WORKER_SECRET=<stesso valore di Convex>
|
|
149
|
+
WORKER_ID=railway-worker-1
|
|
150
|
+
USE_MOCK_API=false
|
|
151
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Integrazione HTTP worker in PrimoUpCore
|
|
2
|
+
|
|
3
|
+
Dopo il deploy del componente, verifica che `PrimoUpCore/convex/http.ts` contenga:
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { registerWorkerRoutes } from "compensi-medici-core/convex/http";
|
|
7
|
+
|
|
8
|
+
registerWorkerRoutes(http, httpAction, components.compensiMedici);
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Imposta su Convex (dashboard → Environment Variables):
|
|
12
|
+
|
|
13
|
+
- `WORKER_SECRET` — stesso valore configurato su Railway per il worker Python
|
|
14
|
+
|
|
15
|
+
Per accodare un run dal frontend (mutation pubblica del componente, via wrapper PrimoUpCore se necessario):
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
components.compensiMedici.workflow.workerApi.enqueueRun
|
|
19
|
+
```
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { HttpRouter } from "convex/server";
|
|
2
|
+
import { assertWorkerAuthorization, WorkerUnauthorizedError } from "./auth";
|
|
3
|
+
|
|
4
|
+
type HttpAction = (handler: (ctx: WorkerHttpCtx, request: Request) => Promise<Response>) => unknown;
|
|
5
|
+
|
|
6
|
+
type WorkerHttpCtx = {
|
|
7
|
+
runMutation: (reference: unknown, args: Record<string, unknown>) => Promise<unknown>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type CompensiWorkerApi = {
|
|
11
|
+
workflow: {
|
|
12
|
+
workerApi: {
|
|
13
|
+
getNextRun: unknown;
|
|
14
|
+
claimRun: unknown;
|
|
15
|
+
updateProgress: unknown;
|
|
16
|
+
saveResults: unknown;
|
|
17
|
+
markCompleted: unknown;
|
|
18
|
+
markFailed: unknown;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
24
|
+
return new Response(JSON.stringify(body), {
|
|
25
|
+
status,
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function httpStatusForError(message: string): number {
|
|
31
|
+
const lower = message.toLowerCase();
|
|
32
|
+
if (lower.includes("non trovato")) return 404;
|
|
33
|
+
if (
|
|
34
|
+
lower.includes("già in processing") ||
|
|
35
|
+
lower.includes("già terminato") ||
|
|
36
|
+
lower.includes("non è in coda") ||
|
|
37
|
+
lower.includes("non consentito")
|
|
38
|
+
) {
|
|
39
|
+
return 409;
|
|
40
|
+
}
|
|
41
|
+
if (lower.includes("richiede workerstatus") || lower.includes("impossibile")) {
|
|
42
|
+
return 409;
|
|
43
|
+
}
|
|
44
|
+
if (lower.includes("invalid json")) return 400;
|
|
45
|
+
return 400;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function errorResponse(error: unknown): Response {
|
|
49
|
+
if (error instanceof WorkerUnauthorizedError) {
|
|
50
|
+
return jsonResponse({ ok: false, error: error.message }, 401);
|
|
51
|
+
}
|
|
52
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
53
|
+
const status = httpStatusForError(message);
|
|
54
|
+
console.error("[worker-http]", status, message);
|
|
55
|
+
return jsonResponse({ ok: false, error: message }, status);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function parseJsonBody(request: Request): Promise<Record<string, unknown>> {
|
|
59
|
+
try {
|
|
60
|
+
const body = (await request.json()) as Record<string, unknown>;
|
|
61
|
+
return body ?? {};
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error("Invalid JSON body");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Registra le route HTTP worker-only sull'app host (PrimoUpCore).
|
|
69
|
+
*
|
|
70
|
+
* Esempio in PrimoUpCore/convex/http.ts:
|
|
71
|
+
* import { registerWorkerRoutes } from "compensi-medici-core/convex/http";
|
|
72
|
+
* registerWorkerRoutes(http, httpAction, components.compensiMedici);
|
|
73
|
+
*/
|
|
74
|
+
export function registerWorkerRoutes(
|
|
75
|
+
http: HttpRouter,
|
|
76
|
+
httpAction: HttpAction,
|
|
77
|
+
compensiMedici: CompensiWorkerApi,
|
|
78
|
+
): void {
|
|
79
|
+
const api = compensiMedici.workflow.workerApi;
|
|
80
|
+
|
|
81
|
+
const route = (
|
|
82
|
+
path: string,
|
|
83
|
+
handler: (ctx: WorkerHttpCtx, request: Request, secret: string) => Promise<Response>,
|
|
84
|
+
) => {
|
|
85
|
+
http.route({
|
|
86
|
+
path,
|
|
87
|
+
method: "POST",
|
|
88
|
+
handler: httpAction(async (ctx, request) => {
|
|
89
|
+
try {
|
|
90
|
+
const secret = assertWorkerAuthorization(request);
|
|
91
|
+
return await handler(ctx as WorkerHttpCtx, request, secret);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return errorResponse(error);
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
route("/worker/get-next-run", async (ctx, _request, secret) => {
|
|
100
|
+
const result = await ctx.runMutation(api.getNextRun, { workerSecret: secret });
|
|
101
|
+
return jsonResponse(result);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
route("/worker/claim-run", async (ctx, request, secret) => {
|
|
105
|
+
const body = await parseJsonBody(request);
|
|
106
|
+
const runId = body.runId;
|
|
107
|
+
const workerId = body.workerId;
|
|
108
|
+
if (typeof runId !== "string" || typeof workerId !== "string") {
|
|
109
|
+
return jsonResponse({ ok: false, error: "runId and workerId required" }, 400);
|
|
110
|
+
}
|
|
111
|
+
const result = await ctx.runMutation(api.claimRun, {
|
|
112
|
+
workerSecret: secret,
|
|
113
|
+
runId,
|
|
114
|
+
workerId,
|
|
115
|
+
});
|
|
116
|
+
return jsonResponse(result);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
route("/worker/update-progress", async (ctx, request, secret) => {
|
|
120
|
+
const body = await parseJsonBody(request);
|
|
121
|
+
const runId = body.runId;
|
|
122
|
+
const progress = body.progress;
|
|
123
|
+
if (typeof runId !== "string" || typeof progress !== "number") {
|
|
124
|
+
return jsonResponse({ ok: false, error: "runId and progress required" }, 400);
|
|
125
|
+
}
|
|
126
|
+
const result = await ctx.runMutation(api.updateProgress, {
|
|
127
|
+
workerSecret: secret,
|
|
128
|
+
runId,
|
|
129
|
+
progress,
|
|
130
|
+
message: typeof body.message === "string" ? body.message : undefined,
|
|
131
|
+
});
|
|
132
|
+
return jsonResponse(result);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
route("/worker/save-results", async (ctx, request, secret) => {
|
|
136
|
+
const body = await parseJsonBody(request);
|
|
137
|
+
const runId = body.runId;
|
|
138
|
+
const result = body.result;
|
|
139
|
+
if (typeof runId !== "string" || !result || typeof result !== "object") {
|
|
140
|
+
return jsonResponse({ ok: false, error: "runId and result required" }, 400);
|
|
141
|
+
}
|
|
142
|
+
const saved = await ctx.runMutation(api.saveResults, {
|
|
143
|
+
workerSecret: secret,
|
|
144
|
+
runId,
|
|
145
|
+
result,
|
|
146
|
+
});
|
|
147
|
+
return jsonResponse(saved);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
route("/worker/mark-completed", async (ctx, request, secret) => {
|
|
151
|
+
const body = await parseJsonBody(request);
|
|
152
|
+
const runId = body.runId;
|
|
153
|
+
if (typeof runId !== "string") {
|
|
154
|
+
return jsonResponse({ ok: false, error: "runId required" }, 400);
|
|
155
|
+
}
|
|
156
|
+
const result = await ctx.runMutation(api.markCompleted, {
|
|
157
|
+
workerSecret: secret,
|
|
158
|
+
runId,
|
|
159
|
+
});
|
|
160
|
+
return jsonResponse(result);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
route("/worker/mark-failed", async (ctx, request, secret) => {
|
|
164
|
+
const body = await parseJsonBody(request);
|
|
165
|
+
const runId = body.runId;
|
|
166
|
+
const errorMessage = body.errorMessage;
|
|
167
|
+
if (typeof runId !== "string" || typeof errorMessage !== "string") {
|
|
168
|
+
return jsonResponse(
|
|
169
|
+
{ ok: false, error: "runId and errorMessage required" },
|
|
170
|
+
400,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const result = await ctx.runMutation(api.markFailed, {
|
|
174
|
+
workerSecret: secret,
|
|
175
|
+
runId,
|
|
176
|
+
errorMessage,
|
|
177
|
+
errorStack: typeof body.errorStack === "string" ? body.errorStack : undefined,
|
|
178
|
+
});
|
|
179
|
+
return jsonResponse(result);
|
|
180
|
+
});
|
|
181
|
+
}
|
package/convex/http.ts
ADDED
package/convex/lib/types.ts
CHANGED
|
@@ -20,6 +20,9 @@ export type RunStatus =
|
|
|
20
20
|
| "approved"
|
|
21
21
|
| "closed";
|
|
22
22
|
|
|
23
|
+
/** Stato esecuzione worker Python (non confondere con RunStatus business). */
|
|
24
|
+
export type WorkerStatus = "queued" | "processing" | "completed" | "failed";
|
|
25
|
+
|
|
23
26
|
export const VALID_TRANSITIONS: Record<RunStatus, RunStatus[]> = {
|
|
24
27
|
draft: ["calculated"],
|
|
25
28
|
calculated: ["reviewing", "draft"],
|
package/convex/schema.ts
CHANGED
|
@@ -32,6 +32,14 @@ export const runStatus = v.union(
|
|
|
32
32
|
v.literal("closed"),
|
|
33
33
|
);
|
|
34
34
|
|
|
35
|
+
/** Stato esecuzione del worker Python (coda / processing / esito). */
|
|
36
|
+
export const workerStatus = v.union(
|
|
37
|
+
v.literal("queued"),
|
|
38
|
+
v.literal("processing"),
|
|
39
|
+
v.literal("completed"),
|
|
40
|
+
v.literal("failed"),
|
|
41
|
+
);
|
|
42
|
+
|
|
35
43
|
export const scopeType = v.union(
|
|
36
44
|
v.literal("global"),
|
|
37
45
|
v.literal("users"),
|
|
@@ -379,11 +387,24 @@ export default defineSchema({
|
|
|
379
387
|
* Garantisce accesso rapido ai dati ufficiali senza ricalcolo.
|
|
380
388
|
*/
|
|
381
389
|
closureSummary: v.optional(v.any()),
|
|
390
|
+
/** Coda worker Python — separato dal workflow business (draft → calculated → …). */
|
|
391
|
+
workerStatus: v.optional(workerStatus),
|
|
392
|
+
workerId: v.optional(v.string()),
|
|
393
|
+
startedAt: v.optional(v.number()),
|
|
394
|
+
workerCompletedAt: v.optional(v.number()),
|
|
395
|
+
workerFailedAt: v.optional(v.number()),
|
|
396
|
+
progress: v.optional(v.number()),
|
|
397
|
+
progressMessage: v.optional(v.string()),
|
|
398
|
+
errorMessage: v.optional(v.string()),
|
|
399
|
+
errorStack: v.optional(v.string()),
|
|
400
|
+
/** Snapshot summary restituito dal worker in save-results. */
|
|
401
|
+
resultSummary: v.optional(v.any()),
|
|
382
402
|
})
|
|
383
403
|
.index("by_period", ["period"])
|
|
384
404
|
.index("by_period_version", ["period", "version"])
|
|
385
405
|
.index("by_status", ["status"])
|
|
386
|
-
.index("by_shadow", ["isShadowRun", "period"])
|
|
406
|
+
.index("by_shadow", ["isShadowRun", "period"])
|
|
407
|
+
.index("by_worker_status", ["workerStatus", "createdAt"]),
|
|
387
408
|
|
|
388
409
|
// ── LEDGER ─────────────────────────────────────────────
|
|
389
410
|
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, type MutationCtx } from "../_generated/server";
|
|
3
|
+
import type { Doc, Id } from "../_generated/dataModel";
|
|
4
|
+
import type { IssueType } from "../lib/types";
|
|
5
|
+
import { assertWorkerSecret } from "./workerAuth";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* API worker-only — esecutore esterno (Python su Railway).
|
|
9
|
+
* La business logic e le regole di dominio restano qui in Convex.
|
|
10
|
+
*
|
|
11
|
+
* `status` nel JSON verso il worker = `workerStatus` (coda esecuzione).
|
|
12
|
+
* `status` business del run (draft/calculated/…) non viene esposto al worker.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const workerSecretArg = { workerSecret: v.string() };
|
|
16
|
+
|
|
17
|
+
const workerResultValidator = v.object({
|
|
18
|
+
summary: v.object({
|
|
19
|
+
totale_medici: v.optional(v.number()),
|
|
20
|
+
totaleMedici: v.optional(v.number()),
|
|
21
|
+
totale_lordo: v.optional(v.number()),
|
|
22
|
+
totaleLordo: v.optional(v.number()),
|
|
23
|
+
totale_netto: v.optional(v.number()),
|
|
24
|
+
totaleNetto: v.optional(v.number()),
|
|
25
|
+
valuta: v.optional(v.string()),
|
|
26
|
+
}),
|
|
27
|
+
ledger_entries: v.optional(v.array(v.any())),
|
|
28
|
+
ledgerEntries: v.optional(v.array(v.any())),
|
|
29
|
+
draft_invoices: v.optional(v.array(v.any())),
|
|
30
|
+
draftInvoices: v.optional(v.array(v.any())),
|
|
31
|
+
issues: v.optional(v.array(v.any())),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function pickStr(obj: Record<string, unknown>, ...keys: string[]): string | undefined {
|
|
35
|
+
for (const k of keys) {
|
|
36
|
+
const val = obj[k];
|
|
37
|
+
if (typeof val === "string" && val.length > 0) return val;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pickNum(obj: Record<string, unknown>, ...keys: string[]): number | undefined {
|
|
43
|
+
for (const k of keys) {
|
|
44
|
+
const val = obj[k];
|
|
45
|
+
if (typeof val === "number" && !Number.isNaN(val)) return val;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Serializza un run per il worker Python (snake_case). */
|
|
51
|
+
export function serializeRunForWorker(run: Doc<"compensationRuns">) {
|
|
52
|
+
const clinicIds = extractClinicIds(run);
|
|
53
|
+
return {
|
|
54
|
+
id: run._id,
|
|
55
|
+
period: run.period,
|
|
56
|
+
company_id: extractCompanyId(run),
|
|
57
|
+
clinic_ids: clinicIds,
|
|
58
|
+
status: run.workerStatus ?? null,
|
|
59
|
+
created_at: run.createdAt,
|
|
60
|
+
progress: run.progress ?? 0,
|
|
61
|
+
progress_message: run.progressMessage ?? null,
|
|
62
|
+
worker_id: run.workerId ?? null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function extractClinicIds(run: Doc<"compensationRuns">): string[] {
|
|
67
|
+
if (run.scopeType === "clinics" && Array.isArray(run.scopePayload)) {
|
|
68
|
+
return run.scopePayload.filter((x): x is string => typeof x === "string");
|
|
69
|
+
}
|
|
70
|
+
if (run.scopeType === "user_clinic_pairs" && Array.isArray(run.scopePayload)) {
|
|
71
|
+
return run.scopePayload
|
|
72
|
+
.map((p) => (typeof p === "object" && p && "clinicId" in p ? String((p as { clinicId: string }).clinicId) : null))
|
|
73
|
+
.filter((x): x is string => !!x);
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractCompanyId(run: Doc<"compensationRuns">): string {
|
|
79
|
+
if (run.scopePayload && typeof run.scopePayload === "object" && !Array.isArray(run.scopePayload)) {
|
|
80
|
+
const cid = (run.scopePayload as Record<string, unknown>).companyId;
|
|
81
|
+
if (typeof cid === "string") return cid;
|
|
82
|
+
}
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertRunNotClosed(run: Doc<"compensationRuns">, action: string): void {
|
|
87
|
+
if (run.status === "closed") {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Impossibile ${action}: run ${run._id} è chiuso (status=closed)`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getRunOrThrow(
|
|
95
|
+
ctx: { db: { get: (id: Id<"compensationRuns">) => Promise<Doc<"compensationRuns"> | null> } },
|
|
96
|
+
runId: Id<"compensationRuns">,
|
|
97
|
+
): Promise<Doc<"compensationRuns">> {
|
|
98
|
+
const run = await ctx.db.get(runId);
|
|
99
|
+
if (!run) throw new Error(`Run non trovato: ${runId}`);
|
|
100
|
+
return run;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function clearWorkerGeneratedData(
|
|
104
|
+
ctx: MutationCtx,
|
|
105
|
+
runId: Id<"compensationRuns">,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const entries = await ctx.db
|
|
108
|
+
.query("ledgerEntries")
|
|
109
|
+
.withIndex("by_run", (q) => q.eq("runId", runId))
|
|
110
|
+
.collect();
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!entry.isManual) await ctx.db.delete(entry._id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const invoices = await ctx.db
|
|
116
|
+
.query("draftInvoices")
|
|
117
|
+
.withIndex("by_run", (q) => q.eq("runId", runId))
|
|
118
|
+
.collect();
|
|
119
|
+
for (const inv of invoices) {
|
|
120
|
+
if (inv.isAutoGenerated) await ctx.db.delete(inv._id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const issues = await ctx.db
|
|
124
|
+
.query("runIssues")
|
|
125
|
+
.withIndex("by_run", (q) => q.eq("runId", runId))
|
|
126
|
+
.collect();
|
|
127
|
+
for (const issue of issues) {
|
|
128
|
+
await ctx.db.delete(issue._id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── get-next-run ─────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export const getNextRun = mutation({
|
|
135
|
+
args: workerSecretArg,
|
|
136
|
+
handler: async (ctx, args) => {
|
|
137
|
+
assertWorkerSecret(args.workerSecret);
|
|
138
|
+
console.log("[worker] get-next-run");
|
|
139
|
+
|
|
140
|
+
const queued = await ctx.db
|
|
141
|
+
.query("compensationRuns")
|
|
142
|
+
.withIndex("by_worker_status", (q) => q.eq("workerStatus", "queued"))
|
|
143
|
+
.collect();
|
|
144
|
+
|
|
145
|
+
queued.sort((a, b) => a.createdAt - b.createdAt);
|
|
146
|
+
const run = queued[0] ?? null;
|
|
147
|
+
|
|
148
|
+
if (!run) {
|
|
149
|
+
console.log("[worker] get-next-run: nessun run in coda");
|
|
150
|
+
return { run: null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log(`[worker] get-next-run: trovato ${run._id}`);
|
|
154
|
+
return { run: serializeRunForWorker(run) };
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── claim-run ────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export const claimRun = mutation({
|
|
161
|
+
args: {
|
|
162
|
+
...workerSecretArg,
|
|
163
|
+
runId: v.id("compensationRuns"),
|
|
164
|
+
workerId: v.string(),
|
|
165
|
+
},
|
|
166
|
+
handler: async (ctx, args) => {
|
|
167
|
+
assertWorkerSecret(args.workerSecret);
|
|
168
|
+
console.log(`[worker] claim-run: ${args.runId} by ${args.workerId}`);
|
|
169
|
+
|
|
170
|
+
const run = await getRunOrThrow(ctx, args.runId);
|
|
171
|
+
assertRunNotClosed(run, "claim-run");
|
|
172
|
+
|
|
173
|
+
if (run.workerStatus === "processing" && run.workerId === args.workerId) {
|
|
174
|
+
console.log(`[worker] claim-run: idempotente, già processing per ${args.workerId}`);
|
|
175
|
+
return { run: serializeRunForWorker(run) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (run.workerStatus === "completed" || run.workerStatus === "failed") {
|
|
179
|
+
throw new Error(`Run ${args.runId} già terminato (workerStatus=${run.workerStatus})`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (run.workerStatus === "processing" && run.workerId !== args.workerId) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Run ${args.runId} già in processing da worker ${run.workerId}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (run.workerStatus !== "queued") {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Run ${args.runId} non è in coda (workerStatus=${run.workerStatus ?? "null"})`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
await ctx.db.patch(args.runId, {
|
|
196
|
+
workerStatus: "processing",
|
|
197
|
+
workerId: args.workerId,
|
|
198
|
+
startedAt: now,
|
|
199
|
+
progress: 0,
|
|
200
|
+
progressMessage: "Claimed by worker",
|
|
201
|
+
updatedAt: now,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const updated = await getRunOrThrow(ctx, args.runId);
|
|
205
|
+
console.log(`[worker] claim-run: ok ${args.runId}`);
|
|
206
|
+
return { run: serializeRunForWorker(updated) };
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── update-progress ──────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
export const updateProgress = mutation({
|
|
213
|
+
args: {
|
|
214
|
+
...workerSecretArg,
|
|
215
|
+
runId: v.id("compensationRuns"),
|
|
216
|
+
progress: v.number(),
|
|
217
|
+
message: v.optional(v.string()),
|
|
218
|
+
},
|
|
219
|
+
handler: async (ctx, args) => {
|
|
220
|
+
assertWorkerSecret(args.workerSecret);
|
|
221
|
+
const run = await getRunOrThrow(ctx, args.runId);
|
|
222
|
+
|
|
223
|
+
if (run.workerStatus !== "processing") {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Impossibile aggiornare progress: workerStatus=${run.workerStatus ?? "null"}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const progress = Math.max(0, Math.min(100, args.progress));
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
await ctx.db.patch(args.runId, {
|
|
232
|
+
progress,
|
|
233
|
+
progressMessage: args.message,
|
|
234
|
+
updatedAt: now,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
console.log(`[worker] update-progress: ${args.runId} → ${progress}%`);
|
|
238
|
+
return { ok: true, progress };
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ── save-results ─────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
export const saveResults = mutation({
|
|
245
|
+
args: {
|
|
246
|
+
...workerSecretArg,
|
|
247
|
+
runId: v.id("compensationRuns"),
|
|
248
|
+
result: workerResultValidator,
|
|
249
|
+
},
|
|
250
|
+
handler: async (ctx, args) => {
|
|
251
|
+
assertWorkerSecret(args.workerSecret);
|
|
252
|
+
console.log(`[worker] save-results: ${args.runId}`);
|
|
253
|
+
|
|
254
|
+
const run = await getRunOrThrow(ctx, args.runId);
|
|
255
|
+
assertRunNotClosed(run, "save-results");
|
|
256
|
+
|
|
257
|
+
if (run.workerStatus !== "processing") {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`save-results richiede workerStatus=processing, attuale=${run.workerStatus ?? "null"}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (run.status === "approved") {
|
|
263
|
+
throw new Error(
|
|
264
|
+
"save-results non consentito: run in stato approved",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { result } = args;
|
|
269
|
+
const ledgerRaw = result.ledger_entries ?? result.ledgerEntries ?? [];
|
|
270
|
+
const invoicesRaw = result.draft_invoices ?? result.draftInvoices ?? [];
|
|
271
|
+
const issuesRaw = result.issues ?? [];
|
|
272
|
+
|
|
273
|
+
await clearWorkerGeneratedData(ctx, args.runId);
|
|
274
|
+
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
const createdBy = run.workerId ?? "python-worker";
|
|
277
|
+
|
|
278
|
+
for (const raw of ledgerRaw) {
|
|
279
|
+
const row = raw as Record<string, unknown>;
|
|
280
|
+
const userId = pickStr(row, "userId", "medico_id", "medicoId") ?? "unknown";
|
|
281
|
+
const clinicId = pickStr(row, "clinicId", "clinic_id") ?? "unknown";
|
|
282
|
+
const amount =
|
|
283
|
+
pickNum(row, "amount", "importo_netto", "importoNetto", "importo") ?? 0;
|
|
284
|
+
const description = pickStr(row, "descrizione", "description") ?? "Worker entry";
|
|
285
|
+
const typeRaw = pickStr(row, "type");
|
|
286
|
+
const entryType =
|
|
287
|
+
typeRaw === "commission" ||
|
|
288
|
+
typeRaw === "fixed" ||
|
|
289
|
+
typeRaw === "bonus" ||
|
|
290
|
+
typeRaw === "production" ||
|
|
291
|
+
typeRaw === "storno" ||
|
|
292
|
+
typeRaw === "reimbursement" ||
|
|
293
|
+
typeRaw === "adjustment"
|
|
294
|
+
? typeRaw
|
|
295
|
+
: "commission";
|
|
296
|
+
|
|
297
|
+
await ctx.db.insert("ledgerEntries", {
|
|
298
|
+
runId: args.runId,
|
|
299
|
+
userId,
|
|
300
|
+
clinicId,
|
|
301
|
+
type: entryType,
|
|
302
|
+
sourceType: "system",
|
|
303
|
+
sourceId: pickStr(row, "id", "sourceId"),
|
|
304
|
+
description,
|
|
305
|
+
amount,
|
|
306
|
+
quantity: pickNum(row, "quantity") ?? 1,
|
|
307
|
+
unitAmount: pickNum(row, "unitAmount", "unit_amount") ?? amount,
|
|
308
|
+
isManual: false,
|
|
309
|
+
effectiveDate: now,
|
|
310
|
+
period: run.period,
|
|
311
|
+
metadata: row,
|
|
312
|
+
createdBy,
|
|
313
|
+
createdAt: now,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const raw of invoicesRaw) {
|
|
318
|
+
const row = raw as Record<string, unknown>;
|
|
319
|
+
const userId = pickStr(row, "userId", "medico_id", "medicoId") ?? "unknown";
|
|
320
|
+
const clinicId = pickStr(row, "clinicId", "clinic_id") ?? "unknown";
|
|
321
|
+
const netToPay = pickNum(row, "importo", "netToPay", "net_to_pay") ?? 0;
|
|
322
|
+
|
|
323
|
+
await ctx.db.insert("draftInvoices", {
|
|
324
|
+
runId: args.runId,
|
|
325
|
+
userId,
|
|
326
|
+
clinicId,
|
|
327
|
+
period: run.period,
|
|
328
|
+
status: "draft",
|
|
329
|
+
isAutoGenerated: true,
|
|
330
|
+
isCompanyGrouped: false,
|
|
331
|
+
lineItemCount: pickNum(row, "lineItemCount") ?? 1,
|
|
332
|
+
netPrice: netToPay,
|
|
333
|
+
subtotal: netToPay,
|
|
334
|
+
vatRate: 0,
|
|
335
|
+
vatAmount: 0,
|
|
336
|
+
withholdingRate: 0,
|
|
337
|
+
withholdingOnRate: 100,
|
|
338
|
+
withholdingAmount: 0,
|
|
339
|
+
contributionRate: 0,
|
|
340
|
+
contributionAmount: 0,
|
|
341
|
+
stampDutyAmount: 0,
|
|
342
|
+
total: netToPay,
|
|
343
|
+
netToPay,
|
|
344
|
+
notes: pickStr(row, "notes"),
|
|
345
|
+
createdBy,
|
|
346
|
+
createdAt: now,
|
|
347
|
+
updatedAt: now,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const raw of issuesRaw) {
|
|
352
|
+
const row = raw as Record<string, unknown>;
|
|
353
|
+
const code = pickStr(row, "code") ?? "worker_issue";
|
|
354
|
+
const severityRaw = pickStr(row, "severity") ?? "warning";
|
|
355
|
+
const severity =
|
|
356
|
+
severityRaw === "error" || severityRaw === "info" || severityRaw === "warning"
|
|
357
|
+
? severityRaw
|
|
358
|
+
: "warning";
|
|
359
|
+
const message = pickStr(row, "message") ?? code;
|
|
360
|
+
|
|
361
|
+
const knownTypes: IssueType[] = [
|
|
362
|
+
"no_active_plan",
|
|
363
|
+
"no_matching_rule",
|
|
364
|
+
"no_tax_profile",
|
|
365
|
+
"multiple_tax_profiles",
|
|
366
|
+
"inactive_tax_profile",
|
|
367
|
+
"fiscal_anomaly",
|
|
368
|
+
"missing_invoices",
|
|
369
|
+
];
|
|
370
|
+
const mappedType: IssueType = knownTypes.includes(code as IssueType)
|
|
371
|
+
? (code as IssueType)
|
|
372
|
+
: "fiscal_anomaly";
|
|
373
|
+
|
|
374
|
+
await ctx.db.insert("runIssues", {
|
|
375
|
+
runId: args.runId,
|
|
376
|
+
issueType: mappedType,
|
|
377
|
+
severity,
|
|
378
|
+
userId: pickStr(row, "userId", "medico_id"),
|
|
379
|
+
clinicId: pickStr(row, "clinicId", "clinic_id"),
|
|
380
|
+
message,
|
|
381
|
+
details: { workerCode: code, ...row },
|
|
382
|
+
createdAt: now,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const summary = {
|
|
387
|
+
totale_medici:
|
|
388
|
+
result.summary.totale_medici ?? result.summary.totaleMedici ?? 0,
|
|
389
|
+
totale_lordo:
|
|
390
|
+
result.summary.totale_lordo ?? result.summary.totaleLordo ?? 0,
|
|
391
|
+
totale_netto:
|
|
392
|
+
result.summary.totale_netto ?? result.summary.totaleNetto ?? 0,
|
|
393
|
+
valuta: result.summary.valuta ?? "EUR",
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
await ctx.db.patch(args.runId, {
|
|
397
|
+
resultSummary: summary,
|
|
398
|
+
updatedAt: now,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await ctx.db.insert("auditLogs", {
|
|
402
|
+
entityType: "compensationRuns",
|
|
403
|
+
entityId: args.runId,
|
|
404
|
+
action: "worker_results_saved",
|
|
405
|
+
userId: createdBy,
|
|
406
|
+
payload: {
|
|
407
|
+
ledgerCount: ledgerRaw.length,
|
|
408
|
+
invoiceCount: invoicesRaw.length,
|
|
409
|
+
issueCount: issuesRaw.length,
|
|
410
|
+
summary,
|
|
411
|
+
},
|
|
412
|
+
createdAt: now,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
console.log(
|
|
416
|
+
`[worker] save-results: ok ${args.runId} (${ledgerRaw.length} ledger, ${invoicesRaw.length} invoices)`,
|
|
417
|
+
);
|
|
418
|
+
return { ok: true, summary };
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// ── mark-completed ───────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
export const markCompleted = mutation({
|
|
425
|
+
args: {
|
|
426
|
+
...workerSecretArg,
|
|
427
|
+
runId: v.id("compensationRuns"),
|
|
428
|
+
},
|
|
429
|
+
handler: async (ctx, args) => {
|
|
430
|
+
assertWorkerSecret(args.workerSecret);
|
|
431
|
+
console.log(`[worker] mark-completed: ${args.runId}`);
|
|
432
|
+
|
|
433
|
+
const run = await getRunOrThrow(ctx, args.runId);
|
|
434
|
+
|
|
435
|
+
if (run.workerStatus === "completed") {
|
|
436
|
+
console.log(`[worker] mark-completed: idempotente ${args.runId}`);
|
|
437
|
+
return { run: serializeRunForWorker(run) };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (run.workerStatus !== "processing") {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`mark-completed richiede workerStatus=processing, attuale=${run.workerStatus ?? "null"}`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const now = Date.now();
|
|
447
|
+
await ctx.db.patch(args.runId, {
|
|
448
|
+
workerStatus: "completed",
|
|
449
|
+
workerCompletedAt: now,
|
|
450
|
+
progress: 100,
|
|
451
|
+
progressMessage: "Completed",
|
|
452
|
+
status: "calculated",
|
|
453
|
+
updatedAt: now,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
await ctx.db.insert("auditLogs", {
|
|
457
|
+
entityType: "compensationRuns",
|
|
458
|
+
entityId: args.runId,
|
|
459
|
+
action: "worker_completed",
|
|
460
|
+
userId: run.workerId ?? "python-worker",
|
|
461
|
+
payload: { resultSummary: run.resultSummary },
|
|
462
|
+
createdAt: now,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const updated = await getRunOrThrow(ctx, args.runId);
|
|
466
|
+
console.log(`[worker] mark-completed: ok ${args.runId}`);
|
|
467
|
+
return { run: serializeRunForWorker(updated) };
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ── mark-failed ──────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
export const markFailed = mutation({
|
|
474
|
+
args: {
|
|
475
|
+
...workerSecretArg,
|
|
476
|
+
runId: v.id("compensationRuns"),
|
|
477
|
+
errorMessage: v.string(),
|
|
478
|
+
errorStack: v.optional(v.string()),
|
|
479
|
+
},
|
|
480
|
+
handler: async (ctx, args) => {
|
|
481
|
+
assertWorkerSecret(args.workerSecret);
|
|
482
|
+
console.log(`[worker] mark-failed: ${args.runId}`);
|
|
483
|
+
|
|
484
|
+
const run = await getRunOrThrow(ctx, args.runId);
|
|
485
|
+
|
|
486
|
+
if (run.workerStatus === "failed") {
|
|
487
|
+
console.log(`[worker] mark-failed: idempotente ${args.runId}`);
|
|
488
|
+
return { run: serializeRunForWorker(run) };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (run.workerStatus !== "processing" && run.workerStatus !== "queued") {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`mark-failed non consentito da workerStatus=${run.workerStatus ?? "null"}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const now = Date.now();
|
|
498
|
+
await ctx.db.patch(args.runId, {
|
|
499
|
+
workerStatus: "failed",
|
|
500
|
+
workerFailedAt: now,
|
|
501
|
+
errorMessage: args.errorMessage,
|
|
502
|
+
errorStack: args.errorStack,
|
|
503
|
+
progressMessage: args.errorMessage.slice(0, 500),
|
|
504
|
+
updatedAt: now,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
await ctx.db.insert("auditLogs", {
|
|
508
|
+
entityType: "compensationRuns",
|
|
509
|
+
entityId: args.runId,
|
|
510
|
+
action: "worker_failed",
|
|
511
|
+
userId: run.workerId ?? "python-worker",
|
|
512
|
+
payload: {
|
|
513
|
+
errorMessage: args.errorMessage,
|
|
514
|
+
errorStack: args.errorStack,
|
|
515
|
+
},
|
|
516
|
+
createdAt: now,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const updated = await getRunOrThrow(ctx, args.runId);
|
|
520
|
+
console.log(`[worker] mark-failed: ok ${args.runId}`);
|
|
521
|
+
return { run: serializeRunForWorker(updated) };
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// ── enqueue (per UI / cron — mette in coda il worker) ───
|
|
526
|
+
|
|
527
|
+
export const enqueueRun = mutation({
|
|
528
|
+
args: {
|
|
529
|
+
runId: v.id("compensationRuns"),
|
|
530
|
+
userId: v.string(),
|
|
531
|
+
},
|
|
532
|
+
handler: async (ctx, args) => {
|
|
533
|
+
const run = await getRunOrThrow(ctx, args.runId);
|
|
534
|
+
|
|
535
|
+
if (run.status === "closed") {
|
|
536
|
+
throw new Error("Impossibile accodare un run chiuso");
|
|
537
|
+
}
|
|
538
|
+
if (run.status === "approved") {
|
|
539
|
+
throw new Error("Impossibile accodare un run approvato");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (run.workerStatus === "queued" || run.workerStatus === "processing") {
|
|
543
|
+
return { runId: args.runId, workerStatus: run.workerStatus };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (run.workerStatus === "completed") {
|
|
547
|
+
throw new Error(
|
|
548
|
+
"Impossibile riaccodare un run già completato dal worker",
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const now = Date.now();
|
|
553
|
+
await ctx.db.patch(args.runId, {
|
|
554
|
+
workerStatus: "queued",
|
|
555
|
+
workerId: undefined,
|
|
556
|
+
startedAt: undefined,
|
|
557
|
+
workerCompletedAt: undefined,
|
|
558
|
+
workerFailedAt: undefined,
|
|
559
|
+
progress: 0,
|
|
560
|
+
progressMessage: "Queued for worker",
|
|
561
|
+
errorMessage: undefined,
|
|
562
|
+
errorStack: undefined,
|
|
563
|
+
updatedAt: now,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
await ctx.db.insert("auditLogs", {
|
|
567
|
+
entityType: "compensationRuns",
|
|
568
|
+
entityId: args.runId,
|
|
569
|
+
action: "worker_enqueued",
|
|
570
|
+
userId: args.userId,
|
|
571
|
+
payload: { period: run.period },
|
|
572
|
+
createdAt: now,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
console.log(`[worker] enqueue-run: ${args.runId}`);
|
|
576
|
+
return { runId: args.runId, workerStatus: "queued" as const };
|
|
577
|
+
},
|
|
578
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autenticazione worker-only (Bearer WORKER_SECRET).
|
|
3
|
+
* Usata da mutation workerApi e da HTTP routes nell'app host.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class WorkerUnauthorizedError extends Error {
|
|
7
|
+
constructor(message = "Unauthorized") {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "WorkerUnauthorizedError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function assertWorkerSecret(workerSecret: string): void {
|
|
14
|
+
const expected = process.env.WORKER_SECRET;
|
|
15
|
+
if (!expected) {
|
|
16
|
+
console.error("[worker] WORKER_SECRET non configurato nel deployment Convex");
|
|
17
|
+
throw new WorkerUnauthorizedError("Worker secret not configured");
|
|
18
|
+
}
|
|
19
|
+
if (workerSecret !== expected) {
|
|
20
|
+
throw new WorkerUnauthorizedError("Invalid worker secret");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assertWorkerAuthorization(request: Request): string {
|
|
25
|
+
const header = request.headers.get("Authorization");
|
|
26
|
+
if (!header?.startsWith("Bearer ")) {
|
|
27
|
+
throw new WorkerUnauthorizedError("Missing Bearer token");
|
|
28
|
+
}
|
|
29
|
+
const token = header.slice("Bearer ".length).trim();
|
|
30
|
+
assertWorkerSecret(token);
|
|
31
|
+
return token;
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primocaredentgroup/compensi-medici-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Componente Convex Compensi Medici — motore compensi strutturato, configurabile e integrato per PrimoUpCore",
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
"@convex-dev/component-source": "./convex/convex.config.ts",
|
|
19
19
|
"types": "./convex/convex.config.ts",
|
|
20
20
|
"default": "./convex/convex.config.ts"
|
|
21
|
+
},
|
|
22
|
+
"./convex/http": {
|
|
23
|
+
"types": "./convex/http.ts",
|
|
24
|
+
"default": "./convex/http.ts"
|
|
21
25
|
}
|
|
22
26
|
},
|
|
23
27
|
"publishConfig": {
|