@primocaredentgroup/compensi-medici-core 0.1.0

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.
Files changed (38) hide show
  1. package/convex/_generated/api.ts +110 -0
  2. package/convex/_generated/component.ts +1356 -0
  3. package/convex/_generated/dataModel.ts +60 -0
  4. package/convex/_generated/server.ts +156 -0
  5. package/convex/audit/logs.ts +69 -0
  6. package/convex/calculation/commissions.ts +497 -0
  7. package/convex/calculation/deductions.ts +119 -0
  8. package/convex/calculation/engine.ts +598 -0
  9. package/convex/calculation/fixedCommissions.ts +217 -0
  10. package/convex/calculation/orchestrator.ts +314 -0
  11. package/convex/calculation/production.ts +495 -0
  12. package/convex/calculation/productionData.ts +155 -0
  13. package/convex/calculation/ruleApplications.ts +121 -0
  14. package/convex/config/categories.ts +114 -0
  15. package/convex/config/commissionPlans.ts +166 -0
  16. package/convex/config/commissionRules.ts +154 -0
  17. package/convex/config/companyMappings.ts +77 -0
  18. package/convex/config/deductionRules.ts +113 -0
  19. package/convex/config/fixedCommissionTypes.ts +79 -0
  20. package/convex/config/globalSettings.ts +79 -0
  21. package/convex/config/invisalignConfig.ts +87 -0
  22. package/convex/config/planTemplates.ts +90 -0
  23. package/convex/config/taxProfiles.ts +122 -0
  24. package/convex/config/userTaxProfiles.ts +87 -0
  25. package/convex/convex.config.ts +3 -0
  26. package/convex/documents/draftInvoices.ts +164 -0
  27. package/convex/documents/invoiceEngine.ts +468 -0
  28. package/convex/documents/invoiceGeneration.ts +611 -0
  29. package/convex/documents/statements.ts +185 -0
  30. package/convex/ledger/entries.ts +314 -0
  31. package/convex/lib/types.ts +126 -0
  32. package/convex/schema.ts +611 -0
  33. package/convex/seedHelpers.ts +41 -0
  34. package/convex/workflow/pendingCompletions.ts +148 -0
  35. package/convex/workflow/pythonWorker.ts +190 -0
  36. package/convex/workflow/runIssues.ts +364 -0
  37. package/convex/workflow/runs.ts +718 -0
  38. package/package.json +43 -0
@@ -0,0 +1,148 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query, internalMutation, internalQuery } from "../_generated/server";
3
+
4
+ /**
5
+ * Gestione coda di eseguiti in attesa di elaborazione.
6
+ *
7
+ * Flusso:
8
+ * 1. PrimoUpCore chiama recordCompletion quando un CPR viene completato
9
+ * 2. Un cron orario chiama getPendingByPeriod per verificare se ci sono pending
10
+ * 3. Se ci sono, triggera il Python Worker per il calcolo batch
11
+ * 4. A calcolo completato, markAsProcessed segna le righe come elaborate
12
+ */
13
+
14
+ export const recordCompletion = mutation({
15
+ args: {
16
+ carePlanRowId: v.string(),
17
+ carePlanId: v.string(),
18
+ userId: v.string(),
19
+ clinicId: v.string(),
20
+ companyId: v.optional(v.string()),
21
+ period: v.string(),
22
+ amount: v.number(),
23
+ serviceDescription: v.optional(v.string()),
24
+ completedAt: v.number(),
25
+ completedBy: v.string(),
26
+ },
27
+ handler: async (ctx, args) => {
28
+ const existing = await ctx.db
29
+ .query("pendingCompletions")
30
+ .withIndex("by_cpr_id", (q) => q.eq("carePlanRowId", args.carePlanRowId))
31
+ .first();
32
+
33
+ if (existing) {
34
+ await ctx.db.patch(existing._id, {
35
+ amount: args.amount,
36
+ completedAt: args.completedAt,
37
+ completedBy: args.completedBy,
38
+ isProcessed: false,
39
+ processedAt: undefined,
40
+ processedRunId: undefined,
41
+ });
42
+ return existing._id;
43
+ }
44
+
45
+ return await ctx.db.insert("pendingCompletions", {
46
+ ...args,
47
+ isProcessed: false,
48
+ createdAt: Date.now(),
49
+ });
50
+ },
51
+ });
52
+
53
+ export const getPendingSummary = query({
54
+ args: {},
55
+ handler: async (ctx) => {
56
+ const pending = await ctx.db
57
+ .query("pendingCompletions")
58
+ .withIndex("by_processed", (q) => q.eq("isProcessed", false))
59
+ .collect();
60
+
61
+ const byPeriod = new Map<string, number>();
62
+ for (const p of pending) {
63
+ byPeriod.set(p.period, (byPeriod.get(p.period) ?? 0) + 1);
64
+ }
65
+
66
+ return {
67
+ totalPending: pending.length,
68
+ byPeriod: Object.fromEntries(byPeriod),
69
+ };
70
+ },
71
+ });
72
+
73
+ export const listPendingByPeriod = query({
74
+ args: { period: v.string() },
75
+ handler: async (ctx, args) => {
76
+ return await ctx.db
77
+ .query("pendingCompletions")
78
+ .withIndex("by_period_processed", (q) =>
79
+ q.eq("period", args.period).eq("isProcessed", false),
80
+ )
81
+ .collect();
82
+ },
83
+ });
84
+
85
+ export const markAsProcessed = mutation({
86
+ args: {
87
+ period: v.string(),
88
+ runId: v.string(),
89
+ },
90
+ handler: async (ctx, args) => {
91
+ const pending = await ctx.db
92
+ .query("pendingCompletions")
93
+ .withIndex("by_period_processed", (q) =>
94
+ q.eq("period", args.period).eq("isProcessed", false),
95
+ )
96
+ .collect();
97
+
98
+ const now = Date.now();
99
+ let count = 0;
100
+
101
+ for (const p of pending) {
102
+ await ctx.db.patch(p._id, {
103
+ isProcessed: true,
104
+ processedAt: now,
105
+ processedRunId: args.runId,
106
+ });
107
+ count++;
108
+ }
109
+
110
+ return { markedCount: count };
111
+ },
112
+ });
113
+
114
+ // ── Internal: usato dal cron per controllare se ci sono pending ──
115
+
116
+ export const _hasPendingCompletions = internalQuery({
117
+ args: {},
118
+ handler: async (ctx) => {
119
+ const pending = await ctx.db
120
+ .query("pendingCompletions")
121
+ .withIndex("by_processed", (q) => q.eq("isProcessed", false))
122
+ .first();
123
+
124
+ if (!pending) return { hasPending: false, periods: [] };
125
+
126
+ const allPending = await ctx.db
127
+ .query("pendingCompletions")
128
+ .withIndex("by_processed", (q) => q.eq("isProcessed", false))
129
+ .collect();
130
+
131
+ const periods = [...new Set(allPending.map((p) => p.period))];
132
+
133
+ return { hasPending: true, periods };
134
+ },
135
+ });
136
+
137
+ export const _getPendingCountForPeriod = internalQuery({
138
+ args: { period: v.string() },
139
+ handler: async (ctx, args) => {
140
+ const pending = await ctx.db
141
+ .query("pendingCompletions")
142
+ .withIndex("by_period_processed", (q) =>
143
+ q.eq("period", args.period).eq("isProcessed", false),
144
+ )
145
+ .collect();
146
+ return pending.length;
147
+ },
148
+ });
@@ -0,0 +1,190 @@
1
+ import { v } from "convex/values";
2
+ import { action, internalMutation, internalQuery } from "../_generated/server";
3
+ import { internal } from "../_generated/api";
4
+
5
+ /**
6
+ * Action di collegamento con il Python Worker.
7
+ *
8
+ * Flusso:
9
+ * 1. Frontend chiama triggerCalculation
10
+ * 2. Action legge l'URL del worker da globalSettings (key: "python_worker_url")
11
+ * 3. Action fa POST al worker con runId + period
12
+ * 4. Il Python worker calcola tutto e scrive i risultati direttamente su Convex
13
+ * 5. Il Python worker aggiorna lo stato del run a "calculated"
14
+ *
15
+ * In locale: il worker gira su http://localhost:8000
16
+ * In produzione: il worker gira su Railway (URL configurato in globalSettings)
17
+ */
18
+
19
+ export const triggerCalculation = action({
20
+ args: {
21
+ runId: v.id("compensationRuns"),
22
+ workerUrl: v.optional(v.string()),
23
+ userIds: v.optional(v.array(v.string())),
24
+ userId: v.string(),
25
+ },
26
+ handler: async (ctx, args) => {
27
+ const run = await ctx.runQuery(internal.workflow.runs._getInternal, { runId: args.runId });
28
+ if (!run) throw new Error("Run non trovato");
29
+
30
+ if (run.status !== "draft") {
31
+ throw new Error(
32
+ `Il run è in stato "${run.status}". Solo i run in stato "draft" possono essere calcolati.`,
33
+ );
34
+ }
35
+
36
+ let workerUrl = args.workerUrl;
37
+ if (!workerUrl) {
38
+ workerUrl = await ctx.runQuery(internal.workflow.pythonWorker._getWorkerUrl, {});
39
+ }
40
+ if (!workerUrl) {
41
+ throw new Error(
42
+ "URL del Python Worker non configurato. " +
43
+ "Aggiungi una globalSetting con key='python_worker_url' o passa workerUrl come argomento.",
44
+ );
45
+ }
46
+
47
+ const endpoint = `${workerUrl.replace(/\/$/, "")}/calculate`;
48
+
49
+ await ctx.runMutation(internal.workflow.pythonWorker._logWorkerCall, {
50
+ runId: args.runId,
51
+ userId: args.userId,
52
+ endpoint,
53
+ status: "started",
54
+ });
55
+
56
+ try {
57
+ let response: Response;
58
+ try {
59
+ response = await fetch(endpoint, {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({
63
+ run_id: args.runId,
64
+ period: run.period,
65
+ user_ids: args.userIds ?? null,
66
+ }),
67
+ });
68
+ } catch (fetchErr: any) {
69
+ const msg = fetchErr?.message ?? String(fetchErr);
70
+ if (msg.includes("forbidden") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
71
+ throw new Error(
72
+ `Impossibile raggiungere il Python Worker su ${endpoint}. ` +
73
+ `Assicurati che il worker sia in esecuzione e che l'URL sia raggiungibile da Convex cloud ` +
74
+ `(localhost non funziona — usa un URL pubblico come Railway).`,
75
+ );
76
+ }
77
+ throw new Error(`Errore di connessione al Python Worker: ${msg}`);
78
+ }
79
+
80
+ if (!response.ok) {
81
+ const errorText = await response.text();
82
+ throw new Error(`Python Worker ha risposto con ${response.status}: ${errorText}`);
83
+ }
84
+
85
+ const result = await response.json();
86
+
87
+ await ctx.runMutation(internal.workflow.pythonWorker._logWorkerCall, {
88
+ runId: args.runId,
89
+ userId: args.userId,
90
+ endpoint,
91
+ status: "completed",
92
+ summary: result.summary ?? result,
93
+ });
94
+
95
+ return {
96
+ status: "ok",
97
+ runId: args.runId,
98
+ period: run.period,
99
+ summary: result.summary ?? result,
100
+ };
101
+ } catch (error: any) {
102
+ await ctx.runMutation(internal.workflow.pythonWorker._logWorkerCall, {
103
+ runId: args.runId,
104
+ userId: args.userId,
105
+ endpoint,
106
+ status: "error",
107
+ summary: { error: error.message },
108
+ });
109
+ throw error;
110
+ }
111
+ },
112
+ });
113
+
114
+ export const testConnection = action({
115
+ args: {
116
+ runId: v.id("compensationRuns"),
117
+ workerUrl: v.optional(v.string()),
118
+ },
119
+ handler: async (ctx, args) => {
120
+ const run = await ctx.runQuery(internal.workflow.runs._getInternal, { runId: args.runId });
121
+ if (!run) throw new Error("Run non trovato");
122
+
123
+ let workerUrl = args.workerUrl;
124
+ if (!workerUrl) {
125
+ workerUrl = await ctx.runQuery(internal.workflow.pythonWorker._getWorkerUrl, {});
126
+ }
127
+ if (!workerUrl) {
128
+ throw new Error("URL del Python Worker non configurato.");
129
+ }
130
+
131
+ const healthUrl = `${workerUrl.replace(/\/$/, "")}/health`;
132
+ const healthResp = await fetch(healthUrl);
133
+ if (!healthResp.ok) {
134
+ throw new Error(`Health check fallito: ${healthResp.status}`);
135
+ }
136
+
137
+ const testUrl = `${workerUrl.replace(/\/$/, "")}/calculate/test`;
138
+ const testResp = await fetch(testUrl, {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json" },
141
+ body: JSON.stringify({
142
+ run_id: args.runId,
143
+ period: run.period,
144
+ }),
145
+ });
146
+
147
+ if (!testResp.ok) {
148
+ throw new Error(`Test calcolo fallito: ${testResp.status}`);
149
+ }
150
+
151
+ return await testResp.json();
152
+ },
153
+ });
154
+
155
+ // ── Internal queries/mutations ───────────────────────────
156
+
157
+ export const _getWorkerUrl = internalQuery({
158
+ args: {},
159
+ handler: async (ctx) => {
160
+ const setting = await ctx.db
161
+ .query("globalSettings")
162
+ .withIndex("by_key", (q: any) => q.eq("key", "python_worker_url"))
163
+ .first();
164
+ return setting?.value ?? null;
165
+ },
166
+ });
167
+
168
+ export const _logWorkerCall = internalMutation({
169
+ args: {
170
+ runId: v.id("compensationRuns"),
171
+ userId: v.string(),
172
+ endpoint: v.string(),
173
+ status: v.string(),
174
+ summary: v.optional(v.any()),
175
+ },
176
+ handler: async (ctx, args) => {
177
+ await ctx.db.insert("auditLogs", {
178
+ entityType: "compensationRuns",
179
+ entityId: args.runId,
180
+ action: `python_worker_${args.status}`,
181
+ userId: args.userId,
182
+ payload: {
183
+ endpoint: args.endpoint,
184
+ status: args.status,
185
+ summary: args.summary,
186
+ },
187
+ createdAt: Date.now(),
188
+ });
189
+ },
190
+ });
@@ -0,0 +1,364 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "../_generated/server";
3
+ import { issueSeverity, issueType } from "../schema";
4
+ import {
5
+ resolveUserTaxProfile,
6
+ type ResolvedTaxProfile,
7
+ type ResolvedUserTaxAssignment,
8
+ } from "../documents/invoiceEngine";
9
+
10
+ /**
11
+ * Anomalie strutturate di un run — raccolta, persistenza e interrogazione.
12
+ *
13
+ * Da analisi.md:
14
+ * - "differenze spiegabili" (criterio di successo)
15
+ * - "audit completo disponibile"
16
+ *
17
+ * A differenza degli auditLogs, le runIssues:
18
+ * - Sono rigenerate ad ogni ciclo di verifica (idempotenti)
19
+ * - Rappresentano lo stato corrente, non lo storico
20
+ * - Sono interrogabili per tipo e severità
21
+ * - Servono per il readiness check prima della chiusura
22
+ *
23
+ * Severità:
24
+ * - error: blocca la chiusura del run
25
+ * - warning: segnalazione, non blocca
26
+ * - info: informativo
27
+ */
28
+
29
+ // ── Raccolta anomalie ────────────────────────────────────
30
+
31
+ /**
32
+ * Scansiona lo stato corrente del run e persiste tutte le anomalie
33
+ * trovate nella tabella runIssues.
34
+ *
35
+ * Idempotente: cancella le issue precedenti e le rigenera.
36
+ *
37
+ * Anomalie rilevate:
38
+ * 1. Righe production/storno senza regola matchata (no_active_plan / no_matching_rule)
39
+ * 2. Medici con commissioni ma senza profilo fiscale (no_tax_profile / multiple_tax_profiles)
40
+ * 3. Anomalie fiscali nelle bozze (fiscal_anomaly)
41
+ * 4. Medici con commissioni ma senza bozza fattura (missing_invoices)
42
+ */
43
+ export const collectIssues = mutation({
44
+ args: {
45
+ runId: v.id("compensationRuns"),
46
+ userId: v.string(),
47
+ },
48
+ handler: async (ctx, args) => {
49
+ const run = await ctx.db.get(args.runId);
50
+ if (!run) throw new Error("Run non trovato");
51
+
52
+ const now = Date.now();
53
+
54
+ // Clear issue precedenti
55
+ const existingIssues = await ctx.db
56
+ .query("runIssues")
57
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
58
+ .collect();
59
+ for (const issue of existingIssues) {
60
+ await ctx.db.delete(issue._id);
61
+ }
62
+
63
+ const issues: Array<{
64
+ issueType: string;
65
+ severity: string;
66
+ userId?: string;
67
+ clinicId?: string;
68
+ message: string;
69
+ details?: Record<string, unknown>;
70
+ }> = [];
71
+
72
+ // ── 1. Carica tutte le entry del run ──
73
+ const allEntries = await ctx.db
74
+ .query("ledgerEntries")
75
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
76
+ .collect();
77
+
78
+ const productionEntries = allEntries.filter(
79
+ (e) =>
80
+ !e.isManual &&
81
+ (e.type === "production" || e.type === "storno"),
82
+ );
83
+
84
+ const commissionEntries = allEntries.filter(
85
+ (e) => !e.isManual && e.type === "commission",
86
+ );
87
+
88
+ // ── 2. Righe senza regola matchata ──
89
+ const ruleApps = await ctx.db
90
+ .query("ruleApplications")
91
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
92
+ .collect();
93
+
94
+ const matchedSourceIds = new Set(
95
+ ruleApps.map((ra) => ra.sourceLedgerEntryId),
96
+ );
97
+
98
+ const unmatchedByUser = new Map<
99
+ string,
100
+ { count: number; totalAmount: number; hasActivePlan: boolean }
101
+ >();
102
+
103
+ for (const entry of productionEntries) {
104
+ if (!matchedSourceIds.has(entry._id)) {
105
+ const existing = unmatchedByUser.get(entry.userId) ?? {
106
+ count: 0,
107
+ totalAmount: 0,
108
+ hasActivePlan: false,
109
+ };
110
+ existing.count++;
111
+ existing.totalAmount += entry.amount;
112
+ unmatchedByUser.set(entry.userId, existing);
113
+ }
114
+ }
115
+
116
+ for (const [uid, data] of unmatchedByUser) {
117
+ const plans = await ctx.db
118
+ .query("commissionPlans")
119
+ .withIndex("by_user_status", (q) =>
120
+ q.eq("userId", uid).eq("status", "active"),
121
+ )
122
+ .collect();
123
+
124
+ const hasPlan = plans.length > 0;
125
+
126
+ issues.push({
127
+ issueType: hasPlan ? "no_matching_rule" : "no_active_plan",
128
+ severity: "error",
129
+ userId: uid,
130
+ message: hasPlan
131
+ ? `${data.count} righe produzione/storno senza regola matchata per userId=${uid}`
132
+ : `Nessun piano provvigionale attivo per userId=${uid}`,
133
+ details: {
134
+ unmatchedCount: data.count,
135
+ unmatchedAmount: data.totalAmount,
136
+ hasActivePlan: hasPlan,
137
+ },
138
+ });
139
+ }
140
+
141
+ // ── 3. Problemi profilo fiscale ──
142
+ const commissionUserIds = [
143
+ ...new Set(commissionEntries.map((e) => e.userId)),
144
+ ];
145
+
146
+ for (const uid of commissionUserIds) {
147
+ const assignments = await ctx.db
148
+ .query("userTaxProfiles")
149
+ .withIndex("by_user", (q) => q.eq("userId", uid))
150
+ .collect();
151
+
152
+ const resolvedAssignments: ResolvedUserTaxAssignment[] = assignments.map(
153
+ (a) => ({
154
+ _id: a._id,
155
+ userId: a.userId,
156
+ taxProfileId: a.taxProfileId,
157
+ effectiveFrom: a.effectiveFrom,
158
+ effectiveTo: a.effectiveTo,
159
+ }),
160
+ );
161
+
162
+ const profilesMap = new Map<string, ResolvedTaxProfile>();
163
+ for (const a of assignments) {
164
+ const profile = await ctx.db.get(a.taxProfileId);
165
+ if (profile) {
166
+ profilesMap.set(a.taxProfileId, {
167
+ _id: profile._id,
168
+ code: profile.code,
169
+ name: profile.name,
170
+ vatRatePercent: profile.vatRatePercent,
171
+ withholdingRatePercent: profile.withholdingRatePercent,
172
+ contributionRatePercent: profile.contributionRatePercent,
173
+ stampDutyAmount: profile.stampDutyAmount,
174
+ });
175
+ }
176
+ }
177
+
178
+ const resolution = resolveUserTaxProfile(
179
+ resolvedAssignments,
180
+ profilesMap,
181
+ uid,
182
+ run.period,
183
+ );
184
+
185
+ if (resolution.status !== "found") {
186
+ const typeMap: Record<string, string> = {
187
+ no_assignment: "no_tax_profile",
188
+ multiple_assignments: "multiple_tax_profiles",
189
+ profile_not_found: "no_tax_profile",
190
+ profile_inactive: "inactive_tax_profile",
191
+ };
192
+
193
+ issues.push({
194
+ issueType: typeMap[resolution.status] ?? "no_tax_profile",
195
+ severity: "error",
196
+ userId: uid,
197
+ message: resolutionToMessage(resolution),
198
+ details: { resolution },
199
+ });
200
+ }
201
+ }
202
+
203
+ // ── 4. Anomalie fiscali nelle bozze ──
204
+ const invoices = await ctx.db
205
+ .query("draftInvoices")
206
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
207
+ .collect();
208
+
209
+ for (const inv of invoices) {
210
+ if (inv.subtotal <= 0) {
211
+ issues.push({
212
+ issueType: "fiscal_anomaly",
213
+ severity: "warning",
214
+ userId: inv.userId,
215
+ clinicId: inv.clinicId,
216
+ message: `Subtotale non positivo (${inv.subtotal}) per userId=${inv.userId}, clinicId=${inv.clinicId}`,
217
+ details: { subtotal: inv.subtotal, invoiceId: inv._id },
218
+ });
219
+ }
220
+ if (inv.netToPay < 0) {
221
+ issues.push({
222
+ issueType: "fiscal_anomaly",
223
+ severity: "warning",
224
+ userId: inv.userId,
225
+ clinicId: inv.clinicId,
226
+ message: `Netto a pagare negativo (${inv.netToPay}) per userId=${inv.userId}, clinicId=${inv.clinicId}`,
227
+ details: {
228
+ netToPay: inv.netToPay,
229
+ total: inv.total,
230
+ invoiceId: inv._id,
231
+ },
232
+ });
233
+ }
234
+ }
235
+
236
+ // ── 5. Medici con commissioni ma senza bozza ──
237
+ const invoiceUserClinicKeys = new Set(
238
+ invoices.map((inv) => `${inv.userId}|${inv.clinicId}`),
239
+ );
240
+
241
+ const commissionKeys = new Set(
242
+ commissionEntries.map((e) => `${e.userId}|${e.clinicId}`),
243
+ );
244
+
245
+ for (const key of commissionKeys) {
246
+ if (!invoiceUserClinicKeys.has(key)) {
247
+ const [uid, cid] = key.split("|");
248
+ issues.push({
249
+ issueType: "missing_invoices",
250
+ severity: "error",
251
+ userId: uid,
252
+ clinicId: cid,
253
+ message: `Commissioni presenti ma nessuna bozza fattura per userId=${uid}, clinicId=${cid}`,
254
+ });
255
+ }
256
+ }
257
+
258
+ // ── Persist ──
259
+ for (const issue of issues) {
260
+ await ctx.db.insert("runIssues", {
261
+ runId: args.runId,
262
+ issueType: issue.issueType as any,
263
+ severity: issue.severity as any,
264
+ userId: issue.userId,
265
+ clinicId: issue.clinicId,
266
+ message: issue.message,
267
+ details: issue.details,
268
+ createdAt: now,
269
+ });
270
+ }
271
+
272
+ return {
273
+ totalIssues: issues.length,
274
+ errors: issues.filter((i) => i.severity === "error").length,
275
+ warnings: issues.filter((i) => i.severity === "warning").length,
276
+ infos: issues.filter((i) => i.severity === "info").length,
277
+ };
278
+ },
279
+ });
280
+
281
+ // ── Query ────────────────────────────────────────────────
282
+
283
+ export const listByRun = query({
284
+ args: { runId: v.id("compensationRuns") },
285
+ handler: async (ctx, args) => {
286
+ return await ctx.db
287
+ .query("runIssues")
288
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
289
+ .collect();
290
+ },
291
+ });
292
+
293
+ export const listErrorsByRun = query({
294
+ args: { runId: v.id("compensationRuns") },
295
+ handler: async (ctx, args) => {
296
+ return await ctx.db
297
+ .query("runIssues")
298
+ .withIndex("by_run_severity", (q) =>
299
+ q.eq("runId", args.runId).eq("severity", "error"),
300
+ )
301
+ .collect();
302
+ },
303
+ });
304
+
305
+ export const listByRunAndType = query({
306
+ args: {
307
+ runId: v.id("compensationRuns"),
308
+ issueType: issueType,
309
+ },
310
+ handler: async (ctx, args) => {
311
+ return await ctx.db
312
+ .query("runIssues")
313
+ .withIndex("by_run_type", (q) =>
314
+ q.eq("runId", args.runId).eq("issueType", args.issueType),
315
+ )
316
+ .collect();
317
+ },
318
+ });
319
+
320
+ export const getSummary = query({
321
+ args: { runId: v.id("compensationRuns") },
322
+ handler: async (ctx, args) => {
323
+ const issues = await ctx.db
324
+ .query("runIssues")
325
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
326
+ .collect();
327
+
328
+ const byType: Record<string, number> = {};
329
+ let errors = 0;
330
+ let warnings = 0;
331
+
332
+ for (const issue of issues) {
333
+ byType[issue.issueType] = (byType[issue.issueType] ?? 0) + 1;
334
+ if (issue.severity === "error") errors++;
335
+ else if (issue.severity === "warning") warnings++;
336
+ }
337
+
338
+ return {
339
+ runId: args.runId,
340
+ totalIssues: issues.length,
341
+ errors,
342
+ warnings,
343
+ byType,
344
+ hasBlockingIssues: errors > 0,
345
+ };
346
+ },
347
+ });
348
+
349
+ // ── Helpers ──────────────────────────────────────────────
350
+
351
+ function resolutionToMessage(resolution: { status: string; userId?: string; count?: number; taxProfileId?: string; profileCode?: string }): string {
352
+ switch (resolution.status) {
353
+ case "no_assignment":
354
+ return `Nessun profilo fiscale assegnato per userId=${resolution.userId}`;
355
+ case "multiple_assignments":
356
+ return `Profili fiscali multipli (${resolution.count}) per userId=${resolution.userId}`;
357
+ case "profile_not_found":
358
+ return `Profilo fiscale ${resolution.taxProfileId} non trovato per userId=${resolution.userId}`;
359
+ case "profile_inactive":
360
+ return `Profilo fiscale "${resolution.profileCode}" non attivo per userId=${resolution.userId}`;
361
+ default:
362
+ return "Anomalia profilo fiscale";
363
+ }
364
+ }