From 92badbba4b40dd35e27ee18d7b880ce113fa9e65 Mon Sep 17 00:00:00 2001 From: Chika Date: Mon, 17 Oct 2022 16:01:02 +0300 Subject: [PATCH] merge branch migration/payments-table --- .../Form/Payments/PaymentsParams.jsx | 45 ++ .../Calculation/Form/Payments/config.ts | 1 - .../Calculation/Form/Payments/index.jsx | 19 +- .../config/elements-render/override.tsx | 38 ++ Components/Layout/Element.tsx | 2 +- config/default-options.ts | 90 +-- config/default-values.ts | 4 +- process/fingap/reactions/validation.ts | 4 +- .../payments/lib/__tests__/seasons.test.js | 25 + process/payments/lib/seasons-constants.ts | 15 + process/payments/lib/seasons-tools.ts | 55 ++ process/payments/reactions.ts | 552 +++++++++++++++++- process/payments/validation.ts | 134 +++++ stores/calculation/options/index.ts | 2 +- stores/tables/payments/index.ts | 6 +- stores/tables/payments/types.ts | 6 + stores/tables/validation.ts | 1 + tools/__tests__/array.test.js | 39 ++ tools/array.ts | 31 + 19 files changed, 977 insertions(+), 92 deletions(-) create mode 100644 Components/Calculation/Form/Payments/PaymentsParams.jsx create mode 100644 process/payments/lib/__tests__/seasons.test.js create mode 100644 process/payments/lib/seasons-constants.ts create mode 100644 process/payments/lib/seasons-tools.ts create mode 100644 process/payments/validation.ts create mode 100644 stores/tables/payments/types.ts create mode 100644 tools/__tests__/array.test.js create mode 100644 tools/array.ts diff --git a/Components/Calculation/Form/Payments/PaymentsParams.jsx b/Components/Calculation/Form/Payments/PaymentsParams.jsx new file mode 100644 index 0000000..4f3174f --- /dev/null +++ b/Components/Calculation/Form/Payments/PaymentsParams.jsx @@ -0,0 +1,45 @@ +import { observer } from 'mobx-react-lite'; +import { useStore } from 'stores/hooks'; +import { Flex } from 'UIKit/grid'; +import elementsRender from '../../config/elements-render'; +import { elements } from './config'; + +function PaymentsParams() { + const renderedElements = elements.map((elementName) => { + const render = elementsRender[elementName]?.render; + + return render(); + }); + + const [selectSeasonType, tbxParmentsDecreasePercent, selectHighSeasonStart] = renderedElements; + + const { $calculation } = useStore(); + const graphType = $calculation.getElementValue('radioGraphType'); + + switch (graphType) { + case 100_000_000: + return null; + case 100_000_001: { + return {selectSeasonType}; + } + case 100_000_002: { + return {tbxParmentsDecreasePercent}; + } + case 100_000_003: { + return ( + + {selectSeasonType} + {selectHighSeasonStart} + + ); + } + case 100_000_004: { + return null; + } + + default: + break; + } +} + +export default observer(PaymentsParams); diff --git a/Components/Calculation/Form/Payments/config.ts b/Components/Calculation/Form/Payments/config.ts index e95a193..796b0c7 100644 --- a/Components/Calculation/Form/Payments/config.ts +++ b/Components/Calculation/Form/Payments/config.ts @@ -4,7 +4,6 @@ export const id = 'payments'; export const title = 'Платежи'; export const elements: ElementsRow['0'] = [ - 'radioGraphType', 'selectSeasonType', 'tbxParmentsDecreasePercent', 'selectHighSeasonStart', diff --git a/Components/Calculation/Form/Payments/index.jsx b/Components/Calculation/Form/Payments/index.jsx index c32133c..11f0673 100644 --- a/Components/Calculation/Form/Payments/index.jsx +++ b/Components/Calculation/Form/Payments/index.jsx @@ -1,18 +1,11 @@ import { Box, Flex } from 'UIKit/grid'; import elementsRender from '../../config/elements-render'; -import { elements, id, title } from './config'; +import { id, title } from './config'; +import PaymentsParams from './PaymentsParams'; import PaymentsTable from './PaymentsTable'; function Payments() { - const renderedElements = elements.map((elementName) => { - const render = elementsRender[elementName]?.render; - - return render(); - }); - - // eslint-disable-next-line operator-linebreak - const [radioGraphType, selectSeasonType, tbxParmentsDecreasePercent, selectHighSeasonStart] = - renderedElements; + const radioGraphType = elementsRender.radioGraphType.render(); return ( @@ -24,11 +17,7 @@ function Payments() { }} > {radioGraphType} - - {selectSeasonType} - {tbxParmentsDecreasePercent} - {selectHighSeasonStart} - + diff --git a/Components/Calculation/config/elements-render/override.tsx b/Components/Calculation/config/elements-render/override.tsx index 506120e..2e08e79 100644 --- a/Components/Calculation/config/elements-render/override.tsx +++ b/Components/Calculation/config/elements-render/override.tsx @@ -2,7 +2,9 @@ import { Container, Head } from 'Components/Layout/Element'; import Link from 'Elements/Link'; import Tooltip from 'Elements/Tooltip'; +import { observer } from 'mobx-react-lite'; import type { ComponentProps } from 'react'; +import { useStore } from 'stores/hooks'; import buildReadonly from '../../builders/build-readonly'; import builders from '../elements-builders'; import components from '../elements-components'; @@ -162,6 +164,42 @@ const overrideRender: Partial> = { ); }, }, + + selectSeasonType: { + render: () => { + const elementName = 'selectSeasonType'; + const valueName = map.selectSeasonType; + const Component = components.selectSeasonType; + const props = elementsProps.selectSeasonType; + const builder = builders.selectSeasonType; + + const Element = builder(Component, { + elementName, + valueName, + }); + + const Title = observer(() => { + const { $calculation } = useStore(); + const graphType = $calculation.getElementValue('radioGraphType'); + + switch (graphType) { + case 100_000_001: + return Тип дегрессии; + case 100_000_003: + return Тип сезонности; + default: + return {titles.selectSeasonType}; + } + }); + + return ( + + } /> + + + ); + }, + }, }; export default overrideRender; diff --git a/Components/Layout/Element.tsx b/Components/Layout/Element.tsx index 0d7570a..5f968d5 100644 --- a/Components/Layout/Element.tsx +++ b/Components/Layout/Element.tsx @@ -23,7 +23,7 @@ export function Head({ addon, htmlFor, }: { - title: string; + title: ReactNode; addon?: ReactNode; htmlFor: string; }) { diff --git a/config/default-options.ts b/config/default-options.ts index 097cf84..7882924 100644 --- a/config/default-options.ts +++ b/config/default-options.ts @@ -1,6 +1,51 @@ import { alphabetical } from 'radash/dist/array'; import type { CalculationOptions } from 'stores/calculation/options/types'; +export const selectSeasonType = [ + { + label: '6/6', + value: 100_000_000, + }, + { + label: '8/4', + value: 100_000_001, + }, + { + label: '4/4/4', + value: 100_000_002, + }, + { + label: 'Гибкая дегрессия', + value: 100_000_007, + }, + { + label: '100.50.25', + value: 100_000_003, + }, + { + label: '100.30.10', + value: 100_000_004, + }, + { + label: '100.70.40', + value: 100_000_005, + }, + { + label: '100.7.3', + value: 100_000_006, + }, +]; + +export const selectHighSeasonStart = Array.from( + { + length: 12, + }, + (_, i) => ({ + label: `${i + 2}`, + value: 100_000_000 + i, + }) +); + const defaultOptions: CalculationOptions = { radioLastPaymentRule: [ { @@ -51,50 +96,9 @@ const defaultOptions: CalculationOptions = { }, ], - selectSeasonType: [ - { - label: '6/6', - value: 100_000_000, - }, - { - label: '8/4', - value: 100_000_001, - }, - { - label: '4/4/4', - value: 100_000_002, - }, - { - label: 'Гибкая дегрессия', - value: 100_000_007, - }, - { - label: '100.50.25', - value: 100_000_003, - }, - { - label: '100.30.10', - value: 100_000_004, - }, - { - label: '100.70.40', - value: 100_000_005, - }, - { - label: '100.7.3', - value: 100_000_006, - }, - ], + selectSeasonType, - selectHighSeasonStart: Array.from( - { - length: 12, - }, - (_, i) => ({ - label: `${i + 2}`, - value: 100_000_000 + i, - }) - ), + selectHighSeasonStart, radioDeliveryTime: [ { diff --git a/config/default-values.ts b/config/default-values.ts index 16308b6..c6a66bb 100644 --- a/config/default-values.ts +++ b/config/default-values.ts @@ -23,8 +23,8 @@ const defaultValues: CalculationValues = { balanceHolder: 100_000_001, graphType: 100_000_000, parmentsDecreasePercent: 94, - seasonType: 100_000_000, - highSeasonStart: 100_000_000, + seasonType: null, + highSeasonStart: null, comissionPerc: 0, comissionRub: 0, saleBonus: 1.3, diff --git a/process/fingap/reactions/validation.ts b/process/fingap/reactions/validation.ts index aa76f91..1a2778d 100644 --- a/process/fingap/reactions/validation.ts +++ b/process/fingap/reactions/validation.ts @@ -26,9 +26,7 @@ export default function validationReactions( ({ hasPaymentsErrors, finGAPInsuranceCompany }) => { if (finGAPInsuranceCompany && hasPaymentsErrors) { removeError = $tables.fingap.validation.addError(errorText); - } else { - removeError(); - } + } else if (removeError) removeError(); }, { fireImmediately: true, diff --git a/process/payments/lib/__tests__/seasons.test.js b/process/payments/lib/__tests__/seasons.test.js new file mode 100644 index 0000000..9bedb98 --- /dev/null +++ b/process/payments/lib/__tests__/seasons.test.js @@ -0,0 +1,25 @@ +import { getPositionIndex } from '../seasons-tools'; + +describe('process/payments/lib/seasons-tools', () => { + describe('[function] getPositionIndex', () => { + test('should return 0', () => { + const result = getPositionIndex(100_000_001, 0); + expect(result).toEqual(0); + }); + + test('should return 0', () => { + const result = getPositionIndex(100_000_001, 7); + expect(result).toEqual(0); + }); + + test('should return 1', () => { + const result = getPositionIndex(100_000_001, 8); + expect(result).toEqual(1); + }); + + test('should return 1', () => { + const result = getPositionIndex(100_000_001, 11); + expect(result).toEqual(1); + }); + }); +}); diff --git a/process/payments/lib/seasons-constants.ts b/process/payments/lib/seasons-constants.ts new file mode 100644 index 0000000..889bc29 --- /dev/null +++ b/process/payments/lib/seasons-constants.ts @@ -0,0 +1,15 @@ +export const SEASONS_PERIODS: Record> = { + 100_000_000: [0, 6], + 100_000_001: [0, 8], + 100_000_002: [0, 4, 8], +}; + +export const FORBIDDEN_HIGH_SEASON_START: Record> = { + 100_000_000: ['9', '10'], + 100_000_001: ['7', '8', '9'], + 100_000_002: ['11'], +}; + +export const SEASONS_PERIOD_NUMBER = 12; + +export const DEFAULT_SEASONS_VALUES = [100, 75, 50]; diff --git a/process/payments/lib/seasons-tools.ts b/process/payments/lib/seasons-tools.ts new file mode 100644 index 0000000..f930165 --- /dev/null +++ b/process/payments/lib/seasons-tools.ts @@ -0,0 +1,55 @@ +/* eslint-disable implicit-arrow-linebreak */ +import type { CalculationValues } from 'stores/calculation/values/types'; +import { SEASONS_PERIODS, SEASONS_PERIOD_NUMBER } from './seasons-constants'; + +type SeasonType = NonNullable; +type LeasingPeriod = CalculationValues['leasingPeriod']; + +export function getPositionIndex(seasonType: SeasonType, seasonsIndex: number) { + let positionIndex = 0; + + SEASONS_PERIODS[seasonType].forEach((position, i) => { + if (seasonsIndex >= position) { + positionIndex = i; + } + }); + + return positionIndex; +} + +export function getSeasonsValues(seasonType: SeasonType, seasons: Array) { + return SEASONS_PERIODS[seasonType].map((index) => seasons[index]); +} + +export function generateSeasons(seasonType: SeasonType, values: Array) { + const positions = SEASONS_PERIODS[seasonType]; + + const seasons: Array = []; + + [...positions, SEASONS_PERIOD_NUMBER].forEach((position, i, arr) => { + if (position < SEASONS_PERIOD_NUMBER) { + const start = arr[i]; + const end = arr[i + 1]; + const value = values[i]; + + // eslint-disable-next-line unicorn/new-for-builtins + seasons.push(...Array(end - start).fill(value)); + } + }); + + return seasons; +} + +export function generateSeasonsPayments(leasingPeriod: LeasingPeriod, seasons: Array) { + return ( + Array.from( + { + length: Math.floor((leasingPeriod - 2) / SEASONS_PERIOD_NUMBER), + }, + () => seasons + ) + .flat() + // eslint-disable-next-line unicorn/prefer-spread + .concat(seasons.slice(0, (leasingPeriod - 2) % SEASONS_PERIOD_NUMBER)) + ); +} diff --git a/process/payments/reactions.ts b/process/payments/reactions.ts index c950d1f..4b9d5ac 100644 --- a/process/payments/reactions.ts +++ b/process/payments/reactions.ts @@ -1,25 +1,78 @@ +/* eslint-disable function-paren-newline */ +/* eslint-disable implicit-arrow-linebreak */ import type { ApolloClient } from '@apollo/client'; -import { reaction, toJS } from 'mobx'; +import type { QueryClient } from '@tanstack/react-query'; +import { selectHighSeasonStart, selectSeasonType } from 'config/default-options'; +import { comparer, reaction, toJS } from 'mobx'; +import type { CalculationOptions } from 'stores/calculation/options/types'; import type RootStore from 'stores/root'; +import type { Row } from 'stores/tables/payments/types'; +import { difference, shift } from 'tools/array'; +import * as seasonsConstants from './lib/seasons-constants'; +import * as seasonsTools from './lib/seasons-tools'; +import validatePaymentsTable from './validation'; -export default function paymentsReactions(store: RootStore, apolloClient: ApolloClient) { +export default function paymentsReactions( + store: RootStore, + apolloClient: ApolloClient, + queryClient: QueryClient +) { const { $calculation, $tables } = store; + reaction( + () => $calculation.getElementValue('tbxFirstPaymentPerc'), + (firstPaymentPerc) => { + $tables.payments.setValue(0, firstPaymentPerc); + } + ); + + reaction( + () => $calculation.getElementValue('tbxLastPaymentPerc'), + (lastPaymentPerc) => { + const paymentsLength = $tables.payments.values.length; + $tables.payments.setValue(paymentsLength - 1, lastPaymentPerc); + } + ); + /** - * При изменении срока лизинга регулируем длину таблицы платежей + * Аннуитет */ reaction( - () => $calculation.$values.getValue('leasingPeriod'), - (leasingPeriod) => { - if (leasingPeriod) { - $tables.payments.setValues( - Array.from( - { - length: leasingPeriod, - }, - () => 0 - ) + () => { + const graphType = $calculation.getElementValue('radioGraphType'); + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + + return { + graphType, + leasingPeriod, + }; + }, + ({ graphType, leasingPeriod }) => { + if (graphType === 100_000_000) { + const middlePayments: Row[] = Array.from( + { + length: leasingPeriod - 2, + }, + () => ({ + value: 100, + status: 'Disabled', + }) ); + + const firstPaymentPerc = $calculation.getElementValue('tbxFirstPaymentPerc'); + const lastPaymentPerc = $calculation.getElementValue('tbxLastPaymentPerc'); + + $tables.payments.setRows([ + { + value: firstPaymentPerc, + status: 'Disabled', + }, + ...middlePayments, + { + value: lastPaymentPerc, + status: 'Disabled', + }, + ]); } }, { @@ -28,22 +81,479 @@ export default function paymentsReactions(store: RootStore, apolloClient: Apollo ); /** - * Проверяем платежи на 0 + * Равноубывающий + */ + reaction( + () => { + const graphType = $calculation.getElementValue('radioGraphType'); + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + const parmentsDecreasePercent = $calculation.getElementValue('tbxParmentsDecreasePercent'); + + return { + graphType, + leasingPeriod, + parmentsDecreasePercent, + }; + }, + ({ graphType, leasingPeriod, parmentsDecreasePercent }) => { + if (graphType === 100_000_002) { + const middlePayments: Row[] = Array.from( + { + length: leasingPeriod - 2, + }, + (_, k) => { + const payment = 100 * (parmentsDecreasePercent / 100) ** k; + + return { + value: Number(payment.toFixed(2)), + status: 'Disabled', + }; + } + ); + + const firstPaymentPerc = $calculation.getElementValue('tbxFirstPaymentPerc'); + const lastPaymentPerc = $calculation.getElementValue('tbxLastPaymentPerc'); + + $tables.payments.setRows([ + { + value: firstPaymentPerc, + status: 'Disabled', + }, + ...middlePayments, + { + value: lastPaymentPerc, + status: 'Disabled', + }, + ]); + } + } + ); + + /** + * Легкий старт + */ + reaction( + () => { + const graphType = $calculation.getElementValue('radioGraphType'); + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + + return { + graphType, + leasingPeriod, + }; + }, + ({ graphType, leasingPeriod }) => { + if (graphType === 100_000_004) { + const editablePayments: Row[] = [ + { + value: 25, + status: 'Default', + }, + { + value: 50, + status: 'Default', + }, + { + value: 75, + status: 'Default', + }, + ]; + + const payments: Row[] = Array.from( + { + length: leasingPeriod - 5, + }, + () => ({ + value: 100, + status: 'Disabled', + }) + ); + + const firstPaymentPerc = $calculation.getElementValue('tbxFirstPaymentPerc'); + const lastPaymentPerc = $calculation.getElementValue('tbxLastPaymentPerc'); + + $tables.payments.setRows([ + { + value: firstPaymentPerc, + status: 'Disabled', + }, + ...editablePayments, + ...payments, + { + value: lastPaymentPerc, + status: 'Disabled', + }, + ]); + } + } + ); + + /** + * Дегрессия + */ + // TODO: const exeption = tarif?.evo_seasons_type_exception + const degressionSeasonTypes = new Set([ + 100_000_003, 100_000_004, 100_000_005, 100_000_006, 100_000_007, + ]); + const seasonSeasonTypes = new Set([100_000_000, 100_000_001, 100_000_002]); + + reaction( + () => { + const graphType = $calculation.getElementValue('radioGraphType'); + + return graphType; + }, + (graphType) => { + let selectSeasonTypeOptions: CalculationOptions['selectSeasonType'] = []; + + if (graphType === 100_000_001) { + selectSeasonTypeOptions = selectSeasonType.filter((option) => + degressionSeasonTypes.has(option.value) + ); + } else if (graphType === 100_000_003) { + selectSeasonTypeOptions = selectSeasonType.filter((option) => + seasonSeasonTypes.has(option.value) + ); + } + $calculation.setElementOptions('selectSeasonType', selectSeasonTypeOptions); + } + ); + + const degressionSteps: { [key: number]: Array } = { + 100_000_003: [100, 50, 25], + 100_000_004: [100, 30, 10], + 100_000_005: [100, 70, 40], + 100_000_006: [100, 7, 3], + }; + + reaction( + () => { + const degressionType = $calculation.getElementValue('selectSeasonType'); + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + const graphType = $calculation.getElementValue('radioGraphType'); + + return { + degressionType, + leasingPeriod, + graphType, + }; + }, + ({ degressionType, leasingPeriod, graphType }) => { + if (graphType === 100_000_001) { + let payments: Row[] = []; + + switch (degressionType) { + case 100_000_007: { + const editablePayments: Row[] = Array.from( + { + length: leasingPeriod - 3, + }, + () => ({ + value: 100, + status: 'Default', + }) + ); + + payments = [ + { + value: 100, + status: 'Disabled', + }, + ...editablePayments, + ]; + + break; + } + case 100_000_003: + case 100_000_004: + case 100_000_005: + case 100_000_006: { + const [step1, step2, step3] = degressionSteps[degressionType]; + const paymentsInStep = Math.ceil((leasingPeriod - 2) / 3); + + payments = Array.from( + { + length: leasingPeriod - 2, + }, + (_v, i) => { + let value = step3; + + if (i <= paymentsInStep * 2 - 1) { + value = step2; + } + + if (i <= paymentsInStep - 1) { + value = step1; + } + + return { + value, + status: 'Disabled', + }; + } + ); + break; + } + default: { + break; + } + } + + const firstPaymentPerc = $calculation.getElementValue('tbxFirstPaymentPerc'); + const lastPaymentPerc = $calculation.getElementValue('tbxLastPaymentPerc'); + + $tables.payments.setRows([ + { + value: firstPaymentPerc, + status: 'Disabled', + }, + ...payments, + { + value: lastPaymentPerc, + status: 'Disabled', + }, + ]); + } + } + ); + + reaction( + () => { + const graphType = $calculation.getElementValue('radioGraphType'); + const payments = toJS($tables.payments.values); + const degressionType = $calculation.getElementValue('selectSeasonType'); + + return { + graphType, + payments, + degressionType, + }; + }, + (nextParams, prevParams) => { + if (nextParams.graphType === 100_000_001 && nextParams.degressionType === 100_000_007) { + const changes = difference(nextParams.payments, prevParams.payments); + + if (changes === null || changes.length > 1) return; + + const [changeIndex] = changes; + const value = nextParams.payments[changeIndex]; + const payments = nextParams.payments.slice(1, -1).map((payment, i) => { + if (i <= changeIndex - 2) return payment; + + return value; + }); + + const firstPaymentPerc = $calculation.getElementValue('tbxFirstPaymentPerc'); + const lastPaymentPerc = $calculation.getElementValue('tbxLastPaymentPerc'); + + $tables.payments.setValues([firstPaymentPerc, ...payments, lastPaymentPerc]); + } + }, + { + delay: 50, + equals: comparer.structural, + } + ); + + /** + * Сезонный + */ + + reaction( + () => { + const graphType = $calculation.getElementValue('radioGraphType'); + + return { + graphType, + }; + }, + ({ graphType }) => { + if (graphType !== 100_000_003) return; + + const seasonType = $calculation.getElementValue('selectSeasonType'); + const highSeasonStart = $calculation.getElementValue('selectHighSeasonStart'); + + if (!seasonType || !highSeasonStart) { + $tables.payments.setValues([]); + } + } + ); + + reaction( + () => { + const seasonType = $calculation.getElementValue('selectSeasonType'); + + return seasonType; + }, + (seasonType) => { + const graphType = $calculation.getElementValue('radioGraphType'); + if (graphType !== 100_000_003) return; + + if (!seasonType) { + $calculation.resetElement('selectHighSeasonStart'); + + return; + } + + const highSeasonStartOptions = selectHighSeasonStart.filter( + (option) => !seasonsConstants.FORBIDDEN_HIGH_SEASON_START[seasonType].includes(option.label) + ); + + $calculation.setElementOptions('selectHighSeasonStart', highSeasonStartOptions); + } + ); + + function generateSeasonPaymentsRows(seasonType: number, shiftNumber: number, payments: number[]) { + const shiftedPeriods = new Set( + seasonsConstants.SEASONS_PERIODS[seasonType].map( + (position) => (position + shiftNumber) % seasonsConstants.SEASONS_PERIOD_NUMBER + ) + ); + const rows: Row[] = payments.map((value, i) => ({ + value, + status: shiftedPeriods.has(i) ? 'Default' : 'Disabled', + })); + + return rows; + } + + reaction( + () => { + const seasonType = $calculation.getElementValue('selectSeasonType'); + const highSeasonStartOption = $calculation.getElementOption('selectHighSeasonStart'); + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + + return { + seasonType, + highSeasonStartOption, + leasingPeriod, + }; + }, + ({ seasonType, highSeasonStartOption, leasingPeriod }) => { + const graphType = $calculation.getElementValue('radioGraphType'); + if (graphType !== 100_000_003) return; + + if (!seasonType || !highSeasonStartOption) { + $tables.payments.setValues([]); + + return; + } + + const seasons = seasonsTools.generateSeasons( + seasonType, + seasonsConstants.DEFAULT_SEASONS_VALUES + ); + const shiftNumber = Number.parseInt(highSeasonStartOption.label, 10) - 2; + const payments = seasonsTools.generateSeasonsPayments( + leasingPeriod, + shift(seasons, shiftNumber) + ); + const rows: Row[] = generateSeasonPaymentsRows(seasonType, shiftNumber, payments); + const firstPaymentPerc = $calculation.getElementValue('tbxFirstPaymentPerc'); + const lastPaymentPerc = $calculation.getElementValue('tbxLastPaymentPerc'); + + $tables.payments.setRows([ + { + value: firstPaymentPerc, + status: 'Disabled', + }, + ...rows, + { + value: lastPaymentPerc, + status: 'Disabled', + }, + ]); + } + ); + + reaction( + () => { + const payments = toJS($tables.payments.values); + const seasons = payments.slice(1, seasonsConstants.SEASONS_PERIOD_NUMBER + 1); + + return seasons; + }, + (nextSeasons, prevSeasons) => { + const graphType = $calculation.getElementValue('radioGraphType'); + if (graphType !== 100_000_003) return; + + const seasonType = $calculation.getElementValue('selectSeasonType'); + const highSeasonStartOption = $calculation.getElementOption('selectHighSeasonStart'); + if (!seasonType || !highSeasonStartOption) return; + + const shiftNumber = Number.parseInt(highSeasonStartOption.label, 10) - 2; + const unshiftedNextSeasons = shift(nextSeasons, -shiftNumber); + const unshiftedPrevSeasons = shift(prevSeasons, -shiftNumber); + + const changes = difference(unshiftedNextSeasons, unshiftedPrevSeasons); + if (changes === null || changes.length > 1) return; + + const [changeIndex] = changes; + const positionIndex = seasonsTools.getPositionIndex(seasonType, changeIndex); + + const values = seasonsTools.getSeasonsValues(seasonType, unshiftedNextSeasons); + values[positionIndex] = unshiftedNextSeasons[changeIndex]; + + const seasons = seasonsTools.generateSeasons(seasonType, values); + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + const payments = seasonsTools.generateSeasonsPayments( + leasingPeriod, + shift(seasons, shiftNumber) + ); + const rows: Row[] = generateSeasonPaymentsRows(seasonType, shiftNumber, payments); + const firstPaymentPerc = $calculation.getElementValue('tbxFirstPaymentPerc'); + const lastPaymentPerc = $calculation.getElementValue('tbxLastPaymentPerc'); + + $tables.payments.setRows([ + { + value: firstPaymentPerc, + status: 'Disabled', + }, + ...rows, + { + value: lastPaymentPerc, + status: 'Disabled', + }, + ]); + }, + { + delay: 50, + equals: comparer.structural, + } + ); + + /** + * Валидация */ - const errorText = 'Значения должны быть больше 0'; let removeError: () => void; reaction( - () => toJS($tables.payments.values), - (values) => { - if (values.includes(0)) { + () => { + const payments = toJS($tables.payments.values); + const graphType = $calculation.getElementValue('radioGraphType'); + const seasonType = $calculation.getElementValue('selectSeasonType'); + const highSeasonStart = $calculation.getElementValue('selectHighSeasonStart'); + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + + return { + payments, + graphType, + seasonType, + highSeasonStart, + leasingPeriod, + }; + }, + () => { + if (removeError) removeError(); + const errorText = validatePaymentsTable(store); + + if (errorText) { removeError = $tables.payments.validation.addError(errorText); - } else { - removeError(); } }, { - fireImmediately: true, + delay: 50, + equals: comparer.structural, } ); } diff --git a/process/payments/validation.ts b/process/payments/validation.ts new file mode 100644 index 0000000..5ae0b88 --- /dev/null +++ b/process/payments/validation.ts @@ -0,0 +1,134 @@ +/* eslint-disable max-len */ +/* eslint-disable no-case-declarations */ +import { counting, max, min, sort } from 'radash/dist/array'; +import type RootStore from 'stores/root'; +import { areEqual, isSorted, shift } from 'tools/array'; +import { SEASONS_PERIODS, SEASONS_PERIOD_NUMBER } from './lib/seasons-constants'; + +export default function validatePaymentsTable({ $calculation, $tables }: RootStore) { + switch ($calculation.getElementValue('radioGraphType')) { + // Дегрессия + case 100_000_001: + if (!$calculation.getElementValue('selectSeasonType')) { + return 'Не выбран тип дегрессии'; + } + /** + * в таблице платежей в столбце Соотношение платежей для строк с 2 до "Срок лизинга-1" минимальное значение должно быть равно 3 + */ + { + const MIN_PAYMENT = 3; + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + const targetPayments = $tables.payments.values.slice(1, leasingPeriod - 1); + + if (!targetPayments.every((payment) => payment >= MIN_PAYMENT)) { + return `Минимальное значение платежа должно быть равно ${MIN_PAYMENT}`; + } + } + + /** + * при Дегрессии все значения не должны быть равны друг другу + что при Легком старте 2,3 и 4 платежи не должны быть равны 100 + */ + { + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + const targetPayments = $tables.payments.values.slice(1, leasingPeriod - 1); + + if (new Set(targetPayments).size === 1) { + return 'Платежи не должны быть равны друг другу'; + } + } + + /** + * Проверка на возрастание + */ + { + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + const targetPayments = $tables.payments.values.slice(1, leasingPeriod - 1); + + for (let i = 2; i < targetPayments.length - 1; i += 1) { + if (targetPayments[i] > targetPayments[i - 1]) { + return 'Платежи должны убывать'; + } + } + } + /** + * Если вид графика = Дегрессия И значения в "Соотношении платежей" у 2, 3 и 4 платежа отличаются друг от друга не более чем на 10 + * (т.е. берем значения в этих полях, определяем максимальное и минимальное значение и смотрим на их разницу) + * то не осуществлять Расчет графика и выводить ошибку "Нельзя осуществить расчет - указана очень жетская дегрессия. + * На 2-4 платежах Соотношение платежа должен отличаться не более чем на 10%", + * иначе осуществлять расчет + */ + { + const targetPayments = $tables.payments.values.slice(1, 4); + + if (max(targetPayments) - min(targetPayments) > 10) { + return 'Указана очень жесткая дегрессия'; + } + } + + /** + * Если вид графика = Дегрессия И значения в "Соотношении платежей" для строк с 2 До "Срок лизинга-1" как минимум 2 раза по 2 платежа должны между собой быть равны + * (т.е. берем значения "Соотношения платежей" для строк с 2 до "Срок лизинга-1" и делаем сводную таблицу - если кол-во одинаковых значение больше 2 встречаются 2 и более раза), + * то осуществлять расчет, + * иначе не осуществлять Расчет графика и выводить ошибку "Нельзя осуществить расчет - указана очень жетская дегрессия. Не менее чем у 4х платежей "Соотношение платежа" должно не отличаться между самой", + */ + { + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + const targetPayments = $tables.payments.values.slice(1, leasingPeriod - 1); + const counts = counting(targetPayments, (v) => v); + if (Object.values(counts).filter((count) => count > 1).length < 2) { + return 'Указана очень жесткая дегрессия. Не менее чем у 4х платежей соотношение должно не отличаться между собой'; + } + } + + break; + + case 100_000_003: { + const leasingPeriod = $calculation.getElementValue('tbxLeasingPeriod'); + if (leasingPeriod < 14) { + return 'При сезонном виде графика срок лизинга должен быть больше 14 месяцев'; + } + + const seasonType = $calculation.getElementValue('selectSeasonType'); + if (!seasonType) { + return 'Не выбран тип сезонности'; + } + + const highSeasonStartOption = $calculation.getElementOption('selectHighSeasonStart'); + if (!highSeasonStartOption) { + return 'Не выбрано смещение сезонности'; + } + + { + const seasons = $tables.payments.values.slice(1, SEASONS_PERIOD_NUMBER + 1); + const shiftNumber = Number.parseInt(highSeasonStartOption.label, 10) - 2; + const unshiftedSeasons = shift(seasons, -shiftNumber); + + const positions = SEASONS_PERIODS[seasonType]; + const seasonsValues = positions.map((position) => unshiftedSeasons[position]); + + if (isSorted(seasonsValues)) { + return 'Сезонные платежи должны убывать'; + } + } + + break; + } + + // Легкий старт + case 100_000_004: { + const targetPayments = $tables.payments.values.slice(1, 4); + const sortedPayments = sort(targetPayments, (x) => x); + const areEqualPayments = new Set(targetPayments).size === 1; + + if (!areEqual(targetPayments, sortedPayments) || areEqualPayments) { + return '2, 3, 4 платежи должны возрастать'; + } + + break; + } + default: + return null; + } + + return null; +} diff --git a/stores/calculation/options/index.ts b/stores/calculation/options/index.ts index 0043b38..3b2811c 100644 --- a/stores/calculation/options/index.ts +++ b/stores/calculation/options/index.ts @@ -34,7 +34,7 @@ export default class OptionsStore { if ( // eslint-disable-next-line operator-linebreak !this.options[elementName]?.length || - this.options[elementName].some((x) => x.value === value) + !this.options[elementName].some((x) => x.value === value) ) { this.root.$calculation.resetElementValue(elementName); } diff --git a/stores/tables/payments/index.ts b/stores/tables/payments/index.ts index f6b8898..d3c0eed 100644 --- a/stores/tables/payments/index.ts +++ b/stores/tables/payments/index.ts @@ -4,11 +4,7 @@ import { makeAutoObservable, observable, reaction } from 'mobx'; import type RootStore from 'stores/root'; import Validation from '../validation'; - -type Row = { - value: number; - status: Status; -}; +import type { Row } from './types'; export default class PaymentsTable { root: RootStore; diff --git a/stores/tables/payments/types.ts b/stores/tables/payments/types.ts new file mode 100644 index 0000000..7e086e1 --- /dev/null +++ b/stores/tables/payments/types.ts @@ -0,0 +1,6 @@ +import type { Status } from 'Elements/types'; + +export type Row = { + value: number; + status: Status; +}; diff --git a/stores/tables/validation.ts b/stores/tables/validation.ts index fb4b6b9..3b504da 100644 --- a/stores/tables/validation.ts +++ b/stores/tables/validation.ts @@ -29,6 +29,7 @@ export default class Validation { const removeError = () => { this.messages.delete(message); + notification.close(this.params.err_key); }; notification.error({ diff --git a/tools/__tests__/array.test.js b/tools/__tests__/array.test.js new file mode 100644 index 0000000..964b9ec --- /dev/null +++ b/tools/__tests__/array.test.js @@ -0,0 +1,39 @@ +import { shift } from 'tools/array'; + +describe('tools/array', () => { + describe('[function] shift', () => { + const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + test('should shift array right 3 positions', () => { + const result = shift(arr, 3); + expect(result).toEqual([7, 8, 9, 1, 2, 3, 4, 5, 6]); + }); + test('should shift array left 3 positions', () => { + const result = shift(arr, -3); + expect(result).toEqual([4, 5, 6, 7, 8, 9, 1, 2, 3]); + }); + test('should shift array right 6 positions', () => { + const result = shift(arr, 15); + expect(result).toEqual([4, 5, 6, 7, 8, 9, 1, 2, 3]); + }); + test('should shift array left 6 positions', () => { + const result = shift(arr, -15); + expect(result).toEqual([7, 8, 9, 1, 2, 3, 4, 5, 6]); + }); + test('should keep array as is', () => { + const result = shift(arr, 0); + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + test('should keep array as is', () => { + const result = shift(arr, 9); + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + test('should return empty array', () => { + const result = shift([], 0); + expect(result).toEqual([]); + }); + test('should return empty array', () => { + const result = shift([], 0); + expect(result).toEqual([]); + }); + }); +}); diff --git a/tools/array.ts b/tools/array.ts new file mode 100644 index 0000000..3460805 --- /dev/null +++ b/tools/array.ts @@ -0,0 +1,31 @@ +export function areEqual(arr1: ReadonlyArray, arr2: ReadonlyArray) { + return arr1.length === arr2.length && arr1.every((value, index) => value === arr2[index]); +} + +export function difference(arr1: ReadonlyArray, arr2: ReadonlyArray) { + if (arr1.length !== arr2.length) return null; + + const changes = []; + // eslint-disable-next-line unicorn/no-for-loop + for (let i = 0; i < arr1.length; i += 1) { + if (arr1[i] !== arr2[i]) { + changes.push(i); + } + } + + return changes; +} + +export function shift(arr: Array, n: number) { + if (arr.length === 0) return arr; + + const shiftNumber = n % arr.length; + + if (shiftNumber === 0) return arr; + + return [...arr.slice(-shiftNumber, arr.length), ...arr.slice(0, -shiftNumber)]; +} + +export function isSorted(arr: Array) { + return arr.every((value, index, array) => !index || array[index - 1] <= value); +}