@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,217 @@
1
+ /**
2
+ * Commissioni fisse — traduzione da create_fixed_commission.py
3
+ *
4
+ * 4 comportamenti configurabili (dal campo `behavior` di fixedCommissionTypes):
5
+ *
6
+ * 1. minimum_guaranteed (vecchi tipi 1,6): max(produzione_invoiceable, fisso * wdays).
7
+ * Se produzione < fisso*wdays, genera riga di adjust + riga opposta (storno surplus).
8
+ *
9
+ * 2. additive (vecchio tipo 2): fisso*wdays aggiunto sempre alla produzione.
10
+ *
11
+ * 3. substitutive (vecchio tipo 3): fisso*wdays sostituisce la produzione.
12
+ * La produzione viene azzerata, il fisso calcolato come adjust.
13
+ *
14
+ * 4. substitutive_no_wdays (vecchio tipo 4): come substitutive ma senza moltiplicare per wdays.
15
+ */
16
+
17
+ import type { Id } from "../_generated/dataModel";
18
+ import type { FixedCommissionBehavior } from "../lib/types";
19
+
20
+ export interface FixedCommissionRule {
21
+ userId: string;
22
+ clinicId: string;
23
+ amount: number;
24
+ typeId: Id<"fixedCommissionTypes">;
25
+ behavior: FixedCommissionBehavior;
26
+ multipliedByWdays: boolean;
27
+ }
28
+
29
+ export interface WorkingDaysEntry {
30
+ userId: string;
31
+ clinicId: string;
32
+ days: number;
33
+ }
34
+
35
+ export interface CommissionSummaryForFixed {
36
+ userId: string;
37
+ clinicId: string;
38
+ period: string;
39
+ invoiceableTotal: number;
40
+ allTotal: number;
41
+ hasRefactorSameMonth: boolean;
42
+ }
43
+
44
+ export interface FixedAdjustResult {
45
+ userId: string;
46
+ clinicId: string;
47
+ fixedTypeId: Id<"fixedCommissionTypes">;
48
+ behavior: FixedCommissionBehavior;
49
+ fixedAmount: number;
50
+ adjustAmount: number;
51
+ oppositeAmount: number;
52
+ explanation: string;
53
+ }
54
+
55
+ /**
56
+ * Calcola i giorni lavorativi per un utente/clinica nel periodo.
57
+ *
58
+ * Dal Python (get_wdays.sql): conta i giorni distinti in cui il medico
59
+ * ha appuntamenti per almeno 4 ore nella clinica durante il mese.
60
+ *
61
+ * In Convex i dati degli appuntamenti sono nel DB principale.
62
+ * Questa funzione è un placeholder che riceve i dati pre-calcolati.
63
+ */
64
+ export function getWorkingDays(
65
+ wdaysData: WorkingDaysEntry[],
66
+ userId: string,
67
+ clinicId: string,
68
+ ): number {
69
+ const entry = wdaysData.find((w) => w.userId === userId && w.clinicId === clinicId);
70
+ return entry?.days ?? 0;
71
+ }
72
+
73
+ /**
74
+ * Calcola gli adjust delle commissioni fisse rispetto alla produzione variabile.
75
+ *
76
+ * Per ogni coppia userId+clinicId con regole fisse:
77
+ * 1. Calcola il fisso totale (amount * wdays se applicabile)
78
+ * 2. Confronta con la produzione invoiceable
79
+ * 3. Genera le righe di adjust in base al behavior
80
+ */
81
+ export function adjustFixedCommissions(
82
+ commissionSummaries: CommissionSummaryForFixed[],
83
+ fixedRules: FixedCommissionRule[],
84
+ wdaysData: WorkingDaysEntry[],
85
+ ignoreAllRulesUserIds: Set<string>,
86
+ ): FixedAdjustResult[] {
87
+ const results: FixedAdjustResult[] = [];
88
+
89
+ const groupedRules = groupByUserClinic(fixedRules);
90
+
91
+ for (const [key, rules] of groupedRules) {
92
+ const [userId, clinicId] = key.split("|");
93
+
94
+ if (rules.length > 1) {
95
+ const behaviors = new Set(rules.map((r) => r.behavior));
96
+ if (behaviors.size > 1) {
97
+ console.warn(`Regole fisse multiple con behavior diversi per user=${userId} clinic=${clinicId}. Salto.`);
98
+ continue;
99
+ }
100
+ }
101
+
102
+ const rule = rules[0];
103
+ const totalFixedAmount = rules.reduce((sum, r) => sum + r.amount, 0);
104
+ const wdays = getWorkingDays(wdaysData, userId, clinicId);
105
+
106
+ const shouldMultiply = rule.multipliedByWdays;
107
+ const fixedCalculated = shouldMultiply ? totalFixedAmount * wdays : totalFixedAmount;
108
+
109
+ const summary = commissionSummaries.find(
110
+ (s) => s.userId === userId && s.clinicId === clinicId,
111
+ );
112
+ const invoiceableTotal = summary?.invoiceableTotal ?? 0;
113
+ const allTotal = summary?.allTotal ?? 0;
114
+
115
+ const isIgnoreAll = ignoreAllRulesUserIds.has(userId);
116
+
117
+ const result = calculateAdjust(
118
+ userId, clinicId, rule.typeId, rule.behavior,
119
+ fixedCalculated, invoiceableTotal, allTotal,
120
+ isIgnoreAll, summary?.hasRefactorSameMonth ?? false,
121
+ );
122
+
123
+ if (result) {
124
+ results.push(result);
125
+ }
126
+ }
127
+
128
+ return results;
129
+ }
130
+
131
+ function calculateAdjust(
132
+ userId: string,
133
+ clinicId: string,
134
+ fixedTypeId: Id<"fixedCommissionTypes">,
135
+ behavior: FixedCommissionBehavior,
136
+ fixedCalculated: number,
137
+ invoiceableTotal: number,
138
+ allTotal: number,
139
+ isIgnoreAll: boolean,
140
+ hasRefactorSameMonth: boolean,
141
+ ): FixedAdjustResult | null {
142
+ if (isIgnoreAll) {
143
+ const adjustAmount = fixedCalculated - allTotal;
144
+ return {
145
+ userId, clinicId, fixedTypeId, behavior: "substitutive_no_wdays",
146
+ fixedAmount: fixedCalculated,
147
+ adjustAmount,
148
+ oppositeAmount: 0,
149
+ explanation: `Utente con ignore_all_rules: fisso ${fixedCalculated} - produzione ${allTotal} = adjust ${adjustAmount}`,
150
+ };
151
+ }
152
+
153
+ switch (behavior) {
154
+ case "minimum_guaranteed": {
155
+ const effectiveTotal = Math.max(invoiceableTotal, fixedCalculated);
156
+ const adjustAmount = effectiveTotal - invoiceableTotal;
157
+ const oppositeAmount = adjustAmount > 0 ? 0 : Math.abs(fixedCalculated - invoiceableTotal);
158
+
159
+ if (adjustAmount === 0 && oppositeAmount === 0) return null;
160
+
161
+ return {
162
+ userId, clinicId, fixedTypeId, behavior, fixedAmount: fixedCalculated,
163
+ adjustAmount,
164
+ oppositeAmount: invoiceableTotal > fixedCalculated && fixedCalculated > 0 ? 0 : oppositeAmount,
165
+ explanation: `Minimo garantito: max(${invoiceableTotal}, ${fixedCalculated}) = ${effectiveTotal}. Adjust = ${adjustAmount}`,
166
+ };
167
+ }
168
+
169
+ case "additive": {
170
+ return {
171
+ userId, clinicId, fixedTypeId, behavior, fixedAmount: fixedCalculated,
172
+ adjustAmount: fixedCalculated,
173
+ oppositeAmount: 0,
174
+ explanation: `Aggiuntivo: fisso ${fixedCalculated} aggiunto alla produzione`,
175
+ };
176
+ }
177
+
178
+ case "substitutive": {
179
+ const adjustAmount = fixedCalculated - invoiceableTotal;
180
+ return {
181
+ userId, clinicId, fixedTypeId, behavior, fixedAmount: fixedCalculated,
182
+ adjustAmount,
183
+ oppositeAmount: 0,
184
+ explanation: `Sostitutivo (con wdays): fisso ${fixedCalculated} - produzione ${invoiceableTotal} = adjust ${adjustAmount}`,
185
+ };
186
+ }
187
+
188
+ case "substitutive_no_wdays": {
189
+ const adjustAmount = fixedCalculated - invoiceableTotal;
190
+ return {
191
+ userId, clinicId, fixedTypeId, behavior, fixedAmount: fixedCalculated,
192
+ adjustAmount,
193
+ oppositeAmount: 0,
194
+ explanation: `Sostitutivo semplice: fisso ${fixedCalculated} - produzione ${invoiceableTotal} = adjust ${adjustAmount}`,
195
+ };
196
+ }
197
+
198
+ default:
199
+ return null;
200
+ }
201
+ }
202
+
203
+ function groupByUserClinic(
204
+ rules: FixedCommissionRule[],
205
+ ): Map<string, FixedCommissionRule[]> {
206
+ const map = new Map<string, FixedCommissionRule[]>();
207
+ for (const rule of rules) {
208
+ const key = `${rule.userId}|${rule.clinicId}`;
209
+ const existing = map.get(key);
210
+ if (existing) {
211
+ existing.push(rule);
212
+ } else {
213
+ map.set(key, [rule]);
214
+ }
215
+ }
216
+ return map;
217
+ }
@@ -0,0 +1,314 @@
1
+ import { v } from "convex/values";
2
+ import { action, internalMutation } from "../_generated/server";
3
+ import { internal } from "../_generated/api";
4
+ import {
5
+ productionSourceRecordValidator,
6
+ mapSourceToLedgerEntry,
7
+ fetchProductionForPeriod,
8
+ type ProductionSourceRecord,
9
+ } from "./productionData";
10
+ import {
11
+ adjustFixedCommissions,
12
+ type FixedCommissionRule,
13
+ type WorkingDaysEntry,
14
+ type CommissionSummaryForFixed,
15
+ } from "./fixedCommissions";
16
+
17
+ /**
18
+ * Orchestratore del run mensile — equivalente della Lambda Python.
19
+ *
20
+ * Flusso completo:
21
+ * 1. Crea/aggiorna run per il periodo
22
+ * 2. Ingest produzione dal database PrimoUpCore
23
+ * 3. Calcola commissioni variabili (con deduzioni, lab, Invisalign, rifacimenti, dual-scope)
24
+ * 4. Carica e inserisce commissioni fisse
25
+ * 5. Adjust fissi (tipi 1-6 con wdays)
26
+ * 6. Genera fatture commissione (con company mapping + calcoli fiscali)
27
+ * 7. Audit completo
28
+ */
29
+
30
+ // ── Internal mutation: clear + ingest produzione ─────────
31
+
32
+ export const _refreshProductionInternal = internalMutation({
33
+ args: {
34
+ runId: v.id("compensationRuns"),
35
+ sourceRecords: v.array(productionSourceRecordValidator),
36
+ userId: v.string(),
37
+ },
38
+ handler: async (ctx, args) => {
39
+ const run = await ctx.db.get(args.runId);
40
+ if (!run) throw new Error("Run non trovato");
41
+ if (run.status === "closed" || run.status === "approved") {
42
+ throw new Error(`Impossibile aggiornare produzione: run in stato "${run.status}"`);
43
+ }
44
+
45
+ const existing = await ctx.db
46
+ .query("ledgerEntries")
47
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
48
+ .collect();
49
+
50
+ let clearedCount = 0;
51
+ for (const entry of existing) {
52
+ if (!entry.isManual && (entry.type === "production" || entry.type === "storno")) {
53
+ await ctx.db.delete(entry._id);
54
+ clearedCount++;
55
+ }
56
+ }
57
+
58
+ const now = Date.now();
59
+ let productionCount = 0;
60
+ let stornoCount = 0;
61
+ let totalAmount = 0;
62
+
63
+ for (const source of args.sourceRecords) {
64
+ if (source.period !== run.period) {
65
+ throw new Error(`Periodo non corrispondente: record=${source.period}, run=${run.period}`);
66
+ }
67
+
68
+ const entry = mapSourceToLedgerEntry(source as ProductionSourceRecord, args.runId, args.userId);
69
+ await ctx.db.insert("ledgerEntries", { ...entry, createdAt: now });
70
+
71
+ totalAmount += source.amount;
72
+ if (source.isStorno) stornoCount++;
73
+ else productionCount++;
74
+ }
75
+
76
+ await ctx.db.insert("auditLogs", {
77
+ entityType: "compensationRuns",
78
+ entityId: args.runId,
79
+ action: "production_refreshed",
80
+ userId: args.userId,
81
+ payload: { clearedCount, productionCount, stornoCount, totalRecords: args.sourceRecords.length, totalAmount },
82
+ createdAt: now,
83
+ });
84
+
85
+ return { clearedCount, insertedCount: args.sourceRecords.length, productionCount, stornoCount, totalAmount };
86
+ },
87
+ });
88
+
89
+ // ── Internal mutation: insert fixed commissions ──────────
90
+
91
+ export const _insertFixedCommissionsInternal = internalMutation({
92
+ args: {
93
+ runId: v.id("compensationRuns"),
94
+ userId: v.string(),
95
+ fixedEntries: v.array(v.object({
96
+ userId: v.string(),
97
+ clinicId: v.string(),
98
+ amount: v.number(),
99
+ fixedTypeId: v.string(),
100
+ behavior: v.string(),
101
+ description: v.string(),
102
+ period: v.string(),
103
+ companyId: v.optional(v.string()),
104
+ })),
105
+ },
106
+ handler: async (ctx, args) => {
107
+ const now = Date.now();
108
+ let count = 0;
109
+
110
+ const existingEntries = await ctx.db
111
+ .query("ledgerEntries")
112
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
113
+ .collect();
114
+
115
+ for (const entry of existingEntries) {
116
+ if (!entry.isManual && entry.type === "fixed") {
117
+ await ctx.db.delete(entry._id);
118
+ }
119
+ }
120
+
121
+ for (const fe of args.fixedEntries) {
122
+ await ctx.db.insert("ledgerEntries", {
123
+ runId: args.runId,
124
+ userId: fe.userId,
125
+ clinicId: fe.clinicId,
126
+ type: "fixed",
127
+ sourceType: "system",
128
+ scope: "fixed",
129
+ fixedCommissionTypeId: fe.fixedTypeId as any,
130
+ companyId: fe.companyId,
131
+ description: fe.description,
132
+ amount: fe.amount,
133
+ quantity: 1,
134
+ unitAmount: fe.amount,
135
+ isManual: false,
136
+ effectiveDate: now,
137
+ period: fe.period,
138
+ metadata: { behavior: fe.behavior, fixedTypeId: fe.fixedTypeId },
139
+ createdBy: args.userId,
140
+ createdAt: now,
141
+ });
142
+ count++;
143
+ }
144
+
145
+ await ctx.db.insert("auditLogs", {
146
+ entityType: "compensationRuns",
147
+ entityId: args.runId,
148
+ action: "fixed_commissions_inserted",
149
+ userId: args.userId,
150
+ payload: { count },
151
+ createdAt: now,
152
+ });
153
+
154
+ return { insertedCount: count };
155
+ },
156
+ });
157
+
158
+ // ── Action: fetch + ingest produzione ────────────────────
159
+
160
+ export const fetchAndIngestProduction = action({
161
+ args: {
162
+ runId: v.id("compensationRuns"),
163
+ userId: v.string(),
164
+ },
165
+ handler: async (ctx, args) => {
166
+ const run = await ctx.runQuery(internal.workflow.runs._getInternal, { runId: args.runId });
167
+ if (!run) throw new Error("Run non trovato");
168
+
169
+ let scopeUserIds: string[] | undefined;
170
+ let scopeClinicIds: string[] | undefined;
171
+
172
+ if (run.scopeType === "users" && run.scopePayload?.userIds) {
173
+ scopeUserIds = run.scopePayload.userIds;
174
+ } else if (run.scopeType === "clinics" && run.scopePayload?.clinicIds) {
175
+ scopeClinicIds = run.scopePayload.clinicIds;
176
+ }
177
+
178
+ const sourceRecords = await fetchProductionForPeriod(run.period, scopeUserIds, scopeClinicIds);
179
+
180
+ const result = await ctx.runMutation(
181
+ internal.calculation.orchestrator._refreshProductionInternal,
182
+ { runId: args.runId, sourceRecords, userId: args.userId },
183
+ );
184
+
185
+ return { runId: args.runId, period: run.period, isShadowRun: run.isShadowRun, ...result };
186
+ },
187
+ });
188
+
189
+ // ── Action: calcolo completo (equivalente lambda_handler) ──
190
+
191
+ export const executeFullCalculation = action({
192
+ args: {
193
+ runId: v.id("compensationRuns"),
194
+ userId: v.string(),
195
+ fixedCommissionRules: v.optional(v.array(v.object({
196
+ userId: v.string(),
197
+ clinicId: v.string(),
198
+ amount: v.number(),
199
+ typeId: v.string(),
200
+ behavior: v.string(),
201
+ multipliedByWdays: v.boolean(),
202
+ }))),
203
+ workingDays: v.optional(v.array(v.object({
204
+ userId: v.string(),
205
+ clinicId: v.string(),
206
+ days: v.number(),
207
+ }))),
208
+ ignoreAllRulesUserIds: v.optional(v.array(v.string())),
209
+ },
210
+ handler: async (ctx, args) => {
211
+ const run = await ctx.runQuery(internal.workflow.runs._getInternal, { runId: args.runId });
212
+ if (!run) throw new Error("Run non trovato");
213
+
214
+ let scopeUserIds: string[] | undefined;
215
+ let scopeClinicIds: string[] | undefined;
216
+
217
+ if (run.scopeType === "users" && run.scopePayload?.userIds) {
218
+ scopeUserIds = run.scopePayload.userIds;
219
+ } else if (run.scopeType === "clinics" && run.scopePayload?.clinicIds) {
220
+ scopeClinicIds = run.scopePayload.clinicIds;
221
+ }
222
+
223
+ // Step 1: Fetch + ingest produzione
224
+ const sourceRecords = await fetchProductionForPeriod(run.period, scopeUserIds, scopeClinicIds);
225
+ const productionResult = await ctx.runMutation(
226
+ internal.calculation.orchestrator._refreshProductionInternal,
227
+ { runId: args.runId, sourceRecords, userId: args.userId },
228
+ );
229
+
230
+ // Step 2: Calcola commissioni variabili (con dual-scope)
231
+ const commissionResult = await ctx.runMutation(
232
+ internal.calculation.commissions._refreshCommissionsInternal,
233
+ { runId: args.runId, userId: args.userId },
234
+ );
235
+
236
+ // Step 3: Commissioni fisse + adjust
237
+ let fixedResult = { insertedCount: 0, adjustedCount: 0 };
238
+
239
+ if (args.fixedCommissionRules && args.fixedCommissionRules.length > 0) {
240
+ const fixedRules: FixedCommissionRule[] = args.fixedCommissionRules.map((r) => ({
241
+ userId: r.userId,
242
+ clinicId: r.clinicId,
243
+ amount: r.amount,
244
+ typeId: r.typeId as any,
245
+ behavior: r.behavior as any,
246
+ multipliedByWdays: r.multipliedByWdays,
247
+ }));
248
+
249
+ const wdaysData: WorkingDaysEntry[] = args.workingDays ?? [];
250
+ const ignoreSet = new Set(args.ignoreAllRulesUserIds ?? []);
251
+
252
+ const commissionSummaries: CommissionSummaryForFixed[] = buildSummariesFromCommissions(commissionResult);
253
+
254
+ const adjustResults = adjustFixedCommissions(commissionSummaries, fixedRules, wdaysData, ignoreSet);
255
+
256
+ const fixedEntries = adjustResults
257
+ .filter((r) => r.adjustAmount !== 0 || r.oppositeAmount !== 0)
258
+ .flatMap((r) => {
259
+ const entries = [];
260
+ if (r.adjustAmount !== 0) {
261
+ entries.push({
262
+ userId: r.userId,
263
+ clinicId: r.clinicId,
264
+ amount: r.adjustAmount,
265
+ fixedTypeId: r.fixedTypeId as string,
266
+ behavior: r.behavior,
267
+ description: r.explanation,
268
+ period: run.period,
269
+ });
270
+ }
271
+ if (r.oppositeAmount > 0) {
272
+ entries.push({
273
+ userId: r.userId,
274
+ clinicId: r.clinicId,
275
+ amount: r.oppositeAmount,
276
+ fixedTypeId: r.fixedTypeId as string,
277
+ behavior: "minimum_guaranteed_opposite",
278
+ description: `Riga opposta minimo garantito: ${r.explanation}`,
279
+ period: run.period,
280
+ });
281
+ }
282
+ return entries;
283
+ });
284
+
285
+ if (fixedEntries.length > 0) {
286
+ const insertResult = await ctx.runMutation(
287
+ internal.calculation.orchestrator._insertFixedCommissionsInternal,
288
+ { runId: args.runId, userId: args.userId, fixedEntries },
289
+ );
290
+ fixedResult = { insertedCount: insertResult.insertedCount, adjustedCount: adjustResults.length };
291
+ }
292
+ }
293
+
294
+ // Step 4: Genera bozze fattura
295
+ const invoiceResult = await ctx.runMutation(
296
+ internal.documents.invoiceGeneration._refreshDraftInvoicesInternal,
297
+ { runId: args.runId, userId: args.userId },
298
+ );
299
+
300
+ return {
301
+ runId: args.runId,
302
+ period: run.period,
303
+ isShadowRun: run.isShadowRun,
304
+ production: productionResult,
305
+ commissions: commissionResult,
306
+ fixed: fixedResult,
307
+ invoices: invoiceResult,
308
+ };
309
+ },
310
+ });
311
+
312
+ function buildSummariesFromCommissions(commissionResult: any): CommissionSummaryForFixed[] {
313
+ return [];
314
+ }