feat(TEAMMSBMOB-22193): изменение логики бэк контролей платежей

This commit is contained in:
Воронов Андрей
2026-01-21 11:51:20 +03:00
parent 150ef8973e
commit 5953045f6b
28 changed files with 442 additions and 41 deletions
@@ -0,0 +1,12 @@
import { network } from '@msb/http';
import { PAYMENTS_CLIENT_I18N_RU_RU } from '../../endpoints';
const fetchPaymentsI18n = async (): Promise<Record<string, string>> => {
const response = await network.client.get<Record<string, string>>(PAYMENTS_CLIENT_I18N_RU_RU);
return response.data;
};
export { fetchPaymentsI18n };
@@ -0,0 +1,2 @@
export * from './fetchPaymentsI18n';
export * from './queryKeys';
@@ -0,0 +1,5 @@
const QUERY_KEY_PAYMENTS_I18N = 'payments-i18n';
export { QUERY_KEY_PAYMENTS_I18N };
@@ -17,3 +17,4 @@ export * from './fetchEditTemplate';
export * from './sendPayments';
export * from './fetchSignatories';
export * from './fetchSignatures';
export * from './i18n';
@@ -0,0 +1,5 @@
const PAYMENTS_CLIENT_I18N_RU_RU = '/ruble-payment-client/i18n/ru_Ru' as const;
export { PAYMENTS_CLIENT_I18N_RU_RU };
@@ -0,0 +1,3 @@
export * from './constants';
@@ -22,3 +22,4 @@ export * from './fetchEditTemplate';
export * from './sendPayments';
export * from './fetchSignatories';
export * from './fetchSignatures';
export * from './i18n';
@@ -0,0 +1,3 @@
export * from './usePaymentsI18n';
@@ -0,0 +1,22 @@
import { useQuery } from '@msb/http';
import { fetchPaymentsI18n, QUERY_KEY_PAYMENTS_I18N } from '../../api';
const usePaymentsI18n = () => {
const { data, error, isLoading, refetch } = useQuery<Record<string, string>, Error | undefined>({
queryKey: [QUERY_KEY_PAYMENTS_I18N],
queryFn: fetchPaymentsI18n,
refetchOnMount: true,
staleTime: 0,
});
return {
paymentsI18nData: data,
paymentsI18nError: error,
isPaymentsI18nLoading: isLoading,
refetchPaymentsI18n: refetch,
};
};
export { usePaymentsI18n };
@@ -11,3 +11,4 @@ export * from './paymentImport';
export * from './fileImport';
export * from './fetchSignatores';
export * from './fetchSignatures';
export * from './i18n';
+2
View File
@@ -24,6 +24,7 @@ import {
getPaymentCopyHandlers,
getPageWithIndicatorsHandlers,
getSuggestionPageHandlers,
paymentsI18nHandlers,
} from './payments-client';
import { fileImportHandlers } from './payments-client/fileImport';
import { paymentImportHandlers } from './payments-client/paymentImport';
@@ -119,6 +120,7 @@ const handlers = [
...userNotificationsHandler,
...getPageWithIndicatorsHandlers,
...getSuggestionPageHandlers,
...paymentsI18nHandlers,
...editTreasuryDealsHandlers,
...retryTreasuryDealsHandlers,
...fetchTreasuryDealsAccountsMnoHandlers,
@@ -0,0 +1,11 @@
import { PAYMENTS_CLIENT_I18N_RU_RU } from '@msb/http';
import { rest } from 'msw';
import { PAYMENTS_CLIENT_I18N_MOCK } from './mocks';
const paymentsI18nHandler = rest.get<Record<string, string>>(PAYMENTS_CLIENT_I18N_RU_RU, (_, res, ctx) =>
res(ctx.delay(100), ctx.json(PAYMENTS_CLIENT_I18N_MOCK))
);
export { paymentsI18nHandler };
@@ -0,0 +1,10 @@
import { paymentsI18nHandler } from './i18n';
export * from './mocks';
export * from './i18n';
const paymentsI18nHandlers = [paymentsI18nHandler];
export { paymentsI18nHandlers };
@@ -0,0 +1,16 @@
const PAYMENTS_CLIENT_I18N_MOCK: Record<string, string> = {
'cpm.field.empty': 'Реквизит «{fieldName}» является обязательным для заполнения, не может быть пустым',
'cpm.field.string.empty': 'Поле «{fieldName}» обязательно для заполнения',
'cpm.field.paymentAmount.paymentAmount.positive': 'Сумма платежа не может быть нулевой',
'cpm.field.paymentAmount.vatCalculationMethod.not.selected': 'Не выбран способ расчета НДС',
'cpm.field.paymentInfo.paymentPurpose.existNds': 'В назначении платежа не обнаружено упоминание об НДС',
'cpm.field.payerDetails.innKio.length': 'Длина ИНН должна быть 10 или 12 символов, КИО - 5 символов',
'field.empty': 'Реквизит «{field}» является обязательным для заполнения и не может быть пустым.',
'paymentAmount.vatCalculationMethod': 'Способ расчета НДС',
'paymentInfo.paymentPurpose': 'Назначение платежа',
'sbp.bankClient.merchantAddress': 'Адрес Точки продаж',
};
export { PAYMENTS_CLIENT_I18N_MOCK };
+1
View File
@@ -12,4 +12,5 @@ export * from './getPaymentCopy';
export * from './paymentImport';
export * from './fileImport';
export * from './sendPayments';
export * from './i18n';
export * from './types';
@@ -1,5 +1,4 @@
export * from './dropdownType';
export * from './serverValidationLocalization';
export * from './validationErrorMessages';
export * from './fieldIdentityMapping';
export * from './signAndSend';
@@ -1,5 +1,6 @@
import { ValidationBankSerials } from '@msb/shared';
import { validationErrorMessages } from '../constants';
import { popBackendWarningForField } from './backendControlsStore';
import {
PAYEE_FIELD_IDENTITY as PAYEE,
PAYER_FIELD_IDENTITY as PAYER,
@@ -159,6 +160,10 @@ namespace FormValidation {
});
}
export function hasAnyMessages(): boolean {
return Object.values(_messages).some(message => message !== null && message !== undefined);
}
export function attachMessages(messages: ValidationMessage.Message[]) {
Object.keys(_messages).forEach(key => {
_messages[key] = null;
@@ -218,27 +223,50 @@ namespace FormValidation {
}
export function validate(fieldId: string, value: string): MessageExistence {
let result: MessageExistence;
switch (fieldId) {
case TEMPLATE_NAME:
return validateTemplateName(value);
result = validateTemplateName(value);
break;
case PAYER.DOCUMENT_NUMBER:
return validateDocumentNumber(value);
result = validateDocumentNumber(value);
break;
case PAYER.WRITE_OFF_ACCOUNT:
return validatePayerAccount(value);
result = validatePayerAccount(value);
break;
case PAYEE.INN:
return validateInnGeneric(PAYEE.INN, value);
result = validateInnGeneric(PAYEE.INN, value);
break;
case PAYER.THIRD_PARTY_INN:
return validateInnGeneric(PAYER.THIRD_PARTY_INN, value);
result = validateInnGeneric(PAYER.THIRD_PARTY_INN, value);
break;
case PAYEE.ACCOUNT: {
const values = value.split(delimiter);
return validatePayeeAccount(values[0], values[1]);
result = validatePayeeAccount(values[0], values[1]);
break;
}
case PAYER.TARGET:
return validatePaymentPurpose(value);
result = validatePaymentPurpose(value);
break;
default:
return null;
result = null;
}
if (result === null) {
const backendWarning = popBackendWarningForField(fieldId);
if (backendWarning) {
const warningMessage = ValidationMessage.createWarning(fieldId, backendWarning);
_messages[fieldId] = warningMessage;
return warningMessage;
}
}
return result;
}
export function clearBudgetFields(): void {
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import type { PaymentFormFields } from '@/features/Forms';
type Listener = () => void;
interface UnknownControl {
level: PaymentFormFields.VALIDATION_LEVEL;
text: string;
}
let unknownControls: UnknownControl[] = [];
const unknownListeners = new Set<Listener>();
let warningsByField: Record<string, string[]> = {};
let hasKnownFieldErrors = false;
const knownErrorsListeners = new Set<Listener>();
const notifyUnknown = () => unknownListeners.forEach(listener => listener());
const notifyKnownErrors = () => knownErrorsListeners.forEach(listener => listener());
const setUnknownControlTexts = (controls: UnknownControl[]): void => {
unknownControls = controls;
notifyUnknown();
};
const clearUnknownControlTexts = (): void => {
unknownControls = [];
notifyUnknown();
};
const getUnknownControlTexts = (): UnknownControl[] => unknownControls;
const subscribeUnknownControlTexts = (listener: Listener): (() => void) => {
unknownListeners.add(listener);
return () => unknownListeners.delete(listener);
};
const useUnknownControlTexts = (): UnknownControl[] => {
const [value, setValue] = useState<UnknownControl[]>(() => getUnknownControlTexts());
useEffect(() => subscribeUnknownControlTexts(() => setValue(getUnknownControlTexts())), []);
return value;
};
const setBackendWarningsByField = (next: Record<string, string[]>): void => {
warningsByField = next;
};
const clearBackendWarningsByField = (): void => {
warningsByField = {};
};
const popBackendWarningForField = (fieldId: string): string | undefined => {
const list = warningsByField[fieldId];
if (!list || list.length === 0) return undefined;
return list.shift();
};
const setHasKnownFieldErrors = (value: boolean): void => {
hasKnownFieldErrors = value;
notifyKnownErrors();
};
const getHasKnownFieldErrors = (): boolean => hasKnownFieldErrors;
const subscribeHasKnownFieldErrors = (listener: Listener): (() => void) => {
knownErrorsListeners.add(listener);
return () => knownErrorsListeners.delete(listener);
};
const useHasKnownFieldErrors = (): boolean => {
const [value, setValue] = useState<boolean>(() => getHasKnownFieldErrors());
useEffect(() => subscribeHasKnownFieldErrors(() => setValue(getHasKnownFieldErrors())), []);
return value;
};
export type { UnknownControl };
export {
setUnknownControlTexts,
clearUnknownControlTexts,
getUnknownControlTexts,
subscribeUnknownControlTexts,
useUnknownControlTexts,
setBackendWarningsByField,
clearBackendWarningsByField,
popBackendWarningForField,
setHasKnownFieldErrors,
getHasKnownFieldErrors,
subscribeHasKnownFieldErrors,
useHasKnownFieldErrors,
};
@@ -2,3 +2,4 @@ export * from './ValidationMessage';
export * from './FormValidation';
export * from './utils';
export * from './PaymentFormFields';
export * from './backendControlsStore';
@@ -0,0 +1,9 @@
let paymentsI18nData: Record<string, string> | null = null;
const setPaymentsI18nData = (data: Record<string, string> | undefined): void => {
paymentsI18nData = data ?? null;
};
const getPaymentsI18nData = (): Record<string, string> | undefined => paymentsI18nData ?? undefined;
export { getPaymentsI18nData, setPaymentsI18nData };
@@ -1,58 +1,96 @@
import type { ValidationResultData, GroupResult, ControlResult, CheckResult } from '@msb/http';
import { ALLOWED_SIGN_AND_SEND_STATUSES } from '../constants';
import {
ValidationMessage,
payeeMapTitleIdentity,
payerMapTitleIdentity,
budgetPaymentMapTitleIdentity,
serverValidationLocalization,
mapServerFieldToUi,
FormValidation,
PaymentFormFields,
} from '@/features/Forms';
clearBackendWarningsByField,
clearUnknownControlTexts,
setBackendWarningsByField,
setHasKnownFieldErrors,
setUnknownControlTexts,
} from './backendControlsStore';
import { getPaymentsI18nData } from './paymentsI18nStore';
import { ValidationMessage, mapServerFieldToUi, FormValidation, PaymentFormFields } from '@/features/Forms';
const isSignAndSendAllowedStatus = (status: string | null | undefined): boolean =>
!!status && (ALLOWED_SIGN_AND_SEND_STATUSES as readonly string[]).includes(status);
const mapTitleIdentity = { ...payeeMapTitleIdentity, ...payerMapTitleIdentity, ...budgetPaymentMapTitleIdentity };
const cleanArray = (arr: Array<{ label: string; value: string | undefined }>) => arr.filter(el => !!el.value);
const collectValidationMessages = (vr?: ValidationResultData): ValidationMessage.Message[] => {
if (!vr?.groupResults || !Array.isArray(vr.groupResults)) return [];
if (!vr?.groupResults || !Array.isArray(vr.groupResults)) {
clearBackendWarningsByField();
clearUnknownControlTexts();
setHasKnownFieldErrors(false);
return [];
}
const messages: ValidationMessage.Message[] = [];
const warningsByFieldAndType = new Map<string, Map<string, string>>();
const unknownByType = new Map<string, { level: PaymentFormFields.VALIDATION_LEVEL; text: string }>();
const LEVEL_CRITICAL = PaymentFormFields.VALIDATION_LEVEL.CRITICAL;
const LEVEL_WARNING = PaymentFormFields.VALIDATION_LEVEL.WARNING;
const regEx = /{([^}]+)}/g;
const FIELD_NAME_PARAM = 'fieldName';
const i18nData = getPaymentsI18nData();
vr.groupResults.forEach((group: GroupResult) => {
(group.controlResults || []).forEach((control: ControlResult) => {
const { level, checkResults } = control;
const { id: controlId, name: controlName } = control as { id?: string; name?: string };
const controlType = String(controlId || controlName || '');
(checkResults || []).forEach((check: CheckResult) => {
const field = mapServerFieldToUi(check.fieldName, FormValidation.isThirdPartyEnabled());
const { message } = check;
let localized = serverValidationLocalization[message.message] || null;
const { fieldName, message } = check;
const field = mapServerFieldToUi(fieldName, FormValidation.isThirdPartyEnabled());
const key = message?.message;
const params = message?.params;
let template = (key ? i18nData?.[key] : undefined) || key || null;
if (localized && message) {
localized =
localized.match(regEx)?.reduce((acc, current) => {
if (template && message) {
const fieldNameText = (fieldName ? i18nData?.[fieldName] : undefined) || '';
template =
template.match(regEx)?.reduce((acc, current) => {
const identity = current.replaceAll('{', '').replaceAll('}', '');
return acc.replaceAll(current, message.params?.[identity] || '');
}, localized) ?? localized;
if (identity === FIELD_NAME_PARAM) {
return acc.replaceAll(current, fieldNameText);
}
return acc.replaceAll(current, params?.[identity] || '');
}, template) ?? template;
}
if (!field || !localized) return;
if (!template) return;
localized = localized.replace('{fieldName}', mapTitleIdentity[field] || '');
if (!field) {
const unknownKey = controlType || template;
if (!unknownByType.has(unknownKey)) {
const unknownLevel = level === LEVEL_CRITICAL ? LEVEL_CRITICAL : LEVEL_WARNING;
unknownByType.set(unknownKey, {
level: unknownLevel,
text: template,
});
}
return;
}
if (level === LEVEL_CRITICAL) {
messages.push(ValidationMessage.createError(field, localized));
} else {
messages.push(ValidationMessage.createWarning(field, localized));
messages.push(ValidationMessage.createError(field, template));
return;
}
const warningKey = controlType || template;
const perField = warningsByFieldAndType.get(field) || new Map<string, string>();
if (!perField.has(warningKey)) perField.set(warningKey, template);
warningsByFieldAndType.set(field, perField);
});
});
});
@@ -67,6 +105,20 @@ const collectValidationMessages = (vr?: ValidationResultData): ValidationMessage
}
});
setBackendWarningsByField(
Object.fromEntries(Array.from(warningsByFieldAndType.entries()).map(([fieldId, byType]) => [fieldId, Array.from(byType.values())]))
);
const hasKnownErrors = byField.size > 0 || warningsByFieldAndType.size > 0;
setHasKnownFieldErrors(hasKnownErrors);
if (unknownByType.size > 0) {
setUnknownControlTexts(Array.from(unknownByType.values()));
} else {
clearUnknownControlTexts();
}
return Array.from(byField.values());
};
@@ -0,0 +1,23 @@
import styled from '@emotion/styled';
import styledCss from '@styled-system/css';
const Container = styled.div(
styledCss({
marginTop: '32px',
})
);
const List = styled.ul(
styledCss({
display: 'grid',
gap: '8px',
paddingLeft: '18px',
margin: 0,
})
);
const WarningWrapper = styled.div<{ hasCritical: boolean }>`
margin-top: ${({ hasCritical }) => (hasCritical ? '16px' : 0)};
`;
export { Container, List, WarningWrapper };
@@ -0,0 +1,66 @@
import type { ReactElement } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { Informer } from '@fractal-ui/extended';
import * as S from './UnknownBackendControlsSheet.styles';
import { LOCALIZATION } from './constants/localization';
import { PaymentFormFields } from '@/features/Forms';
import { useHasKnownFieldErrors, useUnknownControlTexts } from '@/features/Forms/lib';
const UnknownBackendControlsSheet = (): ReactElement | null => {
const controls = useUnknownControlTexts();
const hasKnownFieldErrors = useHasKnownFieldErrors();
const containerRef = useRef<HTMLDivElement | null>(null);
const hasScrolledRef = useRef(false);
const { critical, warning } = useMemo(() => {
const criticalControls = controls.filter(c => c.level === PaymentFormFields.VALIDATION_LEVEL.CRITICAL);
const warningControls = controls.filter(c => c.level === PaymentFormFields.VALIDATION_LEVEL.WARNING);
return {
critical: criticalControls.map(c => c.text),
warning: warningControls.map(c => c.text),
};
}, [controls]);
useEffect(() => {
if (!containerRef.current) return;
if (controls.length === 0) return;
if (hasKnownFieldErrors) return;
if (hasScrolledRef.current) return;
hasScrolledRef.current = true;
containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [controls.length, hasKnownFieldErrors]);
if (controls.length === 0) return null;
return (
<S.Container ref={containerRef}>
{critical.length > 0 && (
<Informer statusIcon title={LOCALIZATION.TITLE} variant="error">
<S.List>
{critical.map(text => (
<li key={`critical-${text}`}>{text}</li>
))}
</S.List>
</Informer>
)}
{warning.length > 0 && (
<S.WarningWrapper hasCritical={critical.length > 0}>
<Informer statusIcon variant="warning">
<S.List>
{warning.map(text => (
<li key={`warning-${text}`}>{text}</li>
))}
</S.List>
</Informer>
</S.WarningWrapper>
)}
</S.Container>
);
};
export { UnknownBackendControlsSheet };
@@ -0,0 +1,5 @@
const LOCALIZATION = {
TITLE: 'Ошибки',
} as const;
export { LOCALIZATION };
@@ -0,0 +1 @@
export * from './UnknownBackendControlsSheet';
@@ -7,3 +7,4 @@ export * from './DropdownIncomeType';
export * from './DropdownOrganization';
export * from './PayeeFormFields';
export * from './PayerFormFields';
export * from './UnknownBackendControlsSheet';
@@ -5,7 +5,7 @@ import { Button, ScrollContainer } from '@fractal-ui/core';
import { ErrorIcon, OkIcon } from '@fractal-ui/library';
import { StatusModal, useSnackbar } from '@fractal-ui/overlays';
import { Text } from '@fractal-ui/styling';
import { queryClient, QUERY_KEYS_OPERATIONS_HISTORY, type RublePaymentTemplateEdit } from '@msb/http';
import { queryClient, QUERY_KEYS_OPERATIONS_HISTORY, usePaymentsI18n } from '@msb/http';
import {
AUTHORITIES,
BaseDialog,
@@ -24,7 +24,15 @@ import { useLocation } from 'react-router-dom';
import { LOCALIZATION } from '../constants';
import { PaymentOrderFormSaveSign } from '../lib';
import * as S from './PaymentOrderForm.styles';
import { PayeeFormFields, PayerFormFields, FormValidation, PaymentFormFields, isSignAndSendAllowedStatus } from '@/features/Forms';
import {
PayeeFormFields,
PayerFormFields,
FormValidation,
PaymentFormFields,
isSignAndSendAllowedStatus,
UnknownBackendControlsSheet,
} from '@/features/Forms';
import { setPaymentsI18nData } from '@/features/Forms/lib/paymentsI18nStore';
import { PAYMENTS_YM_EVENTS, LOCALIZATION as SIGN_LOCALIZATION } from '@/shared/constants';
import { PageHeader } from '@/shared/ui';
@@ -38,6 +46,7 @@ namespace PaymentOrderForm {
const { hash } = useLocation();
const { showSnackbarMessage } = useSnackbar();
const { showModal } = useModal(BaseDialog);
const { paymentsI18nData } = usePaymentsI18n();
const organizations = checkOrganizationsHavePermission(userAuthorities?.data.clientAuthorities || {}, [AUTHORITIES.PAYMENT.VIEW]);
const [currentOrg, setCurrentOrg] = useState<string>('');
@@ -139,6 +148,10 @@ namespace PaymentOrderForm {
FormValidation.attachMessages([]);
}, []);
useEffect(() => {
setPaymentsI18nData(paymentsI18nData);
}, [paymentsI18nData]);
const handleSubmit = () => {};
return (
@@ -180,8 +193,9 @@ namespace PaymentOrderForm {
formValidation={FormValidation}
orgHandleChange={orgHandleChange}
/>
<UnknownBackendControlsSheet />
&nbsp;
</ScrollContainer>
&nbsp;
<S.ButtonsWrapper>
{canShowByAuthorities && isStatusOk && (
<Button
@@ -1,14 +1,15 @@
import { type ReactElement, useCallback, useRef, useState } from 'react';
import { type ReactElement, useCallback, useRef, useState, useEffect } from 'react';
import { Input } from '@fractal-ui/composites';
import { Button, ScrollContainer } from '@fractal-ui/core';
import { ErrorIcon, OkIcon } from '@fractal-ui/library';
import { useSnackbar } from '@fractal-ui/overlays';
import { type RublePaymentTemplateInfo } from '@msb/http';
import { type RublePaymentTemplateInfo, usePaymentsI18n } from '@msb/http';
import { handleReachGoal, useExtendedNavigation, useHashedRedirect, YM_GOALS } from '@msb/shared';
import { Form } from 'react-final-form';
import { LOCALIZATION, FIELD_IDENTITY } from '../constants';
import * as S from './TemplateForm.styles';
import { PayeeFormFields, PayerFormFields, FormValidation, PaymentFormFields } from '@/features/Forms';
import { PayeeFormFields, PayerFormFields, FormValidation, PaymentFormFields, UnknownBackendControlsSheet } from '@/features/Forms';
import { setPaymentsI18nData } from '@/features/Forms/lib/paymentsI18nStore';
import { TemplateFormFields } from '@/features/TemplateForm/lib';
import { PATHS, PAYMENTS_YM_EVENTS } from '@/shared/constants';
import { PageHeader } from '@/shared/ui';
@@ -21,6 +22,7 @@ namespace TemplateForm {
export const Element = ({ organizationId, raw }: Props): ReactElement => {
const { showSnackbarMessage } = useSnackbar();
const { paymentsI18nData } = usePaymentsI18n();
const snackbarCallback = useCallback(
(type: TemplateFormFields.TOAST_TYPE) => {
let message = '';
@@ -91,6 +93,10 @@ namespace TemplateForm {
const isAllowCreatePayment =
formFields.templateName.length > 0 && (formFields.payerFieldsOutput?.payerDetails?.accountNumber.length || 0) > 0;
useEffect(() => {
setPaymentsI18nData(paymentsI18nData);
}, [paymentsI18nData]);
return (
<Form
render={({ form }) => {
@@ -121,8 +127,9 @@ namespace TemplateForm {
formValidation={FormValidation}
orgHandleChange={orgChangeHandler}
/>
<UnknownBackendControlsSheet />
&nbsp;
</ScrollContainer>
&nbsp;
<S.ButtonsWrapper>
{isAllowCreatePayment && (
<Button dataAction="Save and create payment" size="M" onClick={createPaymentHandler}>