fix(install): разблокировать мастер при прерванной установке и валидировать состав совета по окружению.

Не показываем заглушку техобслуживания на /install, разрешаем повтор install из maintenance без vars, проверяем минимум членов совета до записи в цепь (3 на prod, 1 на dev).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alex Ant
2026-06-24 11:35:01 +05:00
parent ee5f7e1850
commit eb16d85abc
10 changed files with 112 additions and 17 deletions
@@ -25,6 +25,9 @@ import { Workflows } from '@coopenomics/notifications';
import { TokenApplicationService } from '~/application/token/services/token-application.service';
import { normalizeUserEmail } from '~/utils/normalize-user-email';
/** Минимум членов совета — как MIN_SOVIET_MEMBERS_COUNT в контракте soviet (3 prod / 1 dev). */
const MIN_SOVIET_MEMBERS_COUNT = config.min_soviet_members_count;
@Injectable()
export class InstallInteractor {
constructor(
@@ -118,14 +121,30 @@ export class InstallInteractor {
async install(data: InstallInputDomainInterface): Promise<void> {
const status = await this.monoStatusRepository.getStatus();
const savedVars = await this.varsRepository.get();
const isIncompleteInstallMaintenance =
status === SystemStatus.maintenance && !savedVars?.name;
// Разрешаем установку ТОЛЬКО если система в статусе 'initialized' (после initSystem)
if (status !== SystemStatus.initialized) {
// Разрешаем установку если:
// - initialized (нормальный прогон)
// - maintenance без сохранённых vars (прерванная установка, можно повторить)
if (status !== SystemStatus.initialized && !isIncompleteInstallMaintenance) {
throw new BadRequestException(
`Установка невозможна. Текущий статус: ${status}. Требуется статус: ${SystemStatus.initialized}`
);
}
if (data.soviet.length < MIN_SOVIET_MEMBERS_COUNT) {
throw new BadRequestException(
`Количество членов совета должно быть не менее ${MIN_SOVIET_MEMBERS_COUNT}`
);
}
// Повтор после прерванной установки: снимаем maintenance, дальше — обычный прогон.
if (isIncompleteInstallMaintenance) {
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
}
const info = await this.blockchainPort.getInfo();
const coop = await this.blockchainPort.getCooperative(config.coopname);
@@ -189,6 +189,10 @@ const envVarsSchema = z.object({
.string()
.optional()
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
MIN_SOVIET_MEMBERS_COUNT: z
.string()
.optional()
.describe('Минимум членов совета при install; пусто — 3 на production, 1 на development/test'),
});
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
@@ -260,6 +264,11 @@ export default {
},
},
coopname: envVars.data.COOPNAME,
min_soviet_members_count: envVars.data.MIN_SOVIET_MEMBERS_COUNT
? parseInt(envVars.data.MIN_SOVIET_MEMBERS_COUNT, 10)
: envVars.data.NODE_ENV === 'production'
? 3
: 1,
graphql_service: envVars.data.GRAPHQL_SERVICE,
provider_base_url: envVars.data.PROVIDER_BASE_URL,
union: {
@@ -0,0 +1 @@
export { getMinSovietMembersCount } from './minSovietMembers';
@@ -0,0 +1,6 @@
import { env } from 'src/shared/config';
/** Как MIN_SOVIET_MEMBERS_COUNT в контракте soviet: 1 на dev/testnet, 3 на production. */
export function getMinSovietMembersCount(): number {
return env.NODE_ENV === 'production' ? 3 : 1;
}
@@ -1,5 +1,7 @@
import type { Mutations, Queries } from '@coopenomics/sdk';
import { useInstallCooperativeStore } from 'src/entities/Installer/model'
import { stripLegacyBankKpp } from 'src/shared/lib/utils/stripLegacyBankKpp';
import { getMinSovietMembersCount } from '../lib';
import { api } from '../api'
export type IInstallInput = Mutations.System.InstallSystem.IInput['data']
@@ -30,7 +32,15 @@ export const useInstallCooperative = (): {
}
async function initSystem(data: IInitSystemInput): Promise<IInitSystemOutput> {
return await api.initSystem(data);
const payload: IInitSystemInput = data.organization_data
? {
...data,
// TODO(удалить после 01.09.2026): strip устаревший «КПП банка» из ответа getInstallationStatus
organization_data: stripLegacyBankKpp(data.organization_data),
}
: data;
return await api.initSystem(payload);
}
async function getInstallationStatus(installCode: string): Promise<IGetInstallationStatusOutput> {
@@ -41,8 +51,15 @@ export const useInstallCooperative = (): {
if (!store.vars)
throw new Error('Переменные не установлены')
if (!store.soviet || store.soviet.length === 0)
throw new Error('Совет не установлен')
const minSovietMembers = getMinSovietMembersCount();
if (!store.soviet || store.soviet.length < minSovietMembers) {
throw new Error(
minSovietMembers === 1
? 'Совет не установлен'
: `Совет должен включать не менее ${minSovietMembers} человек (председатель и члены совета)`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const soviet = store.soviet.map(({ id, type, ...rest }) => rest);
@@ -80,6 +80,7 @@ import { FailAlert } from 'src/shared/api';
import { extractGraphQLErrorMessages } from 'src/shared/api/errors';
import { useSystemStore } from 'src/entities/System/model';
import { notEmpty } from 'src/shared/lib/utils';
import { stripLegacyBankKpp } from 'src/shared/lib/utils/stripLegacyBankKpp';
import { Zeus } from '@coopenomics/sdk';
import { BaseButton } from 'src/shared/ui/base/BaseButton';
@@ -187,7 +188,7 @@ const loadData = async () => {
organizationData.type = organizationData.type.toUpperCase();
}
installStore.organization_data = organizationData;
installStore.organization_data = stripLegacyBankKpp(organizationData);
}
} catch (error: any) {
const errorMessage = extractGraphQLErrorMessages(error);
@@ -43,16 +43,18 @@
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useInstallCooperativeStore } from 'src/entities/Installer/model';
import { IndividualDataForm } from 'src/shared/ui/UserDataForm/IndividualDataForm';
import type { IIndividualData } from 'src/shared/lib/types/user/IUserData';
import { FailAlert } from 'src/shared/api';
import { ref } from 'vue';
import { validEmail } from 'src/shared/lib/utils/validEmailRule';
import { getMinSovietMembersCount } from '../../lib';
import { notEmpty } from 'src/shared/lib/utils';
import { validEmail } from 'src/shared/lib/utils/validEmailRule';
import { BaseButton } from 'src/shared/ui/base/BaseButton';
const installStore = useInstallCooperativeStore();
const minSovietMembers = getMinSovietMembersCount();
installStore.is_finish = false;
@@ -74,10 +76,16 @@ const back = () => {
installStore.current_step = 'init';
};
const MIN_SOVIET_MEMBERS = minSovietMembers;
const next = async () => {
try {
if (installStore.soviet.length === 0) {
FailAlert('Необходимо добавить хотя бы одного члена совета');
if (installStore.soviet.length < MIN_SOVIET_MEMBERS) {
FailAlert(
MIN_SOVIET_MEMBERS === 1
? 'Необходимо добавить хотя бы одного члена совета'
: `Необходимо добавить не менее ${MIN_SOVIET_MEMBERS} членов совета (председатель и члены совета)`,
);
return;
}
@@ -90,8 +98,11 @@ const next = async () => {
}
};
if (installStore.soviet.length == 0)
add();
if (installStore.soviet.length == 0) {
for (let i = 0; i < minSovietMembers; i++) {
add();
}
}
</script>
<style scoped lang="scss">
@@ -26,9 +26,7 @@
//- ---------- Шаг 3: члены совета ----------
.install-step(v-else-if='step.key === "soviet"')
p.install-step__intro
| Введите данные председателя и членов совета. Каждому будет создан
| аккаунт пайщика и отправлено приглашение на электронную почту.
p.install-step__intro {{ sovietStepIntro }}
SetSovietForm
//- ---------- Шаг 4: переменные документов ----------
@@ -60,10 +58,17 @@ import { useSystemStore } from 'src/entities/System/model';
import { VerticalStepper } from 'src/shared/ui/domain/VerticalStepper';
import type { StepperStep } from 'src/shared/ui/domain/VerticalStepper';
import { BaseButton } from 'src/shared/ui/base/BaseButton';
import { getMinSovietMembersCount } from 'src/features/Installer/lib';
const router = useRouter();
const systemStore = useSystemStore();
const installStore = useInstallCooperativeStore();
const minSovietMembers = getMinSovietMembersCount();
const sovietStepIntro =
minSovietMembers === 1
? 'Введите данные председателя. Будет создан аккаунт пайщика и отправлено приглашение на электронную почту.'
: 'Введите данные председателя и не менее двух членов совета (всего минимум 3 человека). Каждому будет создан аккаунт пайщика и отправлено приглашение на электронную почту.';
const steps: StepperStep[] = [
{ key: 'key', label: 'Ключ установки', description: 'Ключ, выданный при регистрации пайщиком' },
@@ -26,7 +26,15 @@ export function setupNavigationGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
// если требуется установка
const allowedRoutesDuringInstall = ['install', 'invite'];
if ((systemStore.info.system_status === Zeus.SystemStatus.install || systemStore.info.system_status === Zeus.SystemStatus.initialized) && !allowedRoutesDuringInstall.includes(to.name as string)) {
const isIncompleteInstallMaintenance =
systemStore.info.system_status === Zeus.SystemStatus.maintenance &&
!systemStore.info.vars?.name;
const requiresInstallFlow =
systemStore.info.system_status === Zeus.SystemStatus.install ||
systemStore.info.system_status === Zeus.SystemStatus.initialized ||
isIncompleteInstallMaintenance;
if (requiresInstallFlow && !allowedRoutesDuringInstall.includes(to.name as string)) {
next({ name: 'install', params: { coopname: systemStore.info.coopname }, query: to.query });
return;
}
@@ -4,6 +4,18 @@ import { QSpinnerGears, useQuasar } from 'quasar';
import { useSystemStore } from 'src/entities/System/model';
import { Zeus } from '@coopenomics/sdk';
/** Мастер установки не должен перекрываться заглушкой техобслуживания. */
function isOnInstallPage(): boolean {
if (typeof window === 'undefined') return false;
const path = window.location.hash || window.location.pathname;
return path.includes('/install');
}
/** maintenance без сохранённых vars — прерванная установка, а не плановое обслуживание. */
function isIncompleteInstallMaintenance(systemStatus: string, hasSavedVars: boolean): boolean {
return systemStatus === Zeus.SystemStatus.maintenance && !hasSavedVars;
}
export function useDesktopHealthWatcherProcess() {
const $q = useQuasar();
const systemStore = useSystemStore();
@@ -11,6 +23,7 @@ export function useDesktopHealthWatcherProcess() {
// Создаем computed для лучшей реактивности
const systemStatus = computed(() => systemStore.info.system_status);
const hasSavedVars = computed(() => Boolean(systemStore.info.vars?.name));
const enableLoading = () => {
$q.loading.show({
@@ -25,7 +38,12 @@ export function useDesktopHealthWatcherProcess() {
};
const check = () => {
if (systemStatus.value === 'maintenance') {
const blockedByMaintenance =
systemStatus.value === Zeus.SystemStatus.maintenance &&
!isIncompleteInstallMaintenance(systemStatus.value, hasSavedVars.value) &&
!isOnInstallPage();
if (blockedByMaintenance) {
enableLoading();
} else {
disableLoading();