@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.
- package/convex/_generated/api.ts +110 -0
- package/convex/_generated/component.ts +1356 -0
- package/convex/_generated/dataModel.ts +60 -0
- package/convex/_generated/server.ts +156 -0
- package/convex/audit/logs.ts +69 -0
- package/convex/calculation/commissions.ts +497 -0
- package/convex/calculation/deductions.ts +119 -0
- package/convex/calculation/engine.ts +598 -0
- package/convex/calculation/fixedCommissions.ts +217 -0
- package/convex/calculation/orchestrator.ts +314 -0
- package/convex/calculation/production.ts +495 -0
- package/convex/calculation/productionData.ts +155 -0
- package/convex/calculation/ruleApplications.ts +121 -0
- package/convex/config/categories.ts +114 -0
- package/convex/config/commissionPlans.ts +166 -0
- package/convex/config/commissionRules.ts +154 -0
- package/convex/config/companyMappings.ts +77 -0
- package/convex/config/deductionRules.ts +113 -0
- package/convex/config/fixedCommissionTypes.ts +79 -0
- package/convex/config/globalSettings.ts +79 -0
- package/convex/config/invisalignConfig.ts +87 -0
- package/convex/config/planTemplates.ts +90 -0
- package/convex/config/taxProfiles.ts +122 -0
- package/convex/config/userTaxProfiles.ts +87 -0
- package/convex/convex.config.ts +3 -0
- package/convex/documents/draftInvoices.ts +164 -0
- package/convex/documents/invoiceEngine.ts +468 -0
- package/convex/documents/invoiceGeneration.ts +611 -0
- package/convex/documents/statements.ts +185 -0
- package/convex/ledger/entries.ts +314 -0
- package/convex/lib/types.ts +126 -0
- package/convex/schema.ts +611 -0
- package/convex/seedHelpers.ts +41 -0
- package/convex/workflow/pendingCompletions.ts +148 -0
- package/convex/workflow/pythonWorker.ts +190 -0
- package/convex/workflow/runIssues.ts +364 -0
- package/convex/workflow/runs.ts +718 -0
- 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
|
+
}
|