@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,495 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query, internalMutation } from "../_generated/server";
|
|
3
|
+
import {
|
|
4
|
+
productionSourceRecordValidator,
|
|
5
|
+
mapSourceToLedgerEntry,
|
|
6
|
+
type ProductionSourceRecord,
|
|
7
|
+
} from "./productionData";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pipeline di produzione base — ingestione dati nel ledger.
|
|
11
|
+
*
|
|
12
|
+
* Da analisi.md:
|
|
13
|
+
* - "Produzione da care_plan_rows (prestazioni eseguite)"
|
|
14
|
+
* - "Storni (già forniti da Primoupcore)"
|
|
15
|
+
* - "Mese aperto: sempre ricalcolabile"
|
|
16
|
+
*
|
|
17
|
+
* Questo modulo gestisce il ciclo di vita delle righe di produzione:
|
|
18
|
+
*
|
|
19
|
+
* 1. clearProductionEntries — rimuove le righe automatiche production/storno
|
|
20
|
+
* 2. ingestProductionData — scrive nuove righe da dati sorgente
|
|
21
|
+
* 3. refreshProduction — clear + ingest (operazione idempotente)
|
|
22
|
+
*
|
|
23
|
+
* Strategia di idempotenza:
|
|
24
|
+
* Al ricalcolo, si cancellano SOLO le righe con:
|
|
25
|
+
* - type in ("production", "storno")
|
|
26
|
+
* - isManual = false
|
|
27
|
+
* Le righe manuali (rimborsi, rettifiche, bonus manuali) restano intatte.
|
|
28
|
+
* Poi si riscrivono le righe di produzione dai dati sorgente aggiornati.
|
|
29
|
+
*
|
|
30
|
+
* Shadow mode:
|
|
31
|
+
* La pipeline non distingue run ufficiali da shadow.
|
|
32
|
+
* Le righe vengono scritte sul run passato come argomento.
|
|
33
|
+
* L'isolamento avviene a livello di run (isShadowRun sul compensationRun).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// ── Guard: verifica stato run ────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Valida che il run esista e sia in uno stato che ammette
|
|
40
|
+
* modifiche alle righe di produzione.
|
|
41
|
+
*/
|
|
42
|
+
async function assertRunWritable(
|
|
43
|
+
ctx: { db: { get: (id: any) => Promise<any> } },
|
|
44
|
+
runId: any,
|
|
45
|
+
): Promise<{ period: string }> {
|
|
46
|
+
const run = await ctx.db.get(runId);
|
|
47
|
+
if (!run) throw new Error("Run non trovato");
|
|
48
|
+
if (run.status === "closed") {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"Impossibile modificare la produzione di un run chiuso. " +
|
|
51
|
+
"Riaprire il run per creare una nuova versione.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (run.status === "approved") {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Impossibile modificare la produzione di un run approvato. " +
|
|
57
|
+
"Riportarlo in reviewing o draft prima di rigenerare.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return { period: run.period };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Clear ────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Cancella le righe automatiche di produzione e storno per un run.
|
|
67
|
+
*
|
|
68
|
+
* Filtra per:
|
|
69
|
+
* type in ("production", "storno") AND isManual = false
|
|
70
|
+
*
|
|
71
|
+
* NON tocca mai: reimbursement, adjustment, fixed, bonus,
|
|
72
|
+
* né qualunque riga con isManual = true.
|
|
73
|
+
*/
|
|
74
|
+
export const clearProductionEntries = mutation({
|
|
75
|
+
args: {
|
|
76
|
+
runId: v.id("compensationRuns"),
|
|
77
|
+
userId: v.string(),
|
|
78
|
+
},
|
|
79
|
+
handler: async (ctx, args) => {
|
|
80
|
+
await assertRunWritable(ctx, args.runId);
|
|
81
|
+
|
|
82
|
+
const entries = await ctx.db
|
|
83
|
+
.query("ledgerEntries")
|
|
84
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
85
|
+
.collect();
|
|
86
|
+
|
|
87
|
+
let productionDeleted = 0;
|
|
88
|
+
let stornoDeleted = 0;
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const isAutoProduction =
|
|
92
|
+
!entry.isManual &&
|
|
93
|
+
(entry.type === "production" || entry.type === "storno");
|
|
94
|
+
|
|
95
|
+
if (isAutoProduction) {
|
|
96
|
+
await ctx.db.delete(entry._id);
|
|
97
|
+
if (entry.type === "production") productionDeleted++;
|
|
98
|
+
else stornoDeleted++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const totalDeleted = productionDeleted + stornoDeleted;
|
|
103
|
+
|
|
104
|
+
if (totalDeleted > 0) {
|
|
105
|
+
await ctx.db.insert("auditLogs", {
|
|
106
|
+
entityType: "compensationRuns",
|
|
107
|
+
entityId: args.runId,
|
|
108
|
+
action: "production_entries_cleared",
|
|
109
|
+
userId: args.userId,
|
|
110
|
+
payload: { productionDeleted, stornoDeleted },
|
|
111
|
+
createdAt: Date.now(),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { productionDeleted, stornoDeleted, totalDeleted };
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── Ingest ───────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Scrive righe di produzione nel ledger a partire da dati sorgente.
|
|
123
|
+
*
|
|
124
|
+
* Riceve un array di ProductionSourceRecord e li mappa
|
|
125
|
+
* in ledger entries tramite mapSourceToLedgerEntry.
|
|
126
|
+
*
|
|
127
|
+
* NON cancella righe esistenti — va chiamato dopo clearProductionEntries
|
|
128
|
+
* oppure usare refreshProduction per l'operazione completa.
|
|
129
|
+
*/
|
|
130
|
+
export const ingestProductionData = mutation({
|
|
131
|
+
args: {
|
|
132
|
+
runId: v.id("compensationRuns"),
|
|
133
|
+
sourceRecords: v.array(productionSourceRecordValidator),
|
|
134
|
+
userId: v.string(),
|
|
135
|
+
},
|
|
136
|
+
handler: async (ctx, args) => {
|
|
137
|
+
const { period } = await assertRunWritable(ctx, args.runId);
|
|
138
|
+
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
let productionCount = 0;
|
|
141
|
+
let stornoCount = 0;
|
|
142
|
+
let totalAmount = 0;
|
|
143
|
+
|
|
144
|
+
const insertedIds: string[] = [];
|
|
145
|
+
|
|
146
|
+
for (const source of args.sourceRecords) {
|
|
147
|
+
if (source.period !== period) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Periodo del record sorgente (${source.period}) non corrisponde ` +
|
|
150
|
+
`al periodo del run (${period}). carePlanRowId: ${source.carePlanRowId}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const entry = mapSourceToLedgerEntry(
|
|
155
|
+
source as ProductionSourceRecord,
|
|
156
|
+
args.runId,
|
|
157
|
+
args.userId,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const id = await ctx.db.insert("ledgerEntries", {
|
|
161
|
+
...entry,
|
|
162
|
+
createdAt: now,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
insertedIds.push(id);
|
|
166
|
+
totalAmount += source.amount;
|
|
167
|
+
|
|
168
|
+
if (source.isStorno) stornoCount++;
|
|
169
|
+
else productionCount++;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await ctx.db.insert("auditLogs", {
|
|
173
|
+
entityType: "compensationRuns",
|
|
174
|
+
entityId: args.runId,
|
|
175
|
+
action: "production_ingested",
|
|
176
|
+
userId: args.userId,
|
|
177
|
+
payload: {
|
|
178
|
+
productionCount,
|
|
179
|
+
stornoCount,
|
|
180
|
+
totalRecords: args.sourceRecords.length,
|
|
181
|
+
totalAmount,
|
|
182
|
+
},
|
|
183
|
+
createdAt: now,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
insertedCount: insertedIds.length,
|
|
188
|
+
productionCount,
|
|
189
|
+
stornoCount,
|
|
190
|
+
totalAmount,
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── Refresh (idempotente) ────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Rigenera la produzione per un run: clear + ingest.
|
|
199
|
+
*
|
|
200
|
+
* Operazione idempotente: può essere chiamata più volte
|
|
201
|
+
* per lo stesso run con lo stesso risultato.
|
|
202
|
+
*
|
|
203
|
+
* Flusso:
|
|
204
|
+
* 1. Cancella le righe automatiche production/storno
|
|
205
|
+
* 2. Inserisce le nuove righe dai dati sorgente
|
|
206
|
+
* 3. Logga l'operazione completa
|
|
207
|
+
*
|
|
208
|
+
* Le righe manuali (rimborsi, rettifiche) non vengono toccate.
|
|
209
|
+
*/
|
|
210
|
+
export const refreshProduction = mutation({
|
|
211
|
+
args: {
|
|
212
|
+
runId: v.id("compensationRuns"),
|
|
213
|
+
sourceRecords: v.array(productionSourceRecordValidator),
|
|
214
|
+
userId: v.string(),
|
|
215
|
+
},
|
|
216
|
+
handler: async (ctx, args) => {
|
|
217
|
+
const { period } = await assertRunWritable(ctx, args.runId);
|
|
218
|
+
|
|
219
|
+
// ── Step 1: Clear ──
|
|
220
|
+
const allEntries = await ctx.db
|
|
221
|
+
.query("ledgerEntries")
|
|
222
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
223
|
+
.collect();
|
|
224
|
+
|
|
225
|
+
let clearedCount = 0;
|
|
226
|
+
for (const entry of allEntries) {
|
|
227
|
+
if (
|
|
228
|
+
!entry.isManual &&
|
|
229
|
+
(entry.type === "production" || entry.type === "storno")
|
|
230
|
+
) {
|
|
231
|
+
await ctx.db.delete(entry._id);
|
|
232
|
+
clearedCount++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Step 2: Ingest ──
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
let productionCount = 0;
|
|
239
|
+
let stornoCount = 0;
|
|
240
|
+
let totalAmount = 0;
|
|
241
|
+
|
|
242
|
+
for (const source of args.sourceRecords) {
|
|
243
|
+
if (source.period !== period) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Periodo non corrispondente: record=${source.period}, run=${period}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const entry = mapSourceToLedgerEntry(
|
|
250
|
+
source as ProductionSourceRecord,
|
|
251
|
+
args.runId,
|
|
252
|
+
args.userId,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
await ctx.db.insert("ledgerEntries", {
|
|
256
|
+
...entry,
|
|
257
|
+
createdAt: now,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
totalAmount += source.amount;
|
|
261
|
+
if (source.isStorno) stornoCount++;
|
|
262
|
+
else productionCount++;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Step 3: Audit ──
|
|
266
|
+
await ctx.db.insert("auditLogs", {
|
|
267
|
+
entityType: "compensationRuns",
|
|
268
|
+
entityId: args.runId,
|
|
269
|
+
action: "production_refreshed",
|
|
270
|
+
userId: args.userId,
|
|
271
|
+
payload: {
|
|
272
|
+
clearedCount,
|
|
273
|
+
productionCount,
|
|
274
|
+
stornoCount,
|
|
275
|
+
totalRecords: args.sourceRecords.length,
|
|
276
|
+
totalAmount,
|
|
277
|
+
},
|
|
278
|
+
createdAt: now,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
clearedCount,
|
|
283
|
+
insertedCount: args.sourceRecords.length,
|
|
284
|
+
productionCount,
|
|
285
|
+
stornoCount,
|
|
286
|
+
totalAmount,
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── Query: sommario produzione ───────────────────────────
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Riepilogo produzione per un run: totale produzione, totale storni,
|
|
295
|
+
* netto, conteggi. Solo righe automatiche di tipo production/storno.
|
|
296
|
+
*/
|
|
297
|
+
export const getProductionSummary = query({
|
|
298
|
+
args: { runId: v.id("compensationRuns") },
|
|
299
|
+
handler: async (ctx, args) => {
|
|
300
|
+
const allEntries = await ctx.db
|
|
301
|
+
.query("ledgerEntries")
|
|
302
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
303
|
+
.collect();
|
|
304
|
+
|
|
305
|
+
let productionTotal = 0;
|
|
306
|
+
let productionCount = 0;
|
|
307
|
+
let stornoTotal = 0;
|
|
308
|
+
let stornoCount = 0;
|
|
309
|
+
|
|
310
|
+
for (const entry of allEntries) {
|
|
311
|
+
if (!entry.isManual) {
|
|
312
|
+
if (entry.type === "production") {
|
|
313
|
+
productionTotal += entry.amount;
|
|
314
|
+
productionCount++;
|
|
315
|
+
} else if (entry.type === "storno") {
|
|
316
|
+
stornoTotal += entry.amount;
|
|
317
|
+
stornoCount++;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
runId: args.runId,
|
|
324
|
+
productionTotal,
|
|
325
|
+
productionCount,
|
|
326
|
+
stornoTotal,
|
|
327
|
+
stornoCount,
|
|
328
|
+
netProduction: productionTotal + stornoTotal,
|
|
329
|
+
totalEntries: productionCount + stornoCount,
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Riepilogo produzione per un run e un medico specifico.
|
|
336
|
+
*/
|
|
337
|
+
export const getProductionSummaryByUser = query({
|
|
338
|
+
args: {
|
|
339
|
+
runId: v.id("compensationRuns"),
|
|
340
|
+
userId: v.string(),
|
|
341
|
+
},
|
|
342
|
+
handler: async (ctx, args) => {
|
|
343
|
+
const entries = await ctx.db
|
|
344
|
+
.query("ledgerEntries")
|
|
345
|
+
.withIndex("by_run_user", (q) =>
|
|
346
|
+
q.eq("runId", args.runId).eq("userId", args.userId),
|
|
347
|
+
)
|
|
348
|
+
.collect();
|
|
349
|
+
|
|
350
|
+
let productionTotal = 0;
|
|
351
|
+
let productionCount = 0;
|
|
352
|
+
let stornoTotal = 0;
|
|
353
|
+
let stornoCount = 0;
|
|
354
|
+
|
|
355
|
+
const byClinic: Record<
|
|
356
|
+
string,
|
|
357
|
+
{ production: number; storno: number; net: number }
|
|
358
|
+
> = {};
|
|
359
|
+
|
|
360
|
+
for (const entry of entries) {
|
|
361
|
+
if (!entry.isManual) {
|
|
362
|
+
if (entry.type === "production") {
|
|
363
|
+
productionTotal += entry.amount;
|
|
364
|
+
productionCount++;
|
|
365
|
+
} else if (entry.type === "storno") {
|
|
366
|
+
stornoTotal += entry.amount;
|
|
367
|
+
stornoCount++;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (entry.type === "production" || entry.type === "storno") {
|
|
371
|
+
if (!byClinic[entry.clinicId]) {
|
|
372
|
+
byClinic[entry.clinicId] = { production: 0, storno: 0, net: 0 };
|
|
373
|
+
}
|
|
374
|
+
if (entry.type === "production") {
|
|
375
|
+
byClinic[entry.clinicId].production += entry.amount;
|
|
376
|
+
} else {
|
|
377
|
+
byClinic[entry.clinicId].storno += entry.amount;
|
|
378
|
+
}
|
|
379
|
+
byClinic[entry.clinicId].net += entry.amount;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
runId: args.runId,
|
|
386
|
+
userId: args.userId,
|
|
387
|
+
productionTotal,
|
|
388
|
+
productionCount,
|
|
389
|
+
stornoTotal,
|
|
390
|
+
stornoCount,
|
|
391
|
+
netProduction: productionTotal + stornoTotal,
|
|
392
|
+
byClinic,
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Riepilogo produzione per run, raggruppato per medico.
|
|
399
|
+
* Utile per la vista riepilogativa del run.
|
|
400
|
+
*/
|
|
401
|
+
export const getProductionByUserSummary = query({
|
|
402
|
+
args: { runId: v.id("compensationRuns") },
|
|
403
|
+
handler: async (ctx, args) => {
|
|
404
|
+
const allEntries = await ctx.db
|
|
405
|
+
.query("ledgerEntries")
|
|
406
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
407
|
+
.collect();
|
|
408
|
+
|
|
409
|
+
const byUser: Record<
|
|
410
|
+
string,
|
|
411
|
+
{
|
|
412
|
+
productionTotal: number;
|
|
413
|
+
productionCount: number;
|
|
414
|
+
stornoTotal: number;
|
|
415
|
+
stornoCount: number;
|
|
416
|
+
netProduction: number;
|
|
417
|
+
}
|
|
418
|
+
> = {};
|
|
419
|
+
|
|
420
|
+
for (const entry of allEntries) {
|
|
421
|
+
if (
|
|
422
|
+
!entry.isManual &&
|
|
423
|
+
(entry.type === "production" || entry.type === "storno")
|
|
424
|
+
) {
|
|
425
|
+
if (!byUser[entry.userId]) {
|
|
426
|
+
byUser[entry.userId] = {
|
|
427
|
+
productionTotal: 0,
|
|
428
|
+
productionCount: 0,
|
|
429
|
+
stornoTotal: 0,
|
|
430
|
+
stornoCount: 0,
|
|
431
|
+
netProduction: 0,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const u = byUser[entry.userId];
|
|
435
|
+
if (entry.type === "production") {
|
|
436
|
+
u.productionTotal += entry.amount;
|
|
437
|
+
u.productionCount++;
|
|
438
|
+
} else {
|
|
439
|
+
u.stornoTotal += entry.amount;
|
|
440
|
+
u.stornoCount++;
|
|
441
|
+
}
|
|
442
|
+
u.netProduction += entry.amount;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
runId: args.runId,
|
|
448
|
+
users: byUser,
|
|
449
|
+
totalUsers: Object.keys(byUser).length,
|
|
450
|
+
};
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Elenca le righe di produzione di un run (production + storno, non manuali).
|
|
456
|
+
*/
|
|
457
|
+
export const listProductionEntries = query({
|
|
458
|
+
args: { runId: v.id("compensationRuns") },
|
|
459
|
+
handler: async (ctx, args) => {
|
|
460
|
+
const allEntries = await ctx.db
|
|
461
|
+
.query("ledgerEntries")
|
|
462
|
+
.withIndex("by_run", (q) => q.eq("runId", args.runId))
|
|
463
|
+
.collect();
|
|
464
|
+
|
|
465
|
+
return allEntries.filter(
|
|
466
|
+
(e) =>
|
|
467
|
+
!e.isManual &&
|
|
468
|
+
(e.type === "production" || e.type === "storno"),
|
|
469
|
+
);
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Elenca le righe di produzione di un run per un medico.
|
|
475
|
+
*/
|
|
476
|
+
export const listProductionEntriesByUser = query({
|
|
477
|
+
args: {
|
|
478
|
+
runId: v.id("compensationRuns"),
|
|
479
|
+
userId: v.string(),
|
|
480
|
+
},
|
|
481
|
+
handler: async (ctx, args) => {
|
|
482
|
+
const entries = await ctx.db
|
|
483
|
+
.query("ledgerEntries")
|
|
484
|
+
.withIndex("by_run_user", (q) =>
|
|
485
|
+
q.eq("runId", args.runId).eq("userId", args.userId),
|
|
486
|
+
)
|
|
487
|
+
.collect();
|
|
488
|
+
|
|
489
|
+
return entries.filter(
|
|
490
|
+
(e) =>
|
|
491
|
+
!e.isManual &&
|
|
492
|
+
(e.type === "production" || e.type === "storno"),
|
|
493
|
+
);
|
|
494
|
+
},
|
|
495
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { Id } from "../_generated/dataModel";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Source model per i dati di produzione da Primoupcore.
|
|
6
|
+
*
|
|
7
|
+
* Da analisi.md:
|
|
8
|
+
* - Fonte dati: "Primoupcore → eseguiti + storni"
|
|
9
|
+
* - "Produzione: somma delle prestazioni eseguite nel mese per medico"
|
|
10
|
+
* - "Storni: già forniti da Primoupcore"
|
|
11
|
+
*
|
|
12
|
+
* Questo modulo definisce:
|
|
13
|
+
* 1. Il tipo TypeScript per il dato sorgente
|
|
14
|
+
* 2. Il validator Convex corrispondente (per args delle mutations)
|
|
15
|
+
* 3. La funzione pura di mapping sorgente → ledger entry
|
|
16
|
+
* 4. Il placeholder per il fetch da Primoupcore
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ── Source model ─────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Record sorgente di produzione da Primoupcore.
|
|
23
|
+
* Rappresenta una singola riga di prestazione eseguita o storno.
|
|
24
|
+
*
|
|
25
|
+
* Il campo isStorno distingue produzione da storni:
|
|
26
|
+
* - false → prestazione eseguita (riga produzione)
|
|
27
|
+
* - true → storno già identificato da Primoupcore
|
|
28
|
+
*
|
|
29
|
+
* category, subcategory, conventionId sono opzionali
|
|
30
|
+
* ma saranno fondamentali per il futuro matching dei piani.
|
|
31
|
+
*/
|
|
32
|
+
export interface ProductionSourceRecord {
|
|
33
|
+
carePlanRowId: string;
|
|
34
|
+
userId: string;
|
|
35
|
+
clinicId: string;
|
|
36
|
+
amount: number;
|
|
37
|
+
performedAt: number;
|
|
38
|
+
period: string;
|
|
39
|
+
isStorno: boolean;
|
|
40
|
+
category?: string;
|
|
41
|
+
subcategory?: string;
|
|
42
|
+
conventionId?: string;
|
|
43
|
+
/** Per storni: riferimento alla riga originale stornata */
|
|
44
|
+
originalCarePlanRowId?: string;
|
|
45
|
+
metadata?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validator Convex per ProductionSourceRecord.
|
|
50
|
+
* Usato come arg nelle mutations che ricevono dati sorgente.
|
|
51
|
+
*/
|
|
52
|
+
export const productionSourceRecordValidator = v.object({
|
|
53
|
+
carePlanRowId: v.string(),
|
|
54
|
+
userId: v.string(),
|
|
55
|
+
clinicId: v.string(),
|
|
56
|
+
amount: v.number(),
|
|
57
|
+
performedAt: v.number(),
|
|
58
|
+
period: v.string(),
|
|
59
|
+
isStorno: v.boolean(),
|
|
60
|
+
category: v.optional(v.string()),
|
|
61
|
+
subcategory: v.optional(v.string()),
|
|
62
|
+
conventionId: v.optional(v.string()),
|
|
63
|
+
originalCarePlanRowId: v.optional(v.string()),
|
|
64
|
+
metadata: v.optional(v.any()),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── Mapping ──────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shape di una ledger entry pronta per l'inserimento.
|
|
71
|
+
* Corrisponde ai campi della tabella ledgerEntries meno _id e _creationTime.
|
|
72
|
+
*/
|
|
73
|
+
export interface LedgerEntryInsert {
|
|
74
|
+
runId: Id<"compensationRuns">;
|
|
75
|
+
userId: string;
|
|
76
|
+
clinicId: string;
|
|
77
|
+
type: "production" | "storno";
|
|
78
|
+
sourceType: "care_plan_row" | "storno";
|
|
79
|
+
sourceId: string;
|
|
80
|
+
description: string;
|
|
81
|
+
amount: number;
|
|
82
|
+
quantity: number;
|
|
83
|
+
unitAmount: number;
|
|
84
|
+
isManual: false;
|
|
85
|
+
effectiveDate: number;
|
|
86
|
+
period: string;
|
|
87
|
+
metadata: Record<string, unknown>;
|
|
88
|
+
createdBy: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Trasforma un record sorgente di produzione nella shape
|
|
93
|
+
* di una ledger entry pronta per l'inserimento.
|
|
94
|
+
*
|
|
95
|
+
* Funzione pura, testabile, senza side-effect.
|
|
96
|
+
* Non accede al DB — solo trasformazione dati.
|
|
97
|
+
*/
|
|
98
|
+
export function mapSourceToLedgerEntry(
|
|
99
|
+
source: ProductionSourceRecord,
|
|
100
|
+
runId: Id<"compensationRuns">,
|
|
101
|
+
createdBy: string,
|
|
102
|
+
): LedgerEntryInsert {
|
|
103
|
+
const isStorno = source.isStorno;
|
|
104
|
+
|
|
105
|
+
const meta: Record<string, unknown> = {};
|
|
106
|
+
if (source.category) meta.category = source.category;
|
|
107
|
+
if (source.subcategory) meta.subcategory = source.subcategory;
|
|
108
|
+
if (source.conventionId) meta.conventionId = source.conventionId;
|
|
109
|
+
if (source.originalCarePlanRowId) {
|
|
110
|
+
meta.originalCarePlanRowId = source.originalCarePlanRowId;
|
|
111
|
+
}
|
|
112
|
+
if (source.metadata) {
|
|
113
|
+
Object.assign(meta, source.metadata);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
runId,
|
|
118
|
+
userId: source.userId,
|
|
119
|
+
clinicId: source.clinicId,
|
|
120
|
+
type: isStorno ? "storno" : "production",
|
|
121
|
+
sourceType: isStorno ? "storno" : "care_plan_row",
|
|
122
|
+
sourceId: source.carePlanRowId,
|
|
123
|
+
description: isStorno
|
|
124
|
+
? `Storno: ${source.carePlanRowId}`
|
|
125
|
+
: `Produzione: ${source.carePlanRowId}`,
|
|
126
|
+
amount: source.amount,
|
|
127
|
+
quantity: 1,
|
|
128
|
+
unitAmount: source.amount,
|
|
129
|
+
isManual: false as const,
|
|
130
|
+
effectiveDate: source.performedAt,
|
|
131
|
+
period: source.period,
|
|
132
|
+
metadata: meta,
|
|
133
|
+
createdBy,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Fetch placeholder ────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* PLACEHOLDER: recupera i dati di produzione da Primoupcore per un periodo.
|
|
141
|
+
*
|
|
142
|
+
* Verrà sostituito con l'integrazione reale (query Convex verso
|
|
143
|
+
* le tabelle di Primoupcore oppure chiamata API).
|
|
144
|
+
*
|
|
145
|
+
* Restituisce un array di ProductionSourceRecord già normalizzati.
|
|
146
|
+
* Il campo isStorno sarà true per gli storni.
|
|
147
|
+
*/
|
|
148
|
+
export async function fetchProductionForPeriod(
|
|
149
|
+
_period: string,
|
|
150
|
+
_scopeUserIds?: string[],
|
|
151
|
+
_scopeClinicIds?: string[],
|
|
152
|
+
): Promise<ProductionSourceRecord[]> {
|
|
153
|
+
// TODO: integrare con Primoupcore
|
|
154
|
+
return [];
|
|
155
|
+
}
|