@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,497 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query, internalMutation } from "../_generated/server";
3
+ import {
4
+ calculateBatch,
5
+ type ProductionEntry,
6
+ type ResolvedPlan,
7
+ type ResolvedRule,
8
+ type CalculationContext,
9
+ type InvisalignConfigEntry,
10
+ } from "./engine";
11
+ import type { DeductionRuleConfig } from "./deductions";
12
+ import type { RuleType, ExtendedProductionData } from "../lib/types";
13
+
14
+ /**
15
+ * Pipeline di calcolo compensi con supporto dual-scope.
16
+ *
17
+ * Per ogni riga di produzione si generano fino a 2 commissioni:
18
+ * 1. "completed_rows" — per l'esecutore (completedBy)
19
+ * 2. "confirmed_care_plan" — per il titolare del piano di cura (doctorId), se diverso
20
+ *
21
+ * La riga extended con scope="confirmed_care_plan" usa doctorId come userId,
22
+ * e cerca un piano/regola dedicato per quello scope.
23
+ */
24
+
25
+ // ── Guard ────────────────────────────────────────────────
26
+
27
+ async function assertRunWritable(
28
+ ctx: { db: { get: (id: any) => Promise<any> } },
29
+ runId: any,
30
+ ): Promise<{ period: string }> {
31
+ const run = await ctx.db.get(runId);
32
+ if (!run) throw new Error("Run non trovato");
33
+ if (run.status === "closed") {
34
+ throw new Error("Impossibile calcolare compensi su un run chiuso");
35
+ }
36
+ if (run.status === "approved") {
37
+ throw new Error(
38
+ "Impossibile calcolare compensi su un run approvato. " +
39
+ "Riportarlo in draft prima di rigenerare.",
40
+ );
41
+ }
42
+ return { period: run.period };
43
+ }
44
+
45
+ // ── Context loader ───────────────────────────────────────
46
+
47
+ async function loadCalculationContext(ctx: any): Promise<CalculationContext> {
48
+ const deductionRulesRaw = await ctx.db
49
+ .query("deductionRules")
50
+ .withIndex("by_active_priority", (q: any) => q.eq("isActive", true))
51
+ .collect();
52
+
53
+ const deductionRules: DeductionRuleConfig[] = deductionRulesRaw.map((r: any) => ({
54
+ code: r.code,
55
+ conditions: r.conditions,
56
+ deductionPercent: r.deductionPercent,
57
+ priority: r.priority,
58
+ }));
59
+
60
+ const invisalignConfigRaw = await ctx.db
61
+ .query("invisalignConfig")
62
+ .withIndex("by_active", (q: any) => q.eq("isActive", true))
63
+ .collect();
64
+
65
+ const invisalignConfig: InvisalignConfigEntry[] = invisalignConfigRaw.map((c: any) => ({
66
+ listRowId: c.listRowId,
67
+ firstPhasePercentage: c.firstPhasePercentage,
68
+ otherPhasesPercentage: c.otherPhasesPercentage,
69
+ firstPhaseMin: c.firstPhaseMin,
70
+ otherPhasesMin: c.otherPhasesMin,
71
+ }));
72
+
73
+ const invisalignMinUsersRaw = await ctx.db
74
+ .query("invisalignMinUsers")
75
+ .collect();
76
+ const invisalignMinUserIds = new Set<string>(
77
+ invisalignMinUsersRaw.filter((u: any) => u.isActive).map((u: any) => u.userId),
78
+ );
79
+
80
+ const settingsRaw = await ctx.db.query("globalSettings").collect();
81
+ const globalSettings = new Map<string, string>();
82
+ for (const s of settingsRaw) {
83
+ globalSettings.set(s.key, s.value);
84
+ }
85
+
86
+ return { deductionRules, invisalignConfig, invisalignMinUserIds, globalSettings };
87
+ }
88
+
89
+ // ── Plans & rules loader ─────────────────────────────────
90
+
91
+ async function loadPlansAndRules(ctx: any, userIds: string[]) {
92
+ const allPlans: ResolvedPlan[] = [];
93
+ for (const uid of userIds) {
94
+ const userPlans = await ctx.db
95
+ .query("commissionPlans")
96
+ .withIndex("by_user_status", (q: any) => q.eq("userId", uid).eq("status", "active"))
97
+ .collect();
98
+ for (const p of userPlans) {
99
+ allPlans.push({
100
+ _id: p._id,
101
+ userId: p.userId,
102
+ code: p.code,
103
+ name: p.name,
104
+ status: p.status,
105
+ effectiveFrom: p.effectiveFrom,
106
+ effectiveTo: p.effectiveTo,
107
+ });
108
+ }
109
+ }
110
+
111
+ const rulesByPlan = new Map<string, ResolvedRule[]>();
112
+ for (const plan of allPlans) {
113
+ const rules = await ctx.db
114
+ .query("commissionRules")
115
+ .withIndex("by_plan_active", (q: any) => q.eq("planId", plan._id).eq("isActive", true))
116
+ .collect();
117
+
118
+ rulesByPlan.set(
119
+ plan._id,
120
+ rules.map((r: any) => ({
121
+ _id: r._id,
122
+ planId: r.planId,
123
+ name: r.name,
124
+ ruleType: r.ruleType as RuleType,
125
+ priority: r.priority,
126
+ isActive: r.isActive,
127
+ outputType: r.outputType,
128
+ explanationLabel: r.explanationLabel,
129
+ conditions: r.conditions as Record<string, unknown>,
130
+ formula: r.formula as Record<string, unknown>,
131
+ validFrom: r.validFrom,
132
+ validTo: r.validTo,
133
+ })),
134
+ );
135
+ }
136
+
137
+ return { allPlans, rulesByPlan };
138
+ }
139
+
140
+ // ── Dual-scope expansion ─────────────────────────────────
141
+
142
+ /**
143
+ * Espande ogni riga di produzione in 1 o 2 ProductionEntry:
144
+ * - Una per completed_rows (esecutore)
145
+ * - Una per confirmed_care_plan (titolare piano di cura), se doctorId è diverso da completedBy
146
+ */
147
+ function expandForDualScope(
148
+ entries: ProductionEntry[],
149
+ ): ProductionEntry[] {
150
+ const expanded: ProductionEntry[] = [];
151
+
152
+ for (const entry of entries) {
153
+ expanded.push(entry);
154
+
155
+ const ext = entry.extended;
156
+ if (!ext) continue;
157
+
158
+ const doctorId = ext.doctorId;
159
+ const completedBy = ext.completedBy ?? entry.userId;
160
+
161
+ if (doctorId && doctorId !== completedBy) {
162
+ const confirmedEntry: ProductionEntry = {
163
+ ...entry,
164
+ userId: doctorId,
165
+ extended: {
166
+ ...ext,
167
+ scope: "confirmed_care_plan",
168
+ },
169
+ };
170
+ expanded.push(confirmedEntry);
171
+ }
172
+ }
173
+
174
+ return expanded;
175
+ }
176
+
177
+ // ── Clear ────────────────────────────────────────────────
178
+
179
+ export const clearCommissionEntries = mutation({
180
+ args: {
181
+ runId: v.id("compensationRuns"),
182
+ userId: v.string(),
183
+ },
184
+ handler: async (ctx, args) => {
185
+ await assertRunWritable(ctx, args.runId);
186
+
187
+ const entries = await ctx.db
188
+ .query("ledgerEntries")
189
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
190
+ .collect();
191
+
192
+ let commissionDeleted = 0;
193
+ for (const entry of entries) {
194
+ if (!entry.isManual && entry.type === "commission") {
195
+ await ctx.db.delete(entry._id);
196
+ commissionDeleted++;
197
+ }
198
+ }
199
+
200
+ const ruleApps = await ctx.db
201
+ .query("ruleApplications")
202
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
203
+ .collect();
204
+
205
+ let ruleAppsDeleted = 0;
206
+ for (const ra of ruleApps) {
207
+ await ctx.db.delete(ra._id);
208
+ ruleAppsDeleted++;
209
+ }
210
+
211
+ if (commissionDeleted > 0 || ruleAppsDeleted > 0) {
212
+ await ctx.db.insert("auditLogs", {
213
+ entityType: "compensationRuns",
214
+ entityId: args.runId,
215
+ action: "commission_entries_cleared",
216
+ userId: args.userId,
217
+ payload: { commissionDeleted, ruleAppsDeleted },
218
+ createdAt: Date.now(),
219
+ });
220
+ }
221
+
222
+ return { commissionDeleted, ruleAppsDeleted };
223
+ },
224
+ });
225
+
226
+ // ── Refresh (idempotente) ────────────────────────────────
227
+
228
+ async function _refreshHandler(ctx: any, args: { runId: any; userId: string }) {
229
+ const { period } = await assertRunWritable(ctx, args.runId);
230
+ const now = Date.now();
231
+
232
+ const allEntries = await ctx.db
233
+ .query("ledgerEntries")
234
+ .withIndex("by_run", (q: any) => q.eq("runId", args.runId))
235
+ .collect();
236
+
237
+ let clearedCommissions = 0;
238
+ for (const entry of allEntries) {
239
+ if (!entry.isManual && entry.type === "commission") {
240
+ await ctx.db.delete(entry._id);
241
+ clearedCommissions++;
242
+ }
243
+ }
244
+
245
+ const existingRuleApps = await ctx.db
246
+ .query("ruleApplications")
247
+ .withIndex("by_run", (q: any) => q.eq("runId", args.runId))
248
+ .collect();
249
+ for (const ra of existingRuleApps) {
250
+ await ctx.db.delete(ra._id);
251
+ }
252
+
253
+ const freshEntries = await ctx.db
254
+ .query("ledgerEntries")
255
+ .withIndex("by_run", (q: any) => q.eq("runId", args.runId))
256
+ .collect();
257
+
258
+ const productionEntries: ProductionEntry[] = freshEntries
259
+ .filter((e: any) => !e.isManual && (e.type === "production" || e.type === "storno"))
260
+ .map((e: any) => {
261
+ const meta = (e.metadata ?? {}) as Record<string, any>;
262
+ const extended: ExtendedProductionData | undefined = meta.extended ?? undefined;
263
+
264
+ return {
265
+ _id: e._id,
266
+ runId: e.runId,
267
+ userId: e.userId,
268
+ clinicId: e.clinicId,
269
+ type: e.type as "production" | "storno",
270
+ sourceId: e.sourceId,
271
+ amount: e.amount,
272
+ effectiveDate: e.effectiveDate,
273
+ period: e.period,
274
+ metadata: meta,
275
+ extended,
276
+ };
277
+ });
278
+
279
+ if (productionEntries.length === 0) {
280
+ return {
281
+ clearedCommissions,
282
+ productionEntries: 0,
283
+ commissionsGenerated: 0,
284
+ unmatchedEntries: 0,
285
+ unmatched: [],
286
+ totalCommissionAmount: 0,
287
+ };
288
+ }
289
+
290
+ const expandedEntries = expandForDualScope(productionEntries);
291
+
292
+ const userIds = [...new Set(expandedEntries.map((e) => e.userId))];
293
+ const { allPlans, rulesByPlan } = await loadPlansAndRules(ctx, userIds);
294
+ const calcContext = await loadCalculationContext(ctx);
295
+
296
+ const batchResult = calculateBatch(
297
+ expandedEntries,
298
+ allPlans,
299
+ rulesByPlan,
300
+ args.userId,
301
+ calcContext,
302
+ );
303
+
304
+ for (const result of batchResult.results) {
305
+ const commissionId = await ctx.db.insert("ledgerEntries", {
306
+ ...result.entry,
307
+ createdAt: now,
308
+ });
309
+
310
+ await ctx.db.insert("ruleApplications", {
311
+ ...result.ruleApplication,
312
+ generatedLedgerEntryId: commissionId,
313
+ createdAt: now,
314
+ });
315
+ }
316
+
317
+ await ctx.db.insert("auditLogs", {
318
+ entityType: "compensationRuns",
319
+ entityId: args.runId,
320
+ action: "commissions_refreshed",
321
+ userId: args.userId,
322
+ payload: {
323
+ clearedCommissions,
324
+ clearedRuleApps: existingRuleApps.length,
325
+ productionEntries: productionEntries.length,
326
+ expandedEntries: expandedEntries.length,
327
+ commissionsGenerated: batchResult.summary.totalMatched,
328
+ unmatchedEntries: batchResult.summary.totalUnmatched,
329
+ totalCommissionAmount: batchResult.summary.totalCommissionAmount,
330
+ },
331
+ createdAt: now,
332
+ });
333
+
334
+ return {
335
+ clearedCommissions,
336
+ productionEntries: productionEntries.length,
337
+ commissionsGenerated: batchResult.summary.totalMatched,
338
+ unmatchedEntries: batchResult.summary.totalUnmatched,
339
+ unmatched: batchResult.unmatched,
340
+ totalCommissionAmount: batchResult.summary.totalCommissionAmount,
341
+ };
342
+ }
343
+
344
+ export const refreshCommissions = mutation({
345
+ args: {
346
+ runId: v.id("compensationRuns"),
347
+ userId: v.string(),
348
+ },
349
+ handler: async (ctx, args) => _refreshHandler(ctx, args),
350
+ });
351
+
352
+ export const _refreshCommissionsInternal = internalMutation({
353
+ args: {
354
+ runId: v.id("compensationRuns"),
355
+ userId: v.string(),
356
+ },
357
+ handler: async (ctx, args) => _refreshHandler(ctx, args),
358
+ });
359
+
360
+ // ── Query ────────────────────────────────────────────────
361
+
362
+ export const listCommissionEntries = query({
363
+ args: { runId: v.id("compensationRuns") },
364
+ handler: async (ctx, args) => {
365
+ const entries = await ctx.db
366
+ .query("ledgerEntries")
367
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
368
+ .collect();
369
+ return entries.filter((e) => !e.isManual && e.type === "commission");
370
+ },
371
+ });
372
+
373
+ export const listCommissionEntriesByUser = query({
374
+ args: {
375
+ runId: v.id("compensationRuns"),
376
+ userId: v.string(),
377
+ },
378
+ handler: async (ctx, args) => {
379
+ const entries = await ctx.db
380
+ .query("ledgerEntries")
381
+ .withIndex("by_run_user", (q) =>
382
+ q.eq("runId", args.runId).eq("userId", args.userId),
383
+ )
384
+ .collect();
385
+ return entries.filter((e) => !e.isManual && e.type === "commission");
386
+ },
387
+ });
388
+
389
+ export const getCommissionSummary = query({
390
+ args: { runId: v.id("compensationRuns") },
391
+ handler: async (ctx, args) => {
392
+ const entries = await ctx.db
393
+ .query("ledgerEntries")
394
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
395
+ .collect();
396
+
397
+ const commissions = entries.filter((e) => !e.isManual && e.type === "commission");
398
+
399
+ const byUser: Record<string, { total: number; count: number; byClinic: Record<string, number>; byScope: Record<string, number> }> = {};
400
+
401
+ for (const c of commissions) {
402
+ if (!byUser[c.userId]) {
403
+ byUser[c.userId] = { total: 0, count: 0, byClinic: {}, byScope: {} };
404
+ }
405
+ byUser[c.userId].total += c.amount;
406
+ byUser[c.userId].count++;
407
+ byUser[c.userId].byClinic[c.clinicId] = (byUser[c.userId].byClinic[c.clinicId] ?? 0) + c.amount;
408
+ const scope = (c.scope as string) ?? "completed_rows";
409
+ byUser[c.userId].byScope[scope] = (byUser[c.userId].byScope[scope] ?? 0) + c.amount;
410
+ }
411
+
412
+ return {
413
+ runId: args.runId,
414
+ totalCommissions: commissions.reduce((s, c) => s + c.amount, 0),
415
+ totalEntries: commissions.length,
416
+ byUser,
417
+ };
418
+ },
419
+ });
420
+
421
+ export const getCommissionSummaryByUser = query({
422
+ args: {
423
+ runId: v.id("compensationRuns"),
424
+ userId: v.string(),
425
+ },
426
+ handler: async (ctx, args) => {
427
+ const entries = await ctx.db
428
+ .query("ledgerEntries")
429
+ .withIndex("by_run_user", (q) =>
430
+ q.eq("runId", args.runId).eq("userId", args.userId),
431
+ )
432
+ .collect();
433
+
434
+ const commissions = entries.filter((e) => !e.isManual && e.type === "commission");
435
+
436
+ let total = 0;
437
+ const byClinic: Record<string, number> = {};
438
+ const byScope: Record<string, number> = {};
439
+ const byRule: Record<string, { total: number; count: number }> = {};
440
+
441
+ for (const c of commissions) {
442
+ total += c.amount;
443
+ byClinic[c.clinicId] = (byClinic[c.clinicId] ?? 0) + c.amount;
444
+ const scope = (c.scope as string) ?? "completed_rows";
445
+ byScope[scope] = (byScope[scope] ?? 0) + c.amount;
446
+ const ruleId = c.ruleId as string;
447
+ if (ruleId) {
448
+ if (!byRule[ruleId]) byRule[ruleId] = { total: 0, count: 0 };
449
+ byRule[ruleId].total += c.amount;
450
+ byRule[ruleId].count++;
451
+ }
452
+ }
453
+
454
+ return { runId: args.runId, userId: args.userId, total, count: commissions.length, byClinic, byScope, byRule };
455
+ },
456
+ });
457
+
458
+ export const getCommissionsForSourceEntry = query({
459
+ args: { sourceLedgerEntryId: v.id("ledgerEntries") },
460
+ handler: async (ctx, args) => {
461
+ const ruleApps = await ctx.db
462
+ .query("ruleApplications")
463
+ .withIndex("by_source_entry", (q) =>
464
+ q.eq("sourceLedgerEntryId", args.sourceLedgerEntryId),
465
+ )
466
+ .collect();
467
+
468
+ const result = [];
469
+ for (const ra of ruleApps) {
470
+ const entry = await ctx.db.get(ra.generatedLedgerEntryId);
471
+ result.push({ ruleApplication: ra, commissionEntry: entry });
472
+ }
473
+ return result;
474
+ },
475
+ });
476
+
477
+ export const listUnmatchedProductionEntries = query({
478
+ args: { runId: v.id("compensationRuns") },
479
+ handler: async (ctx, args) => {
480
+ const entries = await ctx.db
481
+ .query("ledgerEntries")
482
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
483
+ .collect();
484
+
485
+ const productionEntries = entries.filter(
486
+ (e) => !e.isManual && (e.type === "production" || e.type === "storno"),
487
+ );
488
+
489
+ const ruleApps = await ctx.db
490
+ .query("ruleApplications")
491
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
492
+ .collect();
493
+
494
+ const matchedSourceIds = new Set(ruleApps.map((ra) => ra.sourceLedgerEntryId));
495
+ return productionEntries.filter((e) => !matchedSourceIds.has(e._id));
496
+ },
497
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Motore deduzioni — traduzione dell'albero decisionale dal Python.
3
+ *
4
+ * Nel vecchio sistema le 9 deduzioni erano hardcoded con company_id, care_plan_type_id, ecc.
5
+ * Qui le condizioni sono lette da `deductionRules` (configurabili da DB).
6
+ *
7
+ * L'ordine di valutazione segue la priorità della regola (numeri bassi = alta priorità).
8
+ * La prima regola che matcha viene applicata.
9
+ */
10
+
11
+ import type { ExtendedProductionData } from "../lib/types";
12
+
13
+ export interface DeductionRuleConfig {
14
+ code: string;
15
+ conditions: {
16
+ companyIds?: string[];
17
+ carePlanTypeIds?: number[];
18
+ isInsurance?: boolean;
19
+ isSinistro?: boolean;
20
+ percentageThreshold?: number;
21
+ percentageAboveThreshold?: boolean;
22
+ conventionNameContains?: string;
23
+ serviceCategoryNames?: string[];
24
+ };
25
+ deductionPercent: number;
26
+ priority: number;
27
+ }
28
+
29
+ export interface DeductionResult {
30
+ deductionAmount: number;
31
+ deductionPercent: number;
32
+ ruleCode: string | null;
33
+ explanation: string;
34
+ }
35
+
36
+ /**
37
+ * Risolve la deduzione applicabile a una riga di produzione.
38
+ *
39
+ * Se la riga ha un `amount` fisso (dalla regola commissione), la deduzione è 0.
40
+ * Altrimenti, cerca la prima deductionRule che matcha le condizioni della riga.
41
+ */
42
+ export function resolveDeduction(
43
+ entry: ExtendedProductionData,
44
+ rules: DeductionRuleConfig[],
45
+ settings: Map<string, string>,
46
+ fixedAmount: number | undefined,
47
+ ): DeductionResult {
48
+ if (fixedAmount !== undefined && fixedAmount !== null) {
49
+ return { deductionAmount: 0, deductionPercent: 0, ruleCode: null, explanation: "Importo fisso: nessuna deduzione" };
50
+ }
51
+
52
+ const sorted = [...rules].sort((a, b) => a.priority - b.priority);
53
+
54
+ for (const rule of sorted) {
55
+ if (matchesDeductionConditions(entry, rule.conditions)) {
56
+ const percent = rule.deductionPercent;
57
+ const deductionAmount = roundCurrency(percent * entry.netPrice / 100);
58
+ return {
59
+ deductionAmount,
60
+ deductionPercent: percent,
61
+ ruleCode: rule.code,
62
+ explanation: `Deduzione ${rule.code}: ${percent}% di ${entry.netPrice} = ${deductionAmount}`,
63
+ };
64
+ }
65
+ }
66
+
67
+ return { deductionAmount: 0, deductionPercent: 0, ruleCode: null, explanation: "Nessuna regola di deduzione applicabile" };
68
+ }
69
+
70
+ function matchesDeductionConditions(
71
+ entry: ExtendedProductionData,
72
+ conditions: DeductionRuleConfig["conditions"],
73
+ ): boolean {
74
+ if (conditions.companyIds && conditions.companyIds.length > 0) {
75
+ if (!entry.companyId || !conditions.companyIds.includes(entry.companyId)) return false;
76
+ }
77
+
78
+ if (conditions.carePlanTypeIds && conditions.carePlanTypeIds.length > 0) {
79
+ if (entry.carePlanTypeId === undefined || !conditions.carePlanTypeIds.includes(entry.carePlanTypeId)) return false;
80
+ }
81
+
82
+ if (conditions.isInsurance !== undefined) {
83
+ if (entry.isInsurance !== conditions.isInsurance) return false;
84
+ }
85
+
86
+ if (conditions.isSinistro !== undefined) {
87
+ const entrySinistro = !entry.isInsurance && entry.carePlanTypeId !== 2;
88
+ if (conditions.isSinistro && !entrySinistro) return false;
89
+ if (!conditions.isSinistro && entrySinistro) return false;
90
+ }
91
+
92
+ if (conditions.percentageThreshold !== undefined) {
93
+ const rulePercentage = entry.cprPercentage * 100;
94
+ if (conditions.percentageAboveThreshold) {
95
+ if (rulePercentage < conditions.percentageThreshold) return false;
96
+ } else {
97
+ if (rulePercentage >= conditions.percentageThreshold) return false;
98
+ }
99
+ }
100
+
101
+ if (conditions.conventionNameContains) {
102
+ const convName = entry.conventionName?.toLowerCase() ?? "";
103
+ if (!convName.includes(conditions.conventionNameContains.toLowerCase())) return false;
104
+ }
105
+
106
+ if (conditions.serviceCategoryNames && conditions.serviceCategoryNames.length > 0) {
107
+ const catName = entry.serviceCategoryName?.toLowerCase() ?? "";
108
+ const matches = conditions.serviceCategoryNames.some((name) =>
109
+ catName.includes(name.toLowerCase()),
110
+ );
111
+ if (!matches) return false;
112
+ }
113
+
114
+ return true;
115
+ }
116
+
117
+ function roundCurrency(value: number): number {
118
+ return Math.round(value * 100) / 100;
119
+ }