fix(registration): закрывать signup до готовности vars и сериализовать их patch
Typecheck / desktop (pull_request) Successful in 32m7s
Typecheck / controller (pull_request) Successful in 29m46s

Добавляем расчётный флаг settings.is_registration_open в getSystemInfo и заглушку на странице signup, чтобы пайщик не попадал в ошибку генерации документов. Параллельные утверждения решений больше не затирают vars при read-modify-write.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alex Ant
2026-06-25 18:25:10 +05:00
parent 987600dd38
commit de10e16bae
13 changed files with 134 additions and 7 deletions
@@ -39,7 +39,10 @@ export class SettingsDTO implements SettingsDomainInterface {
@Field(() => Date, { description: 'Дата последнего обновления' })
updated_at!: Date;
constructor(data: SettingsDomainInterface) {
@Field(() => Boolean, { description: 'Открыта ли регистрация новых пайщиков' })
is_registration_open!: boolean;
constructor(data: SettingsDomainInterface, isRegistrationOpen = false) {
this.coopname = data.coopname;
this.authorized_default_workspace = data.authorized_default_workspace;
this.authorized_default_route = data.authorized_default_route;
@@ -48,6 +51,7 @@ export class SettingsDTO implements SettingsDomainInterface {
this.provider_name = data.provider_name;
this.created_at = data.created_at;
this.updated_at = data.updated_at;
this.is_registration_open = isRegistrationOpen;
}
}
@@ -13,6 +13,7 @@ import { SymbolsDTO } from './symbols.dto';
import { SettingsDTO } from './settings.dto';
import { BoardMemberDTO } from './board-member.dto';
import { SystemFeaturesDTO } from './system-features.dto';
import { isRegistrationOpen } from '~/domain/system/utils/is-registration-open.util';
@ObjectType('SystemInfo')
export class SystemInfoDTO {
@@ -79,7 +80,7 @@ export class SystemInfoDTO {
this.blockchain_info = new BlockchainInfoDTO(entity.blockchain_info);
this.system_status = entity.system_status || 'install';
this.symbols = entity.symbols;
this.settings = new SettingsDTO(entity.settings);
this.settings = new SettingsDTO(entity.settings, isRegistrationOpen(entity.vars));
this.blockchain_account = entity.blockchain_account;
this.cooperator_account = new CooperativeOperatorAccountDTO({
...entity.cooperator_account,
@@ -36,6 +36,7 @@ import {
PaymentMethodDomainPort,
} from '~/domain/payment-method/ports/payment-method-domain.port';
import { LoadContactsInteractor } from './load-contacts.interactor';
import { isRegistrationOpen } from '~/domain/system/utils/is-registration-open.util';
@Injectable()
export class SystemInteractor {
@@ -307,4 +308,9 @@ export class SystemInteractor {
async updateSettings(updates: UpdateSettingsInputDomainInterface): Promise<SettingsDomainEntity> {
return this.settingsDomainPort.updateSettings(updates);
}
async getRegistrationOpenStatus(): Promise<boolean> {
const vars = await this.varsRepository.get();
return isRegistrationOpen(vars);
}
}
@@ -99,8 +99,11 @@ export class SystemService {
* Обновляет настройки системы
*/
public async updateSettings(data: UpdateSettingsInputDTO): Promise<SettingsDTO> {
const settings = await this.systemInteractor.updateSettings(data);
return new SettingsDTO(settings);
const [settings, isRegistrationOpenStatus] = await Promise.all([
this.systemInteractor.updateSettings(data),
this.systemInteractor.getRegistrationOpenStatus(),
]);
return new SettingsDTO(settings, isRegistrationOpenStatus);
}
/**
@@ -0,0 +1,35 @@
import type { AgreementNumberDomainInterface } from '~/domain/agreement/interfaces/agreement-number.interface';
import type { VarsDomainInterface } from '~/domain/system/interfaces/vars-domain.interface';
const REGISTRATION_REQUIRED_AGREEMENT_VARS = [
'wallet_agreement',
'signature_agreement',
'privacy_agreement',
'user_agreement',
'participant_application',
] as const satisfies ReadonlyArray<keyof VarsDomainInterface>;
function isAgreementVarFilled(agreement?: AgreementNumberDomainInterface | null): boolean {
if (!agreement) {
return false;
}
const protocolNumber = agreement.protocol_number?.trim();
const protocolDate = agreement.protocol_day_month_year?.trim();
return Boolean(protocolNumber) && Boolean(protocolDate);
}
/**
* Регистрация пайщиков доступна, когда заполнены реквизиты протоколов
* базовых соглашений кооператива — без них factory не сгенерирует пакет документов.
*/
export function isRegistrationOpen(vars: VarsDomainInterface | null | undefined): boolean {
if (!vars) {
return false;
}
return REGISTRATION_REQUIRED_AGREEMENT_VARS.every((field) =>
isAgreementVarFilled(vars[field] as AgreementNumberDomainInterface | null | undefined)
);
}
@@ -23,6 +23,9 @@ import { TrackingRuleRepository } from '../repositories/tracking-rule.repository
*/
@Injectable()
export class DecisionTrackingAdapter implements DecisionTrackingPort, OnModuleInit {
/** Сериализует patch vars — иначе параллельные newdecision перезаписывают друг друга. */
private varsUpdateChain: Promise<void> = Promise.resolve();
constructor(
private readonly repository: TrackingRuleRepository,
@Inject(VARS_DATA_PORT) private readonly varsPort: VarsDataPort,
@@ -239,10 +242,11 @@ export class DecisionTrackingAdapter implements DecisionTrackingPort, OnModuleIn
}
/**
* Обновляет поле в vars
* Обновляет одно поле vars. Запросы идут в очереди: при быстром утверждении
* нескольких решений подряд каждый patch читает актуальный snapshot.
*/
private async updateVars(varsField: string, decisionId: string, decisionDate: string): Promise<void> {
try {
const applyPatch = async (): Promise<void> => {
const currentVars = await this.varsPort.get();
if (!currentVars) {
this.logger.warn('Vars не найдены, пропускаем обновление');
@@ -261,6 +265,12 @@ export class DecisionTrackingAdapter implements DecisionTrackingPort, OnModuleIn
await this.varsPort.create(updatedVars);
this.logger.info(`Обновлено поле vars: ${varsField} = ${decisionId} от ${decisionDate}`);
};
this.varsUpdateChain = this.varsUpdateChain.then(applyPatch, applyPatch);
try {
await this.varsUpdateChain;
} catch (error: any) {
this.logger.error(`Ошибка при обновлении vars: ${error.message}`, error.stack);
}
@@ -0,0 +1,36 @@
import { isRegistrationOpen } from '~/domain/system/utils/is-registration-open.util';
import type { VarsDomainInterface } from '~/domain/system/interfaces/vars-domain.interface';
describe('isRegistrationOpen', () => {
const filledAgreement = {
protocol_number: '1',
protocol_day_month_year: '01.01.2026',
};
const baseVars = {
coopname: 'testcoop',
wallet_agreement: filledAgreement,
signature_agreement: filledAgreement,
privacy_agreement: filledAgreement,
user_agreement: filledAgreement,
participant_application: filledAgreement,
} as VarsDomainInterface;
it('returns false when vars are missing', () => {
expect(isRegistrationOpen(null)).toBe(false);
expect(isRegistrationOpen(undefined)).toBe(false);
});
it('returns false when any required agreement var is incomplete', () => {
expect(
isRegistrationOpen({
...baseVars,
wallet_agreement: { protocol_number: '', protocol_day_month_year: '01.01.2026' },
})
).toBe(false);
});
it('returns true when all required agreement vars are filled', () => {
expect(isRegistrationOpen(baseVars)).toBe(true);
});
});
+1
View File
@@ -5232,6 +5232,7 @@ export const ReturnTypes: Record<string,any> = {
authorized_default_workspace:"String",
coopname:"String",
created_at:"DateTime",
is_registration_open:"Boolean",
non_authorized_default_route:"String",
non_authorized_default_workspace:"String",
provider_name:"String",
+8
View File
@@ -10754,6 +10754,8 @@ validateReportEdits?: [{ editsJson: string | Variable<any, string>, reportType:
coopname?:boolean | `@${string}`,
/** Дата создания */
created_at?:boolean | `@${string}`,
/** Открыта ли регистрация новых пайщиков */
is_registration_open?:boolean | `@${string}`,
/** Маршрут по умолчанию для неавторизованных пользователей */
non_authorized_default_route?:boolean | `@${string}`,
/** Рабочий стол по умолчанию для неавторизованных пользователей */
@@ -21009,6 +21011,8 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo
coopname?:boolean | `@${string}`,
/** Дата создания */
created_at?:boolean | `@${string}`,
/** Открыта ли регистрация новых пайщиков */
is_registration_open?:boolean | `@${string}`,
/** Маршрут по умолчанию для неавторизованных пользователей */
non_authorized_default_route?:boolean | `@${string}`,
/** Рабочий стол по умолчанию для неавторизованных пользователей */
@@ -31810,6 +31814,8 @@ export type ModelTypes = {
coopname: string,
/** Дата создания */
created_at: ModelTypes["DateTime"],
/** Открыта ли регистрация новых пайщиков */
is_registration_open: boolean,
/** Маршрут по умолчанию для неавторизованных пользователей */
non_authorized_default_route: string,
/** Рабочий стол по умолчанию для неавторизованных пользователей */
@@ -43122,6 +43128,8 @@ export type GraphQLTypes = {
coopname: string,
/** Дата создания */
created_at: GraphQLTypes["DateTime"],
/** Открыта ли регистрация новых пайщиков */
is_registration_open: boolean,
/** Маршрут по умолчанию для неавторизованных пользователей */
non_authorized_default_route: string,
/** Рабочий стол по умолчанию для неавторизованных пользователей */
@@ -1,7 +1,14 @@
<template lang="pug">
.signup-page
AuthCard.signup-page__card(:max-width='720', title='Вступить в пайщики')
template(v-if='isRegistrationClosed')
EmptyState.signup-page__closed(
title='Регистрация временно недоступна',
body='Кооператив завершает подготовку к приёму новых пайщиков. Пожалуйста, зайдите позже.'
)
q-stepper.signup-page__stepper(
v-else,
v-model='store.step',
vertical,
animated,
@@ -26,7 +33,7 @@
WaitingRegistration
.signup-page__restart
.signup-page__restart(v-if='!isRegistrationClosed')
q-btn(@click='out', dense, size='sm', flat color='grey') начать с начала
</template>
@@ -42,6 +49,7 @@ import PayInitial from './PayInitial.vue';
import WaitingRegistration from './WaitingRegistration.vue';
import SelectBranch from './SelectBranch.vue';
import { AuthCard } from 'src/shared/ui/domain/AuthCard';
import { EmptyState } from 'src/shared/ui/base/EmptyState';
import { useRegistratorStore } from 'src/entities/Registrator';
import { useLogoutUser } from 'src/features/User/Logout';
@@ -65,6 +73,8 @@ const desktops = useDesktopStore();
const system = useSystemStore();
const { info } = system;
const isRegistrationClosed = computed(() => info.settings?.is_registration_open === false);
// Диалог разрешения уведомлений
const { showDialog } = useNotificationPermissionDialog();
@@ -306,4 +316,7 @@ const isBranched = computed(() => info.cooperator_account.is_branched);
margin-top: var(--p-4, 16px);
text-align: center;
}
.signup-page__closed {
padding: var(--p-4, 16px) 0;
}
</style>
@@ -65,6 +65,7 @@ export const rawSettingsSelector = {
authorized_default_route: true,
non_authorized_default_workspace: true,
non_authorized_default_route: true,
is_registration_open: true,
created_at: true,
updated_at: true,
}
+1
View File
@@ -5232,6 +5232,7 @@ export const ReturnTypes: Record<string,any> = {
authorized_default_workspace:"String",
coopname:"String",
created_at:"DateTime",
is_registration_open:"Boolean",
non_authorized_default_route:"String",
non_authorized_default_workspace:"String",
provider_name:"String",
+8
View File
@@ -10754,6 +10754,8 @@ validateReportEdits?: [{ editsJson: string | Variable<any, string>, reportType:
coopname?:boolean | `@${string}`,
/** Дата создания */
created_at?:boolean | `@${string}`,
/** Открыта ли регистрация новых пайщиков */
is_registration_open?:boolean | `@${string}`,
/** Маршрут по умолчанию для неавторизованных пользователей */
non_authorized_default_route?:boolean | `@${string}`,
/** Рабочий стол по умолчанию для неавторизованных пользователей */
@@ -21009,6 +21011,8 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo
coopname?:boolean | `@${string}`,
/** Дата создания */
created_at?:boolean | `@${string}`,
/** Открыта ли регистрация новых пайщиков */
is_registration_open?:boolean | `@${string}`,
/** Маршрут по умолчанию для неавторизованных пользователей */
non_authorized_default_route?:boolean | `@${string}`,
/** Рабочий стол по умолчанию для неавторизованных пользователей */
@@ -31810,6 +31814,8 @@ export type ModelTypes = {
coopname: string,
/** Дата создания */
created_at: ModelTypes["DateTime"],
/** Открыта ли регистрация новых пайщиков */
is_registration_open: boolean,
/** Маршрут по умолчанию для неавторизованных пользователей */
non_authorized_default_route: string,
/** Рабочий стол по умолчанию для неавторизованных пользователей */
@@ -43122,6 +43128,8 @@ export type GraphQLTypes = {
coopname: string,
/** Дата создания */
created_at: GraphQLTypes["DateTime"],
/** Открыта ли регистрация новых пайщиков */
is_registration_open: boolean,
/** Маршрут по умолчанию для неавторизованных пользователей */
non_authorized_default_route: string,
/** Рабочий стол по умолчанию для неавторизованных пользователей */