diff --git a/cypress/integration/mon-entreprise/simulateurs.js b/cypress/integration/mon-entreprise/simulateurs.js index 99b1e9334..79a105f71 100644 --- a/cypress/integration/mon-entreprise/simulateurs.js +++ b/cypress/integration/mon-entreprise/simulateurs.js @@ -1,5 +1,6 @@ const fr = Cypress.env('language') === 'fr' const inputSelector = 'input.currencyInput__input:not([name$="charges"])' +const chargeInputSelector = 'input.currencyInput__input[name$="charges"]' describe('Simulateurs', function() { if (!fr) { return @@ -15,7 +16,7 @@ describe('Simulateurs', function() { it('should display a result when entering a value in any of the currency input', () => { cy.contains('€ / an').click() if (['indépendant', 'assimilé-salarié'].includes(simulateur)) { - cy.get('input.currencyInput__input[name$="charges"]').type(1000) + cy.get(chargeInputSelector).type(1000) } cy.get(inputSelector).each((testedInput, i) => { cy.wrap(testedInput).type('{selectall}60000') @@ -37,12 +38,21 @@ describe('Simulateurs', function() { cy.get(inputSelector) .first() .type('{selectall}12000') + if (['indépendant', 'assimilé-salarié'].includes(simulateur)) { + cy.get(chargeInputSelector).type('{selectall}6000') + } cy.wait(600) cy.contains('€ / mois').click() cy.get(inputSelector) .first() .invoke('val') .should('match', /1[\s]000/) + if (['indépendant', 'assimilé-salarié'].includes(simulateur)) { + cy.get(chargeInputSelector) + .first() + .invoke('val') + .should('be', '500') + } }) it('should allow to navigate to a documentation page', function() { diff --git a/publicode/rules/dirigeant.yaml b/publicode/rules/dirigeant.yaml index 95eb2ee99..4eb449178 100644 --- a/publicode/rules/dirigeant.yaml +++ b/publicode/rules/dirigeant.yaml @@ -426,6 +426,15 @@ dirigeant . indépendant . revenu net de cotisations: dirigeant . indépendant . revenu professionnel: unité par défaut: €/an + titre: revenu professionnel (net imposable) + description: | + C'est le revenu net de cotisations déductibles du travailleur indépendant, qui sert de base au calcul des cotisations et de l'impôt pour les indépendants. + + Attention, **notre calcul est fait au régime de croisière**: + l'indépendant qui se lance paiera pendant ses 2 premières années un forfait relativement réduit de cotisations sociales. Il devra ensuite régulariser cette situation par rapport au revenu qu'il a vraiment perçu. + + Il faut donc voir ce calcul comme *le montant qui devra de toute façon être payé* à court terme après 2 ans d'exercice. + formule: inversion numérique: avec: @@ -437,7 +446,6 @@ dirigeant . indépendant . revenu professionnel: valeurs négatives possibles: oui dirigeant . indépendant . assiette des cotisations: - unité par défaut: €/an formule: encadrement: plancher: 0 @@ -1032,7 +1040,6 @@ dirigeant . indépendant . cotisations et contributions . cotisations . allocati taux: 3.1% dirigeant . indépendant . cotisations et contributions . exonérations: - période: flexible formule: somme: - ZFU diff --git a/publicode/rules/impôt.yaml b/publicode/rules/impôt.yaml index c24c46f21..cd125770e 100644 --- a/publicode/rules/impôt.yaml +++ b/publicode/rules/impôt.yaml @@ -12,7 +12,6 @@ impôt: produit: assiette: revenu imposable taux: taux du prélèvement à la source - - sinon: 0 - CEHR - dirigeant . auto-entrepreneur . impôt . versement libératoire . montant @@ -358,6 +357,7 @@ impôt . taux personnalisé: revenus net de cotisations: résumé: Avant impôt + unité: €/an question: Quel revenu avant impôt voulez-vous toucher ? description: | Il s'agit du revenu net de cotisations et de charges, avant le paiement de l'impôt sur le revenu. diff --git a/publicode/rules/salarié.yaml b/publicode/rules/salarié.yaml index 7138a0e3e..a0f710a24 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: @@ -2903,13 +2898,12 @@ contrat salarié . taxe sur les salaires: entreprise . effectif: 1 valeur attendue: 0 - nom: association non lucrative - unités par défaut: [€/mois] situation: entreprise . association non lucrative: oui rémunération . brut de base: 2300 entreprise . effectif: 10 complémentaire santé . forfait: 0 - valeur attendue: 48.10 + valeur attendue: 577 références: fiche: https://www.service-public.fr/professionnels-entreprises/vosdroits/F22576 diff --git a/source/components/PaySlip.tsx b/source/components/PaySlip.tsx index 973557258..7eda9358d 100644 --- a/source/components/PaySlip.tsx +++ b/source/components/PaySlip.tsx @@ -103,13 +103,13 @@ export default function PaySlip() { {/* Salaire chargé */} diff --git a/source/components/PaySlipSections.js b/source/components/PaySlipSections.js index 3b8659807..818ca3be1 100644 --- a/source/components/PaySlipSections.js +++ b/source/components/PaySlipSections.js @@ -1,6 +1,8 @@ import Value from 'Components/Value' import React from 'react' import { Trans } from 'react-i18next' +import { useSelector } from 'react-redux' +import { defaultUnitSelector } from 'Selectors/analyseSelectors' import RuleLink from './RuleLink' export let SalaireBrutSection = ({ getRule }) => { @@ -43,12 +45,20 @@ export let SalaireBrutSection = ({ getRule }) => { ) } -export let Line = ({ rule, ...props }) => ( - <> - - - -) +export let Line = ({ rule, ...props }) => { + const defaultUnit = useSelector(defaultUnitSelector) + return ( + <> + + + + ) +} export let SalaireNetSection = ({ getRule }) => { let avantagesEnNature = getRule( diff --git a/source/components/PeriodSwitch.tsx b/source/components/PeriodSwitch.tsx index dbc8fd0d4..a42b7be43 100644 --- a/source/components/PeriodSwitch.tsx +++ b/source/components/PeriodSwitch.tsx @@ -3,13 +3,14 @@ import { parseUnit, serializeUnit } from 'Engine/units' import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' -import { defaultUnitsSelector } from 'Selectors/analyseSelectors' +import { defaultUnitSelector } from 'Selectors/analyseSelectors' import './PeriodSwitch.css' export default function PeriodSwitch() { const dispatch = useDispatch() - const currentUnit = useSelector(defaultUnitsSelector)[0] const language = useTranslation().i18n.language + const currentUnit = useSelector(defaultUnitSelector) + let units = ['€/mois', '€/an'] return ( diff --git a/source/components/SalaryExplanation.tsx b/source/components/SalaryExplanation.tsx index 20d50f780..b4c8b3cd9 100644 --- a/source/components/SalaryExplanation.tsx +++ b/source/components/SalaryExplanation.tsx @@ -10,7 +10,7 @@ import { useSelector } from 'react-redux' import { RootState } from 'Reducers/rootReducer' import { analysisWithDefaultsSelector, - defaultUnitsSelector + defaultUnitSelector } from 'Selectors/analyseSelectors' import * as Animate from 'Ui/animate' @@ -51,9 +51,9 @@ export default function SalaryExplanation() { {emoji('📊')} Voir la répartition des cotisations -
+
)} @@ -117,7 +117,7 @@ function RevenueRepatitionSection() { } function PaySlipSection() { - const unit = useSelector(defaultUnitsSelector)[0] + const unit = useSelector(defaultUnitSelector) return (

diff --git a/source/components/Value.tsx b/source/components/Value.tsx index 5d02570b7..464b7ca44 100644 --- a/source/components/Value.tsx +++ b/source/components/Value.tsx @@ -26,12 +26,16 @@ export type ValueProps = Partial< children: number negative: boolean customCSS: string + defaultUnit?: string + printedUnit?: string } > export default function Value({ nodeValue: value, unit, + defaultUnit, + printedUnit, nilValueSymbol, maximumFractionDigits, minimumFractionDigits, diff --git a/source/components/conversation/InputSuggestions.tsx b/source/components/conversation/InputSuggestions.tsx index 02f3e3af7..c19a173a8 100644 --- a/source/components/conversation/InputSuggestions.tsx +++ b/source/components/conversation/InputSuggestions.tsx @@ -2,7 +2,7 @@ import { toPairs } from 'ramda' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' -import { defaultUnitsSelector } from 'Selectors/analyseSelectors' +import { defaultUnitSelector } from 'Selectors/analyseSelectors' import { convertUnit, parseUnit, Unit } from '../../engine/units' type InputSuggestionsProps = { @@ -20,7 +20,7 @@ export default function InputSuggestions({ }: InputSuggestionsProps) { const [suggestion, setSuggestion] = useState() const { t } = useTranslation() - const defaultUnit = parseUnit(useSelector(defaultUnitsSelector)[0] ?? '') + const defaultUnit = parseUnit(useSelector(defaultUnitSelector) ?? '') if (!suggestions) return null return ( diff --git a/source/components/simulationConfigs/artiste-auteur.yaml b/source/components/simulationConfigs/artiste-auteur.yaml index a9573a517..c9d5494df 100644 --- a/source/components/simulationConfigs/artiste-auteur.yaml +++ b/source/components/simulationConfigs/artiste-auteur.yaml @@ -1,5 +1,5 @@ situation: dirigeant: artiste-auteur -unités par défaut: [€/an] +unité par défaut: €/an objectifs: - artiste-auteur . cotisations diff --git a/source/components/simulationConfigs/assimilé.yaml b/source/components/simulationConfigs/assimilé.yaml index 758a2d41c..c7ea9640b 100644 --- a/source/components/simulationConfigs/assimilé.yaml +++ b/source/components/simulationConfigs/assimilé.yaml @@ -34,7 +34,7 @@ questions: - contrat salarié . complémentaire santé . part employeur - contrat salarié . régime des impatriés -unités par défaut: [€/an] +unité par défaut: €/an situation: dirigeant: 'assimilé salarié' contrat salarié . ATMP . taux réduit: oui diff --git a/source/components/simulationConfigs/auto-entrepreneur.yaml b/source/components/simulationConfigs/auto-entrepreneur.yaml index d98b090dd..3ed92ae1f 100644 --- a/source/components/simulationConfigs/auto-entrepreneur.yaml +++ b/source/components/simulationConfigs/auto-entrepreneur.yaml @@ -16,6 +16,6 @@ questions: liste noire: - entreprise . charges -unités par défaut: [€/an] +unité par défaut: €/an situation: dirigeant: 'auto-entrepreneur' diff --git a/source/components/simulationConfigs/indépendant.yaml b/source/components/simulationConfigs/indépendant.yaml index 13e230a36..a167b3549 100644 --- a/source/components/simulationConfigs/indépendant.yaml +++ b/source/components/simulationConfigs/indépendant.yaml @@ -29,6 +29,6 @@ questions: - dirigeant . indépendant . cotisations et contributions . exonérations . âge - dirigeant . indépendant . cotisations et contributions . exonérations . invalidité -unités par défaut: [€/an] +unité par défaut: €/an situation: dirigeant: 'indépendant' diff --git a/source/components/simulationConfigs/rémunération-dirigeant.yaml b/source/components/simulationConfigs/rémunération-dirigeant.yaml index 327a200a3..512437a8b 100644 --- a/source/components/simulationConfigs/rémunération-dirigeant.yaml +++ b/source/components/simulationConfigs/rémunération-dirigeant.yaml @@ -18,7 +18,7 @@ questions: - entreprise . catégorie d'activité . restauration ou hébergement - entreprise . catégorie d'activité . libérale règlementée -unités par défaut: [€/an] +unité par défaut: €/an branches: - nom: Assimilé salarié situation: diff --git a/source/components/simulationConfigs/salarié.yaml b/source/components/simulationConfigs/salarié.yaml index dc159f44e..93899f0c8 100644 --- a/source/components/simulationConfigs/salarié.yaml +++ b/source/components/simulationConfigs/salarié.yaml @@ -27,6 +27,6 @@ questions: - contrat salarié . statut JEI - contrat salarié . complémentaire santé . part employeur - contrat salarié . régime des impatriés -unités par défaut: [€/mois] +unité par défaut: €/mois situation: dirigeant: non diff --git a/source/components/ui/AnimatedTargetValue.tsx b/source/components/ui/AnimatedTargetValue.tsx index 2d86bc464..59c1b431d 100644 --- a/source/components/ui/AnimatedTargetValue.tsx +++ b/source/components/ui/AnimatedTargetValue.tsx @@ -25,7 +25,7 @@ export default function AnimatedTargetValue({ // We don't want to show the animated if the difference comes from a change in the unit const currentUnit = useSelector( - (state: RootState) => state?.simulation?.defaultUnits[0] + (state: RootState) => state?.simulation?.defaultUnit ) const previousUnit = useRef(currentUnit) diff --git a/source/engine/date.ts b/source/engine/date.ts index 69781b0e3..3c4273321 100644 --- a/source/engine/date.ts +++ b/source/engine/date.ts @@ -18,22 +18,53 @@ export function normalizeDate( return `${pad(day)}/${pad(month)}/${pad(year)}` } -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) - if (!dateStrings.some(dateString => dateString.match(dateRegexp))) { - return values - } - dateStrings.forEach(dateString => { - if (!dateString.match(dateRegexp)) { - throw new TypeError( - `'${dateString}' n'est pas une date valide (format attendu: mm/aaaa ou jj/mm/aaaa)` - ) - } - }) - return dateStrings.map(convertToDate) + +export function convertToString(date: Date): string { + return normalizeDate(date.getFullYear(), date.getMonth() + 1, date.getDate()) +} + +export function getRelativeDate(date: string, dayDifferential: number): string { + const relativeDate = new Date(convertToDate(date)) + 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/error.ts b/source/engine/error.ts index 3a5a9d006..2283873ca 100644 --- a/source/engine/error.ts +++ b/source/engine/error.ts @@ -1,14 +1,16 @@ import { coerceArray } from '../utils' + +export class EngineError extends Error {} export function syntaxError( rules: string[] | string, message: string, originalError?: Error ) { - throw new Error( + throw new EngineError( `\n[ Erreur syntaxique ] -➡️ Dans la règle \`${coerceArray(rules).slice(-1)[0]}\` -✖️ ${message} - ${originalError && originalError.message} +➡️ Dans la règle \`${coerceArray(rules).slice(-1)[0]}\` +✖️ ${message} + ${originalError ? originalError.message : ''} ` ) } @@ -18,11 +20,11 @@ export function evaluationError( message: string, originalError?: Error ) { - throw new Error( + throw new EngineError( `\n[ Erreur d'évaluation ] -➡️ Dans la règle \`${coerceArray(rules).slice(-1)[0]}\` -✖️ ${message} - ${originalError && originalError.message} +➡️ Dans la règle \`${coerceArray(rules).slice(-1)[0]}\` +✖️ ${message} + ${originalError ? originalError.message : ''} ` ) } @@ -34,9 +36,9 @@ export function typeWarning( ) { console.warn( `\n[ Erreur de type ] -➡️ Dans la règle \`${coerceArray(rules).slice(-1)[0]}\` -✖️ ${message} - ${originalError && originalError.message} +➡️ Dans la règle \`${coerceArray(rules).slice(-1)[0]}\` +✖️ ${message} + ${originalError ? originalError.message : ''} ` ) } @@ -48,9 +50,9 @@ export function warning( ) { console.warn( `\n[ Avertissement ] -➡️ Dans la règle \`${coerceArray(rules).slice(-1)[0]}\` -⚠️ ${message} -💡${solution} +➡️ Dans la règle \`${coerceArray(rules).slice(-1)[0]}\` +⚠️ ${message} +💡 ${solution} ` ) } diff --git a/source/engine/evaluateRule.ts b/source/engine/evaluateRule.ts index a4b4b6a7b..491ec58a1 100644 --- a/source/engine/evaluateRule.ts +++ b/source/engine/evaluateRule.ts @@ -102,6 +102,7 @@ export default (cache, situationGate, parsedRules, node) => { node.defaultUnit || evaluatedFormula.unit + const temporalValue = evaluatedFormula.temporalValue if (unit) { try { nodeValue = convertNodeToUnit(unit, evaluatedFormula).nodeValue @@ -121,6 +122,7 @@ export default (cache, situationGate, parsedRules, node) => { ...(node.formule && { formule: evaluatedFormula }), nodeValue, unit, + temporalValue, isApplicable, missingVariables } diff --git a/source/engine/evaluation.js b/source/engine/evaluation.js deleted file mode 100644 index 4baa7719c..000000000 --- a/source/engine/evaluation.js +++ /dev/null @@ -1,170 +0,0 @@ -import { - add, - any, - equals, - evolve, - filter, - fromPairs, - is, - keys, - map, - mergeWith, - reduce, - values -} from 'ramda' -import React from 'react' -import { typeWarning } from './error' -import { convertNodeToUnit, simplifyNodeUnit } from './nodeUnits' - -export let makeJsx = node => - typeof node.jsx == 'function' - ? node.jsx(node.nodeValue, node.explanation, node.lazyEval, node.unit) - : node.jsx - -export let collectNodeMissing = node => node.missingVariables || {} - -export let bonus = (missings, hasCondition = true) => - hasCondition ? map(x => x + 0.0001, missings || {}) : missings -export let mergeAllMissing = missings => - reduce(mergeWith(add), {}, map(collectNodeMissing, missings)) -export let mergeMissing = (left, right) => - mergeWith(add, left || {}, right || {}) - -export let evaluateNode = (cache, situationGate, parsedRules, node) => { - let evaluatedNode = node.evaluate - ? node.evaluate(cache, situationGate, parsedRules, node) - : node - if (typeof evaluatedNode.nodeValue !== 'number') { - return evaluatedNode - } - evaluatedNode = node.unité - ? convertNodeToUnit(node.unit, evaluatedNode) - : simplifyNodeUnit(evaluatedNode) - return evaluatedNode -} -const sameUnitValues = (explanation, contextRule, mecanismName) => { - const firstNodeWithUnit = explanation.find(node => !!node.unit) - if (!firstNodeWithUnit) { - return [undefined, explanation.map(({ nodeValue }) => nodeValue)] - } - const values = explanation.map(node => { - try { - return convertNodeToUnit(firstNodeWithUnit?.unit, node).nodeValue - } catch (e) { - typeWarning( - contextRule, - `Dans le mécanisme ${mecanismName}, les unités des éléments suivants sont incompatibles entre elles : \n\t\t${node?.name || - node?.rawNode}\n\t\t${firstNodeWithUnit?.name || - firstNodeWithUnit?.rawNode}'`, - e - ) - return node.nodeValue - } - }) - return [firstNodeWithUnit.unit, values] -} - -export let evaluateArray = (reducer, start) => ( - cache, - situationGate, - parsedRules, - node -) => { - let evaluateOne = child => - evaluateNode(cache, situationGate, parsedRules, child), - explanation = map(evaluateOne, node.explanation), - [unit, values] = sameUnitValues( - explanation, - cache._meta.contextRule, - node.name - ), - nodeValue = values.some(value => value === null) - ? null - : reduce(reducer, start, values), - missingVariables = - node.nodeValue == null ? mergeAllMissing(explanation) : {} - return { - ...node, - nodeValue, - explanation, - missingVariables, - unit - } -} - -export let 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 } -} - -export let defaultNode = nodeValue => ({ - nodeValue, - // eslint-disable-next-line - jsx: nodeValue => {nodeValue}, - isDefault: true -}) - -export let parseObject = (recurse, objectShape, value) => { - let recurseOne = key => defaultValue => { - if (value[key] == null && !defaultValue) - throw new Error( - `Il manque une clé '${key}' dans ${JSON.stringify(value)} ` - ) - return value[key] != null ? recurse(value[key]) : defaultValue - } - let transforms = fromPairs(map(k => [k, recurseOne(k)], keys(objectShape))) - return evolve(transforms, objectShape) -} - -export let evaluateObject = (objectShape, effect) => ( - cache, - situationGate, - parsedRules, - node -) => { - let evaluateOne = child => - evaluateNode(cache, situationGate, parsedRules, child) - - let transforms = map(k => [k, evaluateOne], keys(objectShape)), - automaticExplanation = evolve(fromPairs(transforms))(node.explanation) - // the result of effect can either be just a nodeValue, or an object {additionalExplanation, nodeValue}. The latter is useful for a richer JSX visualisation of the mecanism : the view should not duplicate code to recompute intermediate values (e.g. for a marginal 'barème', the marginal 'tranche') - let evaluated = effect( - automaticExplanation, - cache, - situationGate, - parsedRules - ), - explanation = is(Object, evaluated) - ? { ...automaticExplanation, ...evaluated.additionalExplanation } - : automaticExplanation, - nodeValue = is(Object, evaluated) ? evaluated.nodeValue : evaluated, - missingVariables = mergeAllMissing(values(explanation)) - return simplifyNodeUnit({ - ...node, - nodeValue, - explanation, - missingVariables, - unit: explanation.unit - }) -} diff --git a/source/engine/evaluation.tsx b/source/engine/evaluation.tsx new file mode 100644 index 000000000..7e643d97f --- /dev/null +++ b/source/engine/evaluation.tsx @@ -0,0 +1,212 @@ +import { + add, + evolve, + filter, + fromPairs, + keys, + map, + mergeWith, + reduce +} from 'ramda' +import React from 'react' +import { typeWarning } from './error' +import { convertNodeToUnit, simplifyNodeUnit } from './nodeUnits' +import { + concatTemporals, + EvaluatedNode, + liftTemporalNode, + mapTemporal, + pureTemporal, + Temporal, + temporalAverage, + zipTemporals +} from './temporal' + +export let makeJsx = node => + typeof node.jsx == 'function' + ? node.jsx(node.nodeValue, node.explanation, node.lazyEval, node.unit) + : node.jsx + +export let collectNodeMissing = node => node.missingVariables || {} + +export let bonus = (missings, hasCondition = true) => + hasCondition ? map(x => x + 0.0001, missings || {}) : missings +export let mergeAllMissing = missings => + reduce(mergeWith(add), {}, map(collectNodeMissing, missings)) +export let mergeMissing = (left, right) => + mergeWith(add, left || {}, right || {}) + +export let evaluateNode = (cache, situationGate, parsedRules, node) => { + let evaluatedNode = node.evaluate + ? node.evaluate(cache, situationGate, parsedRules, node) + : node + if (typeof evaluatedNode.nodeValue !== 'number') { + return evaluatedNode + } + evaluatedNode = node.unité + ? convertNodeToUnit(node.unit, evaluatedNode) + : simplifyNodeUnit(evaluatedNode) + return evaluatedNode +} + +function convertNodesToSameUnit(nodes, contextRule, mecanismName) { + const firstNodeWithUnit = nodes.find(node => !!node.unit) + if (!firstNodeWithUnit) { + return nodes + } + return nodes.map(node => { + try { + return convertNodeToUnit(firstNodeWithUnit.unit, node) + } catch (e) { + typeWarning( + contextRule, + `Dans le mécanisme ${mecanismName}, les unités des éléments suivants sont incompatibles entre elles : \n\t\t${node?.name || + node?.rawNode}\n\t\t${firstNodeWithUnit?.name || + firstNodeWithUnit?.rawNode}'`, + e + ) + return node + } + }) +} + +export const evaluateArray = (reducer, start) => ( + cache, + situationGate, + parsedRules, + node +) => { + const evaluate = evaluateNode.bind(null, cache, situationGate, parsedRules) + const evaluatedNodes = convertNodesToSameUnit( + node.explanation.map(evaluate), + cache._meta.contextRule, + node.name + ) + + 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, + missingVariables: mergeAllMissing(evaluatedNodes), + explanation: evaluatedNodes, + unit: evaluatedNodes[0].unit + } + if (temporalValue.length === 1) { + return { + ...baseEvaluation, + nodeValue: temporalValue[0].value + } + } + return { + ...baseEvaluation, + temporalValue, + nodeValue: temporalAverage(temporalValue) + } +} + +export const evaluateArrayWithFilter = (evaluationFilter, reducer, start) => ( + cache, + situationGate, + parsedRules, + node +) => { + return evaluateArray(reducer, start)(cache, situationGate, parsedRules, { + ...node, + explanation: filter(evaluationFilter(situationGate), node.explanation) + }) +} + +export let defaultNode = nodeValue => ({ + nodeValue, + // eslint-disable-next-line + jsx: nodeValue => {nodeValue}, + isDefault: true +}) + +export let parseObject = (recurse, objectShape, value) => { + let recurseOne = key => defaultValue => { + if (value[key] == null && !defaultValue) + throw new Error( + `Il manque une clé '${key}' dans ${JSON.stringify(value)} ` + ) + return value[key] != null ? recurse(value[key]) : defaultValue + } + let transforms = fromPairs( + map(k => [k, recurseOne(k)], keys(objectShape)) as any + ) + return evolve(transforms as any, objectShape) +} + +export let evaluateObject = (objectShape, effect) => ( + cache, + situationGate, + parsedRules, + node +) => { + const evaluate = evaluateNode.bind(null, cache, situationGate, parsedRules) + const evaluations = map(evaluate, node.explanation) + const temporalExplanations = mapTemporal( + Object.fromEntries, + concatTemporals( + Object.entries(evaluations).map(([key, node]) => + zipTemporals(pureTemporal(key), liftTemporalNode(node)) + ) + ) + ) + const temporalExplanation = mapTemporal(explanations => { + const evaluation = effect(explanations, cache, situationGate, parsedRules) + return { + ...evaluation, + explanation: { + ...explanations, + ...evaluation.explanation + } + } + }, temporalExplanations) + + const sameUnitTemporalExplanation: Temporal> = convertNodesToSameUnit( + temporalExplanation.map(x => x.value), + cache._meta.contextRule, + node.name + ).map((node, i) => ({ + ...temporalExplanation[i], + value: simplifyNodeUnit(node) + })) + + const temporalValue = mapTemporal( + ({ nodeValue }) => nodeValue, + sameUnitTemporalExplanation + ) + const nodeValue = temporalAverage(temporalValue) + + const baseEvaluation = { + ...node, + nodeValue, + unit: sameUnitTemporalExplanation[0].value.unit, + explanation: evaluations, + missingVariables: mergeAllMissing(Object.values(evaluations)) + } + if (sameUnitTemporalExplanation.length === 1) { + return { + ...baseEvaluation, + explanation: sameUnitTemporalExplanation[0].value.explanation + } + } + return { + ...baseEvaluation, + temporalValue, + temporalExplanation + } +} diff --git a/source/engine/grammar.ne b/source/engine/grammar.ne index 9cc736d1b..6839da02a 100644 --- a/source/engine/grammar.ne +++ b/source/engine/grammar.ne @@ -6,25 +6,33 @@ # @preprocessor esmodule @{% -const {string, filteredVariable, date, variable, variableWithConversion, binaryOperation, unaryOperation, boolean, number, numberWithUnit } = require('./grammarFunctions') +const { + string, filteredVariable, date, variable, variableWithConversion, + temporalNumericValue, binaryOperation, unaryOperation, boolean, number, + numberWithUnit +} = require('./grammarFunctions') const moo = require("moo"); -const dateRegexp = /(?:(?:0?[1-9]|[12][0-9]|3[01])\/)?(?:0?[1-9]|1[012])\/\d{4}/ +const dateRegexp = `(?:(?:0?[1-9]|[12][0-9]|3[01])\\/)?(?:0?[1-9]|1[012])\\/\\d{4}` const letter = '[a-zA-Z\u00C0-\u017F€$%]'; const letterOrNumber = '[a-zA-Z\u00C0-\u017F0-9\']'; -const word = `${letter}(?:[\-']?${letterOrNumber}+)*`; +const word = `${letter}(?:[-']?${letterOrNumber}+)*`; const wordOrNumber = `(?:${word}|${letterOrNumber}+)` const words = `${word}(?:[\\s]?${wordOrNumber}+)*` +const periodWord = `\\| ${word}(?:[\\s]${word})*` + const numberRegExp = '-?(?:[1-9][0-9]+|[0-9])(?:\\.[0-9]+)?'; const lexer = moo.compile({ - date: dateRegexp, '(': '(', ')': ')', '[': '[', ']': ']', comparison: ['>','<','>=','<=','=','!='], infinity: 'Infinity', + colon: " : ", + date: new RegExp(dateRegexp), + periodWord: new RegExp(periodWord), words: new RegExp(words), number: new RegExp(numberRegExp), string: /'[ \t\.'a-zA-Z\-\u00C0-\u017F0-9 ]+'/, @@ -33,7 +41,7 @@ const lexer = moo.compile({ dot: ' . ', '.': '.', letterOrNumber: new RegExp(letterOrNumber), - space: { match: /[\s]+/, lineBreaks: true } + space: { match: /[\s]+/, lineBreaks: true }, }); const join = (args) => ({value: (args.map(x => x && x.value).join(""))}) @@ -43,12 +51,20 @@ const flattenJoin = ([a, b]) => Array.isArray(b) ? join([a, ...b]) : a @lexer lexer main -> - AdditionSubstraction {% id %} - | Comparison {% id %} - | NonNumericTerminal {% id %} - | Negation {% id %} + Comparison {% id %} + | NumericValue {% id %} | Date {% id %} + | NonNumericTerminal {% id %} +NumericValue -> + AdditionSubstraction {% id %} + | Negation {% id %} + | TemporalNumericValue {% id %} + +TemporalNumericValue -> + NumericValue %space %periodWord %space %date {% ([value,,word,,dateString]) => temporalNumericValue(value, word, date([dateString])) %} + | NumericValue %space %periodWord %colon Date {% ([value,,word,,date]) => temporalNumericValue(value, word, date) %} + NumericTerminal -> Variable {% id %} | VariableWithUnitConversion {% id %} @@ -59,8 +75,7 @@ Negation -> "-" %space Parentheses {% unaryOperation('calculation') %} Parentheses -> - "(" AdditionSubstraction ")" {% ([,e]) => e %} - | "(" Negation ")" {% ([,e]) => e %} + "(" NumericValue ")" {% ([,e]) => e %} | NumericTerminal {% id %} Date -> @@ -92,6 +107,7 @@ VariableWithUnitConversion -> Filter -> "." %words {% ([,filter]) => filter %} FilteredVariable -> Variable %space Filter {% filteredVariable %} + AdditionSubstraction -> AdditionSubstraction %space %additionSubstraction %space MultiplicationDivision {% binaryOperation('calculation') %} | MultiplicationDivision {% id %} diff --git a/source/engine/grammarFunctions.js b/source/engine/grammarFunctions.js index eecf5c3ad..442b6242d 100644 --- a/source/engine/grammarFunctions.js +++ b/source/engine/grammarFunctions.js @@ -2,6 +2,7 @@ The advantage of putting them here is to get prettier's JS formatting, since Nealrey doesn't support it https://github.com/kach/nearley/issues/310 */ import { normalizeDateString } from 'Engine/date' import { parseUnit } from 'Engine/units' +import { parsePeriod } from './temporal' export let binaryOperation = operationType => ([A, , operator, , B]) => ({ [operator]: { @@ -25,6 +26,13 @@ export let variableWithConversion = ([{ variable }, , unit]) => ({ unitConversion: { explanation: variable, unit: parseUnit(unit.value) } }) +export let temporalNumericValue = (variable, word, date) => ({ + temporalValue: { + explanation: variable, + period: parsePeriod(word.value.slice(2), date) + } +}) + export let variable = ([firstFragment, nextFragment], _, reject) => { const fragments = [firstFragment, ...nextFragment].map(({ value }) => value) if (!nextFragment.length && ['oui', 'non'].includes(firstFragment)) { diff --git a/source/engine/mecanismViews/Variations.js b/source/engine/mecanismViews/Variations.js index cf895a072..fac9ab951 100644 --- a/source/engine/mecanismViews/Variations.js +++ b/source/engine/mecanismViews/Variations.js @@ -11,7 +11,6 @@ import './Variations.css' let Comp = function Variations({ nodeValue, explanation, unit }) { let [expandedVariation, toggleVariation] = useState(null) const { i18n } = useTranslation() - return ( {showValues => ( @@ -64,7 +63,7 @@ let Comp = function Variations({ nodeValue, explanation, unit }) { )} {(expandedVariation === i || satisfied || !showValues) && (
- {condition && ( + {!condition.isDefault && (
- {condition ? ( + {!condition.isDefault ? ( Alors ) : ( Sinon diff --git a/source/engine/mecanisms.js b/source/engine/mecanisms.js index 1e915cdcd..1ed7e258b 100644 --- a/source/engine/mecanisms.js +++ b/source/engine/mecanisms.js @@ -3,7 +3,6 @@ import variations from 'Engine/mecanisms/variations' import { convertNodeToUnit } from 'Engine/nodeUnits' import { inferUnit, isPercentUnit } from 'Engine/units' import { - add, any, equals, evolve, @@ -15,11 +14,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 +278,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 +290,7 @@ export let mecanismRecalcul = dottedNameContext => (recurse, k, v) => { ]) ) - if (currentCache._metaInRecalcul) { + if (currentCache._meta.inRecalcul) { return defaultNode(false) } @@ -344,7 +341,10 @@ export let mecanismRecalcul = dottedNameContext => (recurse, k, v) => { export let mecanismSum = (recurse, k, v) => { let explanation = v.map(recurse) - let evaluate = evaluateArray(add, 0) + let evaluate = evaluateArray( + (x, y) => (x === false && y === false ? false : x + y), + false + ) return { evaluate, @@ -376,7 +376,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 +431,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 +509,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 +602,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) => ( - -
    -
  • - - cible:{' '} - - {makeJsx(explanation.cible)} -
  • -
  • - - montant à atteindre:{' '} - - {makeJsx(explanation.montant)} -
  • -
-
- ) - } -} - export let mecanismSynchronisation = (recurse, k, v) => { let evaluate = (cache, situationGate, parsedRules, node) => { let APIExplanation = evaluateNode( diff --git a/source/engine/mecanisms/barème.ts b/source/engine/mecanisms/barème.ts index 2ebd8aa4e..663f3b269 100644 --- a/source/engine/mecanisms/barème.ts +++ b/source/engine/mecanisms/barème.ts @@ -1,8 +1,16 @@ +import { evaluationError } from 'Engine/error' import { defaultNode, evaluateNode, mergeAllMissing } from 'Engine/evaluation' import { decompose } from 'Engine/mecanisms/utils' import variations from 'Engine/mecanisms/variations' import Barème from 'Engine/mecanismViews/Barème' -import { convertUnit, parseUnit } from '../units' +import { + liftTemporal2, + liftTemporalNode, + mapTemporal, + temporalAverage +} from 'Engine/temporal' +import { convertUnit } from '../units' +import { parseUnit } from './../units' import { evaluatePlafondUntilActiveTranche, parseTranches @@ -34,29 +42,27 @@ export default function parse(parse, k, v) { } } -const evaluate = ( - cache, - situationGate, - parsedRules, - node: ReturnType -) => { - const evaluate = evaluateNode.bind(null, cache, situationGate, parsedRules) - const assiette = evaluate(node.explanation.assiette) - const multiplicateur = evaluate(node.explanation.multiplicateur) - const tranches = evaluatePlafondUntilActiveTranche( - evaluate, - { - parsedTranches: node.explanation.tranches, - assiette, - multiplicateur - }, - cache - ).map(tranche => { +function evaluateBarème(tranches, assiette, evaluate, cache) { + return tranches.map(tranche => { if (tranche.isAfterActive) { return { ...tranche, nodeValue: 0 } } const taux = evaluate(tranche.taux) - if ([taux.nodeValue, tranche.nodeValue].some(value => value === null)) { + if (taux.temporalValue) { + evaluationError( + cache._meta.contextRule, + "Le taux d'une tranche ne peut pas être une valeur temporelle" + ) + } + + if ( + [ + assiette.nodeValue, + taux.nodeValue, + tranche.plafondValue, + tranche.plancherValue + ].some(value => value === null) + ) { return { ...tranche, taux, @@ -71,20 +77,61 @@ const evaluate = ( nodeValue: (Math.min(assiette.nodeValue, tranche.plafondValue) - tranche.plancherValue) * - convertUnit(taux.unit, parseUnit(''), taux.nodeValue) + convertUnit(taux.unit, parseUnit(''), taux.nodeValue as number) } }) +} +const evaluate = ( + cache, + situationGate, + parsedRules, + node: ReturnType +) => { + const evaluate = evaluateNode.bind(null, cache, situationGate, parsedRules) + const assiette = evaluate(node.explanation.assiette) + const multiplicateur = evaluate(node.explanation.multiplicateur) + const temporalTranchesPlafond = liftTemporal2( + (assiette, multiplicateur) => + evaluatePlafondUntilActiveTranche( + evaluate, + { + parsedTranches: node.explanation.tranches, + assiette, + multiplicateur + }, + cache + ), + liftTemporalNode(assiette), + liftTemporalNode(multiplicateur) + ) + const temporalTranches = liftTemporal2( + (tranches, assiette) => evaluateBarème(tranches, assiette, evaluate, cache), + temporalTranchesPlafond, + liftTemporalNode(assiette) + ) + const temporalValue = mapTemporal( + tranches => + tranches.reduce( + (value, { nodeValue }) => + nodeValue == null ? null : value + nodeValue, + 0 + ), + temporalTranches + ) return { ...node, - nodeValue: tranches.reduce( - (value, { nodeValue }) => (nodeValue == null ? null : value + nodeValue), - 0 - ), - missingVariables: mergeAllMissing(tranches), + nodeValue: temporalAverage(temporalValue), + ...(temporalValue.length > 1 + ? { + temporalValue + } + : { missingVariables: mergeAllMissing(temporalTranches[0].value) }), explanation: { assiette, multiplicateur, - tranches + ...(temporalTranches.length > 1 + ? { temporalTranches } + : { tranches: temporalTranches[0].value }) }, unit: assiette.unit } diff --git a/source/engine/mecanisms/durée.tsx b/source/engine/mecanisms/durée.tsx index 7d9c1438f..d7de95d5f 100644 --- a/source/engine/mecanisms/durée.tsx +++ b/source/engine/mecanisms/durée.tsx @@ -1,4 +1,4 @@ -import { convertToDate, normalizeDate } from 'Engine/date' +import { convertToDate, convertToString } from 'Engine/date' import { defaultNode, evaluateNode, @@ -26,12 +26,7 @@ function MecanismDurée({ nodeValue, explanation, unit }) { ) } -const today = new Date() -const todayString = normalizeDate( - today.getFullYear(), - today.getMonth() + 1, - today.getDate() -) +const todayString = convertToString(new Date()) const objectShape = { depuis: defaultNode(todayString), diff --git a/source/engine/mecanisms/grille.ts b/source/engine/mecanisms/grille.ts index 1fd819fdd..3c1fbfd4e 100644 --- a/source/engine/mecanisms/grille.ts +++ b/source/engine/mecanisms/grille.ts @@ -2,6 +2,12 @@ import { defaultNode, evaluateNode, mergeAllMissing } from 'Engine/evaluation' import { decompose } from 'Engine/mecanisms/utils' import variations from 'Engine/mecanisms/variations' import grille from 'Engine/mecanismViews/Grille' +import { + liftTemporal2, + liftTemporalNode, + mapTemporal, + temporalAverage +} from 'Engine/temporal' import { parseUnit } from 'Engine/units' import { lensPath, over } from 'ramda' import { @@ -35,25 +41,8 @@ export default function parse(parse, k, v) { unit: explanation.tranches[0].montant.unit } } - -const evaluate = ( - cache, - situationGate, - parsedRules, - node: ReturnType -) => { - const evaluate = evaluateNode.bind(null, cache, situationGate, parsedRules) - const assiette = evaluate(node.explanation.assiette) - const multiplicateur = evaluate(node.explanation.multiplicateur) - const tranches = evaluatePlafondUntilActiveTranche( - evaluate, - { - parsedTranches: node.explanation.tranches, - assiette, - multiplicateur - }, - cache - ).map(tranche => { +const evaluateGrille = (tranches, evaluate) => + tranches.map(tranche => { if (tranche.isActive === false) { return tranche } @@ -67,19 +56,65 @@ const evaluate = ( } }) - const activeTranches = tranches.filter(({ isActive }) => isActive != false) - const missingVariables = mergeAllMissing(activeTranches) - const nodeValue = activeTranches.length ? activeTranches[0].nodeValue : false +const evaluate = ( + cache, + situationGate, + parsedRules, + node: ReturnType +) => { + const evaluate = evaluateNode.bind(null, cache, situationGate, parsedRules) + const assiette = evaluate(node.explanation.assiette) + const multiplicateur = evaluate(node.explanation.multiplicateur) + const temporalTranchesPlafond = liftTemporal2( + (assiette, multiplicateur) => + evaluatePlafondUntilActiveTranche( + evaluate, + { + parsedTranches: node.explanation.tranches, + assiette, + multiplicateur + }, + cache + ), + liftTemporalNode(assiette), + liftTemporalNode(multiplicateur) + ) + const temporalTranches = mapTemporal( + tranches => evaluateGrille(tranches, evaluate), + temporalTranchesPlafond + ) + + const activeTranches = mapTemporal(tranches => { + const activeTranche = tranches.find(tranche => tranche.isActive) + if (activeTranche) { + return [activeTranche] + } + const lastTranche = tranches[tranches.length - 1] + if (lastTranche.isAfterActive === false) { + return [{ nodeValue: false }] + } + return tranches.filter(tranche => tranche.isActive === null) + }, temporalTranches) + const temporalValue = mapTemporal( + tranches => (tranches[0].isActive === null ? null : tranches[0].nodeValue), + activeTranches + ) return { ...node, + nodeValue: temporalAverage(temporalValue), + ...(temporalValue.length > 1 + ? { + temporalValue + } + : { missingVariables: mergeAllMissing(activeTranches[0].value) }), explanation: { - tranches, assiette, - multiplicateur + multiplicateur, + ...(temporalTranches.length > 1 + ? { temporalTranches } + : { tranches: temporalTranches[0].value }) }, - missingVariables, - nodeValue, - unit: activeTranches[0]?.unit ?? node.unit + unit: activeTranches[0].value[0]?.unit ?? node.unit } } diff --git a/source/engine/mecanisms/operation.js b/source/engine/mecanisms/operation.js index 8587e2806..8137e631b 100644 --- a/source/engine/mecanisms/operation.js +++ b/source/engine/mecanisms/operation.js @@ -1,12 +1,14 @@ +import { convertToDate } from 'Engine/date' import { typeWarning } from 'Engine/error' import { evaluateNode, makeJsx, mergeMissing } from 'Engine/evaluation' import { Node } from 'Engine/mecanismViews/common' import { convertNodeToUnit } from 'Engine/nodeUnits' +import { liftTemporal2, pureTemporal, temporalAverage } from 'Engine/temporal' import { inferUnit, serializeUnit } from 'Engine/units' import { curry, map } from 'ramda' import React from 'react' -import { convertToDateIfNeeded } from '../date.ts' +const comparisonOperator = ['≠', '=', '<', '>', '≤', '≥'] export default (k, operatorFunction, symbol) => (recurse, k, v) => { let evaluate = (cache, situation, parsedRules, node) => { const explanation = map( @@ -43,30 +45,49 @@ export default (k, operatorFunction, symbol) => (recurse, k, v) => { ) } } - let node1Value = node1.nodeValue - let node2Value = node2.nodeValue - try { - ;[node1Value, node2Value] = convertToDateIfNeeded( - node1.nodeValue, - node2.nodeValue - ) - } catch (e) { - typeWarning( - cache._meta.contextRule, - `Impossible de convertir une des valeur en date`, - e - ) - } - let nodeValue = operatorFunction(node1Value, node2Value) - - let unit = inferUnit(k, [node1.unit, node2.unit]) - return { + const baseNode = { ...node, - nodeValue, - unit, explanation, + unit: inferUnit(k, [node1.unit, node2.unit]), missingVariables } + + let temporalValue = liftTemporal2( + (a, b) => { + if (['∕', '-'].includes(node.operator) && a === false) { + return false + } + if (['+'].includes(node.operator) && a === false) { + return b + } + if (['∕', '-', '×', '+'].includes(node.operator) && b === false) { + return a + } + if ( + !['=', '≠'].includes(node.operator) && + (a === false || b === false) + ) { + return false + } + if ( + comparisonOperator.includes(node.operator) && + [a, b].every(value => value.match?.(/[\d]{2}\/[\d]{2}\/[\d]{4}/)) + ) { + return operatorFunction(convertToDate(a), convertToDate(b)) + } + return operatorFunction(a, b) + }, + node1.temporalValue ?? pureTemporal(node1.nodeValue), + node2.temporalValue ?? pureTemporal(node2.nodeValue) + ) + + const nodeValue = temporalAverage(temporalValue, baseNode.unit) + + return { + ...baseNode, + nodeValue, + ...(temporalValue.length > 1 && { temporalValue }) + } } let explanation = v.explanation.map(recurse) diff --git a/source/engine/mecanisms/régularisation.ts b/source/engine/mecanisms/régularisation.ts new file mode 100644 index 000000000..8d9e74b86 --- /dev/null +++ b/source/engine/mecanisms/régularisation.ts @@ -0,0 +1,151 @@ +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/temporal' +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/trancheUtils.ts b/source/engine/mecanisms/trancheUtils.ts index f0e493316..a0032d3af 100644 --- a/source/engine/mecanisms/trancheUtils.ts +++ b/source/engine/mecanisms/trancheUtils.ts @@ -1,4 +1,5 @@ import { mergeAllMissing } from 'Engine/evaluation' +import { Evaluation } from 'Engine/temporal' import { evolve } from 'ramda' import { evaluationError, typeWarning } from '../error' import { convertUnit, inferUnit } from '../units' @@ -7,7 +8,6 @@ export const parseTranches = (parse, tranches) => { return tranches .map((t, i) => { if (!t.plafond && i > tranches.length) { - console.log(t, i) throw new SyntaxError( `La tranche n°${i} du barème n'a pas de plafond précisé. Seule la dernière tranche peut ne pas être plafonnée` ) @@ -32,35 +32,30 @@ export function evaluatePlafondUntilActiveTranche( } const plafond = evaluate(parsedTranche.plafond) + if (plafond.temporalValue) { + evaluationError( + cache._meta.contextRule, + 'Les valeurs temporelles ne sont pas acceptées pour un plafond de tranche' + ) + } const plancher = tranches[i - 1] ? tranches[i - 1].plafond : { nodeValue: 0 } - const calculationValues = [plafond, assiette, multiplicateur, plancher] - if (calculationValues.some(node => node.nodeValue === null)) { - return [ - [ - ...tranches, - { - ...parsedTranche, - plafond, - nodeValue: null, - isActive: null, - isAfterActive: false, - missingVariables: mergeAllMissing(calculationValues) - } - ], - false - ] - } - let plafondValue = plafond.nodeValue * multiplicateur.nodeValue + + let plafondValue: Evaluation = + plafond.nodeValue === null || multiplicateur.nodeValue === null + ? null + : plafond.nodeValue * multiplicateur.nodeValue + try { - plafondValue = [Infinity || 0].includes(plafondValue) - ? plafondValue - : convertUnit( - inferUnit('*', [plafond.unit, multiplicateur.unit]), - assiette.unit, - plafondValue - ) + plafondValue = + plafondValue === Infinity || plafondValue === 0 + ? plafondValue + : convertUnit( + inferUnit('*', [plafond.unit, multiplicateur.unit]), + assiette.unit, + plafondValue + ) } catch (e) { typeWarning( cache._meta.contextRule, @@ -69,9 +64,37 @@ export function evaluatePlafondUntilActiveTranche( e ) } - let plancherValue = tranches[i - 1] ? tranches[i - 1].plafondValue : 0 - if (!!tranches[i - 1] && plafondValue <= plancherValue) { + const isAfterActive = + plancherValue === null || assiette.nodeValue === null + ? null + : plancherValue > assiette.nodeValue + + const calculationValues = [plafond, assiette, multiplicateur, plancher] + if (calculationValues.some(node => node.nodeValue === null)) { + return [ + [ + ...tranches, + { + ...parsedTranche, + plafond, + plafondValue, + plancherValue, + nodeValue: null, + isActive: null, + isAfterActive, + missingVariables: mergeAllMissing(calculationValues) + } + ], + false + ] + } + + if ( + !!tranches[i - 1] && + !!plancherValue && + plafondValue <= plancherValue + ) { evaluationError( cache._meta.contextRule, `Le plafond de la tranche n°${i + @@ -84,10 +107,10 @@ export function evaluatePlafondUntilActiveTranche( plafond, plancherValue, plafondValue, - isAfterActive: false, + isAfterActive, isActive: assiette.nodeValue >= plancherValue && - assiette.nodeValue < plafondValue + assiette.nodeValue < plafondValue } return [[...tranches, tranche], tranche.isActive] diff --git a/source/engine/mecanisms/variableTemporelle.ts b/source/engine/mecanisms/variableTemporelle.ts new file mode 100644 index 000000000..80f09383b --- /dev/null +++ b/source/engine/mecanisms/variableTemporelle.ts @@ -0,0 +1,63 @@ +import { evaluateNode } from 'Engine/evaluation' +import { + createTemporalEvaluation, + narrowTemporalValue, + temporalAverage +} from 'Engine/temporal' +import { Temporal } from './../temporal' + +function evaluate( + cache: any, + situation: any, + parsedRules: any, + node: ReturnType +) { + const evaluateAttribute = evaluateNode.bind( + null, + cache, + situation, + parsedRules + ) + + 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 = value.temporalValue + ? narrowTemporalValue(period, value.temporalValue) + : createTemporalEvaluation(value.nodeValue, period) + // TODO explanation missingVariables / period missing variables + return { + ...node, + nodeValue: temporalAverage(temporalValue as Temporal, value.unit), + temporalValue, + explanation: { + period: { start, end }, + value + }, + unit: value.unit + } +} + +export default function parseVariableTemporelle(parse, __, v: any) { + const explanation = parse(v.explanation) + return { + evaluate, + 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/mecanisms/variations.js b/source/engine/mecanisms/variations.js deleted file mode 100644 index 127e28ac3..000000000 --- a/source/engine/mecanisms/variations.js +++ /dev/null @@ -1,117 +0,0 @@ -import { - bonus, - collectNodeMissing, - evaluateNode, - mergeAllMissing, - mergeMissing -} from 'Engine/evaluation' -import Variations from 'Engine/mecanismViews/Variations' -import { inferUnit } from 'Engine/units' -import { dissoc, filter, isNil, pluck, reduce, reject } from 'ramda' - -/* @devariate = true => This function will produce variations of a same mecanism (e.g. product) that share some common properties */ -export default (recurse, k, v, devariate) => { - let explanation = devariate - ? devariateExplanation(recurse, k, v) - : v.map(({ si, alors, sinon }) => - sinon !== undefined - ? { consequence: recurse(sinon), condition: undefined } - : { consequence: recurse(alors), condition: recurse(si) } - ) - - let evaluate = (cache, situationGate, parsedRules, node) => { - let evaluateVariationProp = prop => - prop && evaluateNode(cache, situationGate, parsedRules, prop), - // mark the satisfied variation if any in the explanation - [, resolvedExplanation] = reduce( - ([resolved, result], variation) => { - if (resolved) return [true, [...result, variation]] - - // evaluate the condition - let evaluatedCondition = evaluateVariationProp(variation.condition) - - if (evaluatedCondition == undefined) { - // No condition : we've reached the eventual defaut case - let evaluatedVariation = { - consequence: evaluateVariationProp(variation.consequence), - satisfied: true - } - return [true, [...result, evaluatedVariation]] - } - - if (evaluatedCondition.nodeValue === null) - // the current variation case has missing variables => we can't go further - return [ - true, - [...result, { ...variation, condition: evaluatedCondition }] - ] - - if (evaluatedCondition.nodeValue === true) { - let evaluatedVariation = { - condition: evaluatedCondition, - consequence: evaluateVariationProp(variation.consequence), - satisfied: true - } - return [true, [...result, evaluatedVariation]] - } - return [false, [...result, variation]] - }, - [false, []] - )(node.explanation), - satisfiedVariation = resolvedExplanation.find(v => v.satisfied), - nodeValue = satisfiedVariation - ? satisfiedVariation.consequence.nodeValue - : null - - let leftMissing = mergeAllMissing( - reject(isNil, pluck('condition', resolvedExplanation)) - ), - candidateVariations = filter( - node => !node.condition || node.condition.nodeValue !== false, - resolvedExplanation - ), - rightMissing = mergeAllMissing( - reject(isNil, pluck('consequence', candidateVariations)) - ), - missingVariables = satisfiedVariation - ? collectNodeMissing(satisfiedVariation.consequence) - : mergeMissing(bonus(leftMissing), rightMissing) - - return { - ...node, - nodeValue, - ...(satisfiedVariation && { unit: satisfiedVariation?.consequence.unit }), - explanation: resolvedExplanation, - missingVariables - } - } - - // TODO - find an appropriate representation - return { - explanation, - evaluate, - jsx: Variations, - category: 'mecanism', - name: 'variations', - type: 'numeric', - unit: inferUnit( - '+', - explanation.map(r => r.consequence.unit) - ) - } -} - -export let devariateExplanation = (recurse, mecanismKey, v) => { - let fixedProps = dissoc('variations')(v), - explanation = v.variations.map(({ si, alors, sinon }) => ({ - consequence: recurse({ - [mecanismKey]: { - ...fixedProps, - ...(sinon || alors) - } - }), - condition: sinon ? undefined : recurse(si) - })) - - return explanation -} diff --git a/source/engine/mecanisms/variations.ts b/source/engine/mecanisms/variations.ts new file mode 100644 index 000000000..83aa46265 --- /dev/null +++ b/source/engine/mecanisms/variations.ts @@ -0,0 +1,164 @@ +import { typeWarning } from 'Engine/error' +import { defaultNode, evaluateNode } from 'Engine/evaluation' +import Variations from 'Engine/mecanismViews/Variations' +import { convertNodeToUnit } from 'Engine/nodeUnits' +import { + liftTemporal2, + pureTemporal, + sometime, + temporalAverage +} from 'Engine/temporal' +import { inferUnit } from 'Engine/units' +import { or } from 'ramda' +import { mergeAllMissing } from './../evaluation' + +/* @devariate = true => This function will produce variations of a same mecanism (e.g. product) that share some common properties */ +export default function parse(recurse, k, v, devariate) { + let explanation = devariate + ? devariateExplanation(recurse, k, v) + : v.map(({ si, alors, sinon }) => + sinon !== undefined + ? { consequence: recurse(sinon), condition: defaultNode(true) } + : { consequence: recurse(alors), condition: recurse(si) } + ) + + // TODO - find an appropriate representation + return { + explanation, + evaluate, + jsx: Variations, + category: 'mecanism', + name: 'variations', + type: 'numeric', + unit: inferUnit( + '+', + explanation.map(r => r.consequence.unit) + ) + } +} +type Variation = + | { + si: any + alors: Object + } + | { + sinon: Object + } +export let devariateExplanation = ( + recurse, + mecanismKey, + v: { variations: Array } +) => { + const { variations, ...fixedProps } = v + const explanation = variations.map(variation => ({ + condition: 'sinon' in variation ? defaultNode(true) : recurse(variation.si), + consequence: recurse({ + [mecanismKey]: { + ...fixedProps, + ...('sinon' in variation ? variation.sinon : variation.alors) + } + }) + })) + + return explanation +} + +function evaluate( + cache, + situationGate, + parsedRules, + node: ReturnType +) { + const evaluate = evaluateNode.bind(null, cache, situationGate, parsedRules) + + const [temporalValue, explanation, unit] = node.explanation.reduce( + ( + [evaluation, explanations, unit, previousConditions], + { condition, consequence }, + i: number + ) => { + const previousConditionsAlwaysTrue = !sometime( + value => value !== true, + previousConditions + ) + if (previousConditionsAlwaysTrue) { + return [ + evaluation, + [...explanations, { condition, consequence }], + unit, + previousConditions + ] + } + const evaluatedCondition = evaluate(condition) + const currentCondition = liftTemporal2( + (previousCond, currentCond) => + previousCond === null ? previousCond : !previousCond && currentCond, + previousConditions, + evaluatedCondition.temporalValue ?? + pureTemporal(evaluatedCondition.nodeValue) + ) + const currentConditionAlwaysFalse = !sometime( + x => x !== false, + currentCondition + ) + if (currentConditionAlwaysFalse) { + return [ + evaluation, + [...explanations, { condition: evaluatedCondition, consequence }], + unit, + previousConditions + ] + } + let evaluatedConsequence = evaluate(consequence) + + try { + evaluatedConsequence = convertNodeToUnit(unit, evaluatedConsequence) + } catch (e) { + return typeWarning( + cache._meta.contexRule, + `L'unité de la branche n° ${i} du mécanisme 'variations' n'est pas compatible avec celle d'une branche précédente`, + e + ) + } + const currentValue = liftTemporal2( + (cond, value) => cond && value, + currentCondition, + evaluatedConsequence.temporalValue ?? + pureTemporal(evaluatedConsequence.nodeValue) + ) + return [ + liftTemporal2(or, evaluation, currentValue), + [ + ...explanations, + { + condition: evaluatedCondition, + satisfied: !!evaluatedCondition.nodeValue, + consequence: evaluatedConsequence + } + ], + unit || evaluatedConsequence.unit, + liftTemporal2(or, previousConditions, currentCondition) + ] + }, + [pureTemporal(false), [], node.unit, pureTemporal(false)] + ) + const nodeValue = temporalAverage(temporalValue, unit) + const missingVariables = mergeAllMissing( + explanation.reduce( + (values, { condition, consequence }) => [ + ...values, + condition, + consequence + ], + [] + ) + ) + return { + ...node, + nodeValue, + unit, + explanation, + missingVariables, + ...(temporalValue.length > 1 && { temporalValue }) + } +} diff --git a/source/engine/nodeUnits.ts b/source/engine/nodeUnits.ts index c0ef9c773..b41e669e7 100644 --- a/source/engine/nodeUnits.ts +++ b/source/engine/nodeUnits.ts @@ -1,3 +1,4 @@ +import { EvaluatedNode, mapTemporal } from './temporal' import { areUnitConvertible, convertUnit, @@ -6,10 +7,11 @@ import { } from './units' export function simplifyNodeUnit(node) { - if (!node.unit || !node.nodeValue) { + if (!node.unit || node.nodeValue === false || node.nodeValue == null) { return node } const [unit, nodeValue] = simplifyUnitWithValue(node.unit, node.nodeValue) + return { ...node, unit, @@ -35,12 +37,19 @@ export const getNodeDefaultUnit = (node, cache) => { ) } -export function convertNodeToUnit(to: Unit, node) { +export function convertNodeToUnit(to: Unit, node: EvaluatedNode) { return { ...node, nodeValue: node.unit ? convertUnit(node.unit, to, node.nodeValue) : node.nodeValue, + temporalValue: + node.temporalValue && node.unit + ? mapTemporal( + value => convertUnit(node.unit, to, value), + node.temporalValue + ) + : node.temporalValue, unit: to } } diff --git a/source/engine/parse.tsx b/source/engine/parse.tsx index 1a7734f0f..0fe14112d 100644 --- a/source/engine/parse.tsx +++ b/source/engine/parse.tsx @@ -9,7 +9,9 @@ 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' import { Grammar, Parser } from 'nearley' import { @@ -25,7 +27,7 @@ import { subtract } from 'ramda' import React from 'react' -import { syntaxError } from './error' +import { EngineError, syntaxError } from './error' import grammar from './grammar.ne' import { mecanismAllOf, @@ -86,6 +88,17 @@ export const parseExpression = (rule, rawNode) => { } const parseMecanism = (rules, rule, parsedRules) => rawNode => { + if (Array.isArray(rawNode)) { + syntaxError( + rule.dottedName, + ` +Il manque le nom du mécanisme pour le tableau : [${rawNode + .map(x => `'${x}'`) + .join(', ')}] +Les mécanisme possibles sont : 'somme', 'le maximum de', 'le minimum de', 'toutes ces conditions', 'une de ces conditions'. + ` + ) + } if (Object.keys(rawNode).length > 1) { syntaxError( rule.dottedName, @@ -136,7 +149,14 @@ Le mécanisme ${mecanismName} est inconnu. Vérifiez qu'il n'y ait pas d'erreur dans l'orthographe du nom.` ) } - return parseFn(parse(rules, rule, parsedRules), mecanismName, values) + try { + return parseFn(parse(rules, rule, parsedRules), mecanismName, values) + } catch (e) { + if (e instanceof EngineError) { + throw e + } + syntaxError(rule.dottedName, e.message) + } } const knownOperations = { @@ -164,8 +184,10 @@ const statelessParseFunction = { 'une de ces conditions': mecanismOneOf, 'toutes ces conditions': mecanismAllOf, somme: mecanismSum, + régularisation, multiplication: mecanismProduct, produit: mecanismProduct, + temporalValue: variableTemporelle, arrondi: mecanismRound, barème, grille, diff --git a/source/engine/parseReference.js b/source/engine/parseReference.js index aad038595..8f926be86 100644 --- a/source/engine/parseReference.js +++ b/source/engine/parseReference.js @@ -149,10 +149,11 @@ Par défaut, seul le premier s'applique. Si vous voulez un autre comportement, v if (cached) return addReplacementMissingVariable(cached) - let cacheNode = (nodeValue, missingVariables, explanation) => { + let cacheNode = (nodeValue, missingVariables, explanation, temporalValue) => { cache[cacheName] = { ...node, nodeValue, + temporalValue, ...(explanation && { explanation }), @@ -183,7 +184,8 @@ Par défaut, seul le premier s'applique. Si vous voulez un autre comportement, v return cacheNode( evaluation.nodeValue, evaluation.missingVariables, - evaluation + evaluation, + evaluation.temporalValue ) } diff --git a/source/engine/parseRule.tsx b/source/engine/parseRule.tsx index 67dbcbd90..bfdb7ab0d 100644 --- a/source/engine/parseRule.tsx +++ b/source/engine/parseRule.tsx @@ -83,8 +83,16 @@ export default (rules, rule, parsedRules) => { parsedRules, node.explanation ), - { nodeValue, unit, missingVariables } = explanation - return { ...node, nodeValue, unit, missingVariables, explanation } + { nodeValue, unit, missingVariables, temporalValue } = explanation + + return { + ...node, + nodeValue, + unit, + missingVariables, + explanation, + temporalValue + } } let child = parse(rules, rule, parsedRules)(value) @@ -129,8 +137,8 @@ export default (rules, rule, parsedRules) => { ...parsedRoot, evaluate, parsed: true, - isDisabledBy: [], defaultUnit: parsedRoot.defaultUnit || parsedRoot.formule?.unit, + isDisabledBy: [], replacedBy: [] } parsedRules[rule.dottedName]['rendu non applicable'] = { @@ -147,6 +155,7 @@ export default (rules, rule, parsedRules) => { missingVariables: mergeAllMissing(isDisabledBy) } }, + jsx: (_nodeValue, { isDisabledBy }) => { return ( isDisabledBy.length > 0 && ( diff --git a/source/engine/rules.js b/source/engine/rules.js index 83588f66a..0df4a87eb 100644 --- a/source/engine/rules.js +++ b/source/engine/rules.js @@ -1,6 +1,30 @@ import { parseUnit } from 'Engine/units' import rawRules from 'Publicode/rules' -import { assoc, chain, dropLast, filter, fromPairs, is, isNil, join, last, map, path, pipe, propEq, props, range, reduce, reduced, reject, split, take, toPairs, trim, when } from 'ramda' +import { + assoc, + chain, + dropLast, + filter, + fromPairs, + is, + isNil, + join, + last, + map, + path, + pipe, + propEq, + props, + range, + reduce, + reduced, + reject, + split, + take, + toPairs, + trim, + when +} from 'ramda' import translations from '../locales/rules-en.yaml' // TODO - should be in UI, not engine import { capitalise0, coerceArray } from '../utils' @@ -89,7 +113,7 @@ export let ruleParents = dottedName => { */ export let disambiguateRuleReference = ( allRules, - { dottedName, name }, + { dottedName }, partialName ) => { let pathPossibilities = [ @@ -113,9 +137,8 @@ export let disambiguateRuleReference = ( return found.dottedName } - throw new Error( - `OUUUUPS la référence '${partialName}' dans la règle '${name}' est introuvable dans la base` - ) + throw new Error(`La référence '${partialName}' est introuvable. +Vérifiez que l'orthographe et l'espace de nom sont corrects`) } export let collectDefaults = pipe( diff --git a/source/engine/temporal.ts b/source/engine/temporal.ts new file mode 100644 index 000000000..f307198e3 --- /dev/null +++ b/source/engine/temporal.ts @@ -0,0 +1,438 @@ +import { + convertToDate, + getDifferenceInDays, + getDifferenceInMonths, + getDifferenceInYears, + getRelativeDate, + getYear +} from 'Engine/date' +import { Unit } from './units' + +export type Period = { + start: T | null + end: T | null +} + +export function parsePeriod(word: string, date: Date): Period { + const startWords = [ + 'depuis', + 'depuis le', + 'depuis la', + 'à partir de', + 'à partir du', + 'du' + ] + const endWords = [ + "jusqu'à", + "jusqu'au", + "jusqu'à la", + 'avant', + 'avant le', + 'avant la', + 'au' + ] + const intervalWords = ['le', 'en'] + if (!startWords.concat(endWords, intervalWords).includes(word)) { + throw new SyntaxError( + `Le mot clé '${word}' n'est pas valide. Les mots clés possible sont les suivants :\n\t ${startWords.join( + ', ' + )}` + ) + } + if (word === 'le') { + return { + start: date, + end: date + } + } + if (word === 'en') { + return { start: null, end: null } + } + if (startWords.includes(word)) { + return { + start: date, + end: null + } + } + if (endWords.includes(word)) { + return { + start: null, + end: date + } + } + throw new Error('Non implémenté') +} + +// 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)] +export type Evaluation = T | false | null + +export type EvaluatedNode = { + unit: Unit + nodeValue: Evaluation + temporalValue?: Temporal> + explanation?: Object + missingVariables?: Object +} + +export type TemporalNode = Temporal<{ nodeValue: Evaluation }> +export type Temporal = Array & { value: T }> + +export function narrowTemporalValue( + period: Period, + temporalValue: Temporal> +): Temporal> { + return liftTemporal2( + (value, filter) => filter && value, + temporalValue, + createTemporalEvaluation(true, period) + ) +} + +// Returns a temporal value that's true for the given period and false otherwise. +export function createTemporalEvaluation( + value: Evaluation, + period: Period = { start: null, end: null } +): Temporal> { + let temporalValue = [{ ...period, value }] + if (period.start != null) { + temporalValue.unshift({ + start: null, + end: getRelativeDate(period.start, -1), + value: false + }) + } + if (period.end != null) { + temporalValue.push({ + start: getRelativeDate(period.end, 1), + end: null, + value: false + }) + } + return temporalValue +} + +export function pureTemporal(value: T): Temporal { + return [{ start: null, end: null, value }] +} + +export function mapTemporal( + fn: (value: T1) => T2, + temporalValue: Temporal +): Temporal { + return temporalValue.map(({ start, end, value }) => ({ + start, + end, + value: fn(value) + })) +} +export function sometime( + fn: (value: T1) => boolean, + temporalValue: Temporal +): boolean { + return temporalValue.some(({ start, end, value }) => fn(value)) +} + +export function liftTemporal2( + fn: (value1: T1, value2: T2) => T3, + temporalValue1: Temporal, + temporalValue2: Temporal +): Temporal { + return mapTemporal( + ([a, b]) => fn(a, b), + zipTemporals(temporalValue1, temporalValue2) + ) +} + +export function concatTemporals( + temporalValues: Array> +): Temporal> { + return temporalValues.reduce( + (values, value) => liftTemporal2((a, b) => [...a, b], values, value), + pureTemporal([]) as Temporal> + ) +} + +export function liftTemporalNode(node: EvaluatedNode): TemporalNode { + const { temporalValue, ...baseNode } = node + if (!temporalValue) { + return pureTemporal(baseNode) + } + return mapTemporal( + nodeValue => ({ + ...baseNode, + nodeValue + }), + temporalValue + ) +} + +export function zipTemporals( + temporalValue1: Temporal, + temporalValue2: Temporal, + acc: Temporal<[T1, T2]> = [] +): Temporal<[T1, T2]> { + if (!temporalValue1.length && !temporalValue2.length) { + return acc + } + const [value1, ...rest1] = temporalValue1 + const [value2, ...rest2] = temporalValue2 + console.assert(value1.start === value2.start) + const endDateComparison = compareEndDate(value1.end, value2.end) + + // End dates are equals + if (endDateComparison === 0) { + return zipTemporals(rest1, rest2, [ + ...acc, + { ...value1, value: [value1.value, value2.value] } + ]) + } + // Value1 lasts longuer than value1 + if (endDateComparison > 0) { + console.assert(value2.end !== null) + return zipTemporals( + [ + { ...value1, start: getRelativeDate(value2.end as string, 1) }, + ...rest1 + ], + rest2, + [ + ...acc, + { + ...value2, + value: [value1.value, value2.value] + } + ] + ) + } + + // Value2 lasts longuer than value1 + if (endDateComparison < 0) { + console.assert(value1.end !== null) + return zipTemporals( + rest1, + [ + { ...value2, start: getRelativeDate(value1.end as string, 1) }, + ...rest2 + ], + [ + ...acc, + { + ...value1, + value: [value1.value, value2.value] + } + ] + ) + } + 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 +} + +function compareStartDate( + dateA: string | null, + dateB: string | null +): -1 | 0 | 1 { + if (dateA == dateB) { + return 0 + } + if (dateA == null) { + return -1 + } + if (dateB == null) { + return 1 + } + return convertToDate(dateA) < convertToDate(dateB) ? -1 : 1 +} + +function compareEndDate( + dateA: string | null, + dateB: string | null +): -1 | 0 | 1 { + if (dateA == dateB) { + return 0 + } + if (dateA == null) { + return 1 + } + if (dateB == null) { + return -1 + } + return convertToDate(dateA) < convertToDate(dateB) ? -1 : 1 +} + +export function temporalAverage( + temporalValue: Temporal>, + unit?: Unit +): Evaluation { + temporalValue = temporalValue.filter(({ value }) => value !== false) + if (!temporalValue.length) { + return false + } + if (temporalValue.length === 1) { + return temporalValue[0].value + } + + if (temporalValue.some(({ value }) => value == null)) { + return null + } + + const temporalNumber = temporalValue as Temporal + const first = temporalNumber[0] + const last = temporalNumber[temporalNumber.length - 1] + + // La variable est définie sur un interval infini + if (first.start == null || last.end == null) { + if (first.start != null) { + return last.value + } + if (last.end != null) { + return first.value + } + return (first.value + last.value) / 2 + } + + let totalWeight = 0 + const weights = temporalNumber.map(({ start, end, value }) => { + ;[start, end] = [start, end] as [string, string] + 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 * weight + }) + return weights.reduce( + (average, weightedValue) => average + weightedValue / totalWeight, + 0 + ) +} + +export function temporalCumul( + temporalValue: Temporal>, + unit: Unit +): Evaluation { + temporalValue = temporalValue.filter(({ value }) => value !== false) + if (!temporalValue.length) { + return false + } + + if (temporalValue.some(({ value }) => value == null)) { + return null + } + + const temporalNumber = temporalValue as Temporal + const first = temporalNumber[0] + const last = temporalNumber[temporalNumber.length - 1] + + // 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 (temporalNumber.some(({ value }) => value == null)) { + return null + } + + return temporalNumber.reduce((acc, { start, end, value }) => { + ;[start, end] = [start, end] as [string, string] + 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/source/engine/units.ts b/source/engine/units.ts index 0d40601c9..f6b146774 100644 --- a/source/engine/units.ts +++ b/source/engine/units.ts @@ -12,6 +12,7 @@ import { without } from 'ramda' import i18n from '../i18n' +import { Evaluation } from './temporal' type BaseUnit = string @@ -186,7 +187,13 @@ export function convertUnit( from: Unit | undefined, to: Unit | undefined, value: number -) { +): number +export function convertUnit( + from: Unit | undefined, + to: Unit | undefined, + value: Evaluation +): Evaluation +export function convertUnit(from, to, value): any { if (!areUnitConvertible(from, to)) { throw new Error( `Impossible de convertir l'unité '${serializeUnit( diff --git a/source/locales/rules-en.yaml b/source/locales/rules-en.yaml index 3f38fc666..02ba489d6 100644 --- a/source/locales/rules-en.yaml +++ b/source/locales/rules-en.yaml @@ -4207,8 +4207,39 @@ dirigeant . indépendant . revenu net de cotisations: titre.en: net contribution income titre.fr: revenu net de cotisations dirigeant . indépendant . revenu professionnel: - titre.en: '[automatic] occupational income' - titre.fr: revenu professionnel + description.en: > + [automatic] This is the net deductible contribution income of the + self-employed person, which is used as the basis for the calculation of + contributions and tax for self-employed persons. + + + Attention, **our calculation is made at cruising speed**: + + the self-employed person who starts out will pay a relatively small package + of social security contributions for the first 2 years. He will then have to + regularise this situation in relation to the income he has actually + received. + + + Therefore, this calculation should be seen as "the amount that will have to + be paid* in the short term after 2 years of exercise. + description.fr: > + C'est le revenu net de cotisations déductibles du travailleur indépendant, + qui sert de base au calcul des cotisations et de l'impôt pour les + indépendants. + + + Attention, **notre calcul est fait au régime de croisière**: + + l'indépendant qui se lance paiera pendant ses 2 premières années un forfait + relativement réduit de cotisations sociales. Il devra ensuite régulariser + cette situation par rapport au revenu qu'il a vraiment perçu. + + + Il faut donc voir ce calcul comme *le montant qui devra de toute façon être + payé* à court terme après 2 ans d'exercice. + titre.en: '[automatic] professional income (net taxable)' + titre.fr: revenu professionnel (net imposable) dirigeant . indépendant . revenus étrangers: description.en: > [automatic] Foreign income is income declared by self-employed persons in diff --git a/source/reducers/rootReducer.ts b/source/reducers/rootReducer.ts index c496e64f6..d781ec1f1 100644 --- a/source/reducers/rootReducer.ts +++ b/source/reducers/rootReducer.ts @@ -1,6 +1,6 @@ import { Action } from 'Actions/actions' import { Analysis } from 'Engine/traverse' -import { areUnitConvertible, convertUnit, parseUnit, Unit } from 'Engine/units' +import { Unit } from 'Engine/units' import { defaultTo, identity, omit, without } from 'ramda' import reduceReducers from 'reduce-reducers' import { combineReducers, Reducer } from 'redux' @@ -8,6 +8,7 @@ import { analysisWithDefaultsSelector } from 'Selectors/analyseSelectors' import { SavedSimulation } from 'Selectors/storageSelectors' import { DottedName } from 'Types/rule' import i18n, { AvailableLangs } from '../i18n' +import { areUnitConvertible, convertUnit, parseUnit } from './../engine/units' import inFranceAppReducer, { Company } from './inFranceAppReducer' import storageRootReducer from './storageReducer' @@ -37,7 +38,7 @@ type Example = null | { name: string situation: object dottedName: DottedName - defaultUnits?: Array + defaultUnit?: Unit } function currentExample(state: Example = null, action: Action): Example { @@ -155,7 +156,7 @@ export type SimulationConfig = Partial<{ bloquant: Array situation: Simulation['situation'] branches: Array<{ nom: string; situation: SimulationConfig['situation'] }> - 'unités par défaut': [string] + 'unité par défaut': string }> type Situation = Partial> @@ -165,7 +166,7 @@ export type Simulation = { hiddenControls: Array situation: Situation initialSituation: Situation - defaultUnits: [string] + defaultUnit: string foldedSteps: Array unfoldedStep?: DottedName | null } @@ -203,7 +204,7 @@ function simulation( hiddenControls: [], situation: companySituation, initialSituation: companySituation, - defaultUnits: config['unités par défaut'] || ['€/mois'], + defaultUnit: config['unité par défaut'] || '€/mois', foldedSteps: Object.keys(companySituation) as Array, unfoldedStep: null } @@ -251,11 +252,11 @@ function simulation( case 'UPDATE_DEFAULT_UNIT': return { ...state, - defaultUnits: [action.defaultUnit], situation: updateDefaultUnit(state.situation, { toUnit: action.defaultUnit, analysis - }) + }), + defaultUnit: action.defaultUnit } } return state diff --git a/source/selectors/analyseSelectors.ts b/source/selectors/analyseSelectors.ts index e6579d3db..da42e26b0 100644 --- a/source/selectors/analyseSelectors.ts +++ b/source/selectors/analyseSelectors.ts @@ -110,8 +110,8 @@ let validatedStepsSelector = createSelector( [state => state.simulation?.foldedSteps, targetNamesSelector], (foldedSteps, targetNames) => [...(foldedSteps || []), ...targetNames] ) -export const defaultUnitsSelector = (state: RootState) => - state.simulation?.defaultUnits || [] +export const defaultUnitSelector = (state: RootState) => + state.simulation?.defaultUnit ?? '€/mois' let branchesSelector = (state: RootState) => configSelector(state).branches let configSituationSelector = (state: RootState) => configSelector(state).situation || {} @@ -172,9 +172,9 @@ export let ruleAnalysisSelector = createSelector( (_, props: { dottedName: DottedName }) => props.dottedName, situationsWithDefaultsSelector, state => state.situationBranch || 0, - defaultUnitsSelector + defaultUnitSelector ], - (rules, dottedName, situations, situationBranch, defaultUnits) => { + (rules, dottedName, situations, situationBranch, defaultUnit) => { return analyseRule( rules, dottedName, @@ -184,7 +184,7 @@ export let ruleAnalysisSelector = createSelector( : situations return currentSituation[dottedName] }, - defaultUnits + [defaultUnit] ) } ) @@ -217,7 +217,7 @@ export let exampleAnalysisSelector = createSelector( rules, dottedName, (dottedName: DottedName) => situation[dottedName], - example?.defaultUnits + example?.defaultUnit ) ) @@ -227,18 +227,16 @@ let makeAnalysisSelector = (situationSelector: SituationSelectorType) => parsedRulesSelector, targetNamesSelector, situationSelector, - defaultUnitsSelector + defaultUnitSelector ], - (parsedRules, targetNames, situations, defaultUnits) => { + (parsedRules, targetNames, situations, defaultUnit) => { return mapOrApply( situation => - analyseMany( - parsedRules, - targetNames, - defaultUnits - )((dottedName: DottedName) => { - return situation[dottedName] - }), + analyseMany(parsedRules, targetNames, [defaultUnit])( + (dottedName: DottedName) => { + return situation[dottedName] + } + ), situations ) } diff --git a/source/sites/mon-entreprise.fr/pages/Gérer/AideDéclarationIndépendant/Récapitulatif.tsx b/source/sites/mon-entreprise.fr/pages/Gérer/AideDéclarationIndépendant/Récapitulatif.tsx index 0790f69d2..e0e69ea19 100644 --- a/source/sites/mon-entreprise.fr/pages/Gérer/AideDéclarationIndépendant/Récapitulatif.tsx +++ b/source/sites/mon-entreprise.fr/pages/Gérer/AideDéclarationIndépendant/Récapitulatif.tsx @@ -12,7 +12,6 @@ export function AideDéclarationIndépendantsRécapitulatif() { const siren = useSelector( (state: RootState) => state.inFranceApp.existingCompany?.siren ) - console.log(useSelector((state: RootState) => state.rules)) const componentRef = useRef(null) return ( diff --git a/test/bug-cotisations.test.js b/test/bug-cotisations.test.js index c7ac3a7c9..72c5963a3 100644 --- a/test/bug-cotisations.test.js +++ b/test/bug-cotisations.test.js @@ -55,7 +55,6 @@ describe('bug-analyse-many', function() { const one = analyse(rules, 'cotisations')(stateSelector).targets[0] - //console.log(many[0].nodeValue, many[1].nodeValue, one.nodeValue) expect(many[1].nodeValue).to.be.closeTo(one.nodeValue, 0.1) }) it('should compute the same contributions if asked with analyseMany or analyse', function() { diff --git a/test/conversation.test.js b/test/conversation.test.js index dc64fddcf..2e041e956 100644 --- a/test/conversation.test.js +++ b/test/conversation.test.js @@ -8,7 +8,7 @@ import { nextStepsSelector } from '../source/selectors/analyseSelectors' let baseState = { - simulation: { situation: {}, foldedSteps: [] } + simulation: { defaultUnit: '€/an', situation: {}, foldedSteps: [] } } describe('conversation', function() { @@ -24,7 +24,11 @@ describe('conversation', function() { rules = rawRules.map(enrichRule), state = merge(baseState, { rules, - simulation: { config: { objectifs: ['startHere'] }, foldedSteps: [] } + simulation: { + defaultUnit: '€/an', + config: { objectifs: ['startHere'] }, + foldedSteps: [] + } }), currentQuestion = currentQuestionSelector(state) @@ -48,7 +52,11 @@ describe('conversation', function() { let step1 = merge(baseState, { rules, - simulation: { config: { objectifs: ['startHere'] }, foldedSteps: [] } + simulation: { + defaultUnit: '€/an', + config: { objectifs: ['startHere'] }, + foldedSteps: [] + } }) let step2 = reducers( assocPath(['simulation', 'situation'], { 'top . aa': '1' }, step1), @@ -125,7 +133,11 @@ describe('conversation', function() { let step1 = merge(baseState, { rules, - simulation: { config: { objectifs: ['net'] }, foldedSteps: [] } + simulation: { + defaultUnit: '€/an', + config: { objectifs: ['net'] }, + foldedSteps: [] + } }) expect(currentQuestionSelector(step1)).to.equal('brut') @@ -148,7 +160,11 @@ describe('real conversation', function() { it('should not have more than X questions', function() { let state = merge(baseState, { rules, - simulation: { config: salariéConfig, foldedSteps: [] } + simulation: { + defaultUnit: '€/an', + config: salariéConfig, + foldedSteps: [] + } }), nextSteps = nextStepsSelector(state) 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/ficheDePaieSelector.test.js b/test/ficheDePaieSelector.test.js index ee94e05d5..2081fd3cc 100644 --- a/test/ficheDePaieSelector.test.js +++ b/test/ficheDePaieSelector.test.js @@ -10,6 +10,7 @@ import { let state = { rules, simulation: { + defaultUnit: '€/mois', config: salariéConfig, situation: { 'contrat salarié . rémunération . brut de base': '2300', diff --git a/test/generateQuestions.test.js b/test/generateQuestions.test.js index 4053b7b62..0f99293b7 100644 --- a/test/generateQuestions.test.js +++ b/test/generateQuestions.test.js @@ -237,8 +237,6 @@ describe('nextSteps', function() { analysis = analyse(rules, 'sum')(stateSelector), result = collectMissingVariables(analysis.targets) - // console.log('analysis', JSON.stringify(analysis, null, 4)) - expect(result).to.have.lengthOf(1) expect(result[0]).to.equal('top . sum . evt') }) diff --git a/test/library.test.js b/test/library.test.js index 1b480c1df..0a8ff82e4 100644 --- a/test/library.test.js +++ b/test/library.test.js @@ -88,13 +88,13 @@ impôt sur le revenu: assiette: revenu abattu tranches: - taux: 0% - plafond: 9807 + plafond: 9807 € - taux: 14% - plafond: 27086 + plafond: 27086 € - taux: 30% - plafond: 72617 + plafond: 72617 € - taux: 41% - plafond: 153783 + plafond: 153783 € - taux: 45% impôt sur le revenu à payer: diff --git a/test/mécanismes/grille.yaml b/test/mécanismes/grille.yaml index 30402c554..c3af15e9b 100644 --- a/test/mécanismes/grille.yaml +++ b/test/mécanismes/grille.yaml @@ -31,3 +31,35 @@ Grille: situation: assiette: 999.3 valeur attendue: 50 + +plafond: + unité: € +Grille avec valeur manquante: + formule: + grille: + assiette: assiette + unité: € + tranches: + - montant: 100 + plafond: plafond + - montant: 200 + plafond: 2000 € + - montant: 300 + plafond: 4000 € + + unité attendue: € + exemples: + - nom: 'variable manquante' + situation: + assiette: 1000 + variables manquantes: + - plafond + valeur attendue: null + - nom: 'assiette non concernée par variable manquante' + situation: + assiette: 3000 + valeur attendue: 300 + - nom: 'assiette au delà du plagond' + situation: + assiette: 5000 + valeur attendue: false diff --git a/test/mécanismes/régularisation.yaml b/test/mécanismes/régularisation.yaml new file mode 100644 index 000000000..4645c1859 --- /dev/null +++ b/test/mécanismes/régularisation.yaml @@ -0,0 +1,93 @@ +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: + unité: €/mois + 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 + +# ====================== +# Exemple plus complexe +# ====================== + +heures d'absences: + # TODO : mettre les heures chaque jour + formule: + somme: + - 2 heures/mois | du 01/01/2020 | au 31/01/2020 + - 3 heures/mois | du 01/03/2020 | au 31/03/2020 + +temps contractuel: + formule: 145 heures/mois + +temps de travail effectif: + formule: temps contractuel - heures d'absences + +plafond sécurité sociale proratisé: + formule: + multiplication: + assiette: plafond sécurité sociale + facteur: temps de travail effectif / 151.67 heures/mois + +taux variable: + formule: + variations: + - si: salaire < plafond sécurité sociale proratisé + alors: 10% + - sinon: 20% + +cotisation spéciale: + unité: €/mois + formule: + régularisation: + règle: + multiplication: + assiette: salaire + taux: taux variable + valeurs cumulées: + - salaire + - plafond sécurité sociale proratisé + +régularisation . test variations 1: + formule: cotisation spéciale | du 01/01/2020 | au 31/12/2020 + exemples: + - valeur attendue: 710 + +régularisation . test variations 2: + formule: cotisation spéciale | du 01/02/2020 | au 29/02/2020 + exemples: + - valeur attendue: 660 diff --git a/test/mécanismes/variable-temporelle.yaml b/test/mécanismes/variable-temporelle.yaml new file mode 100644 index 000000000..523f81af8 --- /dev/null +++ b/test/mécanismes/variable-temporelle.yaml @@ -0,0 +1,251 @@ +variable temporelle numérique . le . valeur: + formule: 40 €/mois | le 02/04/2019 + +variable temporelle numérique . le . test date applicable: + formule: valeur | le 02/04/2019 + exemples: + - valeur attendue: 40 + +variable temporelle numérique . le . test date non applicable: + formule: valeur | le 02/03/2021 + exemples: + - valeur attendue: false + +variable temporelle numérique . depuis . valeur: + formule: 40 €/mois | depuis le 02/04/2019 + +variable temporelle numérique . depuis . test date applicable: + formule: valeur | depuis le 06/04/2019 + exemples: + - valeur attendue: 40 + +variable temporelle numérique . depuis . test date non applicable: + formule: valeur | le 08/03/2019 + exemples: + - valeur attendue: false + +variable temporelle numérique . intervalle . valeur: + formule: 40 €/mois | du 02/04/2019 | au 04/05/2020 + +variable temporelle numérique . intervalle . test date applicable: + formule: valeur | le 06/04/2019 + exemples: + - valeur attendue: 40 + +variable temporelle numérique . intervalle . test date applicable 2: + formule: valeur | depuis le 05/06/2019 | jusqu'au 19/04/2020 + exemples: + - valeur attendue: 40 + +variable temporelle numérique . intervalle . test date non applicable: + formule: valeur | le 08/03/2021 + exemples: + - valeur attendue: false + +variable temporelle numérique . intervalle . test date non applicable 2: + formule: valeur | le 28/01/2019 + exemples: + - valeur attendue: false + +variable temporelle numérique . variable . date limite de paiement: + formule: 03/09/2020 + +variable temporelle numérique . variable . majorations de retard: + formule: '40 €/jour | à partir de : date limite de paiement' + +variable temporelle numérique . variable . test date non applicable: + formule: "majorations de retard | jusqu'au : 02/09/2020" + exemples: + - valeur attendue: false + +variable temporelle numérique . variable . test date non applicable 2: + formule: majorations de retard | du 01/02/2020 | au 03/08/2020 + exemples: + - valeur attendue: false + +variable temporelle numérique . variable . test date applicable: + formule: 'majorations de retard | depuis la : date limite de paiement' + exemples: + - valeur attendue: 40 + +variable temporelle numérique . variable . test date applicable 2: + formule: majorations de retard | le 03/09/2020 + exemples: + - valeur attendue: 40 + +prix: + formule: (20 €/mois | à partir du 15/11/2019) + (10 €/mois | à partir du 01/02/2020) + +date: +variable temporelle numérique . test addition: + formule: 'prix | le : date' + exemples: + - situation: + date: 01/01/2019 + valeur attendue: false + - situation: + date: 15/12/2019 + valeur attendue: 20 + - situation: + date: 12/09/2020 + valeur attendue: 30 + +prix avec variations: + formule: prix * (50% | du 01/01/2020 | au 31/01/2020) + +début: +fin: +variable temporelle numérique . expression . multiplication: + formule: "prix avec variations | depuis : début | jusqu'à : fin" + # 20 [avant janvier] / 10 [pendant janvier] | 30 [pendant et après février] + exemples: + - situation: + début: 01/01/2020 + fin: 31/01/2020 + valeur attendue: 10 + - situation: + début: 01/01/2020 + fin: 29/02/2020 + valeur attendue: 20 + - situation: + début: 01/02/2020 + fin: 31/03/2020 + valeur attendue: 30 + +taux associé: + formule: + variations: + - si: prix avec variations >= 20 €/mois + alors: 10%/mois + - si: prix avec variations < 20 €/mois + alors: 60%/mois + # Cette formule peut paraître bizarre, mais lorsque multiplication est non + # applicable, c'est bien le sinon qui s'applique + - sinon: 5%/mois +variable temporelle numérique . variation: + formule: "taux associé | depuis : début | jusqu'à : fin" + exemples: + - situation: + début: 01/01/2020 + fin: 31/01/2020 + valeur attendue: 60 + - situation: + début: 01/01/2020 + fin: 29/02/2020 + valeur attendue: 35 + - situation: + début: 01/02/2020 + fin: 31/03/2020 + valeur attendue: 10 + - situation: + début: 01/10/2019 + fin: 30/10/2019 + valeur attendue: 5 + +contrat salarié . date d'embauche: + formule: 12/09/2018 + +contrat salarié . salaire: + formule: + somme: + - brut de base + - primes + +contrat salarié . salaire . brut de base: + formule: + somme: + - "2000€/mois | depuis : date d'embauche | jusqu'au 08/08/2019" + - 2200€/mois | depuis le 09/08/2019 + +contrat salarié . salaire . primes: + formule: 2000€/mois | du 01/12/2019 | au 31/12/2019 + +plafond sécurité sociale: + formule: + somme: + - 3377 €/mois | du 01/01/2019 | au 31/12/2019 + - 3424 €/mois | du 01/01/2020 | au 31/12/2020 + +contrat salarié . cotisations . retraite: + formule: + multiplication: + assiette: salaire + plafond: plafond sécurité sociale + taux: 10% + +variable temporelle numérique . somme: + formule: contrat salarié . salaire | du 01/12/2019 | au 31/12/2019 + exemples: + - valeur attendue: 4200 # 2000 + 2200 + +variable temporelle numérique . somme avec valeur changeant au cours du mois: + formule: contrat salarié . salaire | du 01/08/2019 | au 31/08/2019 + exemples: + - valeur attendue: 2148.387 # (2000 * 8 + 2200 * 23)/31 + +variable temporelle numérique . multiplication: + formule: contrat salarié . cotisations . retraite | du 01/05/2019 | au 31/05/2019 + exemples: + - valeur attendue: 200 # 2000 * 10% + +variable temporelle numérique . multiplication avec valeur changeant au cours du mois: + formule: contrat salarié . cotisations . retraite | du 01/08/2019 | au 31/08/2019 + exemples: + - valeur attendue: 214.839 # (2000 * 8 + 2200 * 23)/31 + +variable temporelle numérique . multiplication avec valeur au dessus du plafond: + formule: contrat salarié . cotisations . retraite | du 01/12/2019 | au 31/12/2019 + 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 * 7 [janvier-juin] + # + 214.839 [juillet] + # + 220 * 3 [aout-novembre] + # + 337.7 [décembre] + # /12 mois + - valeur attendue: 217.7115 +# test . proratisation du salaire avec entrée en cours de mois: +# formule: salaire brut [avril 2019] +# exemples: +# - valeur attendue: 400 # (2000 * 6)/30 +cotisation spéciale: + formule: + barème: + assiette: contrat salarié . salaire + multiplicateur: plafond sécurité sociale + tranches: + - taux: 0% + plafond: 10% + - taux: 10% + plafond: 20% + - taux: 30% + plafond: 50% + - taux: 40% + plafond: 100% + - taux: 50% + +variable temporelle numérique . barème: + formule: cotisation spéciale | du 01/01/2019 | au 31/12/2019 + exemples: + - valeur attendue: 567.438 + +grille: + formule: + barème: + assiette: contrat salarié . salaire + tranches: + - montant: 5 heures + plafond: 1000€ + - montant: 10 heures + plafond: 2000 € + - montant: 30 heures + plafond: 4000 € + - montant: 40 heures + +variable temporelle numérique . grille: + formule: cotisation spéciale | du 01/01/2019 | au 31/12/2019 + exemples: + - valeur attendue: 567.438 diff --git a/test/period.test.js b/test/period.test.js new file mode 100644 index 000000000..4316b3869 --- /dev/null +++ b/test/period.test.js @@ -0,0 +1,132 @@ +import { expect } from 'chai' +import { + concatTemporals, + createTemporalEvaluation, + groupByYear, + zipTemporals +} from '../source/engine/temporal' + +const neverEnding = value => [{ start: null, end: null, value: value }] +describe('Periods : zip', () => { + it('should zip two empty temporalValue', () => { + const result = zipTemporals([], []) + expect(result).to.deep.equal([]) + }) + + it('should zip constant temporalValue', () => { + const result = zipTemporals(neverEnding(1), neverEnding(2)) + expect(result).to.deep.equal(neverEnding([1, 2])) + }) + + it('should zip changing temporalValue', () => { + const value1 = createTemporalEvaluation(true, { + start: null, + end: '01/08/2020' + }) + const value2 = neverEnding(1) + expect(zipTemporals(value1, value2)).to.deep.equal([ + { start: null, end: '01/08/2020', value: [true, 1] }, + { start: '02/08/2020', end: null, value: [false, 1] } + ]) + expect(zipTemporals(value2, value1)).to.deep.equal([ + { start: null, end: '01/08/2020', value: [1, true] }, + { start: '02/08/2020', end: null, value: [1, false] } + ]) + }) + + it('should zip two overlapping temporalValue', () => { + const value1 = createTemporalEvaluation(1, { + start: '01/07/2019', + end: '30/06/2020' + }) + const value2 = createTemporalEvaluation(2, { + start: '01/01/2019', + end: '31/12/2019' + }) + + expect(zipTemporals(value1, value2)).to.deep.equal([ + { start: null, end: '31/12/2018', value: [false, false] }, + { start: '01/01/2019', end: '30/06/2019', value: [false, 2] }, + { start: '01/07/2019', end: '31/12/2019', value: [1, 2] }, + { start: '01/01/2020', end: '30/06/2020', value: [1, false] }, + { start: '01/07/2020', end: null, value: [false, false] } + ]) + }) +}) + +describe('Periods : concat', () => { + it('should merge concat overlapping temporalValue', () => { + const value1 = createTemporalEvaluation(10) + const value2 = [ + { start: null, end: '14/04/2019', value: 100 }, + { start: '15/04/2019', end: '08/08/2019', value: 2000 }, + { start: '09/08/2019', end: null, value: 200 } + ] + + expect(concatTemporals([value1, value2])).to.deep.equal([ + { start: null, end: '14/04/2019', value: [10, 100] }, + { start: '15/04/2019', end: '08/08/2019', value: [10, 2000] }, + { start: '09/08/2019', end: null, value: [10, 200] } + ]) + }) +}) + +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) + }) +}) diff --git a/test/rules/sasu.yaml b/test/rules/sasu.yaml index 1afe5ab0f..c4c345a88 100644 --- a/test/rules/sasu.yaml +++ b/test/rules/sasu.yaml @@ -4,9 +4,11 @@ chiffre affaires: unité par défaut: €/mois + par défaut: 0 charges: - par défaut: 0 €/mois + unité: €/mois + par défaut: 0 répartition salaire sur dividendes: par défaut: 50 diff --git a/test/temporal.test.js b/test/temporal.test.js new file mode 100644 index 000000000..4316b3869 --- /dev/null +++ b/test/temporal.test.js @@ -0,0 +1,132 @@ +import { expect } from 'chai' +import { + concatTemporals, + createTemporalEvaluation, + groupByYear, + zipTemporals +} from '../source/engine/temporal' + +const neverEnding = value => [{ start: null, end: null, value: value }] +describe('Periods : zip', () => { + it('should zip two empty temporalValue', () => { + const result = zipTemporals([], []) + expect(result).to.deep.equal([]) + }) + + it('should zip constant temporalValue', () => { + const result = zipTemporals(neverEnding(1), neverEnding(2)) + expect(result).to.deep.equal(neverEnding([1, 2])) + }) + + it('should zip changing temporalValue', () => { + const value1 = createTemporalEvaluation(true, { + start: null, + end: '01/08/2020' + }) + const value2 = neverEnding(1) + expect(zipTemporals(value1, value2)).to.deep.equal([ + { start: null, end: '01/08/2020', value: [true, 1] }, + { start: '02/08/2020', end: null, value: [false, 1] } + ]) + expect(zipTemporals(value2, value1)).to.deep.equal([ + { start: null, end: '01/08/2020', value: [1, true] }, + { start: '02/08/2020', end: null, value: [1, false] } + ]) + }) + + it('should zip two overlapping temporalValue', () => { + const value1 = createTemporalEvaluation(1, { + start: '01/07/2019', + end: '30/06/2020' + }) + const value2 = createTemporalEvaluation(2, { + start: '01/01/2019', + end: '31/12/2019' + }) + + expect(zipTemporals(value1, value2)).to.deep.equal([ + { start: null, end: '31/12/2018', value: [false, false] }, + { start: '01/01/2019', end: '30/06/2019', value: [false, 2] }, + { start: '01/07/2019', end: '31/12/2019', value: [1, 2] }, + { start: '01/01/2020', end: '30/06/2020', value: [1, false] }, + { start: '01/07/2020', end: null, value: [false, false] } + ]) + }) +}) + +describe('Periods : concat', () => { + it('should merge concat overlapping temporalValue', () => { + const value1 = createTemporalEvaluation(10) + const value2 = [ + { start: null, end: '14/04/2019', value: 100 }, + { start: '15/04/2019', end: '08/08/2019', value: 2000 }, + { start: '09/08/2019', end: null, value: 200 } + ] + + expect(concatTemporals([value1, value2])).to.deep.equal([ + { start: null, end: '14/04/2019', value: [10, 100] }, + { start: '15/04/2019', end: '08/08/2019', value: [10, 2000] }, + { start: '09/08/2019', end: null, value: [10, 200] } + ]) + }) +}) + +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) + }) +})