@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,185 @@
1
+ import { v } from "convex/values";
2
+ import { query } from "../_generated/server";
3
+
4
+ /**
5
+ * Prospetto compensi — vista aggregata del ledger per un medico.
6
+ *
7
+ * Da analisi.md il ledger è "la fonte di verità per: prospetto, fattura, audit, storico".
8
+ * Il prospetto NON è un'entità salvata: è una vista calcolata al volo.
9
+ *
10
+ * Breakdown:
11
+ * - produzione base (production, storno) con netto
12
+ * - compensi calcolati (commission) con dettaglio regole applicate
13
+ * - righe manuali (rimborsi, rettifiche)
14
+ * - righe calcolate future (fixed, bonus)
15
+ */
16
+
17
+ export const getByRunAndUser = query({
18
+ args: {
19
+ runId: v.id("compensationRuns"),
20
+ userId: v.string(),
21
+ },
22
+ handler: async (ctx, args) => {
23
+ const entries = await ctx.db
24
+ .query("ledgerEntries")
25
+ .withIndex("by_run_user", (q) =>
26
+ q.eq("runId", args.runId).eq("userId", args.userId),
27
+ )
28
+ .collect();
29
+
30
+ const byType: Record<
31
+ string,
32
+ { count: number; total: number; manualCount: number }
33
+ > = {};
34
+ let grandTotal = 0;
35
+ let manualTotal = 0;
36
+ let calculatedTotal = 0;
37
+
38
+ let productionTotal = 0;
39
+ let productionCount = 0;
40
+ let stornoTotal = 0;
41
+ let stornoCount = 0;
42
+ let commissionTotal = 0;
43
+ let commissionCount = 0;
44
+
45
+ for (const entry of entries) {
46
+ if (!byType[entry.type]) {
47
+ byType[entry.type] = { count: 0, total: 0, manualCount: 0 };
48
+ }
49
+ byType[entry.type].count += 1;
50
+ byType[entry.type].total += entry.amount;
51
+
52
+ if (entry.isManual) {
53
+ byType[entry.type].manualCount += 1;
54
+ manualTotal += entry.amount;
55
+ } else {
56
+ calculatedTotal += entry.amount;
57
+
58
+ if (entry.type === "production") {
59
+ productionTotal += entry.amount;
60
+ productionCount++;
61
+ } else if (entry.type === "storno") {
62
+ stornoTotal += entry.amount;
63
+ stornoCount++;
64
+ } else if (entry.type === "commission") {
65
+ commissionTotal += entry.amount;
66
+ commissionCount++;
67
+ }
68
+ }
69
+
70
+ grandTotal += entry.amount;
71
+ }
72
+
73
+ const ruleApplications = await ctx.db
74
+ .query("ruleApplications")
75
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
76
+ .collect();
77
+
78
+ const entryIds = new Set(entries.map((e) => e._id));
79
+ const userRuleApplications = ruleApplications.filter(
80
+ (ra) =>
81
+ entryIds.has(ra.sourceLedgerEntryId) ||
82
+ entryIds.has(ra.generatedLedgerEntryId),
83
+ );
84
+
85
+ return {
86
+ runId: args.runId,
87
+ userId: args.userId,
88
+ breakdown: byType,
89
+ production: {
90
+ total: productionTotal,
91
+ count: productionCount,
92
+ stornoTotal,
93
+ stornoCount,
94
+ netProduction: productionTotal + stornoTotal,
95
+ },
96
+ commissions: {
97
+ total: commissionTotal,
98
+ count: commissionCount,
99
+ },
100
+ grandTotal,
101
+ manualTotal,
102
+ calculatedTotal,
103
+ entryCount: entries.length,
104
+ rulesApplied: userRuleApplications.length,
105
+ entries,
106
+ ruleApplications: userRuleApplications,
107
+ };
108
+ },
109
+ });
110
+
111
+ export const getSummaryByRun = query({
112
+ args: { runId: v.id("compensationRuns") },
113
+ handler: async (ctx, args) => {
114
+ const entries = await ctx.db
115
+ .query("ledgerEntries")
116
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
117
+ .collect();
118
+
119
+ const byUser: Record<
120
+ string,
121
+ {
122
+ total: number;
123
+ entryCount: number;
124
+ productionTotal: number;
125
+ stornoTotal: number;
126
+ netProduction: number;
127
+ commissionTotal: number;
128
+ commissionCount: number;
129
+ manualTotal: number;
130
+ calculatedTotal: number;
131
+ }
132
+ > = {};
133
+
134
+ for (const entry of entries) {
135
+ if (!byUser[entry.userId]) {
136
+ byUser[entry.userId] = {
137
+ total: 0,
138
+ entryCount: 0,
139
+ productionTotal: 0,
140
+ stornoTotal: 0,
141
+ netProduction: 0,
142
+ commissionTotal: 0,
143
+ commissionCount: 0,
144
+ manualTotal: 0,
145
+ calculatedTotal: 0,
146
+ };
147
+ }
148
+
149
+ const u = byUser[entry.userId];
150
+ u.total += entry.amount;
151
+ u.entryCount += 1;
152
+
153
+ if (entry.isManual) {
154
+ u.manualTotal += entry.amount;
155
+ } else {
156
+ u.calculatedTotal += entry.amount;
157
+ if (entry.type === "production") {
158
+ u.productionTotal += entry.amount;
159
+ } else if (entry.type === "storno") {
160
+ u.stornoTotal += entry.amount;
161
+ } else if (entry.type === "commission") {
162
+ u.commissionTotal += entry.amount;
163
+ u.commissionCount++;
164
+ }
165
+ }
166
+ u.netProduction = u.productionTotal + u.stornoTotal;
167
+ }
168
+
169
+ let grandTotal = 0;
170
+ let totalCommissions = 0;
171
+ for (const u of Object.values(byUser)) {
172
+ grandTotal += u.total;
173
+ totalCommissions += u.commissionTotal;
174
+ }
175
+
176
+ return {
177
+ runId: args.runId,
178
+ users: byUser,
179
+ totalUsers: Object.keys(byUser).length,
180
+ totalEntries: entries.length,
181
+ grandTotal,
182
+ totalCommissions,
183
+ };
184
+ },
185
+ });
@@ -0,0 +1,314 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "../_generated/server";
3
+ import { ledgerEntryType, sourceType } from "../schema";
4
+
5
+ /**
6
+ * CRUD per le righe del ledger compensi — entità CORE del sistema.
7
+ *
8
+ * Da analisi.md:
9
+ * "Il sistema NON salva solo totali.
10
+ * Ogni mese per medico viene costruito un ledger di righe."
11
+ *
12
+ * "Questo ledger è la fonte di verità per: prospetto, fattura, audit, storico"
13
+ *
14
+ * Rimborsi e rettifiche sono normali righe del ledger con isManual=true,
15
+ * categoria e motivazione nel metadata.
16
+ *
17
+ * Le righe calcolate hanno isManual=false e referenziano planId/ruleId.
18
+ */
19
+
20
+ /**
21
+ * Verifica che il run esista e sia in uno stato che ammette scritture.
22
+ * Blocca: closed (immutabile) e approved (pre-chiusura, no modifiche).
23
+ */
24
+ async function assertRunWritableForLedger(
25
+ ctx: { db: { get: (id: any) => Promise<any> } },
26
+ runId: any,
27
+ ): Promise<void> {
28
+ const run = await ctx.db.get(runId);
29
+ if (!run) throw new Error("Run non trovato");
30
+ if (run.status === "closed") {
31
+ throw new Error(
32
+ "Impossibile modificare righe di un run chiuso. " +
33
+ "I dati di un run chiuso sono congelati.",
34
+ );
35
+ }
36
+ if (run.status === "approved") {
37
+ throw new Error(
38
+ "Impossibile modificare righe di un run approvato. " +
39
+ "Riportare il run in stato precedente prima di modificare.",
40
+ );
41
+ }
42
+ }
43
+
44
+ export const create = mutation({
45
+ args: {
46
+ runId: v.id("compensationRuns"),
47
+ userId: v.string(),
48
+ clinicId: v.string(),
49
+ type: ledgerEntryType,
50
+ sourceType: sourceType,
51
+ sourceId: v.optional(v.string()),
52
+ planId: v.optional(v.id("commissionPlans")),
53
+ ruleId: v.optional(v.id("commissionRules")),
54
+ description: v.string(),
55
+ amount: v.number(),
56
+ quantity: v.optional(v.number()),
57
+ unitAmount: v.optional(v.number()),
58
+ isManual: v.boolean(),
59
+ effectiveDate: v.number(),
60
+ period: v.string(),
61
+ metadata: v.optional(v.any()),
62
+ createdBy: v.string(),
63
+ },
64
+ handler: async (ctx, args) => {
65
+ await assertRunWritableForLedger(ctx, args.runId);
66
+
67
+ const { createdBy, ...entryData } = args;
68
+
69
+ const entryId = await ctx.db.insert("ledgerEntries", {
70
+ ...entryData,
71
+ createdBy,
72
+ createdAt: Date.now(),
73
+ });
74
+
75
+ await ctx.db.insert("auditLogs", {
76
+ entityType: "ledgerEntries",
77
+ entityId: entryId,
78
+ action: "created",
79
+ userId: createdBy,
80
+ payload: {
81
+ type: args.type,
82
+ amount: args.amount,
83
+ isManual: args.isManual,
84
+ },
85
+ createdAt: Date.now(),
86
+ });
87
+
88
+ return entryId;
89
+ },
90
+ });
91
+
92
+ /**
93
+ * Inserimento batch — usato dall'orchestratore per scrivere
94
+ * molte righe calcolate in un'unica mutation.
95
+ */
96
+ export const createBatch = mutation({
97
+ args: {
98
+ entries: v.array(
99
+ v.object({
100
+ runId: v.id("compensationRuns"),
101
+ userId: v.string(),
102
+ clinicId: v.string(),
103
+ type: ledgerEntryType,
104
+ sourceType: sourceType,
105
+ sourceId: v.optional(v.string()),
106
+ planId: v.optional(v.id("commissionPlans")),
107
+ ruleId: v.optional(v.id("commissionRules")),
108
+ description: v.string(),
109
+ amount: v.number(),
110
+ quantity: v.optional(v.number()),
111
+ unitAmount: v.optional(v.number()),
112
+ isManual: v.boolean(),
113
+ effectiveDate: v.number(),
114
+ period: v.string(),
115
+ metadata: v.optional(v.any()),
116
+ createdBy: v.string(),
117
+ }),
118
+ ),
119
+ },
120
+ handler: async (ctx, args) => {
121
+ if (args.entries.length > 0) {
122
+ await assertRunWritableForLedger(ctx, args.entries[0].runId);
123
+ }
124
+
125
+ const now = Date.now();
126
+ const ids: string[] = [];
127
+
128
+ for (const entry of args.entries) {
129
+ const { createdBy, ...entryData } = entry;
130
+ const id = await ctx.db.insert("ledgerEntries", {
131
+ ...entryData,
132
+ createdBy,
133
+ createdAt: now,
134
+ });
135
+ ids.push(id);
136
+ }
137
+
138
+ return ids;
139
+ },
140
+ });
141
+
142
+ /**
143
+ * Cancella tutte le righe NON manuali di un run.
144
+ * Usato prima del ricalcolo per rigenerare le righe calcolate
145
+ * senza perdere rimborsi/rettifiche inseriti manualmente.
146
+ */
147
+ export const deleteCalculatedByRun = mutation({
148
+ args: {
149
+ runId: v.id("compensationRuns"),
150
+ userId: v.string(),
151
+ },
152
+ handler: async (ctx, args) => {
153
+ await assertRunWritableForLedger(ctx, args.runId);
154
+
155
+ const entries = await ctx.db
156
+ .query("ledgerEntries")
157
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
158
+ .collect();
159
+
160
+ let count = 0;
161
+ for (const entry of entries) {
162
+ if (!entry.isManual) {
163
+ await ctx.db.delete(entry._id);
164
+ count++;
165
+ }
166
+ }
167
+
168
+ await ctx.db.insert("auditLogs", {
169
+ entityType: "compensationRuns",
170
+ entityId: args.runId,
171
+ action: "calculated_entries_cleared",
172
+ userId: args.userId,
173
+ payload: { deletedCount: count },
174
+ createdAt: Date.now(),
175
+ });
176
+
177
+ return { deletedCount: count };
178
+ },
179
+ });
180
+
181
+ export const listByRun = query({
182
+ args: { runId: v.id("compensationRuns") },
183
+ handler: async (ctx, args) => {
184
+ return await ctx.db
185
+ .query("ledgerEntries")
186
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
187
+ .collect();
188
+ },
189
+ });
190
+
191
+ export const listByRunAndUser = query({
192
+ args: {
193
+ runId: v.id("compensationRuns"),
194
+ userId: v.string(),
195
+ },
196
+ handler: async (ctx, args) => {
197
+ return await ctx.db
198
+ .query("ledgerEntries")
199
+ .withIndex("by_run_user", (q) =>
200
+ q.eq("runId", args.runId).eq("userId", args.userId),
201
+ )
202
+ .collect();
203
+ },
204
+ });
205
+
206
+ export const listByRunUserClinic = query({
207
+ args: {
208
+ runId: v.id("compensationRuns"),
209
+ userId: v.string(),
210
+ clinicId: v.string(),
211
+ },
212
+ handler: async (ctx, args) => {
213
+ return await ctx.db
214
+ .query("ledgerEntries")
215
+ .withIndex("by_run_user_clinic", (q) =>
216
+ q
217
+ .eq("runId", args.runId)
218
+ .eq("userId", args.userId)
219
+ .eq("clinicId", args.clinicId),
220
+ )
221
+ .collect();
222
+ },
223
+ });
224
+
225
+ export const listByRunAndType = query({
226
+ args: {
227
+ runId: v.id("compensationRuns"),
228
+ type: ledgerEntryType,
229
+ },
230
+ handler: async (ctx, args) => {
231
+ return await ctx.db
232
+ .query("ledgerEntries")
233
+ .withIndex("by_run_type", (q) =>
234
+ q.eq("runId", args.runId).eq("type", args.type),
235
+ )
236
+ .collect();
237
+ },
238
+ });
239
+
240
+ export const listByPeriodAndUser = query({
241
+ args: {
242
+ period: v.string(),
243
+ userId: v.string(),
244
+ },
245
+ handler: async (ctx, args) => {
246
+ return await ctx.db
247
+ .query("ledgerEntries")
248
+ .withIndex("by_period_user", (q) =>
249
+ q.eq("period", args.period).eq("userId", args.userId),
250
+ )
251
+ .collect();
252
+ },
253
+ });
254
+
255
+ export const update = mutation({
256
+ args: {
257
+ entryId: v.id("ledgerEntries"),
258
+ description: v.optional(v.string()),
259
+ amount: v.optional(v.number()),
260
+ metadata: v.optional(v.any()),
261
+ userId: v.string(),
262
+ },
263
+ handler: async (ctx, args) => {
264
+ const entry = await ctx.db.get(args.entryId);
265
+ if (!entry) throw new Error("Entry non trovata");
266
+
267
+ await assertRunWritableForLedger(ctx, entry.runId);
268
+
269
+ const { entryId, userId, ...fields } = args;
270
+ const patch: Record<string, unknown> = {};
271
+ for (const [k, val] of Object.entries(fields)) {
272
+ if (val !== undefined) patch[k] = val;
273
+ }
274
+
275
+ await ctx.db.patch(entryId, patch);
276
+
277
+ await ctx.db.insert("auditLogs", {
278
+ entityType: "ledgerEntries",
279
+ entityId: entryId,
280
+ action: "updated",
281
+ userId,
282
+ payload: patch,
283
+ createdAt: Date.now(),
284
+ });
285
+ },
286
+ });
287
+
288
+ export const remove = mutation({
289
+ args: {
290
+ entryId: v.id("ledgerEntries"),
291
+ userId: v.string(),
292
+ },
293
+ handler: async (ctx, args) => {
294
+ const entry = await ctx.db.get(args.entryId);
295
+ if (!entry) throw new Error("Entry non trovata");
296
+
297
+ await assertRunWritableForLedger(ctx, entry.runId);
298
+
299
+ await ctx.db.delete(args.entryId);
300
+
301
+ await ctx.db.insert("auditLogs", {
302
+ entityType: "ledgerEntries",
303
+ entityId: args.entryId,
304
+ action: "deleted",
305
+ userId: args.userId,
306
+ payload: {
307
+ type: entry.type,
308
+ amount: entry.amount,
309
+ runId: entry.runId,
310
+ },
311
+ createdAt: Date.now(),
312
+ });
313
+ },
314
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tipi di dominio condivisi per il Componente Compensi Medici.
3
+ * Allineati con lo schema Convex e con analisi.md.
4
+ */
5
+
6
+ // ── Piani provvigionali ──────────────────────────────────
7
+
8
+ export type PlanStatus = "draft" | "active" | "archived";
9
+
10
+ export type RuleType = "percentage" | "fixed" | "token" | "combined";
11
+
12
+ export type RuleOutputType = "commission" | "fixed" | "bonus";
13
+
14
+ // ── Workflow mese ────────────────────────────────────────
15
+
16
+ export type RunStatus =
17
+ | "draft"
18
+ | "calculated"
19
+ | "reviewing"
20
+ | "approved"
21
+ | "closed";
22
+
23
+ export const VALID_TRANSITIONS: Record<RunStatus, RunStatus[]> = {
24
+ draft: ["calculated"],
25
+ calculated: ["reviewing", "draft"],
26
+ reviewing: ["approved", "calculated"],
27
+ approved: ["closed", "reviewing"],
28
+ closed: [],
29
+ };
30
+
31
+ export type ScopeType = "global" | "users" | "clinics" | "user_clinic_pairs";
32
+
33
+ export type CalculationMode = "manual" | "scheduled" | "reopen";
34
+
35
+ // ── Ledger ───────────────────────────────────────────────
36
+
37
+ export type LedgerEntryType =
38
+ | "production"
39
+ | "storno"
40
+ | "commission"
41
+ | "fixed"
42
+ | "bonus"
43
+ | "reimbursement"
44
+ | "adjustment";
45
+
46
+ export type SourceType = "care_plan_row" | "storno" | "manual" | "system";
47
+
48
+ // ── Commissioni fisse ────────────────────────────────────
49
+
50
+ export type FixedCommissionBehavior =
51
+ | "minimum_guaranteed"
52
+ | "additive"
53
+ | "substitutive"
54
+ | "substitutive_no_wdays";
55
+
56
+ // ── Scope commissioni ────────────────────────────────────
57
+
58
+ export type CommissionScope =
59
+ | "completed_rows"
60
+ | "confirmed_care_plan"
61
+ | "fixed";
62
+
63
+ // ── Documenti ────────────────────────────────────────────
64
+
65
+ export type InvoiceStatus = "draft" | "ready" | "sent" | "cancelled";
66
+
67
+ export type OutsideCompanyType = "DS" | "Est" | "Est_forfettari" | "None";
68
+
69
+ // ── Categorie ────────────────────────────────────────────
70
+
71
+ export type CategoryKind = "reimbursement" | "adjustment";
72
+
73
+ // ── Anomalie ─────────────────────────────────────────────
74
+
75
+ export type IssueSeverity = "error" | "warning" | "info";
76
+
77
+ export type IssueType =
78
+ | "no_active_plan"
79
+ | "no_matching_rule"
80
+ | "no_tax_profile"
81
+ | "multiple_tax_profiles"
82
+ | "inactive_tax_profile"
83
+ | "fiscal_anomaly"
84
+ | "missing_invoices";
85
+
86
+ // ── Formato periodo ──────────────────────────────────────
87
+
88
+ /** Formato: YYYY-MM */
89
+ export type Period = string;
90
+
91
+ // ── Production entry estesa ──────────────────────────────
92
+
93
+ export interface ExtendedProductionData {
94
+ netPrice: number;
95
+ listPrice: number;
96
+ labCost: number;
97
+ labPrice: number;
98
+ cprPercentage: number;
99
+ companyId?: string;
100
+ carePlanTypeId?: number;
101
+ isInsurance: boolean;
102
+ conventionName?: string;
103
+ serviceCategoryName?: string;
104
+ isFirstPhase: boolean;
105
+ refactorRowId?: string;
106
+ refactorCalculated?: number;
107
+ refactorCalculatedConfirmedCarePlan?: number;
108
+ listRowId?: string;
109
+ listId?: string;
110
+ listName?: string;
111
+ listEnabled?: boolean;
112
+ listRowEnabled?: boolean;
113
+ carePlanRowTypeId?: number;
114
+ isInvoiceable: boolean;
115
+ isInvoiceableAt?: number;
116
+ completedBy?: string;
117
+ doctorId?: string;
118
+ conventionId?: string;
119
+ sourceId?: string;
120
+ serviceRegistryId?: string;
121
+ serviceRegistryGroupId?: string;
122
+ serviceCategoryId?: string;
123
+ commissionProfileId?: string;
124
+ scope: CommissionScope;
125
+ invoiceVat?: number;
126
+ }