From 665943288a3fbdc12768e42bbc432b5d999f4dc0 Mon Sep 17 00:00:00 2001 From: Johan Girod Date: Mon, 24 Feb 2020 18:34:38 +0100 Subject: [PATCH] =?UTF-8?q?:gear:=20Ajoute=20le=20m=C3=A9canisme=20r=C3=A9?= =?UTF-8?q?gularisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - améliore la gestion des unités pour les variables temporelles --- publicode/rules/salarié.yaml | 5 - source/engine/date.ts | 38 +++- source/engine/evaluation.tsx | 169 +++++++++--------- source/engine/mecanisms.js | 66 +------ source/engine/mecanisms/régularisation.ts | 150 ++++++++++++++++ source/engine/mecanisms/variableTemporelle.ts | 41 +++-- source/engine/parse.tsx | 2 + source/engine/period.ts | 167 ++++++++++++++--- test/date.test.js | 19 ++ test/mécanismes/régularisation.yaml | 40 +++++ test/mécanismes/variable-temporelle.yaml | 18 +- test/period.test.js | 61 +++++++ 12 files changed, 587 insertions(+), 189 deletions(-) create mode 100644 source/engine/mecanisms/régularisation.ts create mode 100644 test/date.test.js create mode 100644 test/mécanismes/régularisation.yaml diff --git a/publicode/rules/salarié.yaml b/publicode/rules/salarié.yaml index 7138a0e3e..c8685d5f9 100644 --- a/publicode/rules/salarié.yaml +++ b/publicode/rules/salarié.yaml @@ -1876,11 +1876,6 @@ contrat salarié . statut JEI . exonération de cotisations: - vieillesse .employeur contrat salarié . réduction générale: - aide: - type: réduction de cotisations - thème: aide bas salaires - démarches: non - alias: réduction fillon description: | Dans le cadre du pacte de responsabilité et de solidarité, le dispositif zéro cotisation Urssaf permet à l'employeur d'un salarié au Smic de ne plus payer aucune cotisation. Le montant de l'allègement est égal au produit de la rémunération annuelle brute par un coefficient. Il n'y a pas de formalité particulière à effectuer. références: diff --git a/source/engine/date.ts b/source/engine/date.ts index 2d2a8260a..04bf8b824 100644 --- a/source/engine/date.ts +++ b/source/engine/date.ts @@ -21,7 +21,11 @@ export function normalizeDate( const dateRegexp = /[\d]{2}\/[\d]{2}\/[\d]{4}/ export function convertToDate(value: string): Date { const [day, month, year] = normalizeDateString(value).split('/') - return new Date(+year, +month - 1, +day) + var result = new Date(+year, +month - 1, +day) + // Reset date to utc midnight for exact calculation of day difference (no + // daylight saving effect) + result.setMinutes(result.getMinutes() - result.getTimezoneOffset()) + return result } export function convertToDateIfNeeded(...values: string[]) { const dateStrings = values.map(dateString => '' + dateString) @@ -47,3 +51,35 @@ export function getRelativeDate(date: string, dayDifferential: number): string { relativeDate.setDate(relativeDate.getDate() + dayDifferential) return convertToString(relativeDate) } + +export function getYear(date: string): number { + return +date.slice(-4) +} + +export function getDifferenceInDays(from: string, to: string): number { + const millisecondsPerDay = 1000 * 60 * 60 * 24 + return ( + 1 + + (convertToDate(from).getTime() - convertToDate(to).getTime()) / + millisecondsPerDay + ) +} + +export function getDifferenceInMonths(from: string, to: string): number { + // We want to compute the difference in actual month between the two dates + // For date that start during a month, a pro-rata will be done depending on + // the duration of the month in days + const [dayFrom, monthFrom, yearFrom] = from.split('/').map(x => +x) + const [dayTo, monthTo, yearTo] = to.split('/').map(x => +x) + const numberOfFullMonth = monthTo - monthFrom + 12 * (yearTo - yearFrom) + const numDayMonthFrom = new Date(yearFrom, monthFrom, 0).getDate() + const numDayMonthTo = new Date(yearTo, monthTo, 0).getDate() + const prorataMonthFrom = (dayFrom - 1) / numDayMonthFrom + const prorataMonthTo = dayTo / numDayMonthTo + return numberOfFullMonth - prorataMonthFrom + prorataMonthTo +} + +export function getDifferenceInYears(from: string, to: string): number { + // Todo : take leap year into account + return getDifferenceInDays(from, to) / 365.25 +} diff --git a/source/engine/evaluation.tsx b/source/engine/evaluation.tsx index 4ef7bfc14..216a264fc 100644 --- a/source/engine/evaluation.tsx +++ b/source/engine/evaluation.tsx @@ -1,7 +1,5 @@ import { add, - any, - equals, evolve, filter, fromPairs, @@ -16,8 +14,8 @@ import { concatTemporals, liftTemporalNode, mapTemporal, - periodAverage, - pure, + pureTemporal, + temporalAverage, zipTemporals } from './period' @@ -47,14 +45,15 @@ export let evaluateNode = (cache, situationGate, parsedRules, node) => { : simplifyNodeUnit(evaluatedNode) return evaluatedNode } -const sameUnitValues = (explanation, contextRule, mecanismName) => { - const firstNodeWithUnit = explanation.find(node => !!node.unit) + +function convertNodesToSameUnit(nodes, contextRule, mecanismName) { + const firstNodeWithUnit = nodes.find(node => !!node.unit) if (!firstNodeWithUnit) { - return [undefined, explanation.map(({ nodeValue }) => nodeValue)] + return nodes } - const values = explanation.map(node => { + return nodes.map(node => { try { - return convertNodeToUnit(firstNodeWithUnit?.unit, node).nodeValue + return convertNodeToUnit(firstNodeWithUnit.unit, node) } catch (e) { typeWarning( contextRule, @@ -63,77 +62,66 @@ const sameUnitValues = (explanation, contextRule, mecanismName) => { firstNodeWithUnit?.rawNode}'`, e ) - return node.nodeValue + return node } }) - return [firstNodeWithUnit.unit, values] } -export let evaluateArray = (reducer, start) => ( +export const evaluateArray = (reducer, start) => ( cache, situationGate, parsedRules, node ) => { const evaluate = evaluateNode.bind(null, cache, situationGate, parsedRules) - const temporalExplanation = concatTemporals( - node.explanation.map(evaluate).map(liftTemporalNode) + const evaluatedNodes = convertNodesToSameUnit( + node.explanation.map(evaluate), + cache._meta.contextRule, + node.name ) - const temporalEvaluations = mapTemporal(explanation => { - explanation - const [unit, values] = sameUnitValues( - explanation, - cache._meta.contextRule, - node.name - ) - const nodeValue = values.some(value => value === null) - ? null - : reduce(reducer, start, values) - const missingVariables = - node.nodeValue == null ? mergeAllMissing(explanation) : {} - return { - ...node, - nodeValue, - explanation, - missingVariables, - unit - } - }, temporalExplanation) - if (temporalEvaluations.length === 1) { - return temporalEvaluations[0] + if (!evaluatedNodes.every(Boolean)) { + console.log(node.explanation) } - const temporalValue = mapTemporal(node => node.nodeValue, temporalEvaluations) - return { + const temporalValues = concatTemporals( + evaluatedNodes.map( + ({ temporalValue, nodeValue }) => temporalValue ?? pureTemporal(nodeValue) + ) + ) + const temporalValue = mapTemporal(values => { + if (values.some(value => value === null)) { + return null + } + return reduce(reducer, start, values) + }, temporalValues) + + const baseEvaluation = { ...node, + explanation: evaluatedNodes, + unit: evaluatedNodes[0].unit + } + if (temporalValue.length === 1) { + return { + ...baseEvaluation, + nodeValue: temporalValue[0].value + } + } + return { + ...baseEvaluation, temporalValue, - nodeValue: periodAverage(temporalValue) + nodeValue: temporalAverage(temporalValue) } } -export let evaluateArrayWithFilter = (evaluationFilter, reducer, start) => ( +export const evaluateArrayWithFilter = (evaluationFilter, reducer, start) => ( cache, situationGate, parsedRules, node ) => { - let evaluateOne = child => - evaluateNode(cache, situationGate, parsedRules, child), - explanation = map( - evaluateOne, - filter(evaluationFilter(situationGate), node.explanation) - ), - [unit, values] = sameUnitValues( - explanation, - cache._meta.contextRule, - node.name - ), - nodeValue = any(equals(null), values) - ? null - : reduce(reducer, start, values), - missingVariables = - node.nodeValue == null ? mergeAllMissing(explanation) : {} - - return { ...node, nodeValue, explanation, missingVariables, unit } + return evaluateArray(reducer, start)(cache, situationGate, parsedRules, { + ...node, + explanation: filter(evaluationFilter(situationGate), node.explanation) + }) } export let defaultNode = nodeValue => ({ @@ -167,36 +155,53 @@ export let evaluateObject = (objectShape, effect) => ( Object.fromEntries, concatTemporals( Object.entries(evaluations).map(([key, node]) => - zipTemporals(pure(key), liftTemporalNode(node)) + zipTemporals(pureTemporal(key), liftTemporalNode(node)) ) ) ) - const temporalEvaluations = mapTemporal( - explanations => effect(explanations, cache, situationGate, parsedRules), - temporalExplanations - ) + const temporalExplanation = mapTemporal(explanations => { + const evaluation = effect(explanations, cache, situationGate, parsedRules) + return { + ...evaluation, + explanation: { + ...explanations, + ...evaluation.explanation + } + } + }, temporalExplanations) + + const sameUnitTemporalExplanation = convertNodesToSameUnit( + temporalExplanation.map(x => x.value), + cache._meta.contextRule, + node.name + ).map((node, i) => ({ + ...temporalExplanation[i], + value: simplifyNodeUnit(node) + })) const temporalValue = mapTemporal( - evaluation => - evaluation !== null && typeof evaluation === 'object' - ? evaluation.nodeValue - : evaluation, - temporalEvaluations + ({ nodeValue }) => nodeValue, + sameUnitTemporalExplanation ) - const nodeValue = periodAverage(temporalValue) - - return simplifyNodeUnit({ + const nodeValue = temporalAverage(temporalValue) + if (nodeValue === 495) { + console.log(temporalValue) + } + const baseEvaluation = { ...node, nodeValue, - ...(temporalEvaluations.length > 1 - ? { temporalValue } - : { - missingVariables: mergeAllMissing(Object.values(evaluations)), - explanation: { - ...evaluations, - ...temporalEvaluations[0].additionalExplanation - }, - unit: temporalEvaluations[0].unit - }) - }) + unit: sameUnitTemporalExplanation[0].value.unit, + explanation: evaluations + } + if (sameUnitTemporalExplanation.length === 1) { + return { + ...baseEvaluation, + explanation: sameUnitTemporalExplanation[0].value.explanation + } + } + return { + ...baseEvaluation, + temporalValue, + temporalExplanation + } } diff --git a/source/engine/mecanisms.js b/source/engine/mecanisms.js index 1e915cdcd..337e8cb31 100644 --- a/source/engine/mecanisms.js +++ b/source/engine/mecanisms.js @@ -15,11 +15,9 @@ import { path, pluck, reduce, - subtract, toPairs } from 'ramda' import React from 'react' -import { Trans } from 'react-i18next' import 'react-virtualized/styles.css' import { typeWarning } from './error' import { @@ -281,7 +279,7 @@ export let mecanismRecalcul = dottedNameContext => (recurse, k, v) => { let evaluate = (currentCache, situationGate, parsedRules, node) => { let defaultRuleToEvaluate = dottedNameContext let nodeToEvaluate = recurse(node?.règle ?? defaultRuleToEvaluate) - let cache = { _meta: currentCache._meta, _metaInRecalcul: true } // Create an empty cache + let cache = { _meta: { ...currentCache._meta, inRecalcul: true } } // Create an empty cache let amendedSituation = Object.fromEntries( Object.keys(node.avec).map(dottedName => [ disambiguateRuleReference( @@ -293,7 +291,7 @@ export let mecanismRecalcul = dottedNameContext => (recurse, k, v) => { ]) ) - if (currentCache._metaInRecalcul) { + if (currentCache._meta.inRecalcul) { return defaultNode(false) } @@ -376,7 +374,7 @@ export let mecanismReduction = (recurse, k, v) => { cache ) => { let v_assiette = assiette.nodeValue - if (v_assiette == null) return null + if (v_assiette == null) return { nodeValue: null } if (assiette.unit) { try { franchise = convertNodeToUnit(assiette.unit, franchise) @@ -431,8 +429,8 @@ export let mecanismReduction = (recurse, k, v) => { : montantFranchiséDécoté return { nodeValue, - additionalExplanation: { - unit: assiette.unit, + unit: assiette.unit, + explanation: { franchise, plafond, abattement @@ -509,9 +507,10 @@ export let mecanismProduct = (recurse, k, v) => { ) return { nodeValue, - additionalExplanation: { - plafondActif: assiette.nodeValue > plafond.nodeValue, - unit + + unit, + explanation: { + plafondActif: assiette.nodeValue > plafond.nodeValue } } } @@ -601,53 +600,6 @@ export let mecanismMin = (recurse, k, v) => { } } -export let mecanismComplement = (recurse, k, v) => { - if (v.composantes) { - //mécanisme de composantes. Voir known-mecanisms.md/composantes - return decompose(recurse, k, v) - } - - let objectShape = { cible: false, montant: false } - let effect = ({ cible, montant }) => { - let nulled = cible.nodeValue == null - return nulled - ? null - : subtract(montant.nodeValue, min(cible.nodeValue, montant.nodeValue)) - } - let explanation = parseObject(recurse, objectShape, v) - - return { - evaluate: evaluateObject(objectShape, effect), - explanation, - type: 'numeric', - category: 'mecanism', - name: 'complément pour atteindre', - // eslint-disable-next-line - jsx: (nodeValue, explanation) => ( - - - - ) - } -} - export let mecanismSynchronisation = (recurse, k, v) => { let evaluate = (cache, situationGate, parsedRules, node) => { let APIExplanation = evaluateNode( diff --git a/source/engine/mecanisms/régularisation.ts b/source/engine/mecanisms/régularisation.ts new file mode 100644 index 000000000..a1fda1450 --- /dev/null +++ b/source/engine/mecanisms/régularisation.ts @@ -0,0 +1,150 @@ +import { convertToString, getYear } from 'Engine/date' +import { evaluationError } from 'Engine/error' +import { evaluateNode } from 'Engine/evaluation' +import { + createTemporalEvaluation, + Evaluation, + groupByYear, + liftTemporal2, + Temporal, + temporalAverage, + temporalCumul +} from 'Engine/period' +import { Unit } from 'Engine/units' +import { DottedName } from 'Types/rule' +import { coerceArray } from '../../utils' + +export default function parse(parse, k, v) { + const rule = parse(v.règle) + if (!v['valeurs cumulées']) { + throw new Error( + 'Il manque la clé `valeurs cumulées` dans le mécanisme régularisation' + ) + } + + const variables = coerceArray(v['valeurs cumulées']).map(parse) as Array<{ + dottedName: DottedName + category: string + name: 'string' + }> + if (variables.some(({ category }) => category !== 'reference')) { + throw new Error( + 'Le mécanisme régularisation attend des noms de règles sous la clé `valeurs cumulées`' + ) + } + + return { + evaluate, + explanation: { + rule, + variables + }, + category: 'mecanism', + name: 'taux progressif', + type: 'numeric', + unit: rule.unit + } +} + +function getMonthlyCumulatedValuesOverYear( + year: number, + variable: Temporal>, + unit: Unit +): Temporal> { + const start = convertToString(new Date(year, 0, 1)) + const cumulatedPeriods = [...Array(12).keys()] + .map(monthNumber => ({ + start, + end: convertToString(new Date(year, monthNumber + 1, 0)) + })) + .map(period => { + const temporal = liftTemporal2( + (filter, value) => filter && value, + createTemporalEvaluation(true, period), + variable + ) + return { + ...period, + value: temporalCumul(temporal, unit) + } + }) + + return cumulatedPeriods +} + +function evaluate( + cache, + situation, + parsedRules, + node: ReturnType +) { + const evaluate = evaluateNode.bind(null, cache, situation, parsedRules) + + function recalculWith(situationGate: (dottedName: DottedName) => any, node) { + const newSituation = (dottedName: DottedName) => + situationGate(dottedName) ?? situation(dottedName) + return evaluateNode({ _meta: cache._meta }, newSituation, parsedRules, node) + } + + function regulariseYear(temporalEvaluation: Temporal>) { + if (temporalEvaluation.filter(({ value }) => value !== false).length <= 1) { + return temporalEvaluation + } + + const currentYear = getYear(temporalEvaluation[0].start as string) + const cumulatedVariables = node.explanation.variables.reduce( + (acc, parsedVariable) => { + const evaluation = evaluate(parsedVariable) + if (!evaluation.temporalValue) { + evaluationError( + cache._meta.contextRule, + `Dans le mécanisme régularisation, la valeur annuelle ${parsedVariable.name} n'est pas une variables temporelle` + ) + } + return { + ...acc, + [parsedVariable.dottedName]: getMonthlyCumulatedValuesOverYear( + currentYear, + evaluation.temporalValue, + evaluation.unit + ) + } + }, + {} + ) + + const cumulatedMonthlyEvaluations = [...Array(12).keys()].map(i => ({ + start: convertToString(new Date(currentYear, i, 1)), + end: convertToString(new Date(currentYear, i + 1, 0)), + value: recalculWith( + dottedName => cumulatedVariables[dottedName]?.[i].value, + node.explanation.rule + ).nodeValue + })) + const temporalRégularisée = cumulatedMonthlyEvaluations.map( + (period, i) => ({ + ...period, + value: period.value - (cumulatedMonthlyEvaluations[i - 1]?.value ?? 0) + }) + ) + + return temporalRégularisée as Temporal> + } + + const evaluation = evaluate(node.explanation.rule) + const temporalValue = evaluation.temporalValue + const evaluationWithRegularisation = groupByYear( + temporalValue as Temporal> + ) + .map(regulariseYear) + .flat() + + return { + ...node, + temporalValue: evaluationWithRegularisation, + explanation: evaluation, + nodeValue: temporalAverage(temporalValue), + missingVariables: evaluation.missingVariables, + unit: evaluation.unit + } +} diff --git a/source/engine/mecanisms/variableTemporelle.ts b/source/engine/mecanisms/variableTemporelle.ts index 90c9eb748..7b540fc3e 100644 --- a/source/engine/mecanisms/variableTemporelle.ts +++ b/source/engine/mecanisms/variableTemporelle.ts @@ -2,7 +2,7 @@ import { evaluateNode } from 'Engine/evaluation' import { createTemporalEvaluation, narrowTemporalValue, - periodAverage + temporalAverage } from 'Engine/period' import { Temporal } from './../period' @@ -19,35 +19,46 @@ function evaluate( parsedRules ) - const start = node.period.start && evaluateAttribute(node.period.start) - const end = node.period.end && evaluateAttribute(node.period.end) - const explanation = evaluateAttribute(node.explanation) + const start = + node.explanation.period.start && + evaluateAttribute(node.explanation.period.start) + const end = + node.explanation.period.end && + evaluateAttribute(node.explanation.period.end) + const value = evaluateAttribute(node.explanation.value) const period = { start: start?.nodeValue ?? null, end: end?.nodeValue ?? null } - const temporalValue = explanation.temporalValue - ? narrowTemporalValue(period, explanation.temporalValue) - : createTemporalEvaluation(explanation.nodeValue, period) + const temporalValue = value.temporalValue + ? narrowTemporalValue(period, value.temporalValue) + : createTemporalEvaluation(value.nodeValue, period) // TODO explanation missingVariables / period missing variables return { ...node, - nodeValue: periodAverage(temporalValue as Temporal), + nodeValue: temporalAverage(temporalValue as Temporal, value.unit), temporalValue, - period: { start, end }, - explanation + explanation: { + period: { start, end }, + value + }, + unit: value.unit } } export default function parseVariableTemporelle(parse, __, v: any) { + const explanation = parse(v.explanation) return { evaluate, - explanation: parse(v.explanation), - period: { - start: v.period.start && parse(v.period.start), - end: v.period.end && parse(v.period.end) - } + explanation: { + period: { + start: v.period.start && parse(v.period.start), + end: v.period.end && parse(v.period.end) + }, + value: explanation + }, + unit: explanation.unit } } diff --git a/source/engine/parse.tsx b/source/engine/parse.tsx index e82875715..0fe14112d 100644 --- a/source/engine/parse.tsx +++ b/source/engine/parse.tsx @@ -9,6 +9,7 @@ import durée from 'Engine/mecanisms/durée' import encadrement from 'Engine/mecanisms/encadrement' import grille from 'Engine/mecanisms/grille' import operation from 'Engine/mecanisms/operation' +import régularisation from 'Engine/mecanisms/régularisation' import tauxProgressif from 'Engine/mecanisms/tauxProgressif' import variableTemporelle from 'Engine/mecanisms/variableTemporelle' import variations from 'Engine/mecanisms/variations' @@ -183,6 +184,7 @@ const statelessParseFunction = { 'une de ces conditions': mecanismOneOf, 'toutes ces conditions': mecanismAllOf, somme: mecanismSum, + régularisation, multiplication: mecanismProduct, produit: mecanismProduct, temporalValue: variableTemporelle, diff --git a/source/engine/period.ts b/source/engine/period.ts index 0a6771add..4b2135d9c 100644 --- a/source/engine/period.ts +++ b/source/engine/period.ts @@ -1,8 +1,16 @@ -import { convertToDate, getRelativeDate } from 'Engine/date' +import { + convertToDate, + getDifferenceInDays, + getDifferenceInMonths, + getDifferenceInYears, + getRelativeDate, + getYear +} from 'Engine/date' +import { Unit } from './units' -export type Period = { - start: Date | null - end: Date | null +export type Period = { + start: T | null + end: T | null } export function parsePeriod(word: string, date: Date): Period { @@ -38,7 +46,6 @@ export function parsePeriod(word: string, date: Date): Period { } } if (word === 'en') { - console.log(word, date) return { start: null, end: null } } if (startWords.includes(word)) { @@ -58,14 +65,14 @@ export function parsePeriod(word: string, date: Date): Period { // Idée : une évaluation est un n-uple : (value, unit, missingVariable, isApplicable) // Une temporalEvaluation est une liste d'evaluation sur chaque période. : [(Evaluation, Period)] -type Evaluation = T | false | null +export type Evaluation = T | false | null -type EvaluatedNode = { +export type EvaluatedNode = { nodeValue: Evaluation temporalValue?: Temporal> } -type TemporalNode = Temporal<{ nodeValue: Evaluation }> +export type TemporalNode = Temporal<{ nodeValue: Evaluation }> export type Temporal = Array & { value: T }> export function narrowTemporalValue( @@ -102,7 +109,7 @@ export function createTemporalEvaluation( return temporalValue } -export function pure(value: T): Temporal { +export function pureTemporal(value: T): Temporal { return [{ start: null, end: null, value }] } @@ -117,7 +124,7 @@ export function mapTemporal( })) } -function liftTemporal2( +export function liftTemporal2( fn: (value1: T1, value2: T2) => T3, temporalValue1: Temporal, temporalValue2: Temporal @@ -133,14 +140,14 @@ export function concatTemporals( ): Temporal> { return temporalValues.reduce( (values, value) => liftTemporal2((a, b) => [...a, b], values, value), - pure([]) as Temporal> + pureTemporal([]) as Temporal> ) } export function liftTemporalNode(node: EvaluatedNode): TemporalNode { const { temporalValue, ...baseNode } = node if (!temporalValue) { - return pure(baseNode) + return pureTemporal(baseNode) } return mapTemporal( nodeValue => ({ @@ -211,6 +218,84 @@ export function zipTemporals( throw new EvalError('All case should have been covered') } +function beginningOfNextYear(date: string): string { + return `01/01/${getYear(date) + 1}` +} + +function endsOfPreviousYear(date: string): string { + return `31/12/${getYear(date) - 1}` +} + +function splitStartsAt( + fn: (date: string) => string, + temporal: Temporal +): Temporal { + return temporal.reduce((acc, period) => { + const { start, end } = period + const newStart = start === null ? start : fn(start) + if (compareEndDate(newStart, end) !== -1) { + return [...acc, period] + } + console.assert(newStart !== null) + return [ + ...acc, + { ...period, end: getRelativeDate(newStart as string, -1) }, + { ...period, start: newStart } + ] + }, [] as Temporal) +} + +function splitEndsAt( + fn: (date: string) => string, + temporal: Temporal +): Temporal { + return temporal.reduce((acc, period) => { + const { start, end } = period + const newEnd = end === null ? end : fn(end) + if (compareStartDate(start, newEnd) !== -1) { + return [...acc, period] + } + console.assert(newEnd !== null) + return [ + ...acc, + { ...period, end: newEnd }, + { ...period, start: getRelativeDate(newEnd as string, 1) } + ] + }, [] as Temporal) +} + +export function groupByYear(temporalValue: Temporal): Array> { + return ( + // First step: split period by year if needed + splitEndsAt( + endsOfPreviousYear, + splitStartsAt(beginningOfNextYear, temporalValue) + ) + // Second step: group period by year + .reduce((acc, period) => { + const [currentTemporal, ...otherTemporal] = acc + if (currentTemporal === undefined) { + return [[period]] + } + const firstPeriod = currentTemporal[0] + console.assert( + firstPeriod !== undefined && + firstPeriod.end !== null && + period.start !== null, + 'invariant non verifié' + ) + if ( + (firstPeriod.end as string).slice(-4) !== + (period.start as string).slice(-4) + ) { + return [[period], ...acc] + } + return [[...currentTemporal, period], ...otherTemporal] + }, [] as Array>) + .reverse() + ) +} + function simplify(temporalValue: Temporal): Temporal { return temporalValue } @@ -247,8 +332,9 @@ function compareEndDate( return convertToDate(dateA) < convertToDate(dateB) ? -1 : 1 } -export function periodAverage( - temporalValue: Temporal> +export function temporalAverage( + temporalValue: Temporal>, + unit?: Unit ): Evaluation { temporalValue = temporalValue.filter(({ value }) => value !== false) const first = temporalValue[0] @@ -265,7 +351,7 @@ export function periodAverage( if (last.end != null) { return first.value } - return first.value + last.value / 2 + return (first.value + last.value) / 2 } if (temporalValue.some(({ value }) => value == null)) { @@ -273,11 +359,14 @@ export function periodAverage( } let totalWeight = 0 const weights = temporalValue.map(({ start, end, value }) => { - const day = 1000 * 60 * 60 * 24 - const weight = - convertToDate(end as string).getTime() - - convertToDate(start as string).getTime() + - day + let weight = 0 + if (unit?.denominators.includes('mois')) { + weight = getDifferenceInMonths(start, end) + } else if (unit?.denominators.includes('année')) { + weight = getDifferenceInYears(start, end) + } else { + weight = getDifferenceInDays(start, end) + } totalWeight += weight return (value as number) * weight }) @@ -286,3 +375,41 @@ export function periodAverage( 0 ) } + +export function temporalCumul( + temporalValue: Temporal>, + unit: Unit +): Evaluation { + temporalValue = temporalValue.filter(({ value }) => value !== false) + const first = temporalValue[0] + const last = temporalValue[temporalValue.length - 1] + if (!temporalValue.length) { + return false + } + + // La variable est définie sur un interval infini + if (first.start == null || last.end == null) { + if (first.start != null) { + return !last.value ? last.value : last.value > 0 ? Infinity : -Infinity + } + if (last.end != null) { + return !last.value ? last.value : last.value > 0 ? Infinity : -Infinity + } + return null + } + if (temporalValue.some(({ value }) => value == null)) { + return null + } + + return temporalValue.reduce((acc, { start, end, value }) => { + let weight = 1 + if (unit?.denominators.includes('mois')) { + weight = getDifferenceInMonths(start, end) + } else if (unit?.denominators.includes('année')) { + weight = getDifferenceInYears(start, end) + } else if (unit?.denominators.includes('jour')) { + weight = getDifferenceInDays(start, end) + } + return value * weight + acc + }, 0) +} diff --git a/test/date.test.js b/test/date.test.js new file mode 100644 index 000000000..b8c6779ee --- /dev/null +++ b/test/date.test.js @@ -0,0 +1,19 @@ +import { expect } from 'chai' +import { getDifferenceInMonths } from '../source/engine/date' + +describe('Date : getDifferenceInMonths', () => { + it('should compute the difference for one full month', () => { + expect(getDifferenceInMonths('01/01/2020', '31/01/2020')).to.equal(1) + }) + it('should compute the difference for one month and one day', () => { + expect(getDifferenceInMonths('01/01/2020', '01/02/2020')).to.equal( + 1 + 1 / 29 + ) + }) + it('should compute the difference for 2 days between months', () => { + expect(getDifferenceInMonths('31/01/2020', '01/02/2020')).to.approximately( + 1 / 31 + 1 / 29, + 0.000000000001 + ) + }) +}) diff --git a/test/mécanismes/régularisation.yaml b/test/mécanismes/régularisation.yaml new file mode 100644 index 000000000..afb32a89e --- /dev/null +++ b/test/mécanismes/régularisation.yaml @@ -0,0 +1,40 @@ +salaire: + unité: €/mois + formule: + somme: + - 3300 €/mois | du 01/01/2020 | au 29/02/2020 + - 3600 €/mois | du 01/03/2020 | au 31/12/2020 + +plafond sécurité sociale: + unité: €/mois + formule: 3500 €/mois | du 01/01/2020 | au 31/12/2020 + +retraite: + formule: + multiplication: + assiette: salaire + plafond: plafond sécurité sociale + taux: 10% + +retraite . avec régularisation: + formule: + régularisation: + règle: retraite + valeurs cumulées: + - salaire + - plafond sécurité sociale + +régularisation . avant passage: + formule: retraite . avec régularisation | du 01/01/2020 | au 29/02/2020 + exemples: + - valeur attendue: 330 + +régularisation . test mois régularisés: + formule: retraite . avec régularisation | du 01/03/2020 | au 30/06/2020 + exemples: + - valeur attendue: 360 + +régularisation . test mois après régularisation: + formule: retraite . avec régularisation | du 01/07/2020 | au 31/12/2020 + exemples: + - valeur attendue: 350 diff --git a/test/mécanismes/variable-temporelle.yaml b/test/mécanismes/variable-temporelle.yaml index 6e5d6bc69..85672060b 100644 --- a/test/mécanismes/variable-temporelle.yaml +++ b/test/mécanismes/variable-temporelle.yaml @@ -164,15 +164,15 @@ variable temporelle numérique . multiplication avec valeur au dessus du plafond exemples: - valeur attendue: 337.7 # (2000 * 8 + 2200 * 23)/31 -# variable temporelle numérique . multiplication avec valeur sur l'année: -# formule: contrat salarié . cotisations . retraite | du 01/01/2019 | au 31/12/2019 -# exemples: -# # 200 * 6 [janvier-juin] -# # + 214.839 [juillet] -# # + 220 * 4 [aout-novembre] -# # + 337.7 [décembre] -# # /12 mois -# - valeur attendue: 219.378 +variable temporelle numérique . multiplication avec valeur sur l'année: + formule: contrat salarié . cotisations . retraite | du 01/01/2019 | au 31/12/2019 + exemples: + # 200 * 6 [janvier-juin] + # + 214.839 [juillet] + # + 220 * 4 [aout-novembre] + # + 337.7 [décembre] + # /12 mois + - valeur attendue: 219.378 # test . proratisation du salaire avec entrée en cours de mois: # formule: salaire brut [avril 2019] # exemples: diff --git a/test/period.test.js b/test/period.test.js index 014231d30..416c82aec 100644 --- a/test/period.test.js +++ b/test/period.test.js @@ -2,6 +2,7 @@ import { expect } from 'chai' import { concatTemporals, createTemporalEvaluation, + groupByYear, zipTemporals } from '../source/engine/period' @@ -69,3 +70,63 @@ describe('Periods : concat', () => { ]) }) }) + +describe('Periods : groupByYear', () => { + const invariants = temporalYear => { + const startDate = temporalYear[0].start + const endDate = temporalYear.slice(-1)[0].end + expect( + startDate === null || startDate.startsWith('01/01'), + 'starts at the beginning of a year' + ) + expect( + endDate === null || endDate.startsWith('31/12'), + 'stops at the end of a year' + ) + } + it('should handle constant value', () => { + const value = createTemporalEvaluation(10) + expect(groupByYear(value)).to.deep.equal([value]) + }) + it('should handle changing value', () => { + const value = createTemporalEvaluation(10, { + start: '06/06/2020', + end: '20/12/2020' + }) + const result = groupByYear(value) + expect(result).to.have.length(3) + result.forEach(invariants) + }) + it('should handle changing value over several years', () => { + const value = createTemporalEvaluation(10, { + start: '06/06/2020', + end: '20/12/2022' + }) + const result = groupByYear(value) + expect(result).to.have.length(5) + result.forEach(invariants) + }) + it('should handle complex case', () => { + const result = groupByYear( + concatTemporals([ + createTemporalEvaluation(1, { + start: '06/06/2020', + end: '20/12/2022' + }), + createTemporalEvaluation(2, { + start: '01/01/1991', + end: '20/12/1992' + }), + createTemporalEvaluation(3, { + start: '31/01/1990', + end: '20/12/2021' + }), + createTemporalEvaluation(4, { + start: '31/12/2020', + end: '01/01/2021' + }) + ]) + ) + result.forEach(invariants) + }) +})