Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Ant 74dce675b9 fix(install): блокировать переход с формы совета без заполненных данных
Кнопка «Продолжить» проверяла только число слотов совета, а не email и
обязательные поля ФЛ. Добавлена валидация на фронте и в install.interactor,
чтобы частично заполненный состав не уходил на цепь.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:04:43 +03:00
129 changed files with 1554 additions and 2652 deletions
-12
View File
@@ -390,18 +390,6 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lerna run build
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
- name: Generate controller schema.gql (required by sdk prepublishOnly)
working-directory: components/controller
run: pnpm run generate-schema
- run: pnpm lerna publish from-package --yes
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-8
View File
@@ -45,14 +45,6 @@ RUN corepack enable && npm install -g lerna --no-fund --no-audit
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
COPY . .
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
# Отключаем сами загрузки, пакеты всё равно ставятся.
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
PUPPETEER_SKIP_DOWNLOAD=true
# Используем существующий lockfile. Если он не совпадает с workspace —
# падать сразу, не дрейфовать незаметно.
RUN pnpm install --frozen-lockfile
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.7.21-1",
"version": "2026.6.24",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+1 -8
View File
@@ -35,14 +35,7 @@ RUN npm install -g pnpm@10.33.0
COPY . .
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
&& pnpm rebuild --filter "@coopenomics/boot..."
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..."
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
# свой bundle с inlined workspace-deps.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.7.21-1",
"version": "2026.6.24",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
+1 -1
View File
@@ -76,7 +76,7 @@ export class CooperativeClass {
await this.blockchain.createProgram({
coopname,
username: coopname,
type: 'capital',
type: 'blagorost',
title: 'Целевая потребительская программа "Благорост"',
announce: '',
description: '',
@@ -1,6 +1,6 @@
/**
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
* (id=4, type='capital') + capital::setconfig.
* (id=4, type='blagorost') + capital::setconfig.
*
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
* setconfig — только если `state` пуст.
@@ -88,7 +88,7 @@ export async function phase01(): Promise<void> {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
await blockchain.api.transact({
actions: [{
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
}
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
log(`signAgreement blagorost для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
})
}
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
+1 -1
View File
@@ -213,7 +213,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
+1 -1
View File
@@ -12,6 +12,6 @@ export const capitalDraftId = 1000
export const ratePerHour = '1000.0000 RUB'
export const sourceProgramName = 'generator'
export const capitalProgramName = 'capital'
export const capitalProgramName = 'blagorost'
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
export const circulationAccountId = 80_000
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.7.21-1",
"version": "2026.6.24",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
+1 -1
View File
@@ -80,7 +80,7 @@ static constexpr eosio::name _wallet_program = "wallet"_n; ///< Главный
static constexpr eosio::name _marketplace_program = "marketplace"_n; ///< Кошелёк программы "Маркетплейс"
static constexpr eosio::name _source_program = "generator"_n; ///< Кошелёк для генерации по договору УХД
static constexpr eosio::name _capital_program = "capital"_n; ///< Кошелёк программы "Благорост"
static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелёк программы "Благорост"
static const std::set<eosio::name> soviet_actions = {
@@ -12,22 +12,14 @@
void soviet::migrate() {
require_auth(_soviet); // Проверяем авторизацию
// Откат program_type для кооператива voskhod, программа id=4: blagorost -> capital.
// Предыдущая версия этой миграции переименовывала programs.program_type в
// 'blagorost', но coagreements.type для той же программы (program_id=4) всегда
// оставался 'capital' и никогда не переименовывался — таблицы разъехались.
// 'capital' — каноничное значение (используется coagreements.type, contract-
// константой _capital_program и всем controller-кодом); эта миграция приводит
// programs.program_type к нему же. Идемпотентно.
{
eosio::name coopname = "voskhod"_n;
programs_index programs(_soviet, coopname.value);
auto program_it = programs.find(4);
if (program_it != programs.end() && program_it->program_type != "capital"_n) {
programs.modify(program_it, _soviet, [&](auto &pr) {
pr.program_type = "capital"_n;
});
}
// Миграция program_type для кооператива voskhod, программа id=4 -> blagorost
eosio::name coopname = "voskhod"_n;
programs_index programs(_soviet, coopname.value);
auto program_it = programs.find(4);
if (program_it != programs.end() && program_it->program_type != "blagorost"_n) {
programs.modify(program_it, _soviet, [&](auto &pr) {
pr.program_type = "blagorost"_n;
});
}
// ────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.7.21-1",
"version": "2026.6.24",
"private": true,
"type": "module",
"scripts": {
@@ -14,28 +14,34 @@ const TARGET_COOPNAME = 'voskhod';
const COLLECTION_NAME = 'doc_private_data';
const capitalProgramPrivateData = {
approval_protocol_number: '1',
approval_protocol_day: '15',
approval_protocol_month: 'января',
approval_protocol_year: '26',
cooperative_name: 'ВОСХОД',
cooperative_short_name: 'ПК',
cooperative_quoted_name: '«ВОСХОД»',
website: 'https://цифровой-кооператив.рф',
chairman_full_name: 'Муравьев Алексей Николаевич',
generator_program_purpose:
"развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации., на основе международных кооперативных принципов и законодательства Российской Федерации",
'развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) под названием “Кооперативная Экономика”',
eoap_definition:
"информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”",
'информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”',
generator_task_goal:
"центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений",
idea_unit_cost:
"50",
idea_unit_cost_words:
"пятьдесят",
'центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений',
idea_unit_cost: '50',
idea_unit_cost_words: 'пятьдесят',
blagorost_goal_expansion:
"развитии информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации",
blagorost_goal_reason:
"вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате",
'вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате',
blagorost_task_expansion:
"расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате",
'расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате',
blagorost_task_development:
"развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений",
'развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений',
return_source_description:
"аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕАОП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий (подробнее на Сайте)",
'аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕОАП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий',
return_additional_source:
"взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП"
'взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП',
offer_template_number: '______',
};
function stableStringify(value: unknown): string {
@@ -72,9 +78,6 @@ export default {
try {
mongo = await mongoose.createConnection(config.mongoose.url).asPromise();
const db = mongo.db;
if (!db) {
throw new Error('Подключение к MongoDB установлено, но db не инициализирована');
}
const collection = db.collection(COLLECTION_NAME);
await collection.createIndex({ hash: 1 }, { unique: true });
await collection.updateOne(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.7.21-1",
"version": "2026.6.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.7.21-1",
"version": "2026.6.24",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.7.21-1",
"version": "2026.6.24",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
@@ -14,7 +14,6 @@ const { spawnSync } = require('child_process');
const controllerRoot = path.resolve(__dirname, '..');
const schemaPath = path.join(controllerRoot, 'schema.gql');
const zeusConfigPath = path.join(controllerRoot, 'graphql-zeus.json');
const sdkSrc = path.resolve(controllerRoot, '..', 'sdk', 'src');
const localZeusDir = path.join(controllerRoot, 'zeus');
const sdkZeusDir = path.join(sdkSrc, 'zeus');
@@ -41,12 +40,6 @@ if (!fs.existsSync(sdkSrc)) {
process.exit(1);
}
// graphql-zeus 7.x: yargs кладёт path в argv.path, а CLIClass читает args._[0].
// Без graphql-zeus.json config-maker уходит в inquirer и «зависает» без TTY.
if (!fs.existsSync(zeusConfigPath)) {
fs.writeFileSync(zeusConfigPath, `${JSON.stringify({ urlOrPath: './schema.gql' }, null, 2)}\n`, 'utf8');
}
let zeusBin;
let zeusIsWin;
try {
@@ -58,7 +51,6 @@ try {
const zeusArgs = ['./schema.gql', '../sdk/src', '--subscriptions', 'graphql-ws'];
const startedAt = Date.now();
console.log('generate-client: graphql-zeus… (530 с без вывода — норма, не прерывайте)');
const result = spawnSync(zeusBin, zeusArgs, {
stdio: 'inherit',
cwd: controllerRoot,
@@ -4,11 +4,15 @@ import type { GetDocumentsInputDTO } from '../dto/get-documents-input.dto';
import type { PaginationResultDomainInterface } from '~/domain/common/interfaces/pagination.interface';
import type { DocumentPackageAggregateDomainInterface } from '~/domain/document/interfaces/document-package-aggregate-domain.interface';
import type { GenerateAnyDocumentInputDTO } from '../dto/generate-any-document-input.dto';
import { GeneratorInfrastructureService } from '~/infrastructure/generator/generator.service';
import { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
@Injectable()
export class DocumentService {
constructor(private readonly documentInteractor: DocumentInteractor) {}
constructor(
private readonly documentInteractor: DocumentInteractor,
private readonly generatorService: GeneratorInfrastructureService
) {}
async getDocumentsAggregate(
data: GetDocumentsInputDTO
@@ -30,7 +34,7 @@ export class DocumentService {
}
async generateAnyDocument(input: GenerateAnyDocumentInputDTO): Promise<DocumentDomainEntity> {
return this.documentInteractor.generateDocument({
return this.generatorService.generateDocument({
data: input.data as any,
options: input.options,
});
@@ -28,6 +28,39 @@ 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;
function councilRoleLabel(index: number, role?: string): string {
if (role === 'chairman' || index === 0) return 'председателя совета';
return `члена совета №${index + 1}`;
}
function assertSovietMemberComplete(
member: InstallInputDomainInterface['soviet'][number],
index: number,
): void {
const data = member?.individual_data;
const label = councilRoleLabel(index, member?.role);
const email = typeof data?.email === 'string' ? data.email.trim() : '';
if (!email) {
throw new BadRequestException(`Не указан email для ${label}`);
}
const requiredStringFields: Array<[keyof Cooperative.Users.IIndividualData, string]> = [
['last_name', 'фамилия'],
['first_name', 'имя'],
['full_address', 'адрес регистрации'],
['birthdate', 'дата рождения'],
['phone', 'телефон'],
];
for (const [field, humanName] of requiredStringFields) {
const value = data?.[field];
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException(`Не заполнено поле «${humanName}» для ${label}`);
}
}
}
@Injectable()
export class InstallInteractor {
constructor(
@@ -140,6 +173,8 @@ export class InstallInteractor {
);
}
data.soviet.forEach((member, index) => assertSovietMemberComplete(member, index));
// Повтор после прерванной установки: снимаем maintenance, дальше — обычный прогон.
if (isIncompleteInstallMaintenance) {
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
@@ -6,7 +6,7 @@ import {
UserCertificateDomainService,
USER_CERTIFICATE_DOMAIN_SERVICE,
} from '~/domain/user/services/user-certificate-domain.service';
import { Cooperative, SovietContract } from 'cooptypes';
import { Cooperative, SovietContract, WalletContract } from 'cooptypes';
import { getActions } from '~/utils/getFetch';
import type { DocumentPackageAggregateDomainInterface } from '../interfaces/document-package-aggregate-domain.interface';
import type { StatementDetailAggregateDomainInterface } from '../interfaces/statement-detail-aggregate-domain.interface';
@@ -298,17 +298,9 @@ export class DocumentPackageV1Aggregator {
/**
* Находит on-chain действие-носитель подписанного связанного документа по doc_hash.
* Сначала ищет в реестре соглашений совета (soviet::newagreement — второе,
* явное уведомление, которое дополнительно шлёт soviet::sndagreement), затем —
* в общем уведомлении о завершённом документе (soviet::newresolved), которое
* централизованно шлёт make_complete_document (lib/core/soviet/soviet.hpp) на
* КАЖДЫЙ вызов — и из soviet::sndagreement, и из wallet::signagree — с
* require_recipient(coopname), заданным один раз внутри newresolved.cpp.
* Программные оферты (capital: Благорост/Генератор, program_id>0) идут через
* wallet::signagree и не порождают newagreement, но newresolved у них есть
* всегда — расширять notification-поверхность самого wallet::signagree не
* нужно, единственное место для этого уже newresolved.cpp.
* Возвращает поле document найденного действия либо null.
* Сначала ищет в реестре соглашений совета (soviet::newagreement), затем —
* в программных подписях кошелька (wallet::signagree). Возвращает поле document
* найденного действия либо null.
*/
private async findLinkedAgreementDocument(
linkHash: string
@@ -329,10 +321,10 @@ export class DocumentPackageV1Aggregator {
const sovietDocument = sovietAgreement?.results?.[0]?.data?.document;
if (sovietDocument) return sovietDocument;
const resolvedAgreement = await getActions(`${process.env.SIMPLE_EXPLORER_API}/get-actions`, {
const walletAgreement = await getActions(`${process.env.SIMPLE_EXPLORER_API}/get-actions`, {
filter: JSON.stringify({
account: SovietContract.contractName.production,
name: SovietContract.Actions.Registry.NewResolved.actionName,
account: WalletContract.contractName.production,
name: WalletContract.Actions.SignAgreement.actionName,
receiver: process.env.COOPNAME,
'data.document.doc_hash': docHashFilter,
}),
@@ -340,8 +332,8 @@ export class DocumentPackageV1Aggregator {
limit: 1,
});
const resolvedDocument = resolvedAgreement?.results?.[0]?.data?.document;
if (resolvedDocument) return resolvedDocument;
const walletDocument = walletAgreement?.results?.[0]?.data?.document;
if (walletDocument) return walletDocument;
return null;
}
@@ -1,13 +0,0 @@
import type { Cooperative } from 'cooptypes';
/**
* Узкая задача пред-генерации: документ, у которого часть полей приватна и
* хранится off-chain (`doc_data`) — `DocumentDomainService.generateDocument`
* сохраняет `doc_data` через `saveDocData` и публикует дальше только
* `doc_data_hash`. См. `Cooperative.Document.IDocDataRef` в cooptypes —
* on-chain-сторона того же контракта.
*/
export interface GenerateDocumentWithPrivateDataDomainInterface extends Cooperative.Document.IGenerate {
doc_data?: Record<string, unknown>;
doc_data_hash?: string;
}
@@ -10,7 +10,6 @@ import { DocumentPackageAggregator } from '../aggregators/document-package.aggre
import { getActions } from '~/utils/getFetch';
import { toDotNotation } from '~/utils/toDotNotation';
import type { ISignedDocumentDomainInterface } from '../interfaces/signed-document-domain.interface';
import type { GenerateDocumentWithPrivateDataDomainInterface } from '../interfaces/generate-document-with-private-data.interface';
@Injectable()
export class DocumentDomainService {
@@ -23,7 +22,10 @@ export class DocumentDomainService {
) {}
public async generateDocument(data: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
const documentData: GenerateDocumentWithPrivateDataDomainInterface = data.data;
const documentData = data.data as Cooperative.Document.IGenerate & {
doc_data?: Record<string, unknown>;
doc_data_hash?: string;
};
if (documentData.doc_data) {
const { doc_data, ...publicDocumentData } = documentData;
@@ -36,7 +38,7 @@ export class DocumentDomainService {
data: {
...publicDocumentData,
doc_data_hash: hash,
},
} as Cooperative.Document.IGenerate,
});
}
@@ -8,13 +8,6 @@ export interface ExtensionDomainRepository<TConfig = any> {
create(data: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>>;
find(filter?: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>[]>;
update(data: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>>;
/**
* Атомарно мёржит `patch` в jsonb-колонку `config` одним UPDATE (`config || patch`),
* без чтения-изменения-записи всего объекта в памяти приложения. Конкурентные вызовы
* с разными ключами patch не теряют изменения друг друга (в отличие от `update()`,
* который перезаписывает весь config целиком и подвержен lost update при гонке).
*/
patchConfig(name: string, patch: Partial<TConfig>): Promise<ExtensionDomainEntity<TConfig>>;
}
export const EXTENSION_REPOSITORY = Symbol('ExtensionDomainRepository'); // Создаем уникальный токен
@@ -60,14 +60,6 @@ export interface IAgreementConfigItem {
* Порядок отображения на фронтенде (сортировка)
*/
order: number;
/**
* Резолвер hash'а приватных данных документа (doc_data_hash), если шаблон
* оферты на фабрике требует PrivateData. Заполняется только для
* extension-оферт из AgreementRegistrationSpec; платформенные соглашения
* его не имеют. В GraphQL DTO не попадает (поля мапятся декораторами).
*/
resolve_doc_data_hash?: () => Promise<string | undefined>;
}
/**
@@ -55,18 +55,4 @@ export interface AgreementRegistrationSpec {
* реестр удаляет все записи с этим extension_name.
*/
extension_name: string;
/**
* Опциональный резолвер hash'а приватных данных документа (doc_data_hash).
* Если шаблон оферты на фабрике требует PrivateData (например, параметры
* ЦПП, утверждённые советом), расширение предоставляет резолвер — ядро
* вызывает его перед каждой генерацией документа этой оферты и передаёт
* результат в фабрику как doc_data_hash.
*
* Резолвер вызывается на каждую генерацию (не кэшируется): источник
* значения — конфигурация расширения, которая может обновляться без
* перезапуска (совет пересохранил параметры программ). Ядро не знает,
* откуда берётся hash — это контракт расширения с его фабричным шаблоном.
*/
resolve_doc_data_hash?: () => Promise<string | undefined>;
}
@@ -50,7 +50,6 @@ export class AgreementConfigurationService {
link_to_statement: true,
applicable_account_types: spec.applicable_account_types,
order: spec.order,
resolve_doc_data_hash: spec.resolve_doc_data_hash,
};
}
@@ -132,17 +132,11 @@ export class RegistrationDocumentsService {
): Promise<IGeneratedRegistrationDocument> {
this.logger.debug(`Генерация документа: ${config.id} (registry_id=${config.registry_id})`);
// Если шаблон оферты требует PrivateData, расширение-владелец предоставило
// резолвер hash'а в спеке регистрации — ядро не знает ни источника значения,
// ни того, какие registry_id этого требуют.
const doc_data_hash = config.resolve_doc_data_hash ? await config.resolve_doc_data_hash() : undefined;
const document = await this.documentInteractor.generateDocument({
data: {
coopname,
username,
registry_id: config.registry_id,
...(doc_data_hash ? { doc_data_hash } : {}),
},
options: {
skip_save: false, // Сохраняем документ в базу для последующей сверки
@@ -36,14 +36,6 @@ export class CapitalOnboardingStepInputDTO {
decision!: string;
}
@InputType('SaveCapitalProgramDocDataInput')
export class SaveCapitalProgramDocDataInputDTO {
@Field(() => String)
@IsString()
@IsNotEmpty()
doc_data_hash!: string;
}
@ObjectType('CapitalOnboardingState')
export class CapitalOnboardingStateDTO {
@Field(() => Boolean)
@@ -26,17 +26,10 @@ import type { IConfig } from '../../capital-extension.module';
* • при завершённом L1 — две оферты (generator/blagorost) и две
* программы (generation/capitalization);
* • идемпотентность гарантируется AgreementRegistryService.
*
* `resolveDocDataHash` — резолвер hash'а PrivateData параметров ЦПП,
* прикрепляется к обеим офертам: их фабричные шаблоны (#996/#1000) требуют
* doc_data. Сейчас все документы ЦПП читают единый набор параметров
* (capital_program_doc_data_hash); если параметры разделятся по программам,
* каждая спека получит собственный резолвер — ядро менять не потребуется.
*/
export function registerCapitalInAgreementRegistry(
port: AgreementRegistrationPort,
extensionConfig: IConfig,
resolveDocDataHash?: () => Promise<string | undefined>
extensionConfig: IConfig
): boolean {
const onboardingDone =
extensionConfig.onboarding_generator_program_template_done &&
@@ -59,7 +52,6 @@ export function registerCapitalInAgreementRegistry(
applicable_account_types: [],
order: 6,
extension_name: CAPITAL_EXTENSION_NAME,
resolve_doc_data_hash: resolveDocDataHash,
});
port.registerAgreement({
@@ -78,7 +70,6 @@ export function registerCapitalInAgreementRegistry(
applicable_account_types: [],
order: 5,
extension_name: CAPITAL_EXTENSION_NAME,
resolve_doc_data_hash: resolveDocDataHash,
});
port.registerProgram({
@@ -3,11 +3,7 @@ import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import {
CapitalOnboardingStepInputDTO,
CapitalOnboardingStateDTO,
SaveCapitalProgramDocDataInputDTO,
} from '../dto/onboarding.dto';
import { CapitalOnboardingStepInputDTO, CapitalOnboardingStateDTO } from '../dto/onboarding.dto';
import { CapitalOnboardingService } from '../services/onboarding.service';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
@@ -38,16 +34,4 @@ export class CapitalOnboardingResolver {
): Promise<CapitalOnboardingStateDTO> {
return this.onboardingService.completeStep(data, currentUser?.username);
}
@Mutation(() => CapitalOnboardingStateDTO, {
name: 'saveCapitalProgramDocDataHash',
description: 'Сохранить hash PrivateData параметров документов ЦПП',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async saveProgramDocDataHash(
@Args('data', { type: () => SaveCapitalProgramDocDataInputDTO }) data: SaveCapitalProgramDocDataInputDTO,
): Promise<CapitalOnboardingStateDTO> {
return this.onboardingService.saveProgramDocDataHash(data.doc_data_hash);
}
}
@@ -73,12 +73,13 @@ export class CapitalOnboardingEventsService {
const wasAlreadyDone = !!(plugin.config as any)[flagKey];
// Атомарный частичный UPDATE (config || patch) вместо read-modify-write всего
// config целиком: два DecisionTrackedEvent для разных шагов, обработанные
// конкурентно, иначе теряют флаг друг друга (lost update на общем jsonb-блобе).
const updated = await this.extensionRepository.patchConfig('capital', {
// Обновляем флаг завершения шага
const updatedConfig = {
...plugin.config,
[flagKey]: true,
} as Partial<IConfig>);
};
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
this.logger.info(`Флаг ${flagKey} установлен в true`);
@@ -88,7 +89,7 @@ export class CapitalOnboardingEventsService {
// restartApp('capital'), чтобы initialize(config) увидел свежий config
// и зарегистрировал оферты/программы в платформенном реестре.
// Совет ничего не нажимает (раздел 7.1 плана C28-10, вариант (в)).
if (!wasAlreadyDone && this.isL1Complete(updated.config as IConfig)) {
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
this.logger.info(
'[ONBOARDING_COMPLETED] capital L1 завершён последним шагом, эмиттим событие для auto-restart'
);
@@ -127,24 +127,21 @@ export class CapitalOnboardingService {
if (!plugin) throw new Error('Конфигурация расширения capital не найдена');
const pluginConfig: CapitalOnboardingConfig = { ...plugin.config };
const patch: Partial<CapitalOnboardingConfig> = {};
let needUpdate = false;
if (!pluginConfig.onboarding_init_at) {
pluginConfig.onboarding_init_at = new Date().toISOString();
patch.onboarding_init_at = pluginConfig.onboarding_init_at;
needUpdate = true;
}
if (!pluginConfig.onboarding_expire_at) {
const start = new Date(pluginConfig.onboarding_init_at);
pluginConfig.onboarding_expire_at = computeOnboardingExpiresAt(start);
patch.onboarding_expire_at = pluginConfig.onboarding_expire_at;
needUpdate = true;
}
if (Object.keys(patch).length > 0) {
// Точечный merge только изменившихся полей — полная перезапись config
// целиком (как раньше) конкурирует с параллельными записями других шагов
// онбординга (флаги, хэши) и теряет их изменения (lost update).
const updated = await this.extensionRepository.patchConfig('capital', patch);
return { ...plugin, config: updated.config };
if (needUpdate) {
await this.extensionRepository.update({ ...plugin, config: pluginConfig });
return { ...plugin, config: pluginConfig };
}
return { ...plugin, config: pluginConfig };
@@ -173,21 +170,6 @@ export class CapitalOnboardingService {
return this.buildState(plugin.config);
}
public async saveProgramDocDataHash(docDataHash: string): Promise<CapitalOnboardingStateDTO> {
await this.loadPlugin();
const normalizedHash = docDataHash.trim();
if (!normalizedHash) {
throw new Error('Hash PrivateData документов ЦПП не может быть пустым');
}
const updated = await this.extensionRepository.patchConfig('capital', {
capital_program_doc_data_hash: normalizedHash,
} as Partial<CapitalOnboardingConfig>);
return this.buildState(updated.config as CapitalOnboardingConfig);
}
public async completeStep(data: CapitalOnboardingStepInputDTO, username: string): Promise<CapitalOnboardingStateDTO> {
const plugin = await this.loadPlugin();
const flagKey = this.mapStepToFlag(data.step);
@@ -236,12 +218,12 @@ export class CapitalOnboardingService {
document: documentForPublish,
});
// Сохраняем hash в конфиге для отображения на фронтенде — точечный merge
// (не полная перезапись config), чтобы не потерять конкурентно записанные
// флаги/хэши других шагов онбординга (lost update на общем jsonb-блобе).
const updated = await this.extensionRepository.patchConfig('capital', {
// Сохраняем hash в конфиге для отображения на фронтенде
const updatedConfig = {
...plugin.config,
[hashKey]: generatedDoc.hash,
} as Partial<CapitalOnboardingConfig>);
};
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
// Регистрируем правило отслеживания в фабрике
const varsField = this.mapStepToVarsField(data.step);
@@ -257,6 +239,6 @@ export class CapitalOnboardingService {
},
});
return this.buildState(updated.config as CapitalOnboardingConfig);
return this.buildState(updatedConfig);
}
}
@@ -654,25 +654,7 @@ export class CapitalPlugin extends BaseExtModule {
// Если L1 ещё не завершён — реестр остаётся пустым, SignUp не предлагает
// программы capital (раздел 4.1 req 44 проекта 13).
try {
// Резолвер читает конфиг заново на каждую генерацию: совет может
// пересохранить параметры ЦПП (новый hash) без перезапуска расширения,
// а спека в реестре регистрируется один раз на initialize.
const resolveCapitalProgramDocDataHash = async (): Promise<string | undefined> => {
const ext = await this.extensionRepository.findByName(this.name);
const hash = ext?.config?.capital_program_doc_data_hash?.trim();
if (!hash) {
throw new Error(
'Параметры документов ЦПП не заполнены: отсутствует capital_program_doc_data_hash в конфигурации capital'
);
}
return hash;
};
const registered = registerCapitalInAgreementRegistry(
this.agreementRegistrationPort,
extensionConfig as IConfig,
resolveCapitalProgramDocDataHash
);
const registered = registerCapitalInAgreementRegistry(this.agreementRegistrationPort, extensionConfig as IConfig);
if (registered) {
this.logger.log('[CAPITAL.REGISTRY] зарегистрировано 2 оферты + 2 программы capital');
} else {
@@ -23,13 +23,9 @@ export const GENERATOR_OFFER_AGREEMENT_ID = 'generator_offer';
export const BLAGOROST_OFFER_AGREEMENT_ID = 'blagorost_offer';
export const GENERATOR_AGREEMENT_TYPE = 'generator';
// On-chain имя оферты Благорост в `soviet::coagreements` (_capital_program в
// lib/consts.hpp контракта) — 'capital' (program_id=4). Подтверждено прямым
// запросом к продовой цепочке voskhod (185.70.105.87). Значение здесь ОБЯЗАНО
// совпадать буквально с контрактной константой — иначе account.adapter.ts не
// находит coagreement по type, программный signagree подменяется на
// sndagreement, и `get_coagreement_or_fail` падает с «Соглашение указанного
// типа не найдено».
// On-chain имя оферты Благорост в `soviet::coagreements` — 'capital' (program_id=4).
// Контроллер обязан слать sndagreement/signagree с этим значением, иначе
// `get_coagreement_or_fail` падает с «Соглашение указанного типа не найдено».
export const BLAGOROST_AGREEMENT_TYPE = 'capital';
export const GENERATION_PROGRAM_KEY = 'GENERATION';
@@ -74,6 +74,8 @@ export class ChairmanOnboardingEventsService {
return;
}
const cfg = { ...plugin.config };
// Определяем ключ флага для обновления на основе metadata
const flagKey = this.mapStepToFlag(step);
@@ -82,15 +84,16 @@ export class ChairmanOnboardingEventsService {
return;
}
const wasAlreadyDone = !!(plugin.config as any)[flagKey];
const wasAlreadyDone = !!(cfg as any)[flagKey];
// Атомарный частичный UPDATE (config || patch) вместо read-modify-write всего
// config целиком: несколько DecisionTrackedEvent (согласия/подписи), обработанные
// конкурентно, иначе теряют флаг друг друга (lost update на общем jsonb-блобе) —
// тот же класс бага, что чинили в capital-онбординге.
const updated = await this.extensionRepository.patchConfig('chairman', {
// Обновляем флаг выполнения шага
const patch: Partial<IConfig> = {
[flagKey]: true,
} as Partial<IConfig>);
};
const updatedConfig = { ...cfg, ...patch };
const updated: ExtensionDomainEntity<IConfig> = { ...plugin, config: updatedConfig };
await this.extensionRepository.update(updated);
this.logger.info(`Онбординг обновлён: ${flagKey} = true (решение ${result.decision_id})`);
@@ -98,7 +101,7 @@ export class ChairmanOnboardingEventsService {
// завершённым, эмиттим ONBOARDING_COMPLETED_EVENT.
// У chairman нет L2-оферт в реестре сейчас, но событие важно как
// сигнал «L1 кооператива закрыт» — раздел 7.1 C28-10.
if (!wasAlreadyDone && this.isL1Complete(updated.config as IConfig)) {
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
this.logger.info(
'[ONBOARDING_COMPLETED] chairman L1 завершён последним шагом, эмиттим событие'
);
@@ -99,25 +99,22 @@ export class ChairmanOnboardingService {
if (!plugin) throw new Error('Конфигурация расширения chairman не найдена');
const config = { ...plugin.config };
const patch: Partial<IConfig> = {};
let needUpdate = false;
if (!config.onboarding_init_at) {
config.onboarding_init_at = new Date().toISOString();
patch.onboarding_init_at = config.onboarding_init_at;
needUpdate = true;
}
if (!config.onboarding_expire_at) {
const start = new Date(config.onboarding_init_at);
const expire = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000);
config.onboarding_expire_at = expire.toISOString();
patch.onboarding_expire_at = config.onboarding_expire_at;
needUpdate = true;
}
if (Object.keys(patch).length > 0) {
// Точечный merge только изменившихся полей — полная перезапись config
// целиком конкурирует с параллельными записями флагов/хэшей других шагов
// онбординга и теряет их изменения (lost update на общем jsonb-блобе).
const updated = await this.extensionRepository.patchConfig('chairman', patch);
return { ...plugin, config: updated.config };
if (needUpdate) {
await this.extensionRepository.update({ ...plugin, config });
return { ...plugin, config };
}
return plugin;
@@ -201,12 +198,12 @@ export class ChairmanOnboardingService {
document: documentForPublish,
});
// Сохраняем hash в конфиге для отображения на фронтенде — точечный merge
// (не полная перезапись config), чтобы не потерять конкурентно записанные
// флаги/хэши других шагов онбординга (lost update на общем jsonb-блобе).
const updated = await this.extensionRepository.patchConfig('chairman', {
// Сохраняем hash в конфиге для отображения на фронтенде
const updatedConfig: IConfig = {
...plugin.config,
[hashKey]: generatedDoc.hash,
} as Partial<IConfig>);
};
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
// Регистрируем правило отслеживания в фабрике
const varsField = this.mapStepToVarsField(data.step);
@@ -222,7 +219,7 @@ export class ChairmanOnboardingService {
},
});
return this.buildState(updated.config);
return this.buildState(updatedConfig);
}
// Сохраняем hash общего собрания, флаг закроется после newresolved
@@ -236,9 +233,8 @@ export class ChairmanOnboardingService {
// Если hash не пришёл (не должно быть), не затираем существующее значение
const meetHash = proposal_hash || plugin.config.onboarding_general_meet_hash || '';
const updated = await this.extensionRepository.patchConfig('chairman', {
onboarding_general_meet_hash: meetHash,
} as Partial<IConfig>);
const updatedConfig: IConfig = { ...plugin.config, onboarding_general_meet_hash: meetHash };
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
// Регистрируем правило отслеживания общего собрания
await this.decisionTrackingPort.registerTrackingRule({
@@ -251,6 +247,6 @@ export class ChairmanOnboardingService {
},
});
return this.buildState(updated.config);
return this.buildState(updatedConfig);
}
}
@@ -2,9 +2,9 @@
## Возможности
- **Удобное общение** групповые чаты, личные сообщения, голосовые и видео-звонки
- 🔐 **Безопасность** сообщения зашифрованы и защищены
- 👥 **Для всех участников** каждый член кооператива автоматически получает доступ к чату пайщиков своего кооператива и общему чату кооперативной экономики
- 👥 **Для членов совета** каждый член совета добавляется в зашифрованный чат совета своего кооператива
- 📱 **На всех устройствах** работает на компьютере, телефоне и планшете
- **Быстрая установка** просто установите расширение, и оно заработает
**Удобное общение** - групповые чаты, личные сообщения, голосовые и видео-звонки
🔐 **Безопасность** - сообщения зашифрованы и защищены
👥 **Для всех участников** - каждый член кооператива автоматически получает доступ к чату пайщиков своего кооператива и общему чату кооперативной экономики.
👥 **Для членов совета** - каждый член совета добавляется в зашифрованный чат совета своего кооператива.
📱 **На всех устройствах** - работает на компьютере, телефоне и планшете
**Быстрая установка** - просто установите расширение, и оно заработает
@@ -1,7 +1,6 @@
import { ObjectType, Field, InputType, registerEnumType } from '@nestjs/graphql';
import { IsOptional, IsString, IsIn, Matches, MaxLength } from 'class-validator';
import { ReportType } from '../../domain/enums/report-type.enum';
import { SFR_REG_NUMBER_PATTERN, PFR_REG_NUMBER_PATTERN } from '../../domain/patterns';
export enum RequisiteSource {
DATABASE = 'database',
@@ -44,7 +43,6 @@ export class ReportRequisitesViewDTO {
@Field(() => RequisiteFieldViewDTO) oktmo!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) okpo!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) sfrRegNumber!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) pfrRegNumber!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) chairmanPosition!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) signerSnils!: RequisiteFieldViewDTO;
@Field(() => RequisiteFieldViewDTO) signerRepDoc!: RequisiteFieldViewDTO;
@@ -67,10 +65,7 @@ export class ReportRequisitesViewDTO {
* ОКТМО `\d{8}` или `\d{11}`
* ОКПО `\d{8}` или `\d{10}`
* СНИЛС `\d{3}-\d{3}-\d{3} \d{2}` (14 симв.) или 11 цифр подряд
* РегНом СФР `\d{10}` (10 цифр подряд, без тире)
* РегНом ПФР `\d{3}-\d{3}-\d{6}` (14 симв. с тире), только этот формат
* отдельное поле от РегНом СФР, требуется для ЕФС-1 (внешние
* бухгалтерские системы сверяют его при приёме отчёта)
* РегНом СФР `\d{3}-\d{3}-\d{6}` (14 симв. с тире)
*/
@InputType('UpdateReportRequisitesInput')
export class UpdateReportRequisitesInputDTO {
@@ -104,18 +99,12 @@ export class UpdateReportRequisitesInputDTO {
@Matches(/^\d{8}(\d{2})?$/, { message: 'ОКПО — 8 или 10 цифр' })
okpo?: string | null;
@Field(() => String, { nullable: true, description: 'Рег. номер СФР — 10 цифр' })
@Field(() => String, { nullable: true, description: 'Рег. номер СФР — XXX-XXX-XXXXXX' })
@IsOptional()
@IsString()
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег. номер СФР — 10 цифр' })
@Matches(/^\d{3}-\d{3}-\d{6}$/, { message: 'Рег. номер СФР — XXX-XXX-XXXXXX (14 симв.)' })
sfrRegNumber?: string | null;
@Field(() => String, { nullable: true, description: 'Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР)' })
@IsOptional()
@IsString()
@Matches(PFR_REG_NUMBER_PATTERN, { message: 'Рег. номер ПФР — XXX-XXX-XXXXXX' })
pfrRegNumber?: string | null;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@@ -21,7 +21,6 @@ import {
OKTMO_PATTERN,
OKVED_PATTERN,
SFR_REG_NUMBER_PATTERN,
PFR_REG_NUMBER_PATTERN,
SNILS_PATTERN,
DATE_DDMMYYYY_PATTERN,
} from '../../domain/patterns';
@@ -201,14 +200,9 @@ export class ZeroReportSignerEditsInputDTO {
@Field(() => String, { nullable: true })
@IsOptional()
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег.номер СФР — 10 цифр' })
@Matches(SFR_REG_NUMBER_PATTERN, { message: 'Рег.номер СФР — XXX-XXX-XXXXXX' })
sfrRegNumber!: string | null;
@Field(() => String, { nullable: true })
@IsOptional()
@Matches(PFR_REG_NUMBER_PATTERN, { message: 'Рег.номер ПФР — XXX-XXX-XXXXXX' })
pfrRegNumber!: string | null;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@@ -225,7 +219,6 @@ export class ZeroReportSignerEditsDTO {
@Field(() => String, { nullable: true }) repDoc!: string | null;
@Field(() => String, { nullable: true }) snils!: string | null;
@Field(() => String, { nullable: true }) sfrRegNumber!: string | null;
@Field(() => String, { nullable: true }) pfrRegNumber!: string | null;
@Field(() => String, { nullable: true }) chairmanPosition!: string | null;
}
@@ -53,7 +53,6 @@ export class ReportRequisitesResolver {
oktmo: input.oktmo ?? undefined,
okpo: input.okpo ?? undefined,
sfr_reg_number: input.sfrRegNumber ?? undefined,
pfr_reg_number: input.pfrRegNumber ?? undefined,
chairman_position: input.chairmanPosition ?? undefined,
signer_snils: input.signerSnils ?? undefined,
signer_rep_doc: input.signerRepDoc ?? undefined,
@@ -110,7 +109,6 @@ function toView(m: MergedRequisites): ReportRequisitesViewDTO {
oktmo: f(m.oktmo),
okpo: f(m.okpo),
sfrRegNumber: f(m.sfrRegNumber),
pfrRegNumber: f(m.pfrRegNumber),
chairmanPosition: f(m.chairmanPosition),
signerSnils: f(m.signerSnils),
signerRepDoc: f(m.signerRepDoc),
@@ -51,14 +51,6 @@ export interface ZeroReportSignerShape {
snils: string | null;
/** Регистрационный номер в СФР — обязателен для ЕФС-1. */
sfrRegNumber: string | null;
/**
* Регистрационный номер в ПФР (XXX-XXX-XXXXXX) обязателен для ЕФС-1,
* попадает и в <ЕФС8:РегНомер>, и в имя файла. Отдельное поле от
* sfrRegNumber: сторонние бухгалтерские системы (СБИС и др.) при приёме
* ЕФС-1 сверяют номер отправителя именно с рег. номером ПФР организации,
* не с 10-значным номером СФР.
*/
pfrRegNumber: string | null;
/** Должность председателя — для ЕФС-1 (<УТ8:Должность>). */
chairmanPosition: string | null;
}
@@ -47,7 +47,7 @@ export interface ReportInput {
correctionNumber?: number;
/** СНИЛС председателя (для ПСВ) */
signerSnils?: string;
/** Регистрационный номер страхователя в СФР (для ЕФС-1). Legacy-путь — актуальные генераторы берут ZeroReportSignerShape.pfrRegNumber отдельно. */
/** Регистрационный номер страхователя в СФР/ПФР (для ЕФС-1). */
sfrRegNumber?: string;
/** Должность руководителя для подачи в СФР (по умолчанию «Председатель Совета»). */
chairmanPosition?: string;
@@ -43,19 +43,8 @@ export const OKPO_PATTERN = /^\d{8}(\d{2})?$/;
// СНИЛС: XXX-XXX-XXX YY (с пробелом/тире) или 11 цифр подряд.
export const SNILS_PATTERN = /^(\d{3}-\d{3}-\d{3} \d{2}|\d{11})$/;
// Регистрационный номер страхователя в СФР: только 10 цифр без разделителей
// (унифицированный номер СФР, ТипРегНомерОбщ в XSD ЕФС-1). Формат
// XXX-XXX-XXXXXX сюда больше не входит — он теперь отдельное поле
// PFR_REG_NUMBER_PATTERN, чтобы два похожих номера не путались в одной маске.
export const SFR_REG_NUMBER_PATTERN = /^\d{10}$/;
// Регистрационный номер страхователя в ПФР: только формат XXX-XXX-XXXXXX
// (3-3-6, 12 цифр с тире) — старый/«действующий» формат, который сверяют
// сторонние бухгалтерские системы (СБИС и др.) при приёме ЕФС-1. Это
// отдельное поле от sfrRegNumber (10-значный унифицированный номер СФР):
// внешние системы могут не знать организацию по 10-значному номеру, но
// знать по этому.
export const PFR_REG_NUMBER_PATTERN = /^\d{3}-\d{3}-\d{6}$/;
// Регистрационный номер СФР: XXX-XXX-XXXXXX (3-3-6).
export const SFR_REG_NUMBER_PATTERN = /^\d{3}-\d{3}-\d{6}$/;
/** Дата в формате `DD.MM.YYYY` (XSD `xs:date`-совместимый ФНС-формат). */
export const DATE_DDMMYYYY_PATTERN = /^\d{2}\.\d{2}\.\d{4}$/;
@@ -10,7 +10,6 @@ export interface ReportRequisitesRecord {
oktmo: string | null;
okpo: string | null;
sfr_reg_number: string | null;
pfr_reg_number: string | null;
chairman_position: string | null;
signer_snils: string | null;
signer_rep_doc: string | null;
@@ -29,7 +28,6 @@ export interface UpsertReportRequisitesInput {
oktmo?: string | null;
okpo?: string | null;
sfr_reg_number?: string | null;
pfr_reg_number?: string | null;
chairman_position?: string | null;
signer_snils?: string | null;
signer_rep_doc?: string | null;
@@ -203,12 +203,7 @@ export class ReportEditsBuilderService {
return {
header: {
idFile: this.generateGenericFileName(
reportType,
merged.inn.value ?? '',
merged.kpp.value ?? '',
merged.pfrRegNumber.value,
),
idFile: this.generateGenericFileName(reportType, merged.inn.value ?? '', merged.kpp.value ?? ''),
versProgram: 'Платформа отчётности кооператива 1.0',
docDate: formatDate(new Date()),
reportYear: year,
@@ -235,7 +230,6 @@ export class ReportEditsBuilderService {
repDoc: merged.signerRepDoc.value,
snils: merged.signerSnils.value,
sfrRegNumber: merged.sfrRegNumber.value,
pfrRegNumber: merged.pfrRegNumber.value,
chairmanPosition:
merged.chairmanPosition.value || merged.chairmanPositionFromOrg.value,
},
@@ -249,33 +243,19 @@ export class ReportEditsBuilderService {
return 1;
}
private generateGenericFileName(
reportType: ReportType,
inn: string,
kpp: string,
pfrRegNumber?: string | null,
): string {
// Префикс = первые два underscore-сегмента имени XSD (например,
// 'NO_NDFL6.2_1_231_...xsd' → 'NO_NDFL6.2', 'UT_UVISCHSUMNAL_...xsd' →
// 'UT_UVISCHSUMNAL') — это и есть код формата в имени файла по формату
// ФНС/СФР. Брать только первый сегмент нельзя: для всех форм с 'NO_'
// в начале он всегда равен 'NO' и код формы (NDFL6.2/RASCHSV/PERSSVFL/
// USN) терялся — импорт стороннего ПО (Сбер и др.) отклонял файл как
// нераспознанный по имени.
private generateGenericFileName(reportType: ReportType, inn: string, kpp: string): string {
// Упрощённый префикс по типу отчёта — точный формат имени зависит от
// формы (NO_NDFL6.2_..., СФР_..._ЕФС-1_...), но для предварительного
// idFile достаточно. Генератор сам перезапишет его при необходимости.
const xsd = REPORT_CONFIG[reportType]?.xsdFile ?? 'REPORT';
const prefix = xsd.split('_').slice(0, 2).join('_') || 'REPORT';
const prefix = xsd.split('_')[0] ?? 'REPORT';
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const unit = `${inn}${kpp}`;
const tax = kpp.substring(0, 4);
const uuid = randomUUID();
if (reportType === ReportType.FSS4) {
// Сегмент отправителя в имени файла — рег. номер ПФР организации, не
// заглушка из нулей: сторонние бухгалтерские системы (СБИС и др.)
// сверяют этот сегмент со своим профилем организации и отклоняют файл,
// если он не совпадает ни с известным им номером ПФР, ни с номером
// СФР. См. также <ЕФС8:РегНомер> в fss4.generator.ts — то же значение.
return `СФР_${pfrRegNumber || '0000000000'}_ЕФС-1_${dateStr}_${uuid}`;
return `СФР_0000000000_ЕФС-1_${dateStr}_${uuid}`;
}
return `${prefix}_${tax}_${tax}_${unit}_${dateStr}_${uuid}`;
}
@@ -39,8 +39,6 @@ export interface MergedRequisites {
oktmo: RequisiteField;
okpo: RequisiteField;
sfrRegNumber: RequisiteField;
/** Рег. номер страхователя в ПФР (XXX-XXX-XXXXXX) — отдельный от sfrRegNumber, требуется для ЕФС-1. */
pfrRegNumber: RequisiteField;
chairmanPosition: RequisiteField;
signerSnils: RequisiteField;
signerRepDoc: RequisiteField;
@@ -96,7 +94,6 @@ const REQUIRED_BY_TYPE: Record<ReportType, RequiredFieldSpec[]> = {
[ReportType.FSS4]: [
{ key: 'oktmo', label: 'ОКТМО', source: 'manual' },
{ key: 'sfrRegNumber', label: 'Регистрационный номер в СФР', source: 'manual' },
{ key: 'pfrRegNumber', label: 'Регистрационный номер в ПФР', source: 'manual' },
{ key: 'chairmanPosition', label: 'Должность руководителя', source: 'manual' },
],
[ReportType.PSV]: [{ key: 'signerSnils', label: 'СНИЛС подписанта', source: 'manual' }],
@@ -155,7 +152,6 @@ export class ReportRequisitesService {
oktmo: mn(manual?.oktmo),
okpo: mn(manual?.okpo),
sfrRegNumber: mn(manual?.sfr_reg_number),
pfrRegNumber: mn(manual?.pfr_reg_number),
chairmanPosition: mn(manual?.chairman_position),
signerSnils: mn(manual?.signer_snils),
signerRepDoc: mn(manual?.signer_rep_doc),
@@ -222,7 +218,6 @@ export class ReportRequisitesService {
oktmo: input.oktmo ?? undefined,
okpo: input.okpo ?? undefined,
sfr_reg_number: input.sfr_reg_number ?? undefined,
pfr_reg_number: input.pfr_reg_number ?? undefined,
chairman_position: input.chairman_position ?? undefined,
signer_snils: input.signer_snils ?? undefined,
signer_rep_doc: input.signer_rep_doc ?? undefined,
@@ -5,7 +5,6 @@ import { Entity, Column, PrimaryColumn, UpdateDateColumn } from 'typeorm';
*
* Хранятся поля, которых нет в профиле организации (`IOrganizationData` в
* основной БД кооператива): ОКВЭД, ОКФС, ОКОПФ, ОКТМО, ОКПО, регномер СФР,
* регномер ПФР (для ЕФС-1 отдельный от regномера СФР, см. patterns.ts),
* должность руководителя, СНИЛС подписанта, описание доверенности. Плюс
* возможные override'ы телефона и адреса (если в отчётности нужен не тот,
* что в профиле организации).
@@ -35,9 +34,6 @@ export class ReportRequisitesEntity {
@Column({ type: 'varchar', length: 32, nullable: true })
sfr_reg_number?: string | null;
@Column({ type: 'varchar', length: 32, nullable: true })
pfr_reg_number?: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
chairman_position?: string | null;
@@ -66,14 +66,8 @@ export class Fss4Generator implements IReportGenerator {
const edits = input as ZeroReportEditsShape;
const fileName = edits.header.idFile;
const errors: string[] = [];
if (!edits.signer.pfrRegNumber) {
// В документ идёт только pfrRegNumber (см. strah.ele('ЕФС8:РегНомер')
// ниже) — sfrRegNumber в ЕФС-1 не используется вовсе, поэтому не
// проверяем его здесь, хотя в реквизитах кооператива оно и остаётся
// обязательным полем (может понадобиться другим формам/интеграциям).
errors.push('Для ЕФС-1 обязателен рег. номер страхователя в ПФР (поле signer.pfrRegNumber)');
}
if (errors.length) {
if (!edits.signer.sfrRegNumber) {
errors.push('Для ЕФС-1 обязателен рег. номер страхователя в СФР (поле signer.sfrRegNumber)');
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
}
try {
@@ -106,13 +100,8 @@ export class Fss4Generator implements IReportGenerator {
const efs1 = edsfr.ele('ЕФС-1');
const strah = efs1.ele('Страхователь');
// Тег «Действующий регистрационный номер страхователя» (см. XSD
// efs-types.xsd/ТипСтрахователь) — сюда идёт номер ПФР, а не СФР:
// сторонние бухгалтерские системы (СБИС и др.) при приёме ЕФС-1 сверяют
// рег. номер отправителя в имени файла и в этом теге со своим профилем
// организации, где обычно заведён именно старый номер ПФР (XXX-XXX-XXXXXX).
// pfrRegNumber гарантированно определён — guard в generate() выше.
strah.ele('ЕФС8:РегНомер').txt(signer.pfrRegNumber ?? '').up();
// sfrRegNumber гарантированно определён — guard в generate() выше.
strah.ele('ЕФС8:РегНомер').txt(signer.sfrRegNumber ?? '').up();
strah.ele('ЕФС8:Наименование').txt(organization.orgName).up();
strah.ele('УТ8:ИНН').txt(organization.inn).up();
strah.ele('УТ8:КПП').txt(organization.kpp).up();
@@ -39,7 +39,6 @@ export class ReportRequisitesTypeormRepository implements ReportRequisitesReposi
if (input.oktmo !== undefined) row.oktmo = input.oktmo;
if (input.okpo !== undefined) row.okpo = input.okpo;
if (input.sfr_reg_number !== undefined) row.sfr_reg_number = input.sfr_reg_number;
if (input.pfr_reg_number !== undefined) row.pfr_reg_number = input.pfr_reg_number;
if (input.chairman_position !== undefined) row.chairman_position = input.chairman_position;
if (input.signer_snils !== undefined) row.signer_snils = input.signer_snils;
if (input.signer_rep_doc !== undefined) row.signer_rep_doc = input.signer_rep_doc;
@@ -67,7 +66,6 @@ export class ReportRequisitesTypeormRepository implements ReportRequisitesReposi
oktmo: entity.oktmo ?? null,
okpo: entity.okpo ?? null,
sfr_reg_number: entity.sfr_reg_number ?? null,
pfr_reg_number: entity.pfr_reg_number ?? null,
chairman_position: entity.chairman_position ?? null,
signer_snils: entity.signer_snils ?? null,
signer_rep_doc: entity.signer_rep_doc ?? null,
+5 -23
View File
@@ -62,33 +62,15 @@ async function bootstrap() {
// Проверяем, был ли запущен режим миграций
const args = process.argv.slice(2);
if (args.includes('--migrate')) {
try {
await migrateData();
process.exit(0);
} catch (error) {
// migrateData() уже логирует причину; здесь важен только ненулевой
// exit code. В production Sentry.init() ставит свой глобальный
// process.on('unhandledRejection', ...) с дефолтным mode: 'warn' —
// он перехватывает необработанный reject этого await и НЕ роняет
// процесс (в отличие от штатного поведения Node без Sentry). Без
// явного process.exit(1) здесь deploy-скрипт получал ложноположительный
// exit code 0 при реально упавшей миграции (кейс V2.3.2 на testnet
// 2026-07-19 — TS-ошибка компиляции миграции проглатывалась именно так).
logger.error('Процесс миграции завершился с ошибкой, выходим с кодом 1', error);
process.exit(1);
}
await migrateData();
process.exit(0);
}
// Проверяем, был ли запущен режим только миграций (для обратной совместимости)
if (args.includes('--migrations-only')) {
try {
await migrateData();
logger.info('Режим только миграций - миграции выполнены, сервер не будет запущен');
process.exit(0);
} catch (error) {
logger.error('Процесс миграции завершился с ошибкой, выходим с кодом 1', error);
process.exit(1);
}
await migrateData();
logger.info('Режим только миграций - миграции выполнены, сервер не будет запущен');
process.exit(0);
}
// Подключение к MongoDB
@@ -57,40 +57,4 @@ export class TypeOrmExtensionDomainRepository<TConfig = any> implements Extensio
return existingEntity.toDomainEntity();
}
async patchConfig(name: string, patch: Partial<TConfig>): Promise<ExtensionDomainEntity<TConfig>> {
// TypeORM оборачивает .query() для UPDATE/DELETE в кортеж [rows, affectedCount]
// (в отличие от INSERT, где возвращается голый rows-массив) — распаковываем явно,
// иначе row оказывается всем кортежем и все поля читаются как undefined.
const [rows] = (await this.ormRepo.query(
`UPDATE extensions
SET config = COALESCE(config, '{}'::jsonb) || $2::jsonb,
updated_at = NOW()
WHERE name = $1
RETURNING name, enabled, config, schema_version, created_at, updated_at`,
[name, JSON.stringify(patch)]
)) as [
Array<{
name: string;
enabled: boolean;
config: TConfig;
schema_version: number;
created_at: Date;
updated_at: Date;
}>,
number,
];
const row = rows[0];
if (!row) throw new Error('Расширение не найдено в установленных');
return new ExtensionDomainEntity<TConfig>(
row.name,
row.enabled,
row.config,
row.created_at,
row.updated_at,
row.schema_version
);
}
}
@@ -202,7 +202,6 @@ const zeroBaseEdits: ZeroReportEditsShape = {
repDoc: 'Доверенность №1 от 01.01.2024',
snils: '123-456-789 00',
sfrRegNumber: '7701234567',
pfrRegNumber: '087-701-579643',
chairmanPosition: 'Председатель Совета',
},
};
@@ -565,7 +564,7 @@ describe('ЕФС-1 (Fss4Generator, СФР)', () => {
expect(gen.reportType).toBe(ReportType.FSS4);
});
it('генерирует XML при переданном pfrRegNumber', () => {
it('генерирует XML при переданном sfrRegNumber', () => {
const result = gen.generate(withPeriod(1));
expect(result.isValid).toBe(true);
expect(result.xml).toContain('<?xml');
@@ -573,27 +572,13 @@ describe('ЕФС-1 (Fss4Generator, СФР)', () => {
expect(result.xml).toContain('<ЭДСФР');
});
it('генерируется без sfrRegNumber — ЕФС-1 его не использует', () => {
it('возвращает ошибку без sfrRegNumber', () => {
const result = gen.generate({
...withPeriod(1),
signer: { ...zeroBaseEdits.signer, sfrRegNumber: null },
});
expect(result.isValid).toBe(true);
});
it('возвращает ошибку без pfrRegNumber', () => {
const result = gen.generate({
...withPeriod(1),
signer: { ...zeroBaseEdits.signer, pfrRegNumber: null },
});
expect(result.isValid).toBe(false);
expect(result.errors.join('|')).toContain('pfrRegNumber');
});
it('использует pfrRegNumber (не sfrRegNumber) в <ЕФС8:РегНомер> — сторонние бухгалтерские системы сверяют именно рег. номер ПФР', () => {
const result = gen.generate(withPeriod(1));
expect(result.xml).toContain('<ЕФС8:РегНомер>087-701-579643</ЕФС8:РегНомер>');
expect(result.xml).not.toContain('<ЕФС8:РегНомер>7701234567</ЕФС8:РегНомер>');
expect(result.errors.join('|')).toContain('sfrRegNumber');
});
it('эхом возвращает header.idFile в result.fileName', () => {
-10
View File
@@ -1304,9 +1304,6 @@ export const AllTypesProps: Record<string,any> = {
returnExpenseItem:{
data:"ReturnExpenseItemInput"
},
saveCapitalProgramDocDataHash:{
data:"SaveCapitalProgramDocDataInput"
},
saveReportDraft:{
input:"SaveReportDraftInput"
},
@@ -1962,9 +1959,6 @@ export const AllTypesProps: Record<string,any> = {
},
RoomMessageKind: "enum" as const,
SaveCapitalProgramDocDataInput:{
},
SaveReportDraftInput:{
reportType:"ReportType"
},
@@ -2797,7 +2791,6 @@ export const ReturnTypes: Record<string,any> = {
CapitalOnboardingState:{
blagorost_offer_template_done:"Boolean",
blagorost_provision_done:"Boolean",
capital_program_doc_data_hash:"String",
generation_contract_template_done:"Boolean",
generator_offer_template_done:"Boolean",
generator_program_template_done:"Boolean",
@@ -4058,7 +4051,6 @@ export const ReturnTypes: Record<string,any> = {
resetRegistration:"Account",
restartAnnualGeneralMeet:"MeetAggregate",
returnExpenseItem:"Transaction",
saveCapitalProgramDocDataHash:"CapitalOnboardingState",
saveReportDraft:"ReportDraft",
selectBranch:"Boolean",
sendAgreement:"Transaction",
@@ -4821,7 +4813,6 @@ export const ReturnTypes: Record<string,any> = {
oktmo:"RequisiteFieldView",
okved:"RequisiteFieldView",
orgName:"RequisiteFieldView",
pfrRegNumber:"RequisiteFieldView",
phone:"RequisiteFieldView",
sfrRegNumber:"RequisiteFieldView",
signerFirstName:"RequisiteFieldView",
@@ -5064,7 +5055,6 @@ export const ReturnTypes: Record<string,any> = {
firstName:"String",
lastName:"String",
middleName:"String",
pfrRegNumber:"String",
repDoc:"String",
sfrRegNumber:"String",
snils:"String",
+7 -53
View File
@@ -3009,7 +3009,6 @@ export type ValueTypes = {
["CapitalOnboardingState"]: AliasType<{
blagorost_offer_template_done?:boolean | `@${string}`,
blagorost_provision_done?:boolean | `@${string}`,
capital_program_doc_data_hash?:boolean | `@${string}`,
generation_contract_template_done?:boolean | `@${string}`,
generator_offer_template_done?:boolean | `@${string}`,
generator_program_template_done?:boolean | `@${string}`,
@@ -7206,7 +7205,6 @@ resetKey?: [{ data: ValueTypes["ResetKeyInput"] | Variable<any, string>},boolean
resetRegistration?:ValueTypes["Account"],
restartAnnualGeneralMeet?: [{ data: ValueTypes["RestartAnnualGeneralMeetInput"] | Variable<any, string>},ValueTypes["MeetAggregate"]],
returnExpenseItem?: [{ data: ValueTypes["ReturnExpenseItemInput"] | Variable<any, string>},ValueTypes["Transaction"]],
saveCapitalProgramDocDataHash?: [{ data: ValueTypes["SaveCapitalProgramDocDataInput"] | Variable<any, string>},ValueTypes["CapitalOnboardingState"]],
saveReportDraft?: [{ input: ValueTypes["SaveReportDraftInput"] | Variable<any, string>},ValueTypes["ReportDraft"]],
selectBranch?: [{ data: ValueTypes["SelectBranchInput"] | Variable<any, string>},boolean | `@${string}`],
sendAgreement?: [{ data: ValueTypes["SendAgreementInput"] | Variable<any, string>},ValueTypes["Transaction"]],
@@ -9043,7 +9041,6 @@ validateReportEdits?: [{ editsJson: string | Variable<any, string>, reportType:
oktmo?:ValueTypes["RequisiteFieldView"],
okved?:ValueTypes["RequisiteFieldView"],
orgName?:ValueTypes["RequisiteFieldView"],
pfrRegNumber?:ValueTypes["RequisiteFieldView"],
phone?:ValueTypes["RequisiteFieldView"],
sfrRegNumber?:ValueTypes["RequisiteFieldView"],
signerFirstName?:ValueTypes["RequisiteFieldView"],
@@ -9462,9 +9459,6 @@ validateReportEdits?: [{ editsJson: string | Variable<any, string>, reportType:
};
/** Тип сообщения в истории комнаты Matrix (текст или расшифрованное аудио) */
["RoomMessageKind"]:RoomMessageKind;
["SaveCapitalProgramDocDataInput"]: {
doc_data_hash: string | Variable<any, string>
};
["SaveReportDraftInput"]: {
editedFields: Array<string> | Variable<any, string>,
editsJson: string | Variable<any, string>,
@@ -10180,10 +10174,8 @@ validateReportEdits?: [{ editsJson: string | Variable<any, string>, reportType:
oktmo?: string | undefined | null | Variable<any, string>,
/** ОКВЭД — напр. 94.99, 46.73.7 */
okved?: string | undefined | null | Variable<any, string>,
/** Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР) */
pfrRegNumber?: string | undefined | null | Variable<any, string>,
phoneOverride?: string | undefined | null | Variable<any, string>,
/** Рег. номер СФР — 10 цифр */
/** Рег. номер СФР — XXX-XXX-XXXXXX */
sfrRegNumber?: string | undefined | null | Variable<any, string>,
signerRepDoc?: string | undefined | null | Variable<any, string>,
/** СНИЛС — XXX-XXX-XXX YY или 11 цифр */
@@ -10485,7 +10477,6 @@ validateReportEdits?: [{ editsJson: string | Variable<any, string>, reportType:
firstName?:boolean | `@${string}`,
lastName?:boolean | `@${string}`,
middleName?:boolean | `@${string}`,
pfrRegNumber?:boolean | `@${string}`,
repDoc?:boolean | `@${string}`,
sfrRegNumber?:boolean | `@${string}`,
snils?:boolean | `@${string}`,
@@ -12351,7 +12342,6 @@ export type ResolverInputTypes = {
["CapitalOnboardingState"]: AliasType<{
blagorost_offer_template_done?:boolean | `@${string}`,
blagorost_provision_done?:boolean | `@${string}`,
capital_program_doc_data_hash?:boolean | `@${string}`,
generation_contract_template_done?:boolean | `@${string}`,
generator_offer_template_done?:boolean | `@${string}`,
generator_program_template_done?:boolean | `@${string}`,
@@ -16449,7 +16439,6 @@ resetKey?: [{ data: ResolverInputTypes["ResetKeyInput"]},boolean | `@${string}`]
resetRegistration?:ResolverInputTypes["Account"],
restartAnnualGeneralMeet?: [{ data: ResolverInputTypes["RestartAnnualGeneralMeetInput"]},ResolverInputTypes["MeetAggregate"]],
returnExpenseItem?: [{ data: ResolverInputTypes["ReturnExpenseItemInput"]},ResolverInputTypes["Transaction"]],
saveCapitalProgramDocDataHash?: [{ data: ResolverInputTypes["SaveCapitalProgramDocDataInput"]},ResolverInputTypes["CapitalOnboardingState"]],
saveReportDraft?: [{ input: ResolverInputTypes["SaveReportDraftInput"]},ResolverInputTypes["ReportDraft"]],
selectBranch?: [{ data: ResolverInputTypes["SelectBranchInput"]},boolean | `@${string}`],
sendAgreement?: [{ data: ResolverInputTypes["SendAgreementInput"]},ResolverInputTypes["Transaction"]],
@@ -18208,7 +18197,6 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo
oktmo?:ResolverInputTypes["RequisiteFieldView"],
okved?:ResolverInputTypes["RequisiteFieldView"],
orgName?:ResolverInputTypes["RequisiteFieldView"],
pfrRegNumber?:ResolverInputTypes["RequisiteFieldView"],
phone?:ResolverInputTypes["RequisiteFieldView"],
sfrRegNumber?:ResolverInputTypes["RequisiteFieldView"],
signerFirstName?:ResolverInputTypes["RequisiteFieldView"],
@@ -18621,9 +18609,6 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo
};
/** Тип сообщения в истории комнаты Matrix (текст или расшифрованное аудио) */
["RoomMessageKind"]:RoomMessageKind;
["SaveCapitalProgramDocDataInput"]: {
doc_data_hash: string
};
["SaveReportDraftInput"]: {
editedFields: Array<string>,
editsJson: string,
@@ -19322,10 +19307,8 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo
oktmo?: string | undefined | null,
/** ОКВЭД — напр. 94.99, 46.73.7 */
okved?: string | undefined | null,
/** Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР) */
pfrRegNumber?: string | undefined | null,
phoneOverride?: string | undefined | null,
/** Рег. номер СФР — 10 цифр */
/** Рег. номер СФР — XXX-XXX-XXXXXX */
sfrRegNumber?: string | undefined | null,
signerRepDoc?: string | undefined | null,
/** СНИЛС — XXX-XXX-XXX YY или 11 цифр */
@@ -19621,7 +19604,6 @@ validateReportEdits?: [{ editsJson: string, reportType: ResolverInputTypes["Repo
firstName?:boolean | `@${string}`,
lastName?:boolean | `@${string}`,
middleName?:boolean | `@${string}`,
pfrRegNumber?:boolean | `@${string}`,
repDoc?:boolean | `@${string}`,
sfrRegNumber?:boolean | `@${string}`,
snils?:boolean | `@${string}`,
@@ -21436,7 +21418,6 @@ export type ModelTypes = {
["CapitalOnboardingState"]: {
blagorost_offer_template_done: boolean,
blagorost_provision_done: boolean,
capital_program_doc_data_hash?: string | undefined | null,
generation_contract_template_done: boolean,
generator_offer_template_done: boolean,
generator_program_template_done: boolean,
@@ -25735,7 +25716,7 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
generateExpenseProposalStatementDocument: ModelTypes["GeneratedDocument"],
/** Сгенерировать протокол решения по предложенной повестке
/** Сгенерировать протокол решения по предложенно повестке
Требуемые роли: chairman, member. */
generateFreeDecision: ModelTypes["GeneratedDocument"],
@@ -25889,10 +25870,6 @@ export type ModelTypes = {
Требуемые роли: chairman, member, user. */
returnExpenseItem: ModelTypes["Transaction"],
/** Сохранить hash PrivateData параметров документов ЦПП
Требуемые роли: chairman. */
saveCapitalProgramDocDataHash: ModelTypes["CapitalOnboardingState"],
/** Сохранить/обновить черновик формы отчёта (upsert по owner+type+year+period)
Требуемые роли: chairman. */
@@ -27855,7 +27832,6 @@ export type ModelTypes = {
oktmo: ModelTypes["RequisiteFieldView"],
okved: ModelTypes["RequisiteFieldView"],
orgName: ModelTypes["RequisiteFieldView"],
pfrRegNumber: ModelTypes["RequisiteFieldView"],
phone: ModelTypes["RequisiteFieldView"],
sfrRegNumber: ModelTypes["RequisiteFieldView"],
signerFirstName: ModelTypes["RequisiteFieldView"],
@@ -28259,9 +28235,6 @@ export type ModelTypes = {
return_amount: string
};
["RoomMessageKind"]:RoomMessageKind;
["SaveCapitalProgramDocDataInput"]: {
doc_data_hash: string
};
["SaveReportDraftInput"]: {
editedFields: Array<string>,
editsJson: string,
@@ -28939,10 +28912,8 @@ export type ModelTypes = {
oktmo?: string | undefined | null,
/** ОКВЭД — напр. 94.99, 46.73.7 */
okved?: string | undefined | null,
/** Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР) */
pfrRegNumber?: string | undefined | null,
phoneOverride?: string | undefined | null,
/** Рег. номер СФР — 10 цифр */
/** Рег. номер СФР — XXX-XXX-XXXXXX */
sfrRegNumber?: string | undefined | null,
signerRepDoc?: string | undefined | null,
/** СНИЛС — XXX-XXX-XXX YY или 11 цифр */
@@ -29225,7 +29196,6 @@ export type ModelTypes = {
firstName: string,
lastName: string,
middleName?: string | undefined | null,
pfrRegNumber?: string | undefined | null,
repDoc?: string | undefined | null,
sfrRegNumber?: string | undefined | null,
snils?: string | undefined | null,
@@ -29240,10 +29210,7 @@ export type ModelTypes = {
}
export type GraphQLTypes = {
// ------------------------------------------------------;
// THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY);
// ------------------------------------------------------;
["AcceptChildOrderInput"]: {
["AcceptChildOrderInput"]: {
/** Имя аккаунта кооператива */
coopname: string,
/** Подписанное заявление на имущественный паевый взнос */
@@ -31146,7 +31113,6 @@ export type GraphQLTypes = {
__typename: "CapitalOnboardingState",
blagorost_offer_template_done: boolean,
blagorost_provision_done: boolean,
capital_program_doc_data_hash?: string | undefined | null,
generation_contract_template_done: boolean,
generator_offer_template_done: boolean,
generator_program_template_done: boolean,
@@ -35666,7 +35632,7 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
generateExpenseProposalStatementDocument: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать протокол решения по предложенной повестке
/** Сгенерировать протокол решения по предложенно повестке
Требуемые роли: chairman, member. */
generateFreeDecision: GraphQLTypes["GeneratedDocument"],
@@ -35820,10 +35786,6 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member, user. */
returnExpenseItem: GraphQLTypes["Transaction"],
/** Сохранить hash PrivateData параметров документов ЦПП
Требуемые роли: chairman. */
saveCapitalProgramDocDataHash: GraphQLTypes["CapitalOnboardingState"],
/** Сохранить/обновить черновик формы отчёта (upsert по owner+type+year+period)
Требуемые роли: chairman. */
@@ -37967,7 +37929,6 @@ export type GraphQLTypes = {
oktmo: GraphQLTypes["RequisiteFieldView"],
okved: GraphQLTypes["RequisiteFieldView"],
orgName: GraphQLTypes["RequisiteFieldView"],
pfrRegNumber: GraphQLTypes["RequisiteFieldView"],
phone: GraphQLTypes["RequisiteFieldView"],
sfrRegNumber: GraphQLTypes["RequisiteFieldView"],
signerFirstName: GraphQLTypes["RequisiteFieldView"],
@@ -38385,9 +38346,6 @@ export type GraphQLTypes = {
};
/** Тип сообщения в истории комнаты Matrix (текст или расшифрованное аудио) */
["RoomMessageKind"]: RoomMessageKind;
["SaveCapitalProgramDocDataInput"]: {
doc_data_hash: string
};
["SaveReportDraftInput"]: {
editedFields: Array<string>,
editsJson: string,
@@ -39103,10 +39061,8 @@ export type GraphQLTypes = {
oktmo?: string | undefined | null,
/** ОКВЭД — напр. 94.99, 46.73.7 */
okved?: string | undefined | null,
/** Рег. номер ПФР — XXX-XXX-XXXXXX (для ЕФС-1, отдельно от рег. номера СФР) */
pfrRegNumber?: string | undefined | null,
phoneOverride?: string | undefined | null,
/** Рег. номер СФР — 10 цифр */
/** Рег. номер СФР — XXX-XXX-XXXXXX */
sfrRegNumber?: string | undefined | null,
signerRepDoc?: string | undefined | null,
/** СНИЛС — XXX-XXX-XXX YY или 11 цифр */
@@ -39410,7 +39366,6 @@ export type GraphQLTypes = {
firstName: string,
lastName: string,
middleName?: string | undefined | null,
pfrRegNumber?: string | undefined | null,
repDoc?: string | undefined | null,
sfrRegNumber?: string | undefined | null,
snils?: string | undefined | null,
@@ -40210,7 +40165,6 @@ type ZEUS_VARIABLES = {
["ReturnByMoneySignedMetaDocumentInput"]: ValueTypes["ReturnByMoneySignedMetaDocumentInput"];
["ReturnExpenseItemInput"]: ValueTypes["ReturnExpenseItemInput"];
["RoomMessageKind"]: ValueTypes["RoomMessageKind"];
["SaveCapitalProgramDocDataInput"]: ValueTypes["SaveCapitalProgramDocDataInput"];
["SaveReportDraftInput"]: ValueTypes["SaveReportDraftInput"];
["SbpDataInput"]: ValueTypes["SbpDataInput"];
["SearchDocumentsInput"]: ValueTypes["SearchDocumentsInput"];
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cooptypes",
"type": "module",
"version": "2026.7.21-1",
"version": "2026.6.24",
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,53 +1,49 @@
export interface CapitalProgramPrivateData {
approval_protocol_number: string
approval_protocol_day: string
approval_protocol_month: string
approval_protocol_year: string
cooperative_name: string
cooperative_short_name: string
cooperative_quoted_name: string
website: string
chairman_full_name: string
generator_program_purpose: string
eoap_definition: string
generator_task_goal: string
idea_unit_cost: string
idea_unit_cost_words: string
blagorost_goal_expansion: string
blagorost_goal_reason: string
blagorost_task_expansion: string
blagorost_task_development: string
return_source_description: string
return_additional_source: string
}
export interface CapitalApprovalHeader {
protocol_number: string
protocol_day_month_year: string
}
export const CAPITAL_APPROVAL_PLACEHOLDER: CapitalApprovalHeader = {
protocol_number: '______',
protocol_day_month_year: '«___» __________ 20__ г.',
offer_template_number: string
}
export const capitalProgramPrivateDataRequiredFields = [
'approval_protocol_number',
'approval_protocol_day',
'approval_protocol_month',
'approval_protocol_year',
'cooperative_name',
'cooperative_short_name',
'cooperative_quoted_name',
'website',
'chairman_full_name',
'generator_program_purpose',
'eoap_definition',
'generator_task_goal',
'idea_unit_cost',
'idea_unit_cost_words',
'blagorost_goal_expansion',
'blagorost_goal_reason',
'blagorost_task_expansion',
'blagorost_task_development',
'return_source_description',
'return_additional_source',
'offer_template_number',
] as const
export function resolveCapitalApprovalHeader(
decision?: { protocol_number?: string; protocol_day_month_year?: string } | null,
): CapitalApprovalHeader {
const protocol_number = decision?.protocol_number?.trim()
const protocol_day_month_year = decision?.protocol_day_month_year?.trim()
return {
protocol_number: protocol_number || CAPITAL_APPROVAL_PLACEHOLDER.protocol_number,
protocol_day_month_year: protocol_day_month_year || CAPITAL_APPROVAL_PLACEHOLDER.protocol_day_month_year,
}
}
export function requireCapitalProgramPrivateData(
payload: CapitalProgramPrivateData | null | undefined,
registry_id: number,
@@ -22,20 +22,7 @@ const completeStep = async (
return state;
};
const saveProgramDocDataHash = async (
data: Mutations.Capital.SaveCapitalProgramDocDataHash.IInput['data'],
) => {
const { [Mutations.Capital.SaveCapitalProgramDocDataHash.name]: state } = await client.Mutation(
Mutations.Capital.SaveCapitalProgramDocDataHash.mutation,
{
variables: { data },
},
);
return state;
};
export const api = {
loadOnboardingState,
completeStep,
saveProgramDocDataHash,
};
@@ -1,83 +0,0 @@
export const DOC_WIZARD_STEP_GENERATOR = 'doc_generator';
export const DOC_WIZARD_STEP_BLAGOROST = 'doc_blagorost';
export const DOC_WIZARD_REGISTRY: Record<string, number> = {
[DOC_WIZARD_STEP_GENERATOR]: 994,
[DOC_WIZARD_STEP_BLAGOROST]: 998,
};
export const DOC_WIZARD_STEP_ORDER = [DOC_WIZARD_STEP_GENERATOR, DOC_WIZARD_STEP_BLAGOROST] as const;
export type DocWizardStepKey = (typeof DOC_WIZARD_STEP_ORDER)[number];
export function isDocWizardStep(key: string): key is DocWizardStepKey {
return (DOC_WIZARD_STEP_ORDER as readonly string[]).includes(key);
}
/** Группа 1: положение ГЕНЕРАТОР, шаблон договора, положение БЛАГОРОСТ — объявляются параллельно. */
export const COUNCIL_GROUP_FOUNDATION = 'council_foundation';
/** Группа 2: оферты ГЕНЕРАТОР и БЛАГОРОСТ — объявляются параллельно, каждая после своего положения. */
export const COUNCIL_GROUP_OFFERS = 'council_offers';
/** @deprecated Миграция черновиков stepper → COUNCIL_GROUP_OFFERS */
export const COUNCIL_GROUP_GENERATOR_OFFER = 'council_generator_offer';
/** @deprecated Миграция черновиков stepper → COUNCIL_GROUP_OFFERS */
export const COUNCIL_GROUP_BLAGOROST_OFFER = 'council_blagorost_offer';
export const COUNCIL_STEP_GROUP_ORDER = [
COUNCIL_GROUP_FOUNDATION,
COUNCIL_GROUP_OFFERS,
] as const;
export type CouncilGroupStepKey = (typeof COUNCIL_STEP_GROUP_ORDER)[number];
export const COUNCIL_STEP_IDS_BY_GROUP: Record<CouncilGroupStepKey, readonly string[]> = {
[COUNCIL_GROUP_FOUNDATION]: [
'generator_program_template',
'generation_contract_template',
'blagorost_program',
],
[COUNCIL_GROUP_OFFERS]: ['generator_offer_template', 'blagorost_offer_template'],
};
export const COUNCIL_WIZARD_STEP_META: Record<CouncilGroupStepKey, { label: string; description: string }> = {
[COUNCIL_GROUP_FOUNDATION]: {
label: 'Утверждение положений и шаблонов ЦПП',
description:
'Положение «ГЕНЕРАТОР», шаблон договора участия в хозяйственной деятельности и положение «БЛАГОРОСТ»',
},
[COUNCIL_GROUP_OFFERS]: {
label: 'Утверждение оферт ЦПП',
description: 'Оферты «ГЕНЕРАТОР» и «БЛАГОРОСТ» для пайщиков',
},
};
export function isCouncilGroupStep(key: string): key is CouncilGroupStepKey {
return (COUNCIL_STEP_GROUP_ORDER as readonly string[]).includes(key);
}
export function resolveCouncilGroupForStepId(stepId: string): CouncilGroupStepKey | null {
for (const groupKey of COUNCIL_STEP_GROUP_ORDER) {
if (COUNCIL_STEP_IDS_BY_GROUP[groupKey].includes(stepId)) {
return groupKey;
}
}
return null;
}
export function normalizeWizardStepKey(key: string): string {
if (isDocWizardStep(key)) {
return key;
}
if (key === COUNCIL_GROUP_GENERATOR_OFFER || key === COUNCIL_GROUP_BLAGOROST_OFFER) {
return COUNCIL_GROUP_OFFERS;
}
if (isCouncilGroupStep(key)) {
return key;
}
return resolveCouncilGroupForStepId(key) ?? key;
}
export function getCouncilStepIdsForGroup(groupKey: CouncilGroupStepKey): readonly string[] {
return COUNCIL_STEP_IDS_BY_GROUP[groupKey];
}
@@ -1,71 +0,0 @@
export type EditableFieldKey =
| 'generator_program_purpose'
| 'eoap_definition'
| 'generator_task_goal'
| 'idea_unit_cost'
| 'idea_unit_cost_words'
| 'blagorost_goal_expansion'
| 'blagorost_goal_reason'
| 'blagorost_task_expansion'
| 'blagorost_task_development'
| 'return_source_description'
| 'return_additional_source';
export const PREVIEW_PLACEHOLDER = '______';
export const FIELD_LABELS: Record<EditableFieldKey, string> = {
generator_program_purpose:
'Направление ЦПП: какую экосистему или сферу деятельности развивают пайщики (название, правовая основа, цель интеграции в экономику)',
eoap_definition:
'Определение Платформы: что это, из чего состоит, кто участники, на какой технологии и принципах работает',
generator_task_goal:
'Главная задача ЦПП: чем Платформа должна стать для участников (центр привлечения чего и для чего)',
idea_unit_cost: 'Стоимость одной Идеи в рублях — только число',
idea_unit_cost_words: 'Та же стоимость Идеи прописью',
blagorost_goal_expansion:
'Зачем расширять участие в Платформе: какая потребность участников закрывается при росте сообщества',
blagorost_goal_reason:
'Причина роста числа участников: за счёт чего расширяется сообщество и на что это влияет',
blagorost_task_expansion:
'Задача расширения: как описывается рост числа участников Платформы и их взаимодействия',
blagorost_task_development:
'Задача развития: как описывается привлечение инноваций, разработок и решений в Платформу',
return_source_description:
'Основной источник возврата взносов: что создаёт экономический эффект (инфраструктура, продукт, механизм)',
return_additional_source:
'Дополнительные источники возврата: взносы пользователей, продукты и приложения, передаваемые или создаваемые в Обществе',
};
export const GENERATOR_DOC_FIELDS: EditableFieldKey[] = [
'generator_program_purpose',
'eoap_definition',
'generator_task_goal',
'idea_unit_cost',
'idea_unit_cost_words',
];
export const BLAGOROST_DOC_FIELDS: EditableFieldKey[] = [
'blagorost_goal_expansion',
'blagorost_goal_reason',
'blagorost_task_expansion',
'blagorost_task_development',
'return_source_description',
'return_additional_source',
'eoap_definition',
];
export const ALL_DOC_FIELDS: EditableFieldKey[] = [
...GENERATOR_DOC_FIELDS,
...BLAGOROST_DOC_FIELDS.filter((key) => !GENERATOR_DOC_FIELDS.includes(key)),
];
export const DOCUMENT_SECTIONS = [
{
registryId: 994,
tabLabel: 'Положение ГЕНЕРАТОР',
},
{
registryId: 998,
tabLabel: 'Положение БЛАГОРОСТ',
},
] as const;
@@ -15,16 +15,16 @@ interface GeneratedDocument {
type CapitalOnboardingStepId = Mutations.Capital.CompleteOnboardingStep.IInput['data']['step'];
const onboardingState = ref<CapitalOnboardingState | null>(null);
const loading = ref(false);
const submitting = ref(false);
const generatingDocument = ref(false);
const currentGeneratedDoc = ref<GeneratedDocument | null>(null);
export const useCapitalOnboarding = () => {
const systemStore = useSystemStore();
const sessionStore = useSessionStore();
const onboardingState = ref<CapitalOnboardingState | null>(null);
const loading = ref(false);
const submitting = ref(false);
const generatingDocument = ref(false);
const currentGeneratedDoc = ref<GeneratedDocument | null>(null);
// Маппинг шагов на registry_id
const stepToRegistryId: Record<CapitalOnboardingStepId, number> = {
'generator_program_template': 994,
@@ -50,7 +50,7 @@ export const useCapitalOnboarding = () => {
const registry_id = stepToRegistryId[step.id];
const docDataHash = onboardingState.value?.capital_program_doc_data_hash;
if (capitalProgramDocDataRegistryIds.has(registry_id) && !docDataHash) {
throw new Error('Сначала заполните параметры документов ЦПП и сформируйте предпросмотр');
throw new Error('Параметры документов ЦПП не заполнены: переустановите или обновите настройки расширения capital');
}
const generateDocInput: Mutations.Documents.GenerateDocument.IInput = {
@@ -158,16 +158,10 @@ export const useCapitalOnboarding = () => {
});
const isOnboardingCompleted = computed(() => {
console.log(stepsConfig.value);
return stepsConfig.value.every(step => step.status === 'completed');
});
const hasPendingCouncilDecisions = computed(() =>
stepsConfig.value.some((step) => step.status === 'in_progress'),
);
const docDataHash = computed(() => onboardingState.value?.capital_program_doc_data_hash || null);
const isDocParamsReady = computed(() => Boolean(docDataHash.value));
const config = computed<ICouncilOnboardingConfig>(() => ({
steps: stepsConfig.value,
expireAt: expireAt.value,
@@ -175,27 +169,18 @@ export const useCapitalOnboarding = () => {
completionMessage: 'Все необходимые документы для работы с программой утверждены.',
}));
const loadState = async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
const loadState = async () => {
try {
if (!silent) {
loading.value = true;
}
loading.value = true;
await systemStore.loadSystemInfo();
onboardingState.value = await api.loadOnboardingState();
} catch (error) {
if (!silent) {
FailAlert(error);
}
FailAlert(error);
} finally {
if (!silent) {
loading.value = false;
}
loading.value = false;
}
};
const refreshState = () => loadState({ silent: true });
const handleStepClick = async (step: ICouncilOnboardingStep) => {
try {
const doc = await generateDocument(step);
@@ -240,10 +225,6 @@ export const useCapitalOnboarding = () => {
}
};
const handleDocParamsSaved = async () => {
onboardingState.value = await api.loadOnboardingState();
};
return {
config,
loading,
@@ -251,13 +232,8 @@ export const useCapitalOnboarding = () => {
generatingDocument,
currentGeneratedDoc,
isOnboardingCompleted,
hasPendingCouncilDecisions,
docDataHash,
isDocParamsReady,
loadState,
refreshState,
handleStepClick,
handleStepSubmit,
handleDocParamsSaved,
};
};
@@ -1,8 +1 @@
export { useCapitalOnboarding } from './composable';
export { useCapitalProgramDocParams, getRegistryIdForDocWizardStep } from './useCapitalProgramDocParams';
export {
DOC_WIZARD_STEP_BLAGOROST,
DOC_WIZARD_STEP_GENERATOR,
DOC_WIZARD_STEP_ORDER,
isDocWizardStep,
} from './capitalOnboardingWizard';
@@ -1,226 +0,0 @@
import { computed, reactive, ref } from 'vue';
import { useQuasar } from 'quasar';
import type { Cooperative } from 'cooptypes';
import { useSystemStore } from 'src/entities/System/model';
import { useSessionStore } from 'src/entities/Session';
import { api as documentApi } from 'src/shared/lib/document/api';
import { api } from '../api';
import {
ALL_DOC_FIELDS,
BLAGOROST_DOC_FIELDS,
FIELD_LABELS,
GENERATOR_DOC_FIELDS,
PREVIEW_PLACEHOLDER,
type EditableFieldKey,
} from './capitalProgramDocFields';
import {
DOC_WIZARD_STEP_BLAGOROST,
DOC_WIZARD_STEP_GENERATOR,
} from './capitalOnboardingWizard';
import {
getCapitalProgramDocParamsDraftKey,
readCapitalProgramDocParamsDraft,
writeCapitalProgramDocParamsDraft,
} from './useCapitalProgramDocParamsDraft';
type PreviewState = {
html: string;
docDataHash: string;
};
const DOC_FIELDS_BY_REGISTRY: Record<number, EditableFieldKey[]> = {
994: GENERATOR_DOC_FIELDS,
998: BLAGOROST_DOC_FIELDS,
};
function createEmptyForm(): Record<EditableFieldKey, string> {
return Object.fromEntries(ALL_DOC_FIELDS.map((key) => [key, ''])) as Record<EditableFieldKey, string>;
}
export function useCapitalProgramDocParams(options?: { onSaved?: (hash: string) => void | Promise<void> }) {
const $q = useQuasar();
const systemStore = useSystemStore();
const sessionStore = useSessionStore();
const saving = ref(false);
const savedHash = ref('');
const loadingRegistryId = ref<number | null>(null);
const wizardStepKey = ref<string>(DOC_WIZARD_STEP_GENERATOR);
const previews = reactive<Partial<Record<number, PreviewState>>>({});
const form = reactive<Record<EditableFieldKey, string>>(createEmptyForm());
const draftStorageKey = computed(() =>
getCapitalProgramDocParamsDraftKey(systemStore.info?.coopname ?? '', sessionStore.username ?? ''),
);
const editableFields = computed(() =>
ALL_DOC_FIELDS.map((key) => ({
key,
label: FIELD_LABELS[key],
})),
);
function persistDraftToStorage() {
writeCapitalProgramDocParamsDraft(draftStorageKey.value, {
form: { ...form },
wizardStepKey: wizardStepKey.value,
savedHash: savedHash.value,
previews: { ...previews },
});
}
function restoreDraftFromStorage() {
const draft = readCapitalProgramDocParamsDraft(draftStorageKey.value);
if (!draft) return;
Object.assign(form, draft.form);
savedHash.value = draft.savedHash;
Object.assign(previews, draft.previews);
wizardStepKey.value = draft.wizardStepKey ?? DOC_WIZARD_STEP_GENERATOR;
}
function getPreviewPayload(): Cooperative.Registry.CapitalProgramPrivateData {
return Object.fromEntries(
ALL_DOC_FIELDS.map((key) => [key, String(form[key] ?? '').trim() || PREVIEW_PLACEHOLDER]),
) as Record<EditableFieldKey, string>;
}
function getMissingCount(registryId: number): number {
const fields = DOC_FIELDS_BY_REGISTRY[registryId] ?? [];
return fields.filter((key) => !String(form[key] ?? '').trim()).length;
}
function validateSection(registryId: number): string | null {
const fields = DOC_FIELDS_BY_REGISTRY[registryId] ?? [];
const missing = fields
.filter((key) => !String(form[key] ?? '').trim())
.map((key) => FIELD_LABELS[key]);
if (missing.length) {
return `Заполните все поля документа: ${missing.join(', ')}`;
}
return null;
}
function validateAll(): string | null {
const missing = editableFields.value
.filter(({ key }) => !String(form[key] ?? '').trim())
.map(({ label }) => label);
if (missing.length) {
return `Заполните все параметры в документах: ${missing.join(', ')}`;
}
return null;
}
function getDocDataHashFromResult(result: Awaited<ReturnType<typeof documentApi.generateDocument>>): string {
const meta = result.meta as { doc_data_hash?: string } | undefined;
return meta?.doc_data_hash ?? '';
}
async function loadPreview(registryId: number) {
loadingRegistryId.value = registryId;
try {
const result = await documentApi.generateDocument({
coopname: systemStore.info?.coopname ?? '',
username: sessionStore.username ?? '',
registry_id: registryId,
doc_data: getPreviewPayload(),
});
previews[registryId] = {
html: result.html,
docDataHash: getDocDataHashFromResult(result),
};
persistDraftToStorage();
return result;
} catch (error) {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : 'Не удалось сформировать предпросмотр',
});
throw error;
} finally {
loadingRegistryId.value = null;
}
}
async function saveParams() {
const validationError = validateAll();
if (validationError) {
$q.notify({ type: 'warning', message: validationError });
return false;
}
saving.value = true;
try {
await loadPreview(994);
await loadPreview(998);
const hash = previews[994]?.docDataHash ?? previews[998]?.docDataHash;
if (!hash) {
throw new Error('Не удалось получить хеш параметров документов');
}
await api.saveProgramDocDataHash({ doc_data_hash: hash });
savedHash.value = hash;
persistDraftToStorage();
await options?.onSaved?.(hash);
$q.notify({
type: 'positive',
message: 'Параметры документов ЦПП сохранены',
});
return true;
} catch (error) {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : 'Не удалось сохранить параметры',
});
return false;
} finally {
saving.value = false;
}
}
async function ensurePreview(registryId: number) {
if (previews[registryId]?.html) return;
await loadPreview(registryId);
}
function setWizardStepKey(key: string) {
wizardStepKey.value = key;
persistDraftToStorage();
}
return {
form,
previews,
savedHash,
saving,
loadingRegistryId,
wizardStepKey,
restoreDraftFromStorage,
persistDraftToStorage,
getMissingCount,
validateSection,
loadPreview,
ensurePreview,
saveParams,
setWizardStepKey,
};
}
export function getRegistryIdForDocWizardStep(stepKey: string): number | null {
if (stepKey === DOC_WIZARD_STEP_GENERATOR) return 994;
if (stepKey === DOC_WIZARD_STEP_BLAGOROST) return 998;
return null;
}
@@ -1,105 +0,0 @@
import { ALL_DOC_FIELDS, type EditableFieldKey } from './capitalProgramDocFields';
const STORAGE_PREFIX = 'capital-cpp-doc-params-draft';
export type CapitalProgramDocParamsPreviewState = {
html: string;
docDataHash: string;
};
export type CapitalProgramDocParamsDraft = {
form: Record<EditableFieldKey, string>;
/** @deprecated используйте wizardStepKey */
activeTab?: number;
wizardStepKey?: string;
savedHash: string;
previews: Partial<Record<number, CapitalProgramDocParamsPreviewState>>;
};
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
export function getCapitalProgramDocParamsDraftKey(coopname: string, username: string): string {
return `${STORAGE_PREFIX}:${coopname || 'unknown'}:${username || 'unknown'}`;
}
function isEditableFieldKey(key: string): key is EditableFieldKey {
return (ALL_DOC_FIELDS as readonly string[]).includes(key);
}
function createEmptyForm(): Record<EditableFieldKey, string> {
return Object.fromEntries(ALL_DOC_FIELDS.map((key) => [key, ''])) as Record<EditableFieldKey, string>;
}
function normalizeForm(raw: unknown): Record<EditableFieldKey, string> | null {
if (!raw || typeof raw !== 'object') return null;
const form = createEmptyForm();
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (isEditableFieldKey(key) && typeof value === 'string') {
form[key] = value;
}
}
return form;
}
function normalizePreviews(raw: unknown): Partial<Record<number, CapitalProgramDocParamsPreviewState>> {
const previews: Partial<Record<number, CapitalProgramDocParamsPreviewState>> = {};
if (!raw || typeof raw !== 'object') return previews;
for (const [id, preview] of Object.entries(raw as Record<string, unknown>)) {
const registryId = Number(id);
if (!Number.isFinite(registryId) || !preview || typeof preview !== 'object') continue;
const html = (preview as CapitalProgramDocParamsPreviewState).html;
const docDataHash = (preview as CapitalProgramDocParamsPreviewState).docDataHash;
if (typeof html === 'string' && typeof docDataHash === 'string') {
previews[registryId] = { html, docDataHash };
}
}
return previews;
}
export function readCapitalProgramDocParamsDraft(storageKey: string): CapitalProgramDocParamsDraft | null {
if (!isBrowser()) return null;
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<CapitalProgramDocParamsDraft>;
const form = normalizeForm(parsed.form);
if (!form) return null;
const legacyTab = typeof parsed.activeTab === 'number' ? parsed.activeTab : 994;
const wizardStepKey =
typeof parsed.wizardStepKey === 'string'
? parsed.wizardStepKey
: legacyTab === 998
? 'doc_blagorost'
: 'doc_generator';
return {
form,
wizardStepKey,
savedHash: typeof parsed.savedHash === 'string' ? parsed.savedHash : '',
previews: normalizePreviews(parsed.previews),
};
} catch {
localStorage.removeItem(storageKey);
return null;
}
}
export function writeCapitalProgramDocParamsDraft(storageKey: string, draft: CapitalProgramDocParamsDraft): void {
if (!isBrowser()) return;
try {
localStorage.setItem(storageKey, JSON.stringify(draft));
} catch (error) {
console.warn('Failed to persist capital CPP doc params draft:', error);
}
}
@@ -1,91 +0,0 @@
<template lang="pug">
.capital-council-group
.capital-council-group__item(
v-for='(step, index) in steps'
:key='step.id'
)
.capital-council-group__head
.capital-council-group__title
span.capital-council-group__index {{ index + 1 }}.
span {{ step.title }}
.capital-council-group__status
BaseChip(
v-if='step.status === "completed"'
variant='pos'
size='sm'
) Утверждено советом
BaseChip(
v-else-if='step.status === "in_progress"'
variant='warn'
size='sm'
) Ожидаем решение совета
p.capital-council-group__desc.t-body2 {{ step.description }}
BaseButton.capital-council-group__action(
v-if='canDeclare(step)'
variant='primary'
size='sm'
:disable='busy'
@click='emit("declare", step.id)'
) Объявить собрание совета
q-separator.capital-council-group__sep(v-if='index < steps.length - 1')
</template>
<script setup lang="ts">
import { BaseButton, BaseChip } from 'src/shared/ui/base';
import type { ICouncilOnboardingStep } from 'src/shared/ui/CouncilOnboarding';
defineProps<{
steps: ICouncilOnboardingStep[];
busy?: boolean;
canDeclare: (step: ICouncilOnboardingStep) => boolean;
}>();
const emit = defineEmits<{
declare: [stepId: string];
}>();
</script>
<style scoped lang="scss">
.capital-council-group__item {
padding: var(--p-2) 0;
}
.capital-council-group__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--p-3);
}
.capital-council-group__title {
display: flex;
gap: var(--p-2);
font-weight: 600;
color: var(--p-ink);
}
.capital-council-group__index {
flex: 0 0 auto;
color: var(--p-ink-2);
}
.capital-council-group__status {
flex: 0 0 auto;
}
.capital-council-group__desc {
margin: var(--p-2) 0 0;
color: var(--p-ink-2);
}
.capital-council-group__action {
margin-top: var(--p-3);
}
.capital-council-group__sep {
margin-top: var(--p-4);
}
</style>
@@ -1,124 +1,93 @@
<template lang="pug">
div.capital-onboarding.q-pa-md
div
// Показываем лоадер пока данные загружаются
WindowLoader(
v-if='loading'
text='Загрузка данных онбординга...'
v-if="loading"
text="Загрузка данных онбординга..."
)
template(v-else-if='!isOnboardingCompleted')
.capital-onboarding__shell
BaseBanner.capital-onboarding__banner(variant='info')
template(#icon)
q-icon(name='info' size='20px')
| Сначала заполните параметры положений «ГЕНЕРАТОР» и «БЛАГОРОСТ», затем объявите собрания
| совета для утверждения документов ЦПП.
// Показываем шаги если онбординг не завершен и данные загружены
q-card(v-else-if="!isOnboardingCompleted" flat)
q-card-section.row.items-center.justify-between
div
div.text-h5 Адаптация к работе с программой "Благорост"
VerticalStepper.capital-onboarding__stepper(
:steps='wizardSteps'
:active-key='activeWizardStep'
:completed='completedWizardSteps'
:clickable-completed='true'
@change='onStepperChange'
)
template(#active='{ step }')
CapitalProgramDocumentStepPanel(
v-if='isDocWizardStep(step.key)'
v-model='form'
:preview-html='getDocPreviewHtml(step.key)'
:loading='isDocStepLoading(step.key)'
@refresh='refreshDocStep(step.key)'
)
q-separator
q-card-section
q-list(separator)
q-item(v-for="(step, index) in config?.steps || []" :key="step.id")
q-item-section
div.row.items-center.q-gutter-sm
q-icon(
:name="getIcon(step)"
:color="getIconColor(step)"
size="22px"
)
div.text-subtitle1 {{ index + 1 }}. {{ step.title }}
div.text-caption.text-grey-7 {{ step.description }}
q-chip(
v-if="step.status === 'in_progress'"
dense
color="amber"
text-color="black"
icon="hourglass_top"
class="q-mt-xs"
) Ожидаем решение совета
q-item-section(side)
q-btn(
v-if="showAction(index)"
:disable="submitting || generatingDocument"
:loading="generatingDocument && currentStepId === step.id"
color="primary"
label="Объявить собрание совета"
@click="() => openDialog(step)"
)
CapitalCouncilStepGroup(
v-else-if='isCouncilGroupStep(step.key)'
:steps='getCouncilStepsForGroup(step.key)'
:busy='submitting || generatingDocument'
:can-declare='canDeclareCouncilStep'
@declare='openCouncilDialog'
)
.capital-onboarding__footer(v-if='showFooter')
BaseButton(
v-if='showBackButton'
variant='ghost'
:disable='footerBusy'
@click='goBack'
) Назад
q-space(v-if='showBackButton && showContinueButton')
BaseButton(
v-if='showContinueButton'
variant='primary'
:class='{ "capital-onboarding__continue--solo": !showBackButton }'
:loading='footerBusy'
@click='goNext'
) {{ continueLabel }}
BaseDialog(
v-model='dialogOpen'
title='Предложение повестки'
size='lg'
:close-on-backdrop='false'
:close-on-escape='false'
@update:model-value='(v) => !v && closeDialog()'
)
Loader(
v-if='generatingDocument'
text='Генерация документа...'
BaseDialog(
v-model="dialogOpen",
title="Предложение повестки",
size="lg",
:close-on-backdrop="false",
:close-on-escape="false",
@update:model-value="(v) => !v && closeDialog()"
)
template(v-else-if='currentStep && currentGeneratedDoc && !generatingDocument')
.capital-onboarding__dialog-section
Loader(
v-if="generatingDocument"
text="Генерация документа..."
)
q-card-section(v-else-if="currentStep && currentGeneratedDoc && !generatingDocument")
div.row.items-center.q-gutter-xs.text-subtitle1.text-weight-medium
q-icon(name='help_outline' size='18px' color='primary')
q-icon(name="help_outline" size="18px" class="text-primary")
span Вопрос на повестке
div.q-mt-sm.t-body {{ currentStep.question }}
div.q-mt-sm.q-pa-sm.text-body1.rounded-borders {{ currentStep.question }}
q-separator.q-my-md
div.row.items-center.q-gutter-xs.text-subtitle1.text-weight-medium
q-icon(name='gavel' size='18px' color='primary')
q-icon(name="gavel" size="18px" class="text-primary")
span Проект решения
div.q-mt-sm
div(v-if='currentStep.decisionPrefix') {{ currentStep.decisionPrefix }}
DocumentHtmlReader(:html='currentGeneratedDoc.html' :sanitize='false')
div.t-caption.text-grey-6.q-mt-sm
div.q-mt-sm.q-pa-sm.rounded-borders
div(v-if="currentStep.decisionPrefix") {{ currentStep.decisionPrefix }}
DocumentHtmlReader(:html="currentGeneratedDoc.html" :sanitize="false")
div.q-mt-sm.text-caption.text-grey-6
strong {{ currentGeneratedDoc.full_title }}
template(#footer v-if='currentGeneratedDoc && !generatingDocument')
BaseButton(variant='ghost' :disable='submitting' @click='closeDialog') Отмена
BaseButton(variant='primary' :loading='submitting' @click='submitStep') Объявить
div.q-pb-lg(v-if="currentGeneratedDoc && !generatingDocument")
q-btn(flat label="Отмена" @click="closeDialog" :disable="submitting")
q-btn(
color="primary"
label="Объявить"
:loading="submitting"
@click="submitStep"
)
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useQuasar } from 'quasar';
import { ref, onMounted } from 'vue';
import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader';
import { BaseBanner, BaseButton } from 'src/shared/ui/base';
import { BaseDialog } from 'src/shared/ui/base/BaseDialog';
import { WindowLoader, Loader } from 'src/shared/ui/Loader';
import { VerticalStepper } from 'src/shared/ui/domain/VerticalStepper';
import type { StepperStep } from 'src/shared/ui/domain/VerticalStepper';
import type { ICouncilOnboardingStep } from 'src/shared/ui/CouncilOnboarding';
import { useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import CapitalProgramDocumentStepPanel from './CapitalProgramDocumentStepPanel.vue';
import CapitalCouncilStepGroup from './CapitalCouncilStepGroup.vue';
import { useCapitalOnboarding } from '../model';
import { useCapitalProgramDocParams, getRegistryIdForDocWizardStep } from '../model/useCapitalProgramDocParams';
import {
COUNCIL_GROUP_FOUNDATION,
COUNCIL_GROUP_OFFERS,
COUNCIL_STEP_GROUP_ORDER,
COUNCIL_WIZARD_STEP_META,
DOC_WIZARD_STEP_BLAGOROST,
DOC_WIZARD_STEP_GENERATOR,
DOC_WIZARD_STEP_ORDER,
getCouncilStepIdsForGroup,
isCouncilGroupStep,
isDocWizardStep,
normalizeWizardStepKey,
type CouncilGroupStepKey,
} from '../model/capitalOnboardingWizard';
const $q = useQuasar();
const {
config,
@@ -127,374 +96,67 @@ const {
generatingDocument,
currentGeneratedDoc,
isOnboardingCompleted,
hasPendingCouncilDecisions,
isDocParamsReady,
loadState,
refreshState,
handleStepClick,
handleStepSubmit,
handleDocParamsSaved,
} = useCapitalOnboarding();
const {
form,
previews,
saving,
loadingRegistryId,
wizardStepKey,
restoreDraftFromStorage,
persistDraftToStorage,
validateSection,
loadPreview,
ensurePreview,
saveParams,
setWizardStepKey,
} = useCapitalProgramDocParams({
onSaved: () => handleDocParamsSaved(),
});
const activeWizardStep = wizardStepKey;
const advancing = ref(false);
const dialogOpen = ref(false);
const currentStep = ref<ICouncilOnboardingStep | null>(null);
const currentStepId = ref<string | null>(null);
const councilSteps = computed(() => config.value?.steps ?? []);
const wizardSteps = computed<StepperStep[]>(() => [
{
key: DOC_WIZARD_STEP_GENERATOR,
label: 'Параметры «ГЕНЕРАТОР»',
description: 'Переменные положения о целевой программе «ГЕНЕРАТОР»',
},
{
key: DOC_WIZARD_STEP_BLAGOROST,
label: 'Параметры «БЛАГОРОСТ»',
description: 'Переменные положения о целевой программе «БЛАГОРОСТ»',
},
...COUNCIL_STEP_GROUP_ORDER.map((groupKey) => ({
key: groupKey,
label: COUNCIL_WIZARD_STEP_META[groupKey].label,
description: COUNCIL_WIZARD_STEP_META[groupKey].description,
disabled: isCouncilGroupDisabled(groupKey),
})),
]);
const getIcon = (step: ICouncilOnboardingStep) => {
if (step.status === 'completed') return 'task_alt';
if (step.status === 'in_progress') return 'hourglass_top';
return 'radio_button_unchecked';
};
const wizardStepOrder = computed<string[]>(() => [
...DOC_WIZARD_STEP_ORDER,
...COUNCIL_STEP_GROUP_ORDER,
]);
const getIconColor = (step: ICouncilOnboardingStep) => {
if (step.status === 'completed') return 'green-6';
if (step.status === 'in_progress') return 'orange-6';
return 'grey-6';
};
const completedWizardSteps = computed(() => {
const completed: string[] = [];
const activeIndex = wizardStepOrder.value.indexOf(activeWizardStep.value);
const isDependenciesCompleted = (step: ICouncilOnboardingStep) => {
if (!step.depends_on || step.depends_on.length === 0) return true;
if (!config.value?.steps) return true;
if (activeIndex > 0 || isDocParamsReady.value) {
completed.push(DOC_WIZARD_STEP_GENERATOR);
}
if (isDocParamsReady.value) {
completed.push(DOC_WIZARD_STEP_BLAGOROST);
}
return step.depends_on.every(depId => {
const depStep = config.value.steps.find(s => s.id === depId);
return depStep && depStep.status === 'completed';
});
};
for (const groupKey of COUNCIL_STEP_GROUP_ORDER) {
if (isCouncilGroupCompleted(groupKey)) {
completed.push(groupKey);
}
}
return completed;
});
const footerBusy = computed(() => saving.value || advancing.value || !!loadingRegistryId.value);
const showBackButton = computed(() => {
const index = wizardStepOrder.value.indexOf(activeWizardStep.value);
return index > 0;
});
const showContinueButton = computed(() => isDocWizardStep(activeWizardStep.value));
const showFooter = computed(() => showBackButton.value || showContinueButton.value);
const continueLabel = computed(() =>
activeWizardStep.value === DOC_WIZARD_STEP_BLAGOROST ? 'Сохранить и продолжить' : 'Продолжить',
);
function getCouncilStep(stepId: string): ICouncilOnboardingStep | undefined {
return councilSteps.value.find((step) => step.id === stepId);
}
function getCouncilStepsForGroup(groupKey: CouncilGroupStepKey): ICouncilOnboardingStep[] {
return getCouncilStepIdsForGroup(groupKey)
.map((stepId) => getCouncilStep(stepId))
.filter((step): step is ICouncilOnboardingStep => step != null);
}
function isCouncilGroupCompleted(groupKey: CouncilGroupStepKey): boolean {
return getCouncilStepsForGroup(groupKey).every((step) => step.status === 'completed');
}
function isCouncilGroupDisabled(groupKey: CouncilGroupStepKey): boolean {
if (groupKey === COUNCIL_GROUP_FOUNDATION) {
return !isDocParamsReady.value;
}
if (groupKey === COUNCIL_GROUP_OFFERS) {
return getCouncilStepsForGroup(groupKey).every(
(step) =>
step.status !== 'completed' &&
step.status !== 'in_progress' &&
!canDeclareCouncilStep(step),
);
}
return true;
}
function getFirstAvailableCouncilGroup(): CouncilGroupStepKey | null {
for (const groupKey of COUNCIL_STEP_GROUP_ORDER) {
if (!isCouncilGroupCompleted(groupKey) && !isCouncilGroupDisabled(groupKey)) {
return groupKey;
}
}
return null;
}
function syncWizardStepWithCouncilProgress() {
if (!isDocParamsReady.value || isOnboardingCompleted.value) return;
const firstAvailable = getFirstAvailableCouncilGroup();
if (!firstAvailable) return;
const current = activeWizardStep.value;
if (isDocWizardStep(current)) {
setWizardStepKey(firstAvailable);
return;
}
if (!isCouncilGroupStep(current)) return;
if (isCouncilGroupCompleted(current) && firstAvailable !== current) {
setWizardStepKey(firstAvailable);
}
}
function getDocPreviewHtml(stepKey: string): string {
const registryId = getRegistryIdForDocWizardStep(stepKey);
return registryId ? previews[registryId]?.html ?? '' : '';
}
function isDocStepLoading(stepKey: string): boolean {
const registryId = getRegistryIdForDocWizardStep(stepKey);
return registryId != null && loadingRegistryId.value === registryId;
}
async function refreshDocStep(stepKey: string) {
const registryId = getRegistryIdForDocWizardStep(stepKey);
if (registryId == null) return;
await loadPreview(registryId);
}
function canDeclareCouncilStep(step: ICouncilOnboardingStep): boolean {
const showAction = (index: number) => {
if (!config.value?.steps) return false;
const step = config.value.steps[index];
if (!step) return false;
if (step.status === 'completed' || step.status === 'in_progress') return false;
if (!step.depends_on?.length) return true;
return step.depends_on.every((depId) => getCouncilStep(depId)?.status === 'completed');
}
function onStepperChange(key: string) {
if (isDocWizardStep(key)) {
setWizardStepKey(key);
return;
}
if (isCouncilGroupStep(key) && !isCouncilGroupDisabled(key)) {
setWizardStepKey(key);
}
}
async function goNext() {
if (activeWizardStep.value === DOC_WIZARD_STEP_GENERATOR) {
const validationError = validateSection(994);
if (validationError) {
$q.notify({ type: 'warning', message: validationError });
return;
}
advancing.value = true;
try {
await ensurePreview(994);
setWizardStepKey(DOC_WIZARD_STEP_BLAGOROST);
} finally {
advancing.value = false;
}
return;
}
if (activeWizardStep.value === DOC_WIZARD_STEP_BLAGOROST) {
const validationError = validateSection(998);
if (validationError) {
$q.notify({ type: 'warning', message: validationError });
return;
}
advancing.value = true;
try {
const saved = await saveParams();
if (!saved) return;
const firstCouncilGroup = getFirstAvailableCouncilGroup();
if (firstCouncilGroup) {
setWizardStepKey(firstCouncilGroup);
}
} finally {
advancing.value = false;
}
}
}
function goBack() {
const index = wizardStepOrder.value.indexOf(activeWizardStep.value);
if (index <= 0) return;
setWizardStepKey(wizardStepOrder.value[index - 1]);
}
async function openCouncilDialog(stepId: string) {
const step = getCouncilStep(stepId);
if (!step || !canDeclareCouncilStep(step)) return;
return isDependenciesCompleted(step);
};
const openDialog = async (step: ICouncilOnboardingStep) => {
currentStep.value = step;
currentStepId.value = step.id;
dialogOpen.value = true;
await handleStepClick(step);
}
function closeDialog() {
// Генерируем документ
await handleStepClick(step);
};
const closeDialog = () => {
dialogOpen.value = false;
currentStep.value = null;
}
currentStepId.value = null;
};
async function submitStep() {
const submitStep = async () => {
if (!currentStep.value) return;
await handleStepSubmit(currentStep.value);
closeDialog();
syncWizardStepWithCouncilProgress();
}
};
const shouldPollCouncilState = computed(
() => hasPendingCouncilDecisions.value && !isOnboardingCompleted.value,
);
const { start: startCouncilPoll, stop: stopCouncilPoll } = useDataPoller(
async () => {
await refreshState();
syncWizardStepWithCouncilProgress();
},
{
interval: POLL_INTERVALS.FAST,
immediate: false,
enabled: shouldPollCouncilState,
},
);
function handleVisibilityChange() {
if (document.hidden || !shouldPollCouncilState.value) return;
void refreshState().then(() => syncWizardStepWithCouncilProgress());
}
watch(shouldPollCouncilState, (enabled) => {
if (enabled) {
startCouncilPoll();
} else {
stopCouncilPoll();
}
});
watch(
() => councilSteps.value.map((step) => `${step.id}:${step.status}`).join('|'),
() => {
syncWizardStepWithCouncilProgress();
},
);
watch(
() => activeWizardStep.value,
(stepKey) => {
if (!isDocWizardStep(stepKey)) return;
const registryId = getRegistryIdForDocWizardStep(stepKey);
if (registryId != null) {
void ensurePreview(registryId);
}
},
);
watch(
form,
() => {
persistDraftToStorage();
},
{ deep: true },
);
onMounted(async () => {
await loadState();
restoreDraftFromStorage();
setWizardStepKey(normalizeWizardStepKey(activeWizardStep.value));
syncWizardStepWithCouncilProgress();
if (isDocWizardStep(activeWizardStep.value)) {
const registryId = getRegistryIdForDocWizardStep(activeWizardStep.value);
if (registryId != null) {
void ensurePreview(registryId);
}
}
if (shouldPollCouncilState.value) {
startCouncilPoll();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
stopCouncilPoll();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
onMounted(loadState);
</script>
<style scoped lang="scss">
.capital-onboarding {
display: flex;
justify-content: center;
min-height: 100%;
}
.capital-onboarding__shell {
width: 100%;
max-width: 920px;
}
.capital-onboarding__banner {
margin-bottom: var(--p-4);
}
.capital-onboarding__stepper {
margin-top: var(--p-2);
}
.capital-onboarding__continue--solo {
margin-left: auto;
}
.capital-onboarding__dialog-section {
padding: var(--p-1) 0;
}
.capital-onboarding__footer {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
justify-content: space-between;
gap: var(--p-3);
margin-top: var(--p-5);
padding: var(--p-4) 0;
background: var(--p-canvas);
border-top: 1px solid var(--p-line);
}
</style>
@@ -1,301 +1,387 @@
<template lang="pug">
BaseCard.capital-doc-params(title='Параметры документов ЦПП')
p.t-body-sm.capital-doc-params__intro
| Заполните параметры положений «ГЕНЕРАТОР» и «БЛАГОРОСТ» прямо в тексте документа.
BaseCard(title='Параметры документов ЦПП')
p.capital-doc-params__intro.t-body2
| Заполните поля, которые будут подставлены в положения и оферты программ «ГЕНЕРАТОР» и «БЛАГОРОСТ».
| Перед установкой расширения сформируйте предпросмотр документов: так параметры сохранятся в PrivateData, а в config попадёт только hash.
q-tabs(v-model='activeTab' dense align='left' class='capital-doc-params__tabs')
q-tab(
v-for='section in DOCUMENT_SECTIONS'
:key='section.registryId'
:name='section.registryId'
:label='section.tabLabel'
)
q-badge(v-if='getMissingCount(section.registryId)' color='warning' floating) {{ getMissingCount(section.registryId) }}
BaseInput(
:model-value='config?.capital_program_doc_data_hash || ""'
label='Hash PrivateData документов'
hint='Появится после формирования предпросмотра'
readonly
mono
)
q-tab-panels(v-model='activeTab' animated class='capital-doc-params__panels')
q-tab-panel(
v-for='section in DOCUMENT_SECTIONS'
:key='section.registryId'
:name='section.registryId'
)
.capital-doc-params__toolbar
BaseButton(
color='primary'
outline
:loading='loadingRegistryId === section.registryId'
:disable='!!loadingRegistryId'
@click='loadPreview(section.registryId)'
BaseBanner(v-if='config?.capital_program_doc_data_hash' variant='pos')
| Параметры сохранены. Hash: {{ config.capital_program_doc_data_hash }}
q-expansion-item(default-opened label='Протокол и кооператив' icon='description')
.row.q-col-gutter-md.q-mt-sm
.col-12.col-md-3(v-for='field in protocolFields' :key='field.key')
BaseInput(
v-model='form[field.key]'
:label='field.label'
:error='fieldErrors[field.key]'
)
.col-12.col-md-6(v-for='field in cooperativeFields' :key='field.key')
BaseInput(
v-model='form[field.key]'
:label='field.label'
:error='fieldErrors[field.key]'
)
q-icon(name='refresh' class='q-mr-xs')
| {{ previews[section.registryId] ? 'Обновить предпросмотр' : 'Сформировать предпросмотр' }}
q-inner-loading(:showing='loadingRegistryId === section.registryId')
CapitalProgramInlineDocumentPreview(
v-if='previews[section.registryId]'
v-model='form'
:html='getPreviewHtml(section.registryId)'
:field-labels='FIELD_LABELS'
class='q-mt-md'
q-expansion-item(label='Программа ГЕНЕРАТОР' icon='bolt')
.capital-doc-params__section
BaseInput(
v-for='field in generatorFields'
:key='field.key'
v-model='form[field.key]'
:label='field.label'
:type='field.type || "textarea"'
:autogrow='field.type !== "text"'
:error='fieldErrors[field.key]'
)
p.t-caption.text-grey-6.q-mt-md(v-else)
| Нажмите «Сформировать предпросмотр», чтобы увидеть документ с редактируемыми полями.
q-expansion-item(label='Программа БЛАГОРОСТ' icon='eco')
.capital-doc-params__section
BaseInput(
v-for='field in blagorostFields'
:key='field.key'
v-model='form[field.key]'
:label='field.label'
type='textarea'
autogrow
:error='fieldErrors[field.key]'
)
q-expansion-item(label='Возврат, источники и оферты' icon='article')
.capital-doc-params__section
BaseInput(
v-for='field in returnFields'
:key='field.key'
v-model='form[field.key]'
:label='field.label'
:type='field.type || "textarea"'
:autogrow='field.type !== "text"'
:error='fieldErrors[field.key]'
)
.capital-doc-params__footer
BaseButton(
color='primary'
:loading='saving'
:disable='!!loadingRegistryId'
@click='saveParams'
)
| Сохранить параметры
p.capital-doc-params__hint.t-caption
| Предпросмотр генерирует документы без публикации и сохраняет PrivateData по hash.
BaseButton(:loading='isGenerating' @click='generatePreview')
| Сформировать предпросмотр
BaseDialog(v-model='previewOpen' title='Предпросмотр положений ЦПП' size='xl')
.capital-doc-params__preview(v-if='previewDocuments.length')
q-tabs(v-model='activePreviewTab' dense align='left' class='capital-doc-params__tabs')
q-tab(
v-for='doc in previewDocuments'
:key='doc.registry_id'
:name='String(doc.registry_id)'
:label='doc.title'
)
q-separator
q-tab-panels(v-model='activePreviewTab' animated)
q-tab-panel(
v-for='doc in previewDocuments'
:key='doc.registry_id'
:name='String(doc.registry_id)'
)
.doc-preview
DocumentHtmlReader(:html='doc.html' :sanitize='false')
template(#footer)
BaseButton(variant='ghost' @click='previewOpen = false')
| Закрыть
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import type { Cooperative } from 'cooptypes';
import { computed, reactive, ref, watch } from 'vue';
import { Mutations } from '@coopenomics/sdk';
import { useSystemStore } from 'src/entities/System/model';
import { useSessionStore } from 'src/entities/Session';
import { BaseButton, BaseCard } from 'src/shared/ui/base';
import { api as documentApi } from 'src/shared/lib/document/api';
import { api } from '../api';
import CapitalProgramInlineDocumentPreview from './CapitalProgramInlineDocumentPreview.vue';
import {
ALL_DOC_FIELDS,
BLAGOROST_DOC_FIELDS,
DOCUMENT_SECTIONS,
FIELD_LABELS,
GENERATOR_DOC_FIELDS,
PREVIEW_PLACEHOLDER,
type EditableFieldKey,
} from '../model/capitalProgramDocFields';
import {
getCapitalProgramDocParamsDraftKey,
readCapitalProgramDocParamsDraft,
writeCapitalProgramDocParamsDraft,
} from '../model/useCapitalProgramDocParamsDraft';
import { client } from 'src/shared/api/client';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { BaseBanner, BaseButton, BaseCard, BaseDialog, BaseInput } from 'src/shared/ui/base';
import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader';
type CapitalProgramPrivateData = {
approval_protocol_number: string;
approval_protocol_day: string;
approval_protocol_month: string;
approval_protocol_year: string;
cooperative_name: string;
cooperative_short_name: string;
cooperative_quoted_name: string;
website: string;
chairman_full_name: string;
generator_program_purpose: string;
eoap_definition: string;
generator_task_goal: string;
idea_unit_cost: string;
idea_unit_cost_words: string;
blagorost_goal_expansion: string;
blagorost_task_expansion: string;
blagorost_task_development: string;
return_source_description: string;
return_additional_source: string;
offer_template_number: string;
};
type FieldDefinition = {
key: keyof CapitalProgramPrivateData;
label: string;
type?: 'text' | 'textarea';
};
type CapitalProgramConfig = Record<string, unknown> & {
capital_program_doc_data_hash?: string | null;
};
type GeneratedPreviewDocument = NonNullable<
Mutations.Documents.GenerateDocument.IOutput[typeof Mutations.Documents.GenerateDocument.name]
>;
interface Props {
config: CapitalProgramConfig;
}
const props = defineProps<Props>();
const emit = defineEmits<{
saved: [hash: string];
(event: 'update:config', value: CapitalProgramConfig): void;
}>();
const $q = useQuasar();
const systemStore = useSystemStore();
const sessionStore = useSessionStore();
const system = useSystemStore();
const session = useSessionStore();
const isGenerating = ref(false);
const previewOpen = ref(false);
const activePreviewTab = ref('994');
const previewDocuments = ref<Array<{ registry_id: number; title: string; html: string; hash: string }>>([]);
const fieldErrors = reactive<Partial<Record<keyof CapitalProgramPrivateData, string>>>({});
const activeTab = ref<number>(994);
const saving = ref(false);
const savedHash = ref('');
const loadingRegistryId = ref<number | null>(null);
const form = reactive<Omit<CapitalProgramPrivateData, 'cooperative_quoted_name'>>({
approval_protocol_number: '',
approval_protocol_day: '',
approval_protocol_month: '',
approval_protocol_year: '',
cooperative_name: '',
cooperative_short_name: '',
website: '',
chairman_full_name: '',
generator_program_purpose: '',
eoap_definition: '',
generator_task_goal: '',
idea_unit_cost: '',
idea_unit_cost_words: '',
blagorost_goal_expansion: '',
blagorost_task_expansion: '',
blagorost_task_development: '',
return_source_description: '',
return_additional_source: '',
offer_template_number: '______',
});
type PreviewState = {
html: string;
docDataHash: string;
};
const protocolFields: FieldDefinition[] = [
{ key: 'approval_protocol_number', label: 'Номер протокола' },
{ key: 'approval_protocol_day', label: 'День протокола' },
{ key: 'approval_protocol_month', label: 'Месяц протокола' },
{ key: 'approval_protocol_year', label: 'Год протокола' },
];
const previews = reactive<Partial<Record<number, PreviewState>>>({});
const cooperativeFields: FieldDefinition[] = [
{ key: 'cooperative_name', label: 'Наименование кооператива' },
{ key: 'cooperative_short_name', label: 'Краткое наименование (ПК)' },
{ key: 'website', label: 'Сайт' },
{ key: 'chairman_full_name', label: 'ФИО председателя' },
];
const DOC_FIELDS_BY_REGISTRY: Record<number, EditableFieldKey[]> = {
994: GENERATOR_DOC_FIELDS,
998: BLAGOROST_DOC_FIELDS,
};
const generatorFields: FieldDefinition[] = [
{ key: 'generator_program_purpose', label: 'Назначение программы ГЕНЕРАТОР' },
{ key: 'eoap_definition', label: 'Определение ЕОАП' },
{ key: 'generator_task_goal', label: 'Цель задач ГЕНЕРАТОР' },
{ key: 'idea_unit_cost', label: 'Стоимость единицы идеи', type: 'text' },
{ key: 'idea_unit_cost_words', label: 'Стоимость единицы идеи прописью', type: 'text' },
];
function createEmptyForm(): Record<EditableFieldKey, string> {
return Object.fromEntries(ALL_DOC_FIELDS.map((key) => [key, ''])) as Record<EditableFieldKey, string>;
}
const blagorostFields: FieldDefinition[] = [
{ key: 'blagorost_goal_expansion', label: 'Расширение цели БЛАГОРОСТ' },
{ key: 'blagorost_task_expansion', label: 'Задача расширения БЛАГОРОСТ' },
{ key: 'blagorost_task_development', label: 'Задача развития БЛАГОРОСТ' },
];
const form = reactive<Record<EditableFieldKey, string>>(createEmptyForm());
const returnFields: FieldDefinition[] = [
{ key: 'return_source_description', label: 'Источник возврата' },
{ key: 'return_additional_source', label: 'Дополнительный источник возврата' },
{ key: 'offer_template_number', label: 'Номер шаблона оферты', type: 'text' },
];
const draftStorageKey = computed(() =>
getCapitalProgramDocParamsDraftKey(systemStore.info?.coopname ?? '', sessionStore.username ?? ''),
const allFields = computed(() => [
...protocolFields,
...cooperativeFields,
...generatorFields,
...blagorostFields,
...returnFields,
]);
watch(
() => system.info,
(info) => {
if (!info) return;
form.cooperative_name ||= String(info.coopname || '').toUpperCase();
form.cooperative_short_name ||= 'ПК';
form.website ||= info.website || info.contacts?.website || '';
form.chairman_full_name ||= info.chairman?.full_name || info.chairman_full_name || '';
},
{ immediate: true }
);
function restoreDraftFromStorage() {
const draft = readCapitalProgramDocParamsDraft(draftStorageKey.value);
if (!draft) return;
Object.assign(form, draft.form);
savedHash.value = draft.savedHash;
Object.assign(previews, draft.previews);
activeTab.value = draft.activeTab ?? 994;
function getPayload(): CapitalProgramPrivateData {
const cooperativeName = String(form.cooperative_name || '').trim();
return {
...Object.fromEntries(
allFields.value.map(({ key }) => [key, String(form[key] || '').trim()])
) as Omit<CapitalProgramPrivateData, 'cooperative_quoted_name'>,
cooperative_quoted_name: cooperativeName ? `«${cooperativeName}»` : '',
};
}
function persistDraftToStorage() {
writeCapitalProgramDocParamsDraft(draftStorageKey.value, {
form: { ...form },
activeTab: activeTab.value,
savedHash: savedHash.value,
previews: { ...previews },
});
}
const editableFields = computed(() =>
ALL_DOC_FIELDS.map((key) => ({
key,
label: FIELD_LABELS[key],
})),
);
function getPreviewHtml(registryId: number): string {
return previews[registryId]?.html ?? '';
}
function getMissingCount(registryId: number): number {
const fields = DOC_FIELDS_BY_REGISTRY[registryId] ?? [];
return fields.filter((key) => !String(form[key] ?? '').trim()).length;
}
function getPreviewPayload(): Cooperative.Registry.CapitalProgramPrivateData {
return Object.fromEntries(
ALL_DOC_FIELDS.map((key) => [key, String(form[key] ?? '').trim() || PREVIEW_PLACEHOLDER]),
) as Record<EditableFieldKey, string>;
}
function getDocDataHashFromResult(result: Awaited<ReturnType<typeof documentApi.generateDocument>>): string {
const meta = result.meta as { doc_data_hash?: string } | undefined;
return meta?.doc_data_hash ?? '';
}
function validatePayload(): string | null {
const missing = editableFields.value
.filter(({ key }) => !String(form[key] ?? '').trim())
function validatePayload(payload: CapitalProgramPrivateData): string[] {
return allFields.value
.filter(({ key }) => !payload[key])
.map(({ label }) => label);
}
function clearFieldErrors(): void {
for (const key of Object.keys(fieldErrors) as Array<keyof CapitalProgramPrivateData>) {
delete fieldErrors[key];
}
}
function setFieldErrors(missingLabels: string[]): void {
clearFieldErrors();
for (const { key, label } of allFields.value) {
if (missingLabels.includes(label)) {
fieldErrors[key] = 'Заполните поле';
}
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function getDocDataHash(doc: GeneratedPreviewDocument): string | null {
if (!isRecord(doc.meta)) return null;
const hash = doc.meta.doc_data_hash;
return typeof hash === 'string' && hash.length > 0 ? hash : null;
}
async function generateDocument(registry_id: number, payload: CapitalProgramPrivateData): Promise<GeneratedPreviewDocument> {
const { [Mutations.Documents.GenerateDocument.name]: result } = await client.Mutation(
Mutations.Documents.GenerateDocument.mutation,
{
variables: {
input: {
data: {
coopname: system.info?.coopname || '',
username: session.username,
lang: 'ru',
registry_id,
doc_data: payload,
},
options: { skip_save: true },
},
},
}
);
if (!result) {
throw new Error('Документ не был сгенерирован');
}
return result;
}
async function generatePreview() {
const payload = getPayload();
const missing = validatePayload(payload);
if (missing.length) {
return `Заполните все параметры в документах: ${missing.join(', ')}`;
}
return null;
}
async function loadPreview(registryId: number, options?: { silent?: boolean }) {
if (!options?.silent) {
loadingRegistryId.value = registryId;
}
try {
const result = await documentApi.generateDocument({
coopname: systemStore.info?.coopname ?? '',
username: sessionStore.username ?? '',
registry_id: registryId,
doc_data: getPreviewPayload(),
});
previews[registryId] = {
html: result.html,
docDataHash: getDocDataHashFromResult(result),
};
persistDraftToStorage();
return result;
} catch (error) {
if (!options?.silent) {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : 'Не удалось сформировать предпросмотр',
});
}
throw error;
} finally {
if (!options?.silent) {
loadingRegistryId.value = null;
}
}
}
async function saveParams() {
const validationError = validatePayload();
if (validationError) {
$q.notify({ type: 'warning', message: validationError });
setFieldErrors(missing);
FailAlert(`Заполните параметры документов: ${missing.join(', ')}`);
return;
}
saving.value = true;
clearFieldErrors();
try {
for (const section of DOCUMENT_SECTIONS) {
await loadPreview(section.registryId, { silent: true });
isGenerating.value = true;
const docs = await Promise.all([
generateDocument(994, payload),
generateDocument(998, payload),
]);
const docDataHash = docs
.map(getDocDataHash)
.find((hash: unknown): hash is string => typeof hash === 'string' && hash.length > 0);
if (!docDataHash) {
throw new Error('Генератор не вернул doc_data_hash');
}
const hash = previews[994]?.docDataHash ?? previews[998]?.docDataHash;
if (!hash) {
throw new Error('Не удалось получить хеш параметров документов');
}
await api.saveProgramDocDataHash({ doc_data_hash: hash });
savedHash.value = hash;
emit('saved', hash);
$q.notify({
type: 'positive',
message: 'Параметры документов ЦПП сохранены',
previewDocuments.value = docs.map((doc, index) => ({
registry_id: index === 0 ? 994 : 998,
title: doc.full_title,
html: doc.html,
hash: doc.hash,
}));
activePreviewTab.value = '994';
previewOpen.value = true;
emit('update:config', {
...props.config,
capital_program_doc_data_hash: docDataHash,
});
SuccessAlert('Параметры документов сохранены в PrivateData');
} catch (error) {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : 'Не удалось сохранить параметры',
});
FailAlert(error);
} finally {
saving.value = false;
isGenerating.value = false;
}
}
watch(activeTab, (registryId) => {
persistDraftToStorage();
if (!previews[registryId]) {
void loadPreview(registryId);
}
});
watch(
form,
() => {
persistDraftToStorage();
},
{ deep: true },
);
watch(savedHash, () => {
persistDraftToStorage();
});
onMounted(() => {
restoreDraftFromStorage();
if (!previews[activeTab.value]) {
void loadPreview(activeTab.value);
}
});
</script>
<style scoped lang="scss">
.capital-doc-params__intro {
margin: 0 0 var(--p-4);
color: var(--p-ink-muted);
color: var(--p-ink-secondary);
}
.capital-doc-params__tabs {
margin-bottom: var(--p-2);
}
.capital-doc-params__panels {
background: transparent;
}
.capital-doc-params__toolbar {
.capital-doc-params__section {
display: flex;
align-items: center;
gap: var(--p-2);
flex-direction: column;
gap: var(--p-3);
margin-top: var(--p-2);
}
.capital-doc-params__footer {
display: flex;
justify-content: flex-end;
margin-top: var(--p-4);
padding-top: var(--p-4);
border-top: 1px solid var(--p-line);
align-items: center;
justify-content: space-between;
gap: var(--p-4);
margin-top: var(--p-6);
}
.capital-doc-params__hint {
margin: 0;
color: var(--p-ink-secondary);
}
.capital-doc-params__tabs {
color: var(--p-primary);
}
.doc-preview :deep([data-doc-param]) {
background: var(--p-warn-soft);
border-radius: var(--p-r-sm);
padding: 0 2px;
}
</style>
@@ -1,79 +0,0 @@
<template lang="pug">
.capital-doc-step
.capital-doc-step__preview-shell
.capital-doc-step__toolbar
BaseButton(
variant='ghost'
size='sm'
:disable='loading'
@click='emit("refresh")'
)
q-icon(name='refresh' size='18px')
span.q-ml-xs {{ previewHtml ? 'Обновить предпросмотр' : 'Сформировать предпросмотр' }}
CapitalProgramInlineDocumentPreview(
v-if='previewHtml && !loading'
v-model='form'
:html='previewHtml'
:field-labels='FIELD_LABELS'
)
.capital-doc-step__loading(v-else-if='loading')
q-spinner(size='36px' color='primary')
p.t-body-sm.capital-doc-step__loading-text Формируем предпросмотр документа
EmptyState(
v-else
title='Предпросмотр ещё не сформирован'
body='Нажмите «Сформировать предпросмотр», чтобы увидеть документ с редактируемыми полями.'
)
</template>
<script setup lang="ts">
import { BaseButton, EmptyState } from 'src/shared/ui/base';
import CapitalProgramInlineDocumentPreview from './CapitalProgramInlineDocumentPreview.vue';
import { FIELD_LABELS, type EditableFieldKey } from '../model/capitalProgramDocFields';
defineProps<{
previewHtml: string;
loading: boolean;
}>();
const form = defineModel<Record<EditableFieldKey, string>>({ required: true });
const emit = defineEmits<{
refresh: [];
}>();
</script>
<style scoped lang="scss">
.capital-doc-step__preview-shell {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md);
background: var(--p-surface);
overflow: hidden;
}
.capital-doc-step__toolbar {
display: flex;
justify-content: flex-end;
padding: var(--p-2) var(--p-3);
border-bottom: 1px solid var(--p-line);
background: var(--p-surface-2);
}
.capital-doc-step__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--p-3);
min-height: 240px;
padding: var(--p-6);
}
.capital-doc-step__loading-text {
margin: 0;
color: var(--p-ink-2);
}
</style>
@@ -1,202 +0,0 @@
<template lang="pug">
.capital-inline-doc.statement(ref='containerRef')
</template>
<script setup lang="ts">
import DOMPurify from 'dompurify';
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import type { EditableFieldKey } from '../model/capitalProgramDocFields';
const props = defineProps<{
html: string;
fieldLabels: Partial<Record<EditableFieldKey, string>>;
}>();
const form = defineModel<Record<EditableFieldKey, string>>({ required: true });
const containerRef = ref<HTMLElement | null>(null);
const listeners = new WeakMap<HTMLTextAreaElement, () => void>();
type FocusSnapshot = {
key: EditableFieldKey;
selectionStart: number;
selectionEnd: number;
};
let focusSnapshot: FocusSnapshot | null = null;
function isFieldEmpty(key: EditableFieldKey): boolean {
return !String(form.value[key] ?? '').trim();
}
function applyFieldStyle(textarea: HTMLTextAreaElement, key: EditableFieldKey) {
textarea.classList.toggle('capital-doc-param-inline--empty', isFieldEmpty(key));
}
function captureFocus() {
const active = document.activeElement;
if (!(active instanceof HTMLTextAreaElement) || !active.classList.contains('capital-doc-param-inline')) {
focusSnapshot = null;
return;
}
const key = active.getAttribute('data-field') as EditableFieldKey | null;
if (!key) {
focusSnapshot = null;
return;
}
focusSnapshot = {
key,
selectionStart: active.selectionStart,
selectionEnd: active.selectionEnd,
};
}
function restoreFocus(root: HTMLElement) {
if (!focusSnapshot) return;
const textarea = root.querySelector(`textarea[data-field="${focusSnapshot.key}"]`) as HTMLTextAreaElement | null;
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(focusSnapshot.selectionStart, focusSnapshot.selectionEnd);
}
function cleanupEditors(root: HTMLElement) {
root.querySelectorAll('textarea.capital-doc-param-inline').forEach((node) => {
const textarea = node as HTMLTextAreaElement;
const handler = listeners.get(textarea);
if (handler) {
textarea.removeEventListener('input', handler);
listeners.delete(textarea);
}
});
}
function mountInlineEditors(root: HTMLElement) {
root.querySelectorAll('[data-doc-param]').forEach((node) => {
const key = node.getAttribute('data-doc-param') as EditableFieldKey | null;
if (!key) return;
const textarea = document.createElement('textarea');
textarea.className = 'capital-doc-param-inline';
textarea.setAttribute('data-field', key);
textarea.rows =
key.includes('description') ||
key.includes('expansion') ||
key.includes('development') ||
key.includes('goal') ||
key.includes('purpose')
? 3
: 2;
textarea.value = form.value[key] ?? '';
textarea.placeholder = props.fieldLabels[key] ?? key;
applyFieldStyle(textarea, key);
const onInput = () => {
form.value[key] = textarea.value;
applyFieldStyle(textarea, key);
};
textarea.addEventListener('input', onInput);
listeners.set(textarea, onInput);
node.replaceWith(textarea);
});
}
function syncInlineEditors(root: HTMLElement) {
root.querySelectorAll('textarea.capital-doc-param-inline').forEach((node) => {
const textarea = node as HTMLTextAreaElement;
const key = textarea.getAttribute('data-field') as EditableFieldKey | null;
if (!key) return;
if (textarea !== document.activeElement && textarea.value !== (form.value[key] ?? '')) {
textarea.value = form.value[key] ?? '';
}
applyFieldStyle(textarea, key);
});
}
async function renderDocument(html: string) {
await nextTick();
const root = containerRef.value;
if (!root) return;
captureFocus();
cleanupEditors(root);
root.innerHTML = DOMPurify.sanitize(html);
mountInlineEditors(root);
restoreFocus(root);
}
watch(
() => props.html,
(html) => {
if (html) void renderDocument(html);
},
{ immediate: true },
);
watch(
form,
() => {
if (containerRef.value) syncInlineEditors(containerRef.value);
},
{ deep: true },
);
onBeforeUnmount(() => {
if (containerRef.value) cleanupEditors(containerRef.value);
});
</script>
<style scoped lang="scss">
.capital-inline-doc {
max-height: 60vh;
overflow: auto;
padding: var(--p-4);
border: 1px solid var(--p-line);
border-radius: var(--p-r-md);
background: var(--p-surface);
}
.capital-inline-doc :deep(.capital-doc-param-inline) {
display: block;
width: 100%;
max-width: 100%;
margin: var(--p-1) 0;
padding: var(--p-2) var(--p-3);
font: inherit;
line-height: inherit;
color: var(--p-ink);
background: color-mix(in srgb, var(--p-primary) 8%, var(--p-surface));
border: 1px dashed var(--p-primary);
border-radius: var(--p-r-sm);
resize: vertical;
box-sizing: border-box;
white-space: pre-wrap;
&:focus {
outline: none;
border-style: solid;
background: color-mix(in srgb, var(--p-primary) 12%, var(--p-surface));
}
&::placeholder {
color: var(--p-ink-muted);
font-size: 0.85em;
}
}
.capital-inline-doc :deep(.capital-doc-param-inline--empty) {
border-color: var(--p-warn);
background: color-mix(in srgb, var(--p-warn) 12%, var(--p-surface));
&:focus {
background: color-mix(in srgb, var(--p-warn) 16%, var(--p-surface));
}
}
</style>
@@ -1,4 +1,2 @@
export { default as CapitalCouncilStepGroup } from './CapitalCouncilStepGroup.vue';
export { default as CapitalOnboardingCard } from './CapitalOnboardingCard.vue';
export { default as CapitalProgramDocumentStepPanel } from './CapitalProgramDocumentStepPanel.vue';
export { default as CapitalProgramInlineDocumentPreview } from './CapitalProgramInlineDocumentPreview.vue';
export { default as CapitalProgramDocumentParametersWidget } from './CapitalProgramDocumentParametersWidget.vue';
@@ -116,21 +116,6 @@ q-dialog(
span(v-if='isValid') Форма валидна
span(v-else) Ошибок: {{ errorsCount }}
//- Явный список ошибок полей страховка на случай, если конкретное
//- поле формы не подсвечивается инлайн (не все секции формы ещё
//- подключены к fieldErrors). Без этого списка счётчик показывал
//- «Ошибок: N» без единой подсказки, где именно искать.
q-list.error-list.q-mb-sm(
v-if='!isValid && fieldErrorEntries.length'
dense bordered separator
)
q-item(v-for='err in fieldErrorEntries' :key='err.key' dense)
q-item-section(avatar top)
q-icon(name='fa-solid fa-circle-exclamation' color='negative' size='14px')
q-item-section
q-item-label.text-caption.text-weight-medium {{ err.label }}
q-item-label(caption) {{ err.message }}
.text-subtitle2.q-mb-sm Действия
q-btn.q-mb-sm(
@@ -141,6 +126,7 @@ q-dialog(
:loading='isGenerating'
@click='downloadXml'
no-caps
stack
)
q-tooltip Сохраняет черновик, генерирует XML, валидирует XSD и скачивает файл
q-btn.q-mb-sm(
@@ -151,6 +137,7 @@ q-dialog(
:loading='pdfLoading'
@click='downloadPdf'
no-caps
stack
)
q-tooltip(v-if='!hasPdfPaperView') PDF-экспорт для {{ reportTitle }} пока не поддерживается
q-tooltip(v-else) Заполненный отчёт в PDF (из бумажного вида)
@@ -165,6 +152,7 @@ q-dialog(
:disable='isLoading'
@click='regenerate'
no-caps
stack
)
q-tooltip Подтянуть актуальные данные из реестра; ваши правки сохраняются
@@ -176,6 +164,7 @@ q-dialog(
label='Удалить черновик'
@click='clearDraft'
no-caps
stack
)
q-tooltip Вернуть форму к дефолтам
@@ -203,6 +192,7 @@ q-dialog(
:loading='markLoading'
@click='clearMark'
no-caps
stack
)
q-tooltip Вернуть обычный статус периода
@@ -217,6 +207,7 @@ q-dialog(
:loading='markLoading'
@click='clearMark'
no-caps
stack
)
q-tooltip Вернуть обычный статус периода
@@ -229,6 +220,7 @@ q-dialog(
:loading='markLoading'
@click='markSubmittedExternally'
no-caps
stack
)
q-tooltip Если отчёт уже сдан в бумаге / через стороннюю систему. В архив XML не попадает, но ячейка станет зелёной.
q-btn(
@@ -239,6 +231,7 @@ q-dialog(
:loading='markLoading'
@click='markNotRequired'
no-caps
stack
)
q-tooltip Отметить, что этот период сдавать не нужно ячейка станет серой
@@ -491,7 +484,6 @@ interface ZeroReportEdits {
repDoc: string | null
snils: string | null
sfrRegNumber: string | null
pfrRegNumber: string | null
chairmanPosition: string | null
}
}
@@ -521,34 +513,6 @@ const errorsCount = computed(() => {
return count
})
// Человекочитаемые названия секций формы для расшифровки JSONPath из
// серверной ошибки валидации (см. error-list ниже).
const FIELD_SECTION_LABELS: Record<string, string> = {
header: 'Шапка',
organization: 'Организация',
signer: 'Подписант',
balance: 'Баланс',
notes: 'Пояснения',
}
function humanizeFieldPath(path: string): string {
const [section, ...rest] = path.split('.')
const sectionLabel = FIELD_SECTION_LABELS[section] ?? section
return rest.length ? `${sectionLabel}${rest.join(' → ')}` : sectionLabel
}
interface FieldErrorEntry { key: string; label: string; message: string }
const fieldErrorEntries = computed<FieldErrorEntry[]>(() => {
const out: FieldErrorEntry[] = []
for (const [path, messages] of Object.entries(fieldErrors.value)) {
messages.forEach((message, i) => {
out.push({ key: `${path}#${i}`, label: humanizeFieldPath(path), message })
})
}
return out
})
const saveStatusColor = computed(() => {
if (isSaving.value) return 'orange'
if (hasDraft.value) return 'positive'
@@ -875,22 +839,6 @@ function goToRequisites(): void {
}
}
.error-list {
background: var(--p-surface-1, #fff);
border-color: var(--p-neg-soft);
max-height: 220px;
overflow-y: auto;
:deep(.q-item) {
min-height: unset;
padding: 6px 8px;
}
:deep(.q-item__label--caption) {
color: var(--p-ink-2);
}
}
.mark-hint {
font-size: var(--p-fs-meta, 12px);
color: var(--p-ink-2);
@@ -36,10 +36,9 @@ const props = defineProps<{
mask?: string
/** Подставлять плейсхолдеры маски при вводе */
fillMask?: boolean
/** Разрешить только цифры + перечисленные доп. символы (напр. '.' для
* ОКВЭД: 94.99, 46.73.7; '-' для Рег. номера СФР). Блокирует
* keypress/paste посторонние символы вообще не появляются в поле. */
digitsExtraChars?: string
/** Разрешить только цифры и точку (ОКВЭД: 94.99, 46.73.7). Блокирует
* keypress/paste буквы вообще не появляются в поле. */
digitsDotsOnly?: boolean
/** HTML maxlength. */
maxLength?: number
/** Допустимые точные длины (ИЛИ). Напр. `[8, 11]` для ОКТМО. */
@@ -61,20 +60,12 @@ const labelText = computed(() =>
)
const inputModeComputed = computed<'numeric' | 'decimal' | 'text'>(() => {
// '.' десятичная клавиатура мобильных устройств её показывает; для
// прочих доп.символов (напр. '-') нужна полная клавиатура 'text'.
if (props.digitsExtraChars === '.') return 'decimal'
if (props.digitsExtraChars) return 'text'
if (props.digitsDotsOnly) return 'decimal'
// mask из одних `#` = чистая цифровая клавиатура на мобиле.
if (props.mask && /^#+$/.test(props.mask)) return 'numeric'
return 'text'
})
/** Экранирует спецсимволы regex, чтобы вставить строку в символьный класс `[...]`. */
function escapeForCharClass(chars: string): string {
return chars.replace(/[\]\\^-]/g, '\\$&')
}
const NAV_KEYS = new Set([
'Backspace',
'Delete',
@@ -90,21 +81,19 @@ const NAV_KEYS = new Set([
])
// Блокируем keypress не-допустимых символов буквы не попадают в поле даже
// на миллисекунды. Работает только для digitsExtraChars (для чисто цифровых
// на миллисекунды. Работает только для digitsDotsOnly (для чисто цифровых
// полей используем Quasar-mask он нативно блокирует не-# символы).
function onKeydown(e: KeyboardEvent): void {
if (!props.digitsExtraChars) return
if (!props.digitsDotsOnly) return
if (e.ctrlKey || e.metaKey || e.altKey) return
if (NAV_KEYS.has(e.key)) return
const re = new RegExp(`^[\\d${escapeForCharClass(props.digitsExtraChars)}]$`)
if (!re.test(e.key)) e.preventDefault()
if (!/^[\d.]$/.test(e.key)) e.preventDefault()
}
function onPaste(e: ClipboardEvent): void {
if (!props.digitsExtraChars) return
if (!props.digitsDotsOnly) return
const text = e.clipboardData?.getData('text') ?? ''
const re = new RegExp(`^[\\d${escapeForCharClass(props.digitsExtraChars)}]*$`)
if (!re.test(text)) e.preventDefault()
if (!/^[\d.]*$/.test(text)) e.preventDefault()
}
const rules = computed(() => {
@@ -139,10 +128,7 @@ function onInput(raw: string | number | null): void {
// Фоллбек-фильтр на случай programmatic ввода (drop/autofill). Для ручного
// набора актуальна либо mask (q-input сам режет), либо keydown-блокер.
let s = String(raw ?? '')
if (props.digitsExtraChars) {
const re = new RegExp(`[^\\d${escapeForCharClass(props.digitsExtraChars)}]+`, 'g')
s = s.replace(re, '')
}
if (props.digitsDotsOnly) s = s.replace(/[^\d.]+/g, '')
if (props.maxLength && s.length > props.maxLength) s = s.slice(0, props.maxLength)
emit('update:value', s)
}
@@ -37,7 +37,7 @@ q-form.settings-page(@submit.prevent='save' @validation-error='onValidationError
:value='manualInput[f.key]'
:placeholder='f.placeholder'
:mask='f.mask'
:digits-extra-chars='f.digitsExtraChars'
:digits-dots-only='f.digitsDotsOnly'
:max-length='f.maxLength'
:exact-lengths='f.exactLengths'
:pattern='f.pattern'
@@ -46,11 +46,11 @@ q-form.settings-page(@submit.prevent='save' @validation-error='onValidationError
@update:value='v => (manualInput[f.key] = v)'
)
//- СФР/ПФР (ЕФС-1)
//- СФР (ЕФС-1)
q-card.q-mt-md(flat)
q-card-section.q-py-sm
.text-h6 СФР/ПФР (для ЕФС-1)
.text-caption.t-muted Регистрационные номера страхователя и должность председателя
.text-h6 СФР (для ЕФС-1)
.text-caption.t-muted Регистрационный номер СФР и должность председателя
q-separator
@@ -60,26 +60,12 @@ q-form.settings-page(@submit.prevent='save' @validation-error='onValidationError
id='field-sfrRegNumber'
label='Рег. номер СФР'
:value='manualInput.sfrRegNumber'
placeholder='10 цифр'
mask='##########'
:max-length='10'
:exact-lengths='[10]'
:pattern='SFR_REG_PATTERN'
pattern-message='Формат: 10 цифр без разделителей'
required
@update:value='v => (manualInput.sfrRegNumber = v)'
)
RequisiteField.col-md-6.col-12(
id='field-pfrRegNumber'
label='Рег. номер ПФР'
:value='manualInput.pfrRegNumber'
placeholder='XXX-XXX-XXXXXX'
mask='###-###-######'
:max-length='14'
:pattern='PFR_REG_PATTERN'
pattern-message='Формат: XXX-XXX-XXXXXX (12 цифр с тире)'
:pattern='SFR_REG_PATTERN'
pattern-message='Формат: XXX-XXX-XXXXXX (12 цифр)'
required
@update:value='v => (manualInput.pfrRegNumber = v)'
@update:value='v => (manualInput.sfrRegNumber = v)'
)
RequisiteField.col-md-6.col-12(
id='field-chairmanPosition'
@@ -177,7 +163,6 @@ type ManualKey =
| 'oktmo'
| 'okpo'
| 'sfrRegNumber'
| 'pfrRegNumber'
| 'chairmanPosition'
| 'signerSnils'
| 'signerRepDoc'
@@ -191,7 +176,6 @@ const manualInput = reactive<Record<ManualKey, string>>({
oktmo: '',
okpo: '',
sfrRegNumber: '',
pfrRegNumber: '',
chairmanPosition: '',
signerSnils: '',
signerRepDoc: '',
@@ -218,7 +202,7 @@ interface ClassifierField {
label: string
placeholder?: string
mask?: string
digitsExtraChars?: string
digitsDotsOnly?: boolean
maxLength?: number
exactLengths?: number[]
pattern?: RegExp
@@ -228,13 +212,13 @@ interface ClassifierField {
// Placeholder'ы правило ввода (кол-во цифр), а не конкретные примеры.
// Для цифровых полей фиксированной/короткой длины используем Quasar-mask
// (`#` = только цифра, буквы блокируются на keypress нативно).
// Для ОКВЭД (цифры + точка переменной длины) digitsExtraChars='.' с @keydown-блокером.
// Для ОКВЭД (цифры + точка переменной длины) digitsDotsOnly с @keydown-блокером.
const classifierFields: ClassifierField[] = [
{
key: 'okved',
label: 'ОКВЭД',
placeholder: 'XX.XX или XX.XX.XX',
digitsExtraChars: '.',
digitsDotsOnly: true,
maxLength: 8,
pattern: /^\d{2}(\.\d{1,2}){0,2}$/,
patternMessage: 'Формат: XX.XX или XX.XX.XX',
@@ -281,8 +265,7 @@ const signerTypeOptions = [
// пользователь прожал пару цифр и нажал «Сохранить»: mask не мешает
// сохранить частичный ввод, паттерн-правило блокирует.
const SNILS_PATTERN = /^\d{3}-\d{3}-\d{3} \d{2}$/
const SFR_REG_PATTERN = /^\d{10}$/
const PFR_REG_PATTERN = /^\d{3}-\d{3}-\d{6}$/
const SFR_REG_PATTERN = /^\d{3}-\d{3}-\d{6}$/
function getValue(key: keyof IReportRequisitesView): string {
const v = requisites.value?.[key] as any
@@ -9,8 +9,6 @@
@update:model-value='v => emitField("otch", v)'
dense outlined
input-class='text-right'
:error='errFor("otch")'
:error-message='msgFor("otch")'
)
.br-input
q-input(
@@ -19,8 +17,6 @@
@update:model-value='v => emitField("prev", v)'
dense outlined
input-class='text-right'
:error='errFor("prev")'
:error-message='msgFor("prev")'
)
.br-input
q-input(
@@ -29,8 +25,6 @@
@update:model-value='v => emitField("prePrev", v)'
dense outlined
input-class='text-right'
:error='errFor("prePrev")'
:error-message='msgFor("prePrev")'
)
</template>
@@ -45,10 +39,6 @@ const props = defineProps<{
label: string
code: string
row: BalanceRow | null
/** JSONPath строки без учёта суффикса (например "balance.assetsTotal") — для сопоставления с fieldErrors. */
basePath?: string
/** Серверные ошибки валидации всей формы: ключ = JSONPath поля. */
fieldErrors?: Record<string, string[]>
}>()
const emit = defineEmits<{
@@ -68,18 +58,6 @@ function emitField(key: keyof BalanceRow, raw: unknown): void {
const next: BalanceRow = { ...base, [key]: clamp(raw) }
emit('update', next)
}
function pathFor(key: keyof BalanceRow): string {
return props.basePath ? `${props.basePath}.${key}` : key
}
function errFor(key: keyof BalanceRow): boolean {
return (props.fieldErrors?.[pathFor(key)]?.length ?? 0) > 0
}
function msgFor(key: keyof BalanceRow): string {
return props.fieldErrors?.[pathFor(key)]?.[0] ?? ''
}
</script>
<style scoped lang="scss">
@@ -11,8 +11,6 @@
:model-value='editsValue.header.correctionNumber'
@update:model-value='v => updateField("header.correctionNumber", clampInt(v, 0, 999))'
:rules='[v => (v >= 0 && v <= 999) || "0..999"]'
:error='errFor("header.correctionNumber")'
:error-message='msgFor("header.correctionNumber")'
dense filled
)
q-input(
@@ -20,8 +18,6 @@
type='number'
:model-value='editsValue.header.reportYear'
@update:model-value='v => updateField("header.reportYear", clampInt(v, 2000, 2100))'
:error='errFor("header.reportYear")'
:error-message='msgFor("header.reportYear")'
dense filled
)
q-input(
@@ -30,8 +26,6 @@
@update:model-value='v => updateField("header.docDate", v)'
mask='##.##.####'
:rules='[reportRules.dateDdMmYyyy()]'
:error='errFor("header.docDate")'
:error-message='msgFor("header.docDate")'
dense filled
)
@@ -197,32 +191,24 @@
label='Нематериальные, финансовые и др. внеоборотные активы'
code='1170'
:row='editsValue.balance.nonMaterialAndLongFin'
base-path='balance.nonMaterialAndLongFin'
:field-errors='props.fieldErrors'
@update='row => updateRow("balance.nonMaterialAndLongFin", row)'
)
BalanceRowEditor(
label='Денежные средства и денежные эквиваленты'
code='1250'
:row='editsValue.balance.cash'
base-path='balance.cash'
:field-errors='props.fieldErrors'
@update='row => updateRow("balance.cash", row)'
)
BalanceRowEditor(
label='Финансовые и другие оборотные активы'
code='1260'
:row='editsValue.balance.shortTermFin'
base-path='balance.shortTermFin'
:field-errors='props.fieldErrors'
@update='row => updateRow("balance.shortTermFin", row)'
)
BalanceRowEditor.total(
label='БАЛАНС (актив)'
code='1600'
:row='editsValue.balance.assetsTotal'
base-path='balance.assetsTotal'
:field-errors='props.fieldErrors'
@update='row => updateRow("balance.assetsTotal", row)'
)
@@ -232,16 +218,12 @@
label='Целевые средства (паевой фонд + целевые поступления)'
code='1350'
:row='editsValue.balance.targetFunds'
base-path='balance.targetFunds'
:field-errors='props.fieldErrors'
@update='row => updateRow("balance.targetFunds", row)'
)
BalanceRowEditor.total(
label='БАЛАНС (пассив)'
code='1700'
:row='editsValue.balance.passivesTotal'
base-path='balance.passivesTotal'
:field-errors='props.fieldErrors'
@update='row => updateRow("balance.passivesTotal", row)'
)
@@ -26,8 +26,8 @@
.kv-row
.kv-cell
.kv-label Регистрационный номер страхователя
.kv-value.code {{ header.pfrRegNumber || '—' }}
.kv-label Регистрационный номер в СФР
.kv-value.code {{ header.sfrRegNumber || '—' }}
.kv-cell
.kv-label ОГРН
.kv-value.code {{ ogrn || '—' }}
@@ -9,8 +9,6 @@
type='number'
:model-value='editsValue.header.reportYear'
@update:model-value='v => updateField("header.reportYear", clampInt(v, 2000, 2100))'
:error='errFor("header.reportYear")'
:error-message='msgFor("header.reportYear")'
dense filled
)
q-input(
@@ -19,8 +17,6 @@
type='number'
:model-value='editsValue.header.period'
@update:model-value='v => updateField("header.period", clampInt(v, 1, periodKind === "quarter" ? 4 : 12))'
:error='errFor("header.period")'
:error-message='msgFor("header.period")'
dense filled
)
q-input(
@@ -36,12 +32,14 @@
.editor-section
h3.section-title Организация
.text-caption.t-muted.q-mb-sm Данные берутся из Реквизитов правка доступна только там
q-input(
label='Наименование организации'
:model-value='editsValue.organization.orgName'
readonly disable
@update:model-value='v => updateField("organization.orgName", v)'
:rules='[reportRules.length(1, 1000)]'
:error='errFor("organization.orgName")'
:error-message='msgFor("organization.orgName")'
dense filled
)
@@ -49,20 +47,30 @@
q-input(
label='ИНН'
:model-value='editsValue.organization.inn'
readonly disable
@update:model-value='v => updateField("organization.inn", v)'
:rules='[reportRules.innUl()]'
:error='errFor("organization.inn")'
:error-message='msgFor("organization.inn")'
dense filled
)
q-input(
label='КПП'
:model-value='editsValue.organization.kpp'
readonly disable
@update:model-value='v => updateField("organization.kpp", String(v || "").toUpperCase())'
:rules='[reportRules.kpp()]'
:error='errFor("organization.kpp")'
:error-message='msgFor("organization.kpp")'
dense filled
maxlength='9'
)
q-input(
v-if='needs.oktmo'
label='ОКТМО'
:model-value='editsValue.organization.oktmo || ""'
readonly disable
@update:model-value='v => updateField("organization.oktmo", v || null)'
:rules='[reportRules.oktmo()]'
:error='errFor("organization.oktmo")'
:error-message='msgFor("organization.oktmo")'
dense filled
)
@@ -70,24 +78,29 @@
q-input(
label='ОКВЭД'
:model-value='editsValue.organization.okved || ""'
readonly disable
@update:model-value='v => updateField("organization.okved", v || null)'
:rules='[reportRules.okved()]'
:error='errFor("organization.okved")'
:error-message='msgFor("organization.okved")'
dense filled
)
q-input(
label='ОГРН'
:model-value='editsValue.organization.ogrn || ""'
readonly disable
@update:model-value='v => updateField("organization.ogrn", v || null)'
:rules='[reportRules.optionalRegex(/^\\d{13}$/, "ОГРН ЮЛ — 13 цифр")]'
:error='errFor("organization.ogrn")'
:error-message='msgFor("organization.ogrn")'
dense filled
)
.editor-section
h3.section-title Подписант
.text-caption.t-muted.q-mb-sm Данные берутся из Реквизитов правка доступна только там
q-option-group(
:model-value='editsValue.signer.type'
@update:model-value='v => updateField("signer.type", v)'
:options='signerTypeOptions'
disable
inline
)
@@ -95,19 +108,25 @@
q-input(
label='Фамилия'
:model-value='editsValue.signer.lastName'
readonly disable
@update:model-value='v => updateField("signer.lastName", v)'
dense filled
:rules='[v => !!v || "обязательно"]'
:error='errFor("signer.lastName")'
:error-message='msgFor("signer.lastName")'
)
q-input(
label='Имя'
:model-value='editsValue.signer.firstName'
readonly disable
@update:model-value='v => updateField("signer.firstName", v)'
dense filled
:rules='[v => !!v || "обязательно"]'
:error='errFor("signer.firstName")'
:error-message='msgFor("signer.firstName")'
)
q-input(
label='Отчество'
:model-value='editsValue.signer.middleName || ""'
readonly disable
@update:model-value='v => updateField("signer.middleName", v || null)'
dense filled
)
@@ -115,7 +134,10 @@
v-if='editsValue.signer.type === "representative"'
label='Документ, подтверждающий полномочия'
:model-value='editsValue.signer.repDoc || ""'
readonly disable
@update:model-value='v => updateField("signer.repDoc", v || null)'
:rules='[reportRules.length(1, 120)]'
:error='errFor("signer.repDoc")'
:error-message='msgFor("signer.repDoc")'
dense filled
)
@@ -123,15 +145,25 @@
v-if='needs.snils'
label='СНИЛС председателя'
:model-value='editsValue.signer.snils || ""'
readonly disable
@update:model-value='v => updateField("signer.snils", v || null)'
:rules='[reportRules.snils()]'
:error='errFor("signer.snils")'
:error-message='msgFor("signer.snils")'
mask='###-###-### ##'
hint='Обязателен для ПСВ — попадает в ПерсСвФЛ @СНИЛС'
dense filled
)
q-input(
v-if='needs.sfrExtras'
label='Регистрационный номер ПФР'
:model-value='editsValue.signer.pfrRegNumber || ""'
readonly disable
label='Регистрационный номер СФР'
:model-value='editsValue.signer.sfrRegNumber || ""'
@update:model-value='v => updateField("signer.sfrRegNumber", v || null)'
:rules='[reportRules.sfrRegNumber()]'
:error='errFor("signer.sfrRegNumber")'
:error-message='msgFor("signer.sfrRegNumber")'
mask='###-###-######'
hint='XXX-XXX-XXXXXX — обязателен для ЕФС-1'
dense filled
)
@@ -139,15 +171,19 @@
v-if='needs.sfrExtras'
label='Должность подписанта'
:model-value='editsValue.signer.chairmanPosition || ""'
readonly disable
@update:model-value='v => updateField("signer.chairmanPosition", v || null)'
hint='По умолчанию — "Председатель Совета"'
dense filled
)
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Selectors } from '@coopenomics/sdk'
import type { IReportType } from 'src/entities/Report'
const { reportRules } = Selectors
type SignerType = 'chairman' | 'representative'
interface ZeroReportEdits {
@@ -179,7 +215,6 @@ interface ZeroReportEdits {
repDoc: string | null
snils: string | null
sfrRegNumber: string | null
pfrRegNumber: string | null
chairmanPosition: string | null
}
}
@@ -33,8 +33,7 @@ export interface BaseHeader {
signerMiddleName: string
signerRepDoc: string
signerSnils: string
/** Рег. номер ПФР — читается из тега `<РегНомер>` ЕФС-1 (fss4.generator.ts кладёт туда именно ПФР, не СФР). */
pfrRegNumber: string
sfrRegNumber: string
}
export interface UseReportXmlReturn {
@@ -118,7 +117,7 @@ export function useReportXml(
signerLastName: '', signerFirstName: '', signerMiddleName: '',
signerRepDoc: '',
signerSnils: '',
pfrRegNumber: '',
sfrRegNumber: '',
}
if (!doc.value) return withRequisites(empty)
@@ -148,7 +147,7 @@ export function useReportXml(
signerMiddleName: getAttr('Подписант ФИО', 'Отчество') || getText('Отчество'),
signerRepDoc: getAttr('Подписант СвПред', 'НаимДок'),
signerSnils: getAttr('ПерсСвФЛ', 'СНИЛС'),
pfrRegNumber: getText('РегНомер'),
sfrRegNumber: getText('РегНомер'),
}
return withRequisites(h)
})
@@ -175,7 +174,7 @@ export function useReportXml(
signerFirstName: h.signerFirstName || get('signerFirstName'),
signerMiddleName: h.signerMiddleName || get('signerMiddleName'),
signerSnils: h.signerSnils || get('signerSnils'),
pfrRegNumber: h.pfrRegNumber || get('pfrRegNumber'),
sfrRegNumber: h.sfrRegNumber || get('sfrRegNumber'),
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/desktop",
"version": "2026.7.21-1",
"version": "2026.6.24",
"description": "Рабочий стол кооператива — Vue 3 + Quasar Framework",
"productName": "Desktop App",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
@@ -95,6 +95,7 @@
"@types/serviceworker": "^0.0.141",
"@typescript-eslint/eslint-plugin": "^8.57.0",
"@typescript-eslint/parser": "^8.57.0",
"@vue/devtools": "^8.1.1",
"@vue/language-plugin-pug": "~3.2.6",
"autoprefixer": "^10.4.2",
"concurrently": "^8.2.2",
@@ -7,19 +7,18 @@ BaseDialog(
:close-on-backdrop='false',
:close-on-escape='false'
)
div.row.justify-center.select-branch-overlay__content
div.col-md-8.col-xs-12
div.row.justify-center
div(style="padding-bottom: 100px;").col-md-8.col-col-xs-12
div(v-if="step === 1")
p.q-mt-lg
| Кооператив перешёл на двухэтапную систему управления на основании общего собрания уполномоченных председателей кооперативных участков...
Loader(v-if="branchesLoading" text="Загружаем список кооперативных участков...")
Form#select-branch-form(
v-else
Form(
:handler-submit="next"
:is-submitting="isSubmitting"
:showSubmit="false"
:showSubmit="!isLoading"
:showCancel="false"
:button-submit-txt="'Продолжить'"
)
BranchSelector(
v-model:selectedBranch="selectedBranch"
@@ -28,29 +27,15 @@ BaseDialog(
div(v-else-if="step === 2")
Loader(v-if="isLoading" :text="`Формируем документ...`")
DocumentHtmlReader(v-else :html="document.html")
template(#footer)
.select-branch-overlay__actions(v-if="step === 1 && !branchesLoading")
BaseButton(
variant='primary',
type='submit',
form='select-branch-form',
:loading='isLoading',
:disabled='!selectedBranch || isLoading',
) Продолжить
.select-branch-overlay__actions(v-else-if="step === 2 && !isLoading")
BaseButton(variant='ghost', @click='back') назад
BaseButton(
variant='primary',
:loading='isSubmitting',
@click='sign'
) подписать
div(v-else)
DocumentHtmlReader(:html="document.html")
q-btn(@click="back" flat) назад
q-btn(@click="sign" color="primary") подписать
</template>
<script setup lang="ts">
import { BaseDialog } from 'src/shared/ui/base/BaseDialog'
import { BaseButton } from 'src/shared/ui/base/BaseButton'
import { Form } from 'src/shared/ui/Form'
import { Loader } from 'src/shared/ui/Loader'
import { BranchSelector } from 'src/shared/ui/BranchSelector'
@@ -68,30 +53,12 @@ BaseDialog(
document,
isSubmitting,
isLoading,
branchesLoading,
next,
back,
sign
} = useSelectBranchProcess()
</script>
<style scoped lang="scss">
/* Кнопки в слоте footer BaseDialog вне скроллящегося body,
поэтому «Продолжить» / «подписать» всегда видны на мобильных. */
.select-branch-overlay__actions {
width: 100%;
display: flex;
gap: var(--p-2, 8px);
justify-content: flex-end;
flex-wrap: wrap;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.select-branch-overlay__content {
padding-bottom: var(--p-2, 8px);
}
</style>
<style>
.digital-document .header{
text-align: center;
@@ -3,3 +3,9 @@ export {
getSovietMembersProgressHint,
getSovietContinueBlockedTooltip,
} from './minSovietMembers';
export {
isSovietMemberComplete,
countCompleteSovietMembers,
getFirstIncompleteSovietMemberIndex,
type SovietInstallMember,
} from './sovietMemberValidation';
@@ -1,23 +1,57 @@
import { env } from 'src/shared/config';
import {
countCompleteSovietMembers,
getFirstIncompleteSovietMemberIndex,
type SovietInstallMember,
} from './sovietMemberValidation';
/** Как MIN_SOVIET_MEMBERS_COUNT в контракте soviet: 1 на dev/testnet, 3 на production. */
export function getMinSovietMembersCount(): number {
return env.NODE_ENV === 'production' ? 3 : 1;
}
function councilRoleLabel(index: number, role?: SovietInstallMember['role']): string {
if (role === 'chairman' || index === 0) return 'председателя совета';
return `члена совета №${index + 1}`;
}
/** Краткая подсказка под формой: сколько указано и чего не хватает. */
export function getSovietMembersProgressHint(currentCount: number, min = getMinSovietMembersCount()): string | null {
if (min <= 1 || currentCount >= min) return null;
const missing = min - currentCount;
return `Указано ${currentCount} из ${min}. Добавьте ещё ${missing} ${pluralCouncilSlot(missing)}.`;
export function getSovietMembersProgressHint(
members: SovietInstallMember[],
min = getMinSovietMembersCount(),
): string | null {
const completeCount = countCompleteSovietMembers(members);
if (completeCount < min) {
const missing = min - completeCount;
return `Заполнены данные ${completeCount} из ${min}. Дозаполните ещё ${missing} ${pluralCouncilSlot(missing)}.`;
}
const incompleteIndex = getFirstIncompleteSovietMemberIndex(members);
if (incompleteIndex >= 0) {
return `Заполните все поля для ${councilRoleLabel(incompleteIndex, members[incompleteIndex]?.role)}.`;
}
return null;
}
/** Текст для tooltip заблокированной кнопки «Продолжить». */
export function getSovietContinueBlockedTooltip(currentCount: number, min = getMinSovietMembersCount()): string {
if (currentCount >= min) return '';
if (min === 1) return 'Заполните данные председателя';
const missing = min - currentCount;
return `Для продолжения нужно ${min} человека в составе совета (ещё ${missing} ${pluralCouncilSlot(missing)})`;
export function getSovietContinueBlockedTooltip(
members: SovietInstallMember[],
min = getMinSovietMembersCount(),
): string {
const completeCount = countCompleteSovietMembers(members);
if (completeCount < min) {
const missing = min - completeCount;
if (min === 1) return 'Заполните все данные председателя совета';
return `Для продолжения нужно ${min} человека с полными данными (ещё ${missing} ${pluralCouncilSlot(missing)})`;
}
const incompleteIndex = getFirstIncompleteSovietMemberIndex(members);
if (incompleteIndex >= 0) {
return `Заполните все поля для ${councilRoleLabel(incompleteIndex, members[incompleteIndex]?.role)}`;
}
return '';
}
function pluralCouncilSlot(count: number): string {
@@ -0,0 +1,50 @@
import emailRegex from 'email-regex';
import type { IIndividualData } from 'src/shared/lib/types/user/IUserData';
const emailValidator = emailRegex({ exact: true });
/** Член совета в мастере установки (store + API install). */
export interface SovietInstallMember {
role?: 'chairman' | 'member';
individual_data?: IIndividualData & { email?: string };
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function isPhoneFilled(value: unknown): boolean {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed) return false;
return trimmed !== '+7 (___) ___-__-__';
}
/** Все обязательные поля IndividualDataForm + email (как на экране «Состав совета»). */
export function isSovietMemberComplete(member: SovietInstallMember | null | undefined): boolean {
const data = member?.individual_data;
if (!data) return false;
if (!isNonEmptyString(data.email) || emailValidator.test(data.email.trim()) !== true) {
return false;
}
if (!isNonEmptyString(data.last_name) || !isNonEmptyString(data.first_name)) {
return false;
}
if (!isNonEmptyString(data.full_address) || !isNonEmptyString(data.birthdate)) {
return false;
}
if (!isPhoneFilled(data.phone)) {
return false;
}
return true;
}
export function countCompleteSovietMembers(members: SovietInstallMember[]): number {
return members.filter(isSovietMemberComplete).length;
}
export function getFirstIncompleteSovietMemberIndex(members: SovietInstallMember[]): number {
return members.findIndex((member) => !isSovietMemberComplete(member));
}
@@ -1,7 +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 { getMinSovietMembersCount, isSovietMemberComplete } from '../lib';
import { api } from '../api'
export type IInstallInput = Mutations.System.InstallSystem.IInput['data']
@@ -61,6 +61,15 @@ export const useInstallCooperative = (): {
);
}
const incompleteIndex = store.soviet.findIndex((member) => !isSovietMemberComplete(member));
if (incompleteIndex >= 0) {
const roleLabel =
store.soviet[incompleteIndex]?.role === 'chairman' || incompleteIndex === 0
? 'председателя совета'
: `члена совета №${incompleteIndex + 1}`;
throw new Error(`Заполните все обязательные поля для ${roleLabel}`);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const soviet = store.soviet.map(({ id, type, ...rest }) => rest);
@@ -64,6 +64,7 @@ import {
getMinSovietMembersCount,
getSovietMembersProgressHint,
getSovietContinueBlockedTooltip,
isSovietMemberComplete,
} from '../../lib';
import { notEmpty } from 'src/shared/lib/utils';
import { validEmail } from 'src/shared/lib/utils/validEmailRule';
@@ -74,11 +75,15 @@ const minSovietMembers = getMinSovietMembersCount();
installStore.is_finish = false;
const sovietCount = computed(() => installStore.soviet.length);
const canContinue = computed(() => sovietCount.value >= minSovietMembers);
const progressHint = computed(() => getSovietMembersProgressHint(sovietCount.value, minSovietMembers));
const sovietMembers = computed(() => installStore.soviet);
const canContinue = computed(
() =>
sovietMembers.value.length >= minSovietMembers &&
sovietMembers.value.every(isSovietMemberComplete),
);
const progressHint = computed(() => getSovietMembersProgressHint(sovietMembers.value, minSovietMembers));
const continueBlockedTooltip = computed(() =>
getSovietContinueBlockedTooltip(sovietCount.value, minSovietMembers),
getSovietContinueBlockedTooltip(sovietMembers.value, minSovietMembers),
);
const add = () => {
@@ -236,7 +236,9 @@ const goToCooperativeSite = () => {
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
/* Адаптивность */
@@ -20,62 +20,31 @@ export function useSelectBranchProcess() {
const system = useSystemStore()
const session = useSessionStore()
const branchStore = useBranchStore()
const { isVisible, selectBranch } = useSelectBranch()
const { selectBranch } = useSelectBranch()
const branches = computed(() => branchStore.publicBranches)
const branchesLoading = ref(false)
const loadBranches = async () => {
if (!session.isAuth || !system.info.coopname) return
try {
branchesLoading.value = true
await branchStore.loadPublicBranches({ coopname: system.info.coopname })
} catch (e: unknown) {
FailAlert(e)
} finally {
branchesLoading.value = false
}
}
// Загрузка при входе
// Вотчер на авторизацию
watch(
() => session.isAuth,
(isAuth, wasAuth) => {
if (isAuth && !wasAuth) {
void loadBranches()
branchStore.loadPublicBranches({ coopname: system.info.coopname })
}
},
{ immediate: true }
)
// Перезагрузка при переходе в мажоритарный режим или показе оверлея
watch(
[() => isVisible.value, () => system.info?.cooperator_account?.is_branched],
([visible, branched]) => {
if (visible && branched && session.isAuth) {
void loadBranches()
}
}
)
const next = async () => {
if (!selectedBranch.value || isLoading.value) return
try {
isLoading.value = true
document.value = await digitalDocument.generate<Cooperative.Registry.SelectBranchStatement.Action>({
registry_id: Cooperative.Registry.SelectBranchStatement.registry_id,
coopname: system.info.coopname,
username: session.username,
braname: selectedBranch.value,
})
step.value++
} catch (e: unknown) {
FailAlert(e)
} finally {
isLoading.value = false
}
isLoading.value = true
document.value = await digitalDocument.generate<Cooperative.Registry.SelectBranchStatement.Action>({
registry_id: Cooperative.Registry.SelectBranchStatement.registry_id,
coopname: system.info.coopname,
username: session.username,
braname: selectedBranch.value,
})
isLoading.value = false
step.value++
}
const back = () => {
@@ -111,7 +80,6 @@ export function useSelectBranchProcess() {
document,
isSubmitting,
isLoading,
branchesLoading,
next,
back,
sign,
@@ -9,6 +9,7 @@ div
emit-value
standout="bg-teal text-white"
map-options
@update:model-value="$emit('update:selectedBranch', $event)"
)
template(v-slot:option="scope")
@@ -26,13 +27,22 @@ div
<script lang="ts" setup>
import { type IPublicBranch } from 'src/entities/Branch/model';
import { ref, watch } from 'vue'
defineProps({
const props = defineProps({
branches: {
type: Object as () => IPublicBranch[],
required: true,
},
modelValue: {
type: String,
required: false,
},
})
const selectedBranch = defineModel<string>('selectedBranch', { default: '' })
const selectedBranch = ref(props.modelValue)
watch(() => props.modelValue, (newVal) => {
selectedBranch.value = newVal
})
</script>
@@ -66,23 +66,21 @@ const maxWidthBySize: Record<BaseDialogSize, string> = {
<style scoped>
.base-dialog {
width: 100%;
display: flex;
flex-direction: column;
max-height: calc(100vh - var(--p-6, 48px));
}
/* Maximized: контейнер растягивается на весь экран, контент скроллится. */
.base-dialog--maximized {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
display: flex;
flex-direction: column;
}
.base-dialog--maximized .base-dialog__foot {
flex-shrink: 0;
.base-dialog--maximized .base-dialog__body {
flex: 1 1 auto;
overflow: auto;
}
.base-dialog__head {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: var(--p-3, 12px);
@@ -96,19 +94,13 @@ const maxWidthBySize: Record<BaseDialogSize, string> = {
}
.base-dialog__body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow-y: auto;
padding-bottom: var(--p-2, 8px);
}
.base-dialog__body > :first-child + * {
margin-top: var(--p-4, 16px);
}
.base-dialog__foot {
flex: 0 0 auto;
padding: var(--p-3, 12px) var(--p-4, 16px);
border-top: 1px solid var(--p-line);
background: var(--p-surface);
padding-top: var(--p-2, 8px);
}
</style>
@@ -1,6 +1,11 @@
<template lang="pug">
.extension-install
q-form(ref='formRef')
q-form(:ref='setFormRef')
CapitalProgramDocumentParametersWidget(
v-if='extensionName === "capital"'
:config='config'
@update:config='$emit("update:config", $event)'
)
BaseCard(v-if='schema && !isEmpty', title='Настройки')
ZodForm(
:schema='schema',
@@ -18,7 +23,7 @@ import type { ComponentPublicInstance } from 'vue';
import { ZodForm } from 'src/shared/ui/ZodForm';
import { BaseCard } from 'src/shared/ui/base/BaseCard';
import { isExtensionSchemaEmpty } from 'src/shared/lib/utils';
import type { IExtensionConfigSchema } from 'src/entities/Extension/model';
import { CapitalProgramDocumentParametersWidget } from 'app/extensions/capital/features/Onboarding';
type ExtensionInstallConfig = Record<string, unknown>;
type ExtensionInstallFormRef = {
@@ -27,7 +32,7 @@ type ExtensionInstallFormRef = {
interface Props {
extensionName?: string;
schema?: IExtensionConfigSchema;
schema?: unknown;
config: ExtensionInstallConfig;
formRef?: ExtensionInstallFormRef;
}
@@ -35,6 +40,12 @@ interface Props {
const props = defineProps<Props>();
const isEmpty = computed(() => isExtensionSchemaEmpty(props.schema));
const setFormRef = (element: Element | ComponentPublicInstance | null) => {
if (props.formRef && 'value' in props.formRef) {
props.formRef.value = element;
}
};
</script>
<style scoped lang="scss">
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@coopenomics/docs-harness",
"version": "2026.7.21-1",
"version": "2026.6.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@coopenomics/docs-harness",
"version": "2026.7.21-1",
"version": "2026.6.24",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.59.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/docs-harness",
"version": "2026.7.21-1",
"version": "2026.6.24",
"private": true,
"description": "Playwright harness для съёмки скриншотов в документацию (см. bin/shoot.mjs)",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/docs",
"type": "module",
"version": "2026.7.21-1",
"version": "2026.6.24",
"private": true,
"packageManager": "pnpm@9.9.0",
"description": "Документация для монорепозитория",

Some files were not shown because too many files have changed in this diff Show More