@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,718 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query, internalQuery } from "../_generated/server";
3
+ import { runStatus, scopeType, calculationMode } from "../schema";
4
+ import { VALID_TRANSITIONS, type RunStatus } from "../lib/types";
5
+
6
+ /**
7
+ * Gestione run mensili e workflow del mese.
8
+ *
9
+ * Da analisi.md:
10
+ * - Mese aperto: sempre ricalcolabile, preview aggiornata, modificabile
11
+ * - Mese chiuso: snapshot immutabile, riapertura solo loggata
12
+ * - Ricalcolo crea nuova versione
13
+ *
14
+ * Invarianti di immutabilità:
15
+ * - Un run "closed" non può essere modificato in nessun modo
16
+ * - La riapertura crea una nuova versione (basedOnRunId)
17
+ * - Il closureSummary è lo snapshot ufficiale dei totali
18
+ */
19
+
20
+ // ── Helpers ──────────────────────────────────────────────
21
+
22
+ function assertNotClosed(run: { status: string }): void {
23
+ if (run.status === "closed") {
24
+ throw new Error(
25
+ "Impossibile modificare un run chiuso. " +
26
+ "Riaprire il run per creare una nuova versione.",
27
+ );
28
+ }
29
+ }
30
+
31
+ // ── Create ───────────────────────────────────────────────
32
+
33
+ export const createRun = mutation({
34
+ args: {
35
+ period: v.string(),
36
+ isShadowRun: v.optional(v.boolean()),
37
+ scopeType: v.optional(scopeType),
38
+ scopePayload: v.optional(v.any()),
39
+ calculationMode: v.optional(calculationMode),
40
+ notes: v.optional(v.string()),
41
+ triggeredBy: v.string(),
42
+ },
43
+ handler: async (ctx, args) => {
44
+ const now = Date.now();
45
+
46
+ const existing = await ctx.db
47
+ .query("compensationRuns")
48
+ .withIndex("by_period", (q) => q.eq("period", args.period))
49
+ .collect();
50
+
51
+ const maxVersion = existing.reduce(
52
+ (max, r) => Math.max(max, r.version),
53
+ 0,
54
+ );
55
+
56
+ const runId = await ctx.db.insert("compensationRuns", {
57
+ period: args.period,
58
+ status: "draft",
59
+ version: maxVersion + 1,
60
+ isShadowRun: args.isShadowRun ?? false,
61
+ scopeType: args.scopeType ?? "global",
62
+ scopePayload: args.scopePayload,
63
+ calculationMode: args.calculationMode ?? "manual",
64
+ triggeredBy: args.triggeredBy,
65
+ notes: args.notes,
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ });
69
+
70
+ await ctx.db.insert("auditLogs", {
71
+ entityType: "compensationRuns",
72
+ entityId: runId,
73
+ action: "created",
74
+ userId: args.triggeredBy,
75
+ payload: {
76
+ period: args.period,
77
+ version: maxVersion + 1,
78
+ isShadowRun: args.isShadowRun ?? false,
79
+ scopeType: args.scopeType ?? "global",
80
+ calculationMode: args.calculationMode ?? "manual",
81
+ },
82
+ createdAt: now,
83
+ });
84
+
85
+ return runId;
86
+ },
87
+ });
88
+
89
+ // ── Status transitions ───────────────────────────────────
90
+
91
+ export const updateStatus = mutation({
92
+ args: {
93
+ runId: v.id("compensationRuns"),
94
+ newStatus: runStatus,
95
+ notes: v.optional(v.string()),
96
+ userId: v.string(),
97
+ },
98
+ handler: async (ctx, args) => {
99
+ const run = await ctx.db.get(args.runId);
100
+ if (!run) throw new Error("Run non trovato");
101
+
102
+ assertNotClosed(run);
103
+
104
+ if (args.newStatus === "closed") {
105
+ throw new Error(
106
+ "Per chiudere un run usare la funzione closeRun dedicata, " +
107
+ "che include la generazione dello snapshot.",
108
+ );
109
+ }
110
+
111
+ const currentStatus = run.status as RunStatus;
112
+ const allowed = VALID_TRANSITIONS[currentStatus];
113
+
114
+ if (!allowed.includes(args.newStatus as RunStatus)) {
115
+ throw new Error(
116
+ `Transizione non valida: ${currentStatus} → ${args.newStatus}. ` +
117
+ `Transizioni permesse: ${allowed.join(", ")}`,
118
+ );
119
+ }
120
+
121
+ const now = Date.now();
122
+ const patch: Record<string, unknown> = {
123
+ status: args.newStatus,
124
+ updatedAt: now,
125
+ };
126
+ if (args.notes !== undefined) {
127
+ patch.notes = args.notes;
128
+ }
129
+
130
+ await ctx.db.patch(args.runId, patch);
131
+
132
+ await ctx.db.insert("auditLogs", {
133
+ entityType: "compensationRuns",
134
+ entityId: args.runId,
135
+ action: "status_changed",
136
+ userId: args.userId,
137
+ payload: { from: currentStatus, to: args.newStatus },
138
+ createdAt: now,
139
+ });
140
+ },
141
+ });
142
+
143
+ // ── Close ────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Chiude un run con generazione dello snapshot ufficiale.
147
+ *
148
+ * Da analisi.md:
149
+ * - "Snapshot mese chiuso (immutabile)"
150
+ * - "Salvataggio completo del ledger"
151
+ * - "Salvataggio delle regole applicate"
152
+ * - "Salvataggio profilo fiscale"
153
+ * - "Salvataggio documento generato"
154
+ *
155
+ * Strategia snapshot: IMPLICITA.
156
+ * I dati del run (ledgerEntries, ruleApplications, draftInvoices)
157
+ * sono già persistiti e immutabili dopo la chiusura.
158
+ * Il closureSummary contiene i totali aggregati per accesso rapido.
159
+ * Il taxProfileSnapshot è già salvato dentro ogni draftInvoice.
160
+ *
161
+ * Pre-condizioni:
162
+ * - Il run deve essere in stato "approved"
163
+ * - Non devono esserci anomalie bloccanti (severity = "error")
164
+ */
165
+ export const closeRun = mutation({
166
+ args: {
167
+ runId: v.id("compensationRuns"),
168
+ userId: v.string(),
169
+ force: v.optional(v.boolean()),
170
+ },
171
+ handler: async (ctx, args) => {
172
+ const run = await ctx.db.get(args.runId);
173
+ if (!run) throw new Error("Run non trovato");
174
+
175
+ assertNotClosed(run);
176
+
177
+ if (run.status !== "approved") {
178
+ throw new Error(
179
+ `Solo run in stato "approved" possono essere chiusi. Stato attuale: ${run.status}`,
180
+ );
181
+ }
182
+
183
+ // ── Readiness check inline ──
184
+ const blockingIssues = await ctx.db
185
+ .query("runIssues")
186
+ .withIndex("by_run_severity", (q) =>
187
+ q.eq("runId", args.runId).eq("severity", "error"),
188
+ )
189
+ .collect();
190
+
191
+ if (blockingIssues.length > 0 && !args.force) {
192
+ throw new Error(
193
+ `Impossibile chiudere il run: ${blockingIssues.length} anomalie bloccanti. ` +
194
+ `Risolvere le anomalie o usare force=true per forzare la chiusura.`,
195
+ );
196
+ }
197
+
198
+ // ── Build closure summary ──
199
+ const allEntries = await ctx.db
200
+ .query("ledgerEntries")
201
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
202
+ .collect();
203
+
204
+ let totalProductionAmount = 0;
205
+ let totalStornoAmount = 0;
206
+ let totalCommissionAmount = 0;
207
+ let totalManualAmount = 0;
208
+ let productionCount = 0;
209
+ let stornoCount = 0;
210
+ let commissionCount = 0;
211
+ let manualCount = 0;
212
+ const userIds = new Set<string>();
213
+
214
+ for (const entry of allEntries) {
215
+ userIds.add(entry.userId);
216
+ if (entry.isManual) {
217
+ totalManualAmount += entry.amount;
218
+ manualCount++;
219
+ } else if (entry.type === "production") {
220
+ totalProductionAmount += entry.amount;
221
+ productionCount++;
222
+ } else if (entry.type === "storno") {
223
+ totalStornoAmount += entry.amount;
224
+ stornoCount++;
225
+ } else if (entry.type === "commission") {
226
+ totalCommissionAmount += entry.amount;
227
+ commissionCount++;
228
+ }
229
+ }
230
+
231
+ const ruleApps = await ctx.db
232
+ .query("ruleApplications")
233
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
234
+ .collect();
235
+
236
+ const invoices = await ctx.db
237
+ .query("draftInvoices")
238
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
239
+ .collect();
240
+
241
+ let totalNetToPay = 0;
242
+ let totalInvoiceSubtotal = 0;
243
+ for (const inv of invoices) {
244
+ totalNetToPay += inv.netToPay;
245
+ totalInvoiceSubtotal += inv.subtotal;
246
+ }
247
+
248
+ const allIssues = await ctx.db
249
+ .query("runIssues")
250
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
251
+ .collect();
252
+
253
+ const closureSummary = {
254
+ totalUsers: userIds.size,
255
+ totalLedgerEntries: allEntries.length,
256
+ productionCount,
257
+ stornoCount,
258
+ commissionCount,
259
+ manualCount,
260
+ totalProductionAmount: Math.round(totalProductionAmount * 100) / 100,
261
+ totalStornoAmount: Math.round(totalStornoAmount * 100) / 100,
262
+ netProduction:
263
+ Math.round((totalProductionAmount + totalStornoAmount) * 100) / 100,
264
+ totalCommissionAmount:
265
+ Math.round(totalCommissionAmount * 100) / 100,
266
+ totalManualAmount: Math.round(totalManualAmount * 100) / 100,
267
+ totalRuleApplications: ruleApps.length,
268
+ totalInvoices: invoices.length,
269
+ totalInvoiceSubtotal:
270
+ Math.round(totalInvoiceSubtotal * 100) / 100,
271
+ totalNetToPay: Math.round(totalNetToPay * 100) / 100,
272
+ issuesSummary: {
273
+ total: allIssues.length,
274
+ errors: allIssues.filter((i) => i.severity === "error").length,
275
+ warnings: allIssues.filter((i) => i.severity === "warning").length,
276
+ },
277
+ closedAt: Date.now(),
278
+ closedBy: args.userId,
279
+ };
280
+
281
+ const now = Date.now();
282
+ await ctx.db.patch(args.runId, {
283
+ status: "closed",
284
+ closedAt: now,
285
+ closedBy: args.userId,
286
+ closureSummary,
287
+ updatedAt: now,
288
+ });
289
+
290
+ await ctx.db.insert("auditLogs", {
291
+ entityType: "compensationRuns",
292
+ entityId: args.runId,
293
+ action: "closed",
294
+ userId: args.userId,
295
+ payload: {
296
+ closureSummary,
297
+ forceClosed: args.force ?? false,
298
+ blockingIssuesAtClose: blockingIssues.length,
299
+ },
300
+ createdAt: now,
301
+ });
302
+
303
+ return { runId: args.runId, closureSummary };
304
+ },
305
+ });
306
+
307
+ // ── Reopen ───────────────────────────────────────────────
308
+
309
+ /**
310
+ * Riapre un run chiuso creando una NUOVA versione.
311
+ * Da analisi.md: "riapertura solo loggata, ricalcolo crea nuova versione"
312
+ *
313
+ * Il run chiuso resta intatto e leggibile.
314
+ * Il nuovo run parte in stato "draft" con basedOnRunId che punta all'originale.
315
+ */
316
+ export const reopenRun = mutation({
317
+ args: {
318
+ runId: v.id("compensationRuns"),
319
+ notes: v.optional(v.string()),
320
+ userId: v.string(),
321
+ },
322
+ handler: async (ctx, args) => {
323
+ const run = await ctx.db.get(args.runId);
324
+ if (!run) throw new Error("Run non trovato");
325
+ if (run.status !== "closed") {
326
+ throw new Error(
327
+ `Solo run in stato "closed" possono essere riaperti. Stato attuale: ${run.status}`,
328
+ );
329
+ }
330
+
331
+ const existing = await ctx.db
332
+ .query("compensationRuns")
333
+ .withIndex("by_period", (q) => q.eq("period", run.period))
334
+ .collect();
335
+
336
+ const maxVersion = existing.reduce(
337
+ (max, r) => Math.max(max, r.version),
338
+ 0,
339
+ );
340
+
341
+ const now = Date.now();
342
+ const newRunId = await ctx.db.insert("compensationRuns", {
343
+ period: run.period,
344
+ status: "draft",
345
+ version: maxVersion + 1,
346
+ isShadowRun: run.isShadowRun,
347
+ basedOnRunId: args.runId,
348
+ scopeType: run.scopeType,
349
+ scopePayload: run.scopePayload,
350
+ calculationMode: "reopen",
351
+ triggeredBy: args.userId,
352
+ notes: args.notes,
353
+ createdAt: now,
354
+ updatedAt: now,
355
+ });
356
+
357
+ await ctx.db.insert("auditLogs", {
358
+ entityType: "compensationRuns",
359
+ entityId: newRunId,
360
+ action: "reopened_from",
361
+ userId: args.userId,
362
+ payload: {
363
+ originalRunId: args.runId,
364
+ originalVersion: run.version,
365
+ newVersion: maxVersion + 1,
366
+ originalClosureSummary: run.closureSummary,
367
+ },
368
+ createdAt: now,
369
+ });
370
+
371
+ return newRunId;
372
+ },
373
+ });
374
+
375
+ // ── Readiness check ──────────────────────────────────────
376
+
377
+ /**
378
+ * Verifica se un run è pronto per la chiusura.
379
+ *
380
+ * Controlla:
381
+ * 1. Stato coerente (deve essere "approved")
382
+ * 2. Presenza di ledger entries calcolate
383
+ * 3. Presenza di draft invoices generate
384
+ * 4. Assenza di anomalie bloccanti (severity = "error")
385
+ *
386
+ * Restituisce un oggetto con dettaglio dei check e un flag `isReady`.
387
+ */
388
+ export const checkReadiness = query({
389
+ args: { runId: v.id("compensationRuns") },
390
+ handler: async (ctx, args) => {
391
+ const run = await ctx.db.get(args.runId);
392
+ if (!run) throw new Error("Run non trovato");
393
+
394
+ const checks: Array<{
395
+ check: string;
396
+ passed: boolean;
397
+ details: string;
398
+ }> = [];
399
+
400
+ // Check 1: Stato
401
+ const statusOk = run.status === "approved";
402
+ checks.push({
403
+ check: "status_approved",
404
+ passed: statusOk,
405
+ details: statusOk
406
+ ? "Run in stato approved"
407
+ : `Run in stato "${run.status}", deve essere "approved"`,
408
+ });
409
+
410
+ // Check 2: Ledger entries presenti
411
+ const entries = await ctx.db
412
+ .query("ledgerEntries")
413
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
414
+ .collect();
415
+
416
+ const commissions = entries.filter(
417
+ (e) => !e.isManual && e.type === "commission",
418
+ );
419
+ const hasCommissions = commissions.length > 0;
420
+ checks.push({
421
+ check: "has_commissions",
422
+ passed: hasCommissions,
423
+ details: hasCommissions
424
+ ? `${commissions.length} righe commission presenti`
425
+ : "Nessuna riga commission trovata",
426
+ });
427
+
428
+ // Check 3: Draft invoices presenti
429
+ const invoices = await ctx.db
430
+ .query("draftInvoices")
431
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
432
+ .collect();
433
+
434
+ const hasInvoices = invoices.length > 0;
435
+ checks.push({
436
+ check: "has_invoices",
437
+ passed: hasInvoices,
438
+ details: hasInvoices
439
+ ? `${invoices.length} bozze fattura presenti`
440
+ : "Nessuna bozza fattura trovata",
441
+ });
442
+
443
+ // Check 4: Anomalie bloccanti
444
+ const blockingIssues = await ctx.db
445
+ .query("runIssues")
446
+ .withIndex("by_run_severity", (q) =>
447
+ q.eq("runId", args.runId).eq("severity", "error"),
448
+ )
449
+ .collect();
450
+
451
+ const noBlockingIssues = blockingIssues.length === 0;
452
+ checks.push({
453
+ check: "no_blocking_issues",
454
+ passed: noBlockingIssues,
455
+ details: noBlockingIssues
456
+ ? "Nessuna anomalia bloccante"
457
+ : `${blockingIssues.length} anomalie bloccanti da risolvere`,
458
+ });
459
+
460
+ const isReady = checks.every((c) => c.passed);
461
+
462
+ return {
463
+ runId: args.runId,
464
+ period: run.period,
465
+ version: run.version,
466
+ currentStatus: run.status,
467
+ isReady,
468
+ checks,
469
+ blockingIssues: noBlockingIssues ? [] : blockingIssues,
470
+ };
471
+ },
472
+ });
473
+
474
+ // ── Snapshot / detail ────────────────────────────────────
475
+
476
+ /**
477
+ * Dettaglio completo di un run chiuso: closure summary + conteggi attuali.
478
+ * Utile per verificare la coerenza dello snapshot.
479
+ */
480
+ export const getRunSnapshot = query({
481
+ args: { runId: v.id("compensationRuns") },
482
+ handler: async (ctx, args) => {
483
+ const run = await ctx.db.get(args.runId);
484
+ if (!run) throw new Error("Run non trovato");
485
+
486
+ const entries = await ctx.db
487
+ .query("ledgerEntries")
488
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
489
+ .collect();
490
+
491
+ const ruleApps = await ctx.db
492
+ .query("ruleApplications")
493
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
494
+ .collect();
495
+
496
+ const invoices = await ctx.db
497
+ .query("draftInvoices")
498
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
499
+ .collect();
500
+
501
+ const issues = await ctx.db
502
+ .query("runIssues")
503
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
504
+ .collect();
505
+
506
+ return {
507
+ run,
508
+ closureSummary: run.closureSummary ?? null,
509
+ currentCounts: {
510
+ ledgerEntries: entries.length,
511
+ ruleApplications: ruleApps.length,
512
+ draftInvoices: invoices.length,
513
+ issues: issues.length,
514
+ },
515
+ isClosed: run.status === "closed",
516
+ };
517
+ },
518
+ });
519
+
520
+ // ── Version comparison ───────────────────────────────────
521
+
522
+ /**
523
+ * Confronta due run dello stesso periodo (es. versione N vs N+1).
524
+ * Mostra le differenze nei totali tra i due snapshot.
525
+ */
526
+ export const compareRuns = query({
527
+ args: {
528
+ runIdA: v.id("compensationRuns"),
529
+ runIdB: v.id("compensationRuns"),
530
+ },
531
+ handler: async (ctx, args) => {
532
+ const runA = await ctx.db.get(args.runIdA);
533
+ const runB = await ctx.db.get(args.runIdB);
534
+ if (!runA) throw new Error("Run A non trovato");
535
+ if (!runB) throw new Error("Run B non trovato");
536
+
537
+ if (runA.period !== runB.period) {
538
+ throw new Error(
539
+ `I run devono essere dello stesso periodo. A: ${runA.period}, B: ${runB.period}`,
540
+ );
541
+ }
542
+
543
+ const buildSummary = async (runId: typeof args.runIdA) => {
544
+ const entries = await ctx.db
545
+ .query("ledgerEntries")
546
+ .withIndex("by_run", (q) => q.eq("runId", runId))
547
+ .collect();
548
+
549
+ let production = 0;
550
+ let storno = 0;
551
+ let commission = 0;
552
+ let manual = 0;
553
+ let productionCount = 0;
554
+ let commissionCount = 0;
555
+ const users = new Set<string>();
556
+
557
+ for (const e of entries) {
558
+ users.add(e.userId);
559
+ if (e.isManual) {
560
+ manual += e.amount;
561
+ } else if (e.type === "production") {
562
+ production += e.amount;
563
+ productionCount++;
564
+ } else if (e.type === "storno") {
565
+ storno += e.amount;
566
+ } else if (e.type === "commission") {
567
+ commission += e.amount;
568
+ commissionCount++;
569
+ }
570
+ }
571
+
572
+ const invoices = await ctx.db
573
+ .query("draftInvoices")
574
+ .withIndex("by_run", (q) => q.eq("runId", runId))
575
+ .collect();
576
+
577
+ let netToPay = 0;
578
+ for (const inv of invoices) {
579
+ netToPay += inv.netToPay;
580
+ }
581
+
582
+ return {
583
+ totalUsers: users.size,
584
+ totalEntries: entries.length,
585
+ productionCount,
586
+ commissionCount,
587
+ production: Math.round(production * 100) / 100,
588
+ storno: Math.round(storno * 100) / 100,
589
+ netProduction: Math.round((production + storno) * 100) / 100,
590
+ commission: Math.round(commission * 100) / 100,
591
+ manual: Math.round(manual * 100) / 100,
592
+ totalInvoices: invoices.length,
593
+ netToPay: Math.round(netToPay * 100) / 100,
594
+ };
595
+ };
596
+
597
+ const summaryA = await buildSummary(args.runIdA);
598
+ const summaryB = await buildSummary(args.runIdB);
599
+
600
+ const diff = (a: number, b: number) =>
601
+ Math.round((b - a) * 100) / 100;
602
+
603
+ return {
604
+ period: runA.period,
605
+ runA: {
606
+ runId: args.runIdA,
607
+ version: runA.version,
608
+ status: runA.status,
609
+ summary: summaryA,
610
+ },
611
+ runB: {
612
+ runId: args.runIdB,
613
+ version: runB.version,
614
+ status: runB.status,
615
+ summary: summaryB,
616
+ },
617
+ differences: {
618
+ production: diff(summaryA.production, summaryB.production),
619
+ storno: diff(summaryA.storno, summaryB.storno),
620
+ netProduction: diff(
621
+ summaryA.netProduction,
622
+ summaryB.netProduction,
623
+ ),
624
+ commission: diff(summaryA.commission, summaryB.commission),
625
+ manual: diff(summaryA.manual, summaryB.manual),
626
+ netToPay: diff(summaryA.netToPay, summaryB.netToPay),
627
+ entries: summaryB.totalEntries - summaryA.totalEntries,
628
+ invoices: summaryB.totalInvoices - summaryA.totalInvoices,
629
+ },
630
+ };
631
+ },
632
+ });
633
+
634
+ // ── Query base ───────────────────────────────────────────
635
+
636
+ export const get = query({
637
+ args: { runId: v.id("compensationRuns") },
638
+ handler: async (ctx, args) => {
639
+ return await ctx.db.get(args.runId);
640
+ },
641
+ });
642
+
643
+ export const _getInternal = internalQuery({
644
+ args: { runId: v.id("compensationRuns") },
645
+ handler: async (ctx, args) => {
646
+ return await ctx.db.get(args.runId);
647
+ },
648
+ });
649
+
650
+ export const listByPeriod = query({
651
+ args: { period: v.string() },
652
+ handler: async (ctx, args) => {
653
+ return await ctx.db
654
+ .query("compensationRuns")
655
+ .withIndex("by_period", (q) => q.eq("period", args.period))
656
+ .collect();
657
+ },
658
+ });
659
+
660
+ export const listByStatus = query({
661
+ args: { status: runStatus },
662
+ handler: async (ctx, args) => {
663
+ return await ctx.db
664
+ .query("compensationRuns")
665
+ .withIndex("by_status", (q) => q.eq("status", args.status))
666
+ .collect();
667
+ },
668
+ });
669
+
670
+ export const listClosed = query({
671
+ args: {},
672
+ handler: async (ctx) => {
673
+ return await ctx.db
674
+ .query("compensationRuns")
675
+ .withIndex("by_status", (q) => q.eq("status", "closed"))
676
+ .collect();
677
+ },
678
+ });
679
+
680
+ export const listShadowByPeriod = query({
681
+ args: { period: v.string() },
682
+ handler: async (ctx, args) => {
683
+ return await ctx.db
684
+ .query("compensationRuns")
685
+ .withIndex("by_shadow", (q) =>
686
+ q.eq("isShadowRun", true).eq("period", args.period),
687
+ )
688
+ .collect();
689
+ },
690
+ });
691
+
692
+ /**
693
+ * Storico versioni di un periodo: tutti i run ordinati per versione.
694
+ */
695
+ export const getVersionHistory = query({
696
+ args: { period: v.string() },
697
+ handler: async (ctx, args) => {
698
+ const runs = await ctx.db
699
+ .query("compensationRuns")
700
+ .withIndex("by_period", (q) => q.eq("period", args.period))
701
+ .collect();
702
+
703
+ const sorted = runs.sort((a, b) => a.version - b.version);
704
+
705
+ return sorted.map((r) => ({
706
+ runId: r._id,
707
+ version: r.version,
708
+ status: r.status,
709
+ basedOnRunId: r.basedOnRunId,
710
+ calculationMode: r.calculationMode,
711
+ isShadowRun: r.isShadowRun,
712
+ createdAt: r.createdAt,
713
+ closedAt: r.closedAt,
714
+ closedBy: r.closedBy,
715
+ closureSummary: r.closureSummary,
716
+ }));
717
+ },
718
+ });