@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,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motore di calcolo compensi — funzioni pure.
|
|
3
|
+
*
|
|
4
|
+
* Formula estesa (dal Python create_commissions.py):
|
|
5
|
+
* starting_price = amount (fisso) OPPURE (net_price - deduzione - lab_cost - lab_price)
|
|
6
|
+
* calculated = starting_price * percentage * cpr_percentage * invisalign_perc
|
|
7
|
+
*
|
|
8
|
+
* Supporta: deduzioni configurabili, lab cost/price, Invisalign, rifacimenti, scope multipli.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Id } from "../_generated/dataModel";
|
|
12
|
+
import type { RuleType, ExtendedProductionData, CommissionScope } from "../lib/types";
|
|
13
|
+
import { resolveDeduction, type DeductionRuleConfig, type DeductionResult } from "./deductions";
|
|
14
|
+
|
|
15
|
+
// ── Tipi input ───────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface ResolvedPlan {
|
|
18
|
+
_id: Id<"commissionPlans">;
|
|
19
|
+
userId: string;
|
|
20
|
+
code: string;
|
|
21
|
+
name: string;
|
|
22
|
+
status: string;
|
|
23
|
+
effectiveFrom: number;
|
|
24
|
+
effectiveTo?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResolvedRule {
|
|
28
|
+
_id: Id<"commissionRules">;
|
|
29
|
+
planId: Id<"commissionPlans">;
|
|
30
|
+
name: string;
|
|
31
|
+
ruleType: RuleType;
|
|
32
|
+
priority: number;
|
|
33
|
+
isActive: boolean;
|
|
34
|
+
outputType: string;
|
|
35
|
+
explanationLabel: string;
|
|
36
|
+
conditions: Record<string, unknown>;
|
|
37
|
+
formula: Record<string, unknown>;
|
|
38
|
+
validFrom?: number;
|
|
39
|
+
validTo?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProductionEntry {
|
|
43
|
+
_id: Id<"ledgerEntries">;
|
|
44
|
+
runId: Id<"compensationRuns">;
|
|
45
|
+
userId: string;
|
|
46
|
+
clinicId: string;
|
|
47
|
+
type: "production" | "storno";
|
|
48
|
+
sourceId?: string;
|
|
49
|
+
amount: number;
|
|
50
|
+
effectiveDate: number;
|
|
51
|
+
period: string;
|
|
52
|
+
metadata?: Record<string, unknown>;
|
|
53
|
+
extended?: ExtendedProductionData;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface InvisalignConfigEntry {
|
|
57
|
+
listRowId: string;
|
|
58
|
+
firstPhasePercentage: number;
|
|
59
|
+
otherPhasesPercentage: number;
|
|
60
|
+
firstPhaseMin: number;
|
|
61
|
+
otherPhasesMin: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CalculationContext {
|
|
65
|
+
deductionRules: DeductionRuleConfig[];
|
|
66
|
+
invisalignConfig: InvisalignConfigEntry[];
|
|
67
|
+
invisalignMinUserIds: Set<string>;
|
|
68
|
+
globalSettings: Map<string, string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Tipi output ──────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export interface CommissionEntryData {
|
|
74
|
+
runId: Id<"compensationRuns">;
|
|
75
|
+
userId: string;
|
|
76
|
+
clinicId: string;
|
|
77
|
+
type: "commission";
|
|
78
|
+
sourceType: "system";
|
|
79
|
+
sourceId: string;
|
|
80
|
+
planId: Id<"commissionPlans">;
|
|
81
|
+
ruleId: Id<"commissionRules">;
|
|
82
|
+
scope: CommissionScope;
|
|
83
|
+
companyId?: string;
|
|
84
|
+
description: string;
|
|
85
|
+
amount: number;
|
|
86
|
+
quantity: number;
|
|
87
|
+
unitAmount: number;
|
|
88
|
+
isManual: false;
|
|
89
|
+
effectiveDate: number;
|
|
90
|
+
period: string;
|
|
91
|
+
metadata: Record<string, unknown>;
|
|
92
|
+
createdBy: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface RuleApplicationData {
|
|
96
|
+
runId: Id<"compensationRuns">;
|
|
97
|
+
sourceLedgerEntryId: Id<"ledgerEntries">;
|
|
98
|
+
planId: Id<"commissionPlans">;
|
|
99
|
+
ruleId: Id<"commissionRules">;
|
|
100
|
+
matchedConditions: Record<string, unknown>;
|
|
101
|
+
formulaSnapshot: Record<string, unknown>;
|
|
102
|
+
formulaInput: Record<string, unknown>;
|
|
103
|
+
formulaOutput: Record<string, unknown>;
|
|
104
|
+
resultAmount: number;
|
|
105
|
+
explanationLabel: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface EngineResult {
|
|
109
|
+
entry: CommissionEntryData;
|
|
110
|
+
ruleApplication: RuleApplicationData;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface UnmatchedEntry {
|
|
114
|
+
entryId: Id<"ledgerEntries">;
|
|
115
|
+
userId: string;
|
|
116
|
+
clinicId: string;
|
|
117
|
+
amount: number;
|
|
118
|
+
reason: "no_active_plan" | "no_matching_rule";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface CalculationBatchResult {
|
|
122
|
+
results: EngineResult[];
|
|
123
|
+
unmatched: UnmatchedEntry[];
|
|
124
|
+
summary: {
|
|
125
|
+
totalProcessed: number;
|
|
126
|
+
totalMatched: number;
|
|
127
|
+
totalUnmatched: number;
|
|
128
|
+
totalCommissionAmount: number;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── 1. Risoluzione piano ─────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export function findActivePlan(
|
|
135
|
+
plans: ResolvedPlan[],
|
|
136
|
+
userId: string,
|
|
137
|
+
period: string,
|
|
138
|
+
): ResolvedPlan | null {
|
|
139
|
+
const { periodStart, periodEnd } = parsePeriodBounds(period);
|
|
140
|
+
|
|
141
|
+
const matching = plans.filter((p) => {
|
|
142
|
+
if (p.userId !== userId) return false;
|
|
143
|
+
if (p.status !== "active") return false;
|
|
144
|
+
if (p.effectiveFrom > periodEnd) return false;
|
|
145
|
+
if (p.effectiveTo !== undefined && p.effectiveTo <= periodStart) return false;
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (matching.length === 0) return null;
|
|
150
|
+
if (matching.length > 1) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Ambiguità: trovati ${matching.length} piani attivi per userId=${userId} ` +
|
|
153
|
+
`nel periodo ${period}: ${matching.map((p) => p.code).join(", ")}. ` +
|
|
154
|
+
`Risolvere disattivando i piani in eccesso.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return matching[0];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 2. Matching condizioni ───────────────────────────────
|
|
162
|
+
|
|
163
|
+
export function matchConditions(
|
|
164
|
+
entry: ProductionEntry,
|
|
165
|
+
conditions: Record<string, unknown>,
|
|
166
|
+
): { matches: boolean; matchedFields: Record<string, unknown> } {
|
|
167
|
+
const matchedFields: Record<string, unknown> = {};
|
|
168
|
+
const ext = entry.extended;
|
|
169
|
+
const meta = entry.metadata ?? {};
|
|
170
|
+
|
|
171
|
+
const fieldMapping: Record<string, () => unknown> = {
|
|
172
|
+
clinicId: () => entry.clinicId,
|
|
173
|
+
category: () => meta.category ?? ext?.serviceCategoryName,
|
|
174
|
+
subcategory: () => meta.subcategory,
|
|
175
|
+
conventionId: () => meta.conventionId ?? ext?.conventionId,
|
|
176
|
+
sourceId: () => entry.sourceId ?? ext?.sourceId,
|
|
177
|
+
listId: () => ext?.listId,
|
|
178
|
+
serviceRegistryId: () => ext?.serviceRegistryId,
|
|
179
|
+
serviceRegistryGroupId: () => ext?.serviceRegistryGroupId,
|
|
180
|
+
listRowId: () => ext?.listRowId,
|
|
181
|
+
hasAgreement: () => ext?.conventionId !== undefined && ext?.conventionId !== null,
|
|
182
|
+
commissionProfileId: () => ext?.commissionProfileId,
|
|
183
|
+
serviceCategoryId: () => ext?.serviceCategoryId,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
for (const [condKey, condValue] of Object.entries(conditions)) {
|
|
187
|
+
if (condValue === null || condValue === undefined || condValue === "*") {
|
|
188
|
+
matchedFields[condKey] = { condition: "*", value: "wildcard" };
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const getEntryValue = fieldMapping[condKey];
|
|
193
|
+
if (!getEntryValue) {
|
|
194
|
+
return { matches: false, matchedFields: {} };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const entryValue = getEntryValue();
|
|
198
|
+
|
|
199
|
+
if (Array.isArray(condValue)) {
|
|
200
|
+
if (!condValue.includes(entryValue)) {
|
|
201
|
+
return { matches: false, matchedFields: {} };
|
|
202
|
+
}
|
|
203
|
+
matchedFields[condKey] = { condition: condValue, value: entryValue };
|
|
204
|
+
} else {
|
|
205
|
+
if (entryValue !== condValue) {
|
|
206
|
+
return { matches: false, matchedFields: {} };
|
|
207
|
+
}
|
|
208
|
+
matchedFields[condKey] = { condition: condValue, value: entryValue };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { matches: true, matchedFields };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function findMatchingRule(
|
|
216
|
+
entry: ProductionEntry,
|
|
217
|
+
rules: ResolvedRule[],
|
|
218
|
+
effectiveDate: number,
|
|
219
|
+
): { rule: ResolvedRule; matchedFields: Record<string, unknown> } | null {
|
|
220
|
+
const activeRules = rules
|
|
221
|
+
.filter((r) => {
|
|
222
|
+
if (!r.isActive) return false;
|
|
223
|
+
if (r.validFrom !== undefined && r.validFrom > effectiveDate) return false;
|
|
224
|
+
if (r.validTo !== undefined && r.validTo <= effectiveDate) return false;
|
|
225
|
+
return true;
|
|
226
|
+
})
|
|
227
|
+
.sort((a, b) => a.priority - b.priority);
|
|
228
|
+
|
|
229
|
+
for (const rule of activeRules) {
|
|
230
|
+
const { matches, matchedFields } = matchConditions(
|
|
231
|
+
entry,
|
|
232
|
+
(rule.conditions ?? {}) as Record<string, unknown>,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (matches) {
|
|
236
|
+
return { rule, matchedFields };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── 3. Formula estesa ────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export interface FormulaResult {
|
|
246
|
+
amount: number;
|
|
247
|
+
input: Record<string, unknown>;
|
|
248
|
+
output: Record<string, unknown>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Formula completa dal Python:
|
|
253
|
+
*
|
|
254
|
+
* Se la regola ha un amount fisso:
|
|
255
|
+
* starting_price = amount * sign (negativo se net_price < 0 e amount presente)
|
|
256
|
+
* calculated = starting_price * percentage * cpr_percentage
|
|
257
|
+
*
|
|
258
|
+
* Altrimenti:
|
|
259
|
+
* starting_price = net_price - deduction - lab_cost - lab_price
|
|
260
|
+
* calculated = starting_price * percentage * invisalign_perc
|
|
261
|
+
*/
|
|
262
|
+
export function evaluateExtendedFormula(
|
|
263
|
+
formula: Record<string, unknown>,
|
|
264
|
+
entry: ProductionEntry,
|
|
265
|
+
context: CalculationContext,
|
|
266
|
+
): FormulaResult {
|
|
267
|
+
const formulaType = formula.type as string;
|
|
268
|
+
const ext = entry.extended;
|
|
269
|
+
|
|
270
|
+
if (!ext) {
|
|
271
|
+
return evaluateSimpleFormula(formula, entry.amount);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const ruleFixedAmount = formula.amount as number | undefined;
|
|
275
|
+
const rulePercentage = (formula.rate as number | undefined) ?? (ext.cprPercentage !== undefined ? ext.cprPercentage * 100 : undefined);
|
|
276
|
+
|
|
277
|
+
const deduction = resolveDeduction(ext, context.deductionRules, context.globalSettings, ruleFixedAmount);
|
|
278
|
+
|
|
279
|
+
const netPrice = ext.netPrice;
|
|
280
|
+
const listPrice = ext.listPrice;
|
|
281
|
+
const labCost = ext.labCost ?? 0;
|
|
282
|
+
const labPrice = ext.labPrice ?? 0;
|
|
283
|
+
const cprPercentage = ext.cprPercentage ?? 1;
|
|
284
|
+
|
|
285
|
+
const invisalignResult = resolveInvisalignPercentage(ext, context);
|
|
286
|
+
|
|
287
|
+
let startingPrice: number;
|
|
288
|
+
let percentage: number;
|
|
289
|
+
|
|
290
|
+
if (ruleFixedAmount !== undefined && ruleFixedAmount !== null) {
|
|
291
|
+
startingPrice = ruleFixedAmount;
|
|
292
|
+
if (netPrice < 0) startingPrice *= -1;
|
|
293
|
+
percentage = ((rulePercentage ?? 100) / 100) * (invisalignResult.percentage / 100);
|
|
294
|
+
const calculated = startingPrice * percentage * (ruleFixedAmount !== undefined ? cprPercentage : 1);
|
|
295
|
+
let finalCalculated = roundCurrency(calculated);
|
|
296
|
+
|
|
297
|
+
finalCalculated = applyRefactorLogic(ext, finalCalculated, listPrice);
|
|
298
|
+
finalCalculated = applySpecialCases(ext, finalCalculated, netPrice, listPrice, ruleFixedAmount);
|
|
299
|
+
finalCalculated = applyInvisalignMin(ext, finalCalculated, context, invisalignResult);
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
amount: finalCalculated,
|
|
303
|
+
input: {
|
|
304
|
+
startingPrice, percentage: rulePercentage, cprPercentage, invisalignPerc: invisalignResult.percentage,
|
|
305
|
+
deduction: deduction.deductionAmount, labCost, labPrice, netPrice, listPrice,
|
|
306
|
+
},
|
|
307
|
+
output: {
|
|
308
|
+
calculatedAmount: finalCalculated,
|
|
309
|
+
deductionRule: deduction.ruleCode,
|
|
310
|
+
deductionExplanation: deduction.explanation,
|
|
311
|
+
invisalignApplied: invisalignResult.isInvisalign,
|
|
312
|
+
formula: `fisso ${ruleFixedAmount} × ${rulePercentage ?? 100}% × CPR ${cprPercentage} × Invisa ${invisalignResult.percentage}%`,
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
startingPrice = netPrice - deduction.deductionAmount - labCost - labPrice;
|
|
318
|
+
percentage = ((rulePercentage ?? 0) / 100) * (invisalignResult.percentage / 100);
|
|
319
|
+
const calculated = startingPrice * percentage;
|
|
320
|
+
let finalCalculated = roundCurrency(calculated);
|
|
321
|
+
|
|
322
|
+
finalCalculated = applyRefactorLogic(ext, finalCalculated, listPrice);
|
|
323
|
+
finalCalculated = applySpecialCases(ext, finalCalculated, netPrice, listPrice, ruleFixedAmount);
|
|
324
|
+
finalCalculated = applyInvisalignMin(ext, finalCalculated, context, invisalignResult);
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
amount: finalCalculated,
|
|
328
|
+
input: {
|
|
329
|
+
startingPrice, percentage: rulePercentage, cprPercentage, invisalignPerc: invisalignResult.percentage,
|
|
330
|
+
deduction: deduction.deductionAmount, labCost, labPrice, netPrice, listPrice,
|
|
331
|
+
},
|
|
332
|
+
output: {
|
|
333
|
+
calculatedAmount: finalCalculated,
|
|
334
|
+
deductionRule: deduction.ruleCode,
|
|
335
|
+
deductionExplanation: deduction.explanation,
|
|
336
|
+
invisalignApplied: invisalignResult.isInvisalign,
|
|
337
|
+
formula: `(${netPrice} - ${deduction.deductionAmount} - ${labCost} - ${labPrice}) × ${rulePercentage ?? 0}% × Invisa ${invisalignResult.percentage}%`,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function evaluateSimpleFormula(
|
|
343
|
+
formula: Record<string, unknown>,
|
|
344
|
+
baseAmount: number,
|
|
345
|
+
): FormulaResult {
|
|
346
|
+
const formulaType = formula.type as string;
|
|
347
|
+
|
|
348
|
+
switch (formulaType) {
|
|
349
|
+
case "percentage": {
|
|
350
|
+
const rate = formula.rate as number;
|
|
351
|
+
const amount = roundCurrency(baseAmount * (rate / 100));
|
|
352
|
+
return {
|
|
353
|
+
amount,
|
|
354
|
+
input: { baseAmount, rate },
|
|
355
|
+
output: { calculatedAmount: amount, formula: `${baseAmount} × ${rate}%` },
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
case "fixed_amount": {
|
|
359
|
+
const fixedAmount = formula.amount as number;
|
|
360
|
+
return {
|
|
361
|
+
amount: roundCurrency(fixedAmount),
|
|
362
|
+
input: { baseAmount, fixedAmount },
|
|
363
|
+
output: { calculatedAmount: roundCurrency(fixedAmount), formula: `fisso ${fixedAmount}` },
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
default:
|
|
367
|
+
throw new Error(`Tipo formula non supportato: "${formulaType}"`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Invisalign ───────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
interface InvisalignResult {
|
|
374
|
+
isInvisalign: boolean;
|
|
375
|
+
percentage: number;
|
|
376
|
+
minAmount: number;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function resolveInvisalignPercentage(
|
|
380
|
+
ext: ExtendedProductionData,
|
|
381
|
+
context: CalculationContext,
|
|
382
|
+
): InvisalignResult {
|
|
383
|
+
if (!ext.listRowId) return { isInvisalign: false, percentage: 100, minAmount: 0 };
|
|
384
|
+
|
|
385
|
+
const config = context.invisalignConfig.find((c) => c.listRowId === ext.listRowId);
|
|
386
|
+
if (!config) return { isInvisalign: false, percentage: 100, minAmount: 0 };
|
|
387
|
+
|
|
388
|
+
const percentage = ext.isFirstPhase ? config.firstPhasePercentage : config.otherPhasesPercentage;
|
|
389
|
+
const minAmount = ext.isFirstPhase ? config.firstPhaseMin : config.otherPhasesMin;
|
|
390
|
+
|
|
391
|
+
return { isInvisalign: true, percentage, minAmount };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function applyInvisalignMin(
|
|
395
|
+
ext: ExtendedProductionData,
|
|
396
|
+
calculated: number,
|
|
397
|
+
context: CalculationContext,
|
|
398
|
+
invisalignResult: InvisalignResult,
|
|
399
|
+
): number {
|
|
400
|
+
if (!invisalignResult.isInvisalign) return calculated;
|
|
401
|
+
if (ext.scope !== "completed_rows") return calculated;
|
|
402
|
+
if (calculated === 0) return calculated;
|
|
403
|
+
|
|
404
|
+
const userId = ext.completedBy ?? "";
|
|
405
|
+
if (!context.invisalignMinUserIds.has(userId)) return calculated;
|
|
406
|
+
|
|
407
|
+
const sign = calculated < 0 ? -1 : 1;
|
|
408
|
+
return sign * Math.max(sign * calculated, invisalignResult.minAmount);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── Rifacimenti ──────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
function applyRefactorLogic(
|
|
414
|
+
ext: ExtendedProductionData,
|
|
415
|
+
calculated: number,
|
|
416
|
+
listPrice: number,
|
|
417
|
+
): number {
|
|
418
|
+
if (!ext.refactorRowId) return calculated;
|
|
419
|
+
|
|
420
|
+
if (ext.netPrice === 0 && ext.carePlanRowTypeId !== undefined && ext.carePlanRowTypeId !== 4) {
|
|
421
|
+
return 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (listPrice < 0 || ext.carePlanRowTypeId === 1) {
|
|
425
|
+
const paidLastTime = ext.scope === "confirmed_care_plan"
|
|
426
|
+
? ext.refactorCalculatedConfirmedCarePlan
|
|
427
|
+
: ext.refactorCalculated;
|
|
428
|
+
|
|
429
|
+
if (paidLastTime !== undefined && paidLastTime !== null && paidLastTime >= 0) {
|
|
430
|
+
return -paidLastTime;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return calculated;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Casi speciali ────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
function applySpecialCases(
|
|
440
|
+
ext: ExtendedProductionData,
|
|
441
|
+
calculated: number,
|
|
442
|
+
netPrice: number,
|
|
443
|
+
listPrice: number,
|
|
444
|
+
ruleFixedAmount: number | undefined,
|
|
445
|
+
): number {
|
|
446
|
+
if (ext.carePlanTypeId === 4 && ruleFixedAmount !== undefined && netPrice === 0 && listPrice < 0) {
|
|
447
|
+
return calculated * -1;
|
|
448
|
+
}
|
|
449
|
+
return calculated;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── 4. Orchestrazione per singola entry ──────────────────
|
|
453
|
+
|
|
454
|
+
export function calculateForEntry(
|
|
455
|
+
entry: ProductionEntry,
|
|
456
|
+
plans: ResolvedPlan[],
|
|
457
|
+
rulesByPlan: Map<string, ResolvedRule[]>,
|
|
458
|
+
createdBy: string,
|
|
459
|
+
context: CalculationContext,
|
|
460
|
+
): EngineResult | UnmatchedEntry {
|
|
461
|
+
const plan = findActivePlan(plans, entry.userId, entry.period);
|
|
462
|
+
if (!plan) {
|
|
463
|
+
return {
|
|
464
|
+
entryId: entry._id,
|
|
465
|
+
userId: entry.userId,
|
|
466
|
+
clinicId: entry.clinicId,
|
|
467
|
+
amount: entry.amount,
|
|
468
|
+
reason: "no_active_plan",
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const rules = rulesByPlan.get(plan._id) ?? [];
|
|
473
|
+
const matchResult = findMatchingRule(entry, rules, entry.effectiveDate);
|
|
474
|
+
if (!matchResult) {
|
|
475
|
+
return {
|
|
476
|
+
entryId: entry._id,
|
|
477
|
+
userId: entry.userId,
|
|
478
|
+
clinicId: entry.clinicId,
|
|
479
|
+
amount: entry.amount,
|
|
480
|
+
reason: "no_matching_rule",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const { rule, matchedFields } = matchResult;
|
|
485
|
+
|
|
486
|
+
const formulaResult = entry.extended
|
|
487
|
+
? evaluateExtendedFormula(rule.formula as Record<string, unknown>, entry, context)
|
|
488
|
+
: evaluateSimpleFormula(rule.formula as Record<string, unknown>, entry.amount);
|
|
489
|
+
|
|
490
|
+
const scope: CommissionScope = entry.extended?.scope ?? "completed_rows";
|
|
491
|
+
|
|
492
|
+
const commissionEntry: CommissionEntryData = {
|
|
493
|
+
runId: entry.runId,
|
|
494
|
+
userId: entry.userId,
|
|
495
|
+
clinicId: entry.clinicId,
|
|
496
|
+
type: "commission",
|
|
497
|
+
sourceType: "system",
|
|
498
|
+
sourceId: entry._id,
|
|
499
|
+
planId: plan._id,
|
|
500
|
+
ruleId: rule._id,
|
|
501
|
+
scope,
|
|
502
|
+
companyId: entry.extended?.companyId,
|
|
503
|
+
description: `${rule.explanationLabel} (${entry.type}: ${entry.sourceId ?? entry._id})`,
|
|
504
|
+
amount: formulaResult.amount,
|
|
505
|
+
quantity: 1,
|
|
506
|
+
unitAmount: formulaResult.amount,
|
|
507
|
+
isManual: false as const,
|
|
508
|
+
effectiveDate: entry.effectiveDate,
|
|
509
|
+
period: entry.period,
|
|
510
|
+
metadata: {
|
|
511
|
+
sourceEntryType: entry.type,
|
|
512
|
+
sourceAmount: entry.amount,
|
|
513
|
+
planCode: plan.code,
|
|
514
|
+
ruleName: rule.name,
|
|
515
|
+
ruleType: rule.ruleType,
|
|
516
|
+
scope,
|
|
517
|
+
},
|
|
518
|
+
createdBy,
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const ruleApplication: RuleApplicationData = {
|
|
522
|
+
runId: entry.runId,
|
|
523
|
+
sourceLedgerEntryId: entry._id,
|
|
524
|
+
planId: plan._id,
|
|
525
|
+
ruleId: rule._id,
|
|
526
|
+
matchedConditions: matchedFields,
|
|
527
|
+
formulaSnapshot: rule.formula as Record<string, unknown>,
|
|
528
|
+
formulaInput: formulaResult.input,
|
|
529
|
+
formulaOutput: formulaResult.output,
|
|
530
|
+
resultAmount: formulaResult.amount,
|
|
531
|
+
explanationLabel: rule.explanationLabel,
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
return { entry: commissionEntry, ruleApplication };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── 5. Calcolo batch ─────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
export function calculateBatch(
|
|
540
|
+
entries: ProductionEntry[],
|
|
541
|
+
plans: ResolvedPlan[],
|
|
542
|
+
rulesByPlan: Map<string, ResolvedRule[]>,
|
|
543
|
+
createdBy: string,
|
|
544
|
+
context?: CalculationContext,
|
|
545
|
+
): CalculationBatchResult {
|
|
546
|
+
const ctx: CalculationContext = context ?? {
|
|
547
|
+
deductionRules: [],
|
|
548
|
+
invisalignConfig: [],
|
|
549
|
+
invisalignMinUserIds: new Set(),
|
|
550
|
+
globalSettings: new Map(),
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const results: EngineResult[] = [];
|
|
554
|
+
const unmatched: UnmatchedEntry[] = [];
|
|
555
|
+
let totalCommissionAmount = 0;
|
|
556
|
+
|
|
557
|
+
for (const entry of entries) {
|
|
558
|
+
const result = calculateForEntry(entry, plans, rulesByPlan, createdBy, ctx);
|
|
559
|
+
|
|
560
|
+
if ("entry" in result) {
|
|
561
|
+
results.push(result);
|
|
562
|
+
totalCommissionAmount += result.entry.amount;
|
|
563
|
+
} else {
|
|
564
|
+
unmatched.push(result);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
results,
|
|
570
|
+
unmatched,
|
|
571
|
+
summary: {
|
|
572
|
+
totalProcessed: entries.length,
|
|
573
|
+
totalMatched: results.length,
|
|
574
|
+
totalUnmatched: unmatched.length,
|
|
575
|
+
totalCommissionAmount: roundCurrency(totalCommissionAmount),
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
export function parsePeriodBounds(period: string): {
|
|
583
|
+
periodStart: number;
|
|
584
|
+
periodEnd: number;
|
|
585
|
+
} {
|
|
586
|
+
const [yearStr, monthStr] = period.split("-");
|
|
587
|
+
const year = parseInt(yearStr, 10);
|
|
588
|
+
const month = parseInt(monthStr, 10);
|
|
589
|
+
|
|
590
|
+
const periodStart = new Date(year, month - 1, 1).getTime();
|
|
591
|
+
const periodEnd = new Date(year, month, 0, 23, 59, 59, 999).getTime();
|
|
592
|
+
|
|
593
|
+
return { periodStart, periodEnd };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function roundCurrency(value: number): number {
|
|
597
|
+
return Math.round(value * 100) / 100;
|
|
598
|
+
}
|