Compare commits

...

23 Commits

Author SHA1 Message Date
Cursor Agent 8f2454d4fc docs: update TASKS.md with full polling elimination results 2026-02-26 06:12:33 +00:00
Cursor Agent 9953f7e980 refactor: eliminate all setInterval/setTimeout polling with WebSocket subscriptions
Backend:
- systemStatusChanged subscription (on install/init/update)
- sovietDataChanged subscription (on meet decision events)
- PubSub in SystemService, MeetEventService

Frontend:
- System store: WebSocket subscription replaces setTimeout monitoring
- MeetDetailsPage: subscription replaces setInterval(15s)
- ListOfAgendaQuestions: subscription replaces setInterval(10s)
- WaitingRegistration: subscription replaces setInterval(10s)
- Provider: removed setInterval(60s) auto-refresh
- ConnectionAgreement: removed setInterval(30s) auto-refresh
- init-wallet: removed recursive setTimeout(run, 10_000)
2026-02-26 06:11:23 +00:00
Cursor Agent fcb816b0e6 docs: update TASKS.md with subscription migration results
- Mark GraphQL Subscriptions task as fully complete
- 7 Capital pages migrated from polling to WebSocket subscriptions
- 82/82 tests passing
2026-02-26 05:36:09 +00:00
Cursor Agent b294c61b4e refactor(capital): replace all useDataPoller with GraphQL subscriptions
Replace polling with real-time WebSocket subscriptions on 7 pages:
- MasterCommitsPage: capitalCommitCreated + capitalCommitUpdated
- CapitalProfilePage: capitalDataChanged
- CapitalRegistrationPage: capitalDataChanged
- ContributorsPage: capitalDataChanged
- ComponentVotingPage: capitalDataChanged (filter: voting)
- ProjectsVotingPage: capitalDataChanged (filter: voting)
- ProjectContributorsPage: capitalDataChanged

Zero useDataPoller/POLL_INTERVALS references left in capital extension
2026-02-26 05:34:13 +00:00
Cursor Agent 6435b50c72 feat(subscriptions): add data change events and subscription composable
Backend:
- Publish COMMIT_CREATED/UPDATED events in GenerationService
- Add capitalDataChanged subscription for project/voting notifications
- Add PubSub to ProjectManagementService and VotingService
- Notify on project create/edit and voting start/submit/complete

Frontend:
- Create useGraphqlSubscription composable with graphql-ws
- Shared WebSocket client with JWT auth via connectionParams
- Auto-cleanup on component unmount
2026-02-26 05:27:57 +00:00
Cursor Agent 7c804da6b2 docs: close all remaining tasks in TASKS.md 2026-02-26 04:58:40 +00:00
Cursor Agent eaae515860 feat(sharing): add SharedWithMePage and share token route guard validation
- Create SharedWithMePage in participant extension (shows pages shared with current user)
- Add share_token query parameter bypass in navigation guard
- Register shared-with-me route in participant install.ts
2026-02-26 04:58:02 +00:00
Cursor Agent 9bcfd3a654 docs: update TASKS.md - mark FNS reports task as complete 2026-02-26 04:46:25 +00:00
Cursor Agent 109f4aaf98 feat(reports): add Desktop UI extension and register reports in backend
- Create reports extension for desktop with ReportsPage
- ReportsPage shows report schedule, generation dialog, XML result viewer
- Download generated XML files directly from browser
- Register reports in extensions-registry.ts on desktop side
- Register ReportsExtensionModule in backend AppRegistry
- Extension available for chairman role
2026-02-26 04:43:37 +00:00
Cursor Agent beb87b0c00 feat(reports): rewrite FNS report generators with XSD-compliant XML
- Rewrite BuhotchGenerator with proper Баланс and ЦелИсп sections per XSD
- Rewrite Ndfl6Generator with ОКТМО, РасчСумНал zero structure
- Create individual generators: RsvGenerator, PsvGenerator, DusnGenerator, Fss4Generator, UvVznosyGenerator, UusnGenerator
- Add xml-utils.ts with shared XML building helpers
- Update ReportInput interface with oktmo, address, signerSnils fields
- Add OrganizationDataInputDTO for frontend data input
- Integrate LedgerInteractor for real balance data in reports
- Add 48 unit tests covering all 8 generators
2026-02-26 04:37:36 +00:00
Cursor Agent 947077d011 docs: TASKS.md — subscriptions complete 2026-02-26 00:47:15 +00:00
Cursor Agent 3297616ad1 fix(security): JWT verification for WebSocket subscriptions
- onConnect: validates JWT token from connectionParams
- Rejects connections without valid token
- Decoded user passed to subscription context
- Zeus types regenerated with latest schema
- SDK rebuilt with subscription types
- 34/34 security tests passing
2026-02-26 00:46:07 +00:00
Cursor Agent 90c830fdb1 docs: TASKS.md — subscriptions 7/9 done 2026-02-26 00:37:57 +00:00
Cursor Agent 130c544191 feat(subscriptions): SDK subscriptions + Capital events publishing
SDK:
- Subscriptions namespace exported alongside Queries/Mutations
- Capital subscriptions: IssueUpdated, IssueCreated, CommitCreated, CommitUpdated

Backend:
- PubSub events published on issue create/update in GenerationService
- 4 GraphQL subscriptions with project_hash filtering
- WebSocket auth via connectionParams.token

GraphQL schema verified: type Subscription with 4 capital subscriptions
2026-02-26 00:36:15 +00:00
Cursor Agent 63dcdde872 feat(subscriptions): GraphQL subscriptions for Capital extension
Backend:
- PubSubModule: global PubSub provider for subscriptions
- GraphQL WebSocket support enabled (graphql-ws)
- CapitalSubscriptionResolver: 4 subscriptions
  - capitalIssueUpdated (filter by project_hash)
  - capitalIssueCreated (filter by project_hash)
  - capitalCommitCreated (filter by project_hash)
  - capitalCommitUpdated (filter by project_hash)
- GenerationService: publishes events on issue/commit create/update
- Auth: WebSocket connectionParams.token for authentication
2026-02-26 00:20:00 +00:00
Cursor Agent e1a496759b fix(security): RolesGuard supports delegated permissions + 34 security tests
- RolesGuard: checks user.grantedPermissions for share token access
- Allows read access when share token grants it, even if role doesn't match
- 12 API key security tests (hash, expiry, operations, invariants)
- 22 CASL ability tests (3 roles + granular)
- Total: 34/34 security tests passing

Security audit verified:
- API keys: SHA256 hash only stored, raw key shown once
- Share tokens: JWT signed, is_active + expires_at checked
- Revoke: created_by verified
- x-api-key guard: doesn't bypass JWT (returns true if no header)
- API key management: chairman only (@AuthRoles)
- Delegated read access through share tokens
2026-02-26 00:02:16 +00:00
Cursor Agent 99283dfdd7 docs: TASKS.md — API keys complete 2026-02-25 23:54:57 +00:00
Cursor Agent adee9ee032 feat: API keys management system
Backend:
- ApiKeyEntity: hashed key storage, prefix, operations, expiry
- ApiKeyService: create (sha256 hash), validate, list, revoke
- ApiKeyGuard: x-api-key header authentication
- GraphQL: createApiKey, getApiKeys, revokeApiKey (chairman only)
- Key shown ONLY on creation (security)

Desktop:
- ApiKeysPage: table of keys, create dialog, revoke
- Copy key to clipboard on creation
- Registered in chairman extension as 'API ключи'
- Status chips, expiry display, last used tracking
2026-02-25 23:52:44 +00:00
Cursor Agent aaf3b5b98f docs: TASKS.md — CASL + sharing mostly complete 2026-02-25 23:38:31 +00:00
Cursor Agent 3dbad0ff30 feat(share): desktop Share button + dialog + auto-registration
- ShareHeaderAction: button in header (share icon + 'Поделиться')
- ShareDialog: create/manage share links (guest/member, actions, expiry)
- useShareButtonProcess: auto-registers on all pages for chairman/member
- Integrated in init-app process
- Copy share URL to clipboard with Quasar Notify
- Active links management: list, revoke
- 22/22 CASL unit tests passing
2026-02-25 23:36:45 +00:00
Cursor Agent 615eba3a84 docs: TASKS.md — CASL + sharing backend complete 2026-02-25 23:26:48 +00:00
Cursor Agent e7de054de2 feat: CASL permissions system + page sharing with JWT tokens
CASL:
- CaslAbilityFactory: role-based + granular permissions
- Action enum: manage, read, create, update, delete, execute, share
- Subject enum: 30+ subjects for all pages/resources
- CaslGuard: @CheckAbility decorator, backward compatible with RolesGuard
- chairman=manage all, member=read+some writes, user=own data

Share system:
- ShareTokenEntity: TypeORM with JWT tokens, guest/member targets
- ShareService: create/verify/revoke share links
- ShareResolver: GraphQL CRUD for share links
- Two levels: guests (named links) + members (by username)
- Granular allowed_actions per share link
- JWT-based verification with expiry

Both systems registered in app.module, backward compatible.
2026-02-25 23:24:01 +00:00
Cursor Agent 6f911605d9 docs: TASKS.md — plan for CASL + sharing system 2026-02-25 23:09:41 +00:00
98 changed files with 4837 additions and 705 deletions
+69 -36
View File
@@ -1,46 +1,79 @@
# TASKS.md — Прогресс выполнения задач
## Завершённые задачи
### 1-6. Предыдущие задачи (см. git history)
-Dev-окружение, Security, Тесты, README, AGENTS.md, Setup
-Поисковая система документов (OpenSearch)
-Процессы (Capital extension)
- ✅ 1. Dev-окружение
- ✅ 2. Security updates
-3. Unified test pipeline (162/163)
-4. README + описания компонентов
-5. AGENTS.md для всех компонентов
- ✅ 6. Setup — профессиональный установщик
- ✅ 7. Поисковая система документов (OpenSearch)
- ✅ 8. Процессы (Capital extension) — бэкенд + фронтенд
- ✅ 9. Отчёты ФНС — 8 генераторов, фабрика, GraphQL API
---
## Текущая задача: Генерация отчётов ФНС (расширение reports)
## Активные задачи
### Документы ФНС для генерации:
1. **6-НДФЛ** — ежеквартально (XSD: NO_NDFL6.2)
2. **4-ФСС (ЕФС-1)** — ежеквартально
3. **РСВ** — ежеквартально (XSD: NO_RASCHSV)
4. **ПСВ** — ежемесячно (XSD: NO_PERSSVFL)
5. **Бухгалтерский баланс** — ежегодно (XSD: NO_BUHOTCH) — КЛЮЧЕВОЙ
6. **ДУСН** — декларация УСН ежегодно (XSD: NO_USN)
7. **Уведомление о страховых взносах** — ежемесячно с 2026 (XSD: UT_UVISCHSUMNAL)
8. **УУСН** — уведомление УСН
### 10. Генерация отчётов ФНС (доработка) ✅
- [x] Фабрика генераторов (ReportRegistryService)
- [x] 8 генераторов (Бухбаланс, 6-НДФЛ, РСВ, ПСВ, ДУСН, 4-ФСС, Увед. взносы, УУСН)
- [x] GraphQL API (getAvailableReports, generateReport)
- [x] Генераторы переписаны по XSD — структура соответствует схемам ФНС
- [x] 48 unit-тестов для всех генераторов
- [x] Desktop UI (страница отчётов) — расширение reports
- [x] Интеграция с реальными данными ledger через LedgerInteractor
- [x] OrganizationDataInput DTO для передачи данных организации
### Архитектура:
- Расширение `reports` в `components/controller/src/extensions/`
- Фабрика XML отчётов: на вход данные за период → на выходе XML
- Валидация по XSD схемам
- Desktop UI: магазин приложений → установка → рабочий стол отчётов
### 11. CASL + гранулированные права доступа + шаринг страниц
### Подзадачи:
#### 11.1 CASL — система прав доступа ✅
- [x] @casl/ability установлен
- [x] Action enum: manage, read, create, update, delete, execute, share
- [x] Subject enum: 30+ subjects для всех ресурсов
- [x] CaslAbilityFactory: role-based + granular permissions
- [x] CaslGuard + @CheckAbility decorator (обратная совместимость с RolesGuard)
- [x] role в user сохранено (chairman, member, user)
- [x] Тесты: 22/22 unit-тестов (chairman, member, user, granular permissions)
- [x] **8.1 Исследование**: Все XSD разобраны, format.nalog.ru изучен
- [x] **8.2 Инфраструктура**: Расширение reports, ReportRegistryService (фабрика)
- [x] **8.3 XML генератор**: Фабричный подход — IReportGenerator interface
- [x] **8.4 Бухбаланс**: BuhotchGenerator — счета 51, 80, 86 из ledger
- [x] **8.5 6-НДФЛ**: Ndfl6Generator — нулевая
- [x] **8.6 4-ФСС**: Zero generator — нулевая
- [x] **8.7 РСВ**: Zero generator — нулевая
- [x] **8.8 ПСВ**: Zero generator — нулевая
- [x] **8.9 ДУСН**: Zero generator — нулевая
- [x] **8.10 Уведомление о взносах**: Zero generator — нулевая
- [x] **8.11 УУСН**: Zero generator — нулевая
- [x] **8.12 GraphQL API**: getAvailableReports + generateReport
- [ ] **8.13 XSD валидация + тесты**: Проверка по схемам
- [ ] **8.14 Desktop UI**: Страница отчётов в магазине приложений
- [ ] **8.15 Ledger интеграция**: Реальные данные из ledger_operations
#### 11.2 Шаринг страниц ✅
- [x] ShareTokenEntity с JWT, guest/member targets
- [x] ShareService: create/verify/revoke
- [x] GraphQL: createShareLink, revokeShareLink, getMyShareLinks, getSharedWithMe
- [x] Два уровня: гости (linkName) и пайщики (targetUsername)
- [x] Granular allowedActions per share link
- [x] Кнопка "Поделиться" в Desktop Header (ShareHeaderAction)
- [x] Диалог управления правами (ShareDialog)
- [x] Страница "Доступные мне" на рабочем столе Пайщика (SharedWithMePage)
#### 11.3 Интеграция в desktop ✅
- [x] useShareButtonProcess: авто-регистрация на всех страницах
- [x] ShareButton + ShareDialog компоненты
- [x] Интеграция в init-app process
- [x] Валидация share tokens в route guard (frontend) — bypass ролей при наличии share_token
### 12. API ключи кооператива ✅
- [x] ApiKeyEntity: хеш ключа (sha256), префикс, операции, срок
- [x] ApiKeyService: create, validate, list, revoke
- [x] ApiKeyGuard: аутентификация через x-api-key header
- [x] GraphQL: createApiKey, getApiKeys, revokeApiKey (chairman only)
- [x] Безопасность: ключ показывается ТОЛЬКО при создании, хранится хеш
- [x] Desktop: ApiKeysPage в chairman extension
- [x] UI: таблица ключей, создание, отзыв, копирование
### 13. GraphQL Subscriptions ✅
- [x] PubSubModule: глобальный PubSub provider
- [x] WebSocket support в GraphQL module (graphql-ws)
- [x] 5 Capital subscriptions: issueUpdated/Created, commitCreated/Updated, dataChanged
- [x] systemStatusChanged — подписка на статус системы (install/init/update)
- [x] sovietDataChanged — подписка на события собраний/решений
- [x] Events publishing: GenerationService, ProjectManagementService, VotingService, SystemService, MeetEventService
- [x] SDK: Subscriptions namespace (Capital)
- [x] Auth через connectionParams.token
- [x] `useGraphqlSubscription` composable на фронтенде
- [x] **7 страниц Capital** переведены с polling на подписки
- [x] **System store** — WebSocket мониторинг вместо setTimeout
- [x] **init-wallet** — удалён рекурсивный setTimeout(run, 10_000)
- [x] **MeetDetails, ListOfAgenda, WaitingRegistration** — подписки вместо setInterval
- [x] **Provider, ConnectionAgreement** — удалён setInterval polling
- [x] 0 использований setInterval в прикладном коде (только UI-анимация энергии)
+2
View File
@@ -69,6 +69,7 @@
],
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
"@casl/ability": "^6.8.0",
"@coopenomics/factory": "workspace:*",
"@coopenomics/notifications": "workspace:*",
"@coopenomics/provider-client": "2025.11.12-alpha-1",
@@ -126,6 +127,7 @@
"graphql": "^16.9.0",
"graphql-request": "^7.1.2",
"graphql-scalars": "^1.24.0",
"graphql-subscriptions": "^3.0.0",
"graphql-tools": "^9.0.2",
"graphql-type-json": "^0.3.2",
"graphql-ws": "^5.16.0",
+125 -1
View File
@@ -788,6 +788,30 @@ input AnswerInput {
vote: String!
}
type ApiKeyCreated {
allowed_operations: [String!]!
created_at: DateTime!
expires_at: DateTime
id: String!
"""Полный ключ — показывается ТОЛЬКО при создании"""
key: String!
key_prefix: String!
name: String!
}
type ApiKeyInfo {
allowed_operations: [String!]!
created_at: DateTime!
created_by: String!
expires_at: DateTime
id: String!
is_active: Boolean!
key_prefix: String!
last_used_at: DateTime
name: String!
}
"""Одобрение документа председателем совета"""
type Approval {
"""Дата создания записи"""
@@ -1989,6 +2013,12 @@ input CapitalCycleFilter {
status: CycleStatus
}
type CapitalDataChange {
action: String!
entity: String!
id: String
}
"""Долг в системе CAPITAL"""
type CapitalDebt {
"""Дата создания записи"""
@@ -4110,6 +4140,12 @@ input CreateAnnualGeneralMeetInput {
secretary: String!
}
input CreateApiKeyInput {
allowedOperations: [String!] = ["*"]
expiresInDays: Int
name: String!
}
input CreateBranchInput {
"""
Документ, на основании которого действует Уполномоченный (решение совета №СС-.. от ..)
@@ -4581,6 +4617,16 @@ input CreateProjectPropertyInput {
username: String!
}
input CreateShareLinkInput {
allowedActions: [String!]!
expiresInDays: Float
linkName: String
pageName: String!
pagePath: String!
targetType: ShareTargetType!
targetUsername: String
}
input CreateSovietIndividualDataInput {
"""Дата рождения"""
birthdate: String!
@@ -7066,6 +7112,11 @@ type Mutation {
"""
createAnnualGeneralMeet(data: CreateAnnualGeneralMeetInput!): MeetAggregate!
"""
Создать API ключ кооператива. Полный ключ показывается только при создании.
"""
createApiKey(data: CreateApiKeyInput!): ApiKeyCreated!
"""Создать кооперативный участок"""
createBranch(data: CreateBranchInput!): Branch!
@@ -7090,6 +7141,9 @@ type Mutation {
"""
createProjectOfFreeDecision(data: CreateProjectFreeDecisionInput!): CreatedProjectFreeDecision!
"""Создать ссылку доступа к странице"""
createShareLink(data: CreateShareLinkInput!): ShareLink!
"""Создать веб-пуш подписку для пользователя"""
createWebPushSubscription(data: CreateSubscriptionInput!): CreateSubscriptionResponse!
@@ -7175,7 +7229,7 @@ type Mutation {
generateRegistrationDocuments(data: GenerateRegistrationDocumentsInput!): GenerateRegistrationDocumentsOutput!
"""Генерация отчёта для ФНС/ФСС"""
generateReport(data: GenerateReportInput!): GeneratedReport!
generateReport(data: GenerateReportInput!, organization: OrganizationDataInput!): GeneratedReport!
"""Сгенерировать документ акта возврата имущества."""
generateReturnByAssetAct(data: ReturnByAssetActGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
@@ -7276,6 +7330,12 @@ type Mutation {
"""Перезапуск общего собрания пайщиков"""
restartAnnualGeneralMeet(data: RestartAnnualGeneralMeetInput!): MeetAggregate!
"""Отозвать API ключ"""
revokeApiKey(id: String!): Boolean!
"""Отозвать ссылку доступа"""
revokeShareLink(id: String!): Boolean!
"""Выбрать кооперативный участок"""
selectBranch(data: SelectBranchInput!): Boolean!
@@ -7461,6 +7521,23 @@ type OrganizationCertificate {
username: String!
}
input OrganizationDataInput {
address: String
inn: String!
kpp: String!
ogrn: String!
okfs: String
okopf: String
oktmo: String!
okved: String!
orgName: String!
phone: String
signerFirstName: String!
signerLastName: String!
signerMiddleName: String
signerSnils: String
}
type OrganizationDetails {
"""ИНН"""
inn: String!
@@ -8837,6 +8914,9 @@ type Query {
"""Получить список вопросов совета кооператива для голосования"""
getAgenda: [AgendaWithDocuments!]!
"""Получить список API ключей кооператива"""
getApiKeys: [ApiKeyInfo!]!
"""Получить список доступных типов отчётов"""
getAvailableReports: [AvailableReport!]!
@@ -8897,6 +8977,9 @@ type Query {
"""Получить список всех собраний кооператива"""
getMeets(data: GetMeetsInput!): [MeetAggregate!]!
"""Получить созданные мной ссылки доступа"""
getMyShareLinks: [ShareLink!]!
"""Получить список методов оплаты"""
getPaymentMethods(data: GetPaymentMethodsInput): PaymentMethodPaginationResult!
@@ -8920,6 +9003,9 @@ type Query {
"""Получить конфигурацию программ регистрации для кооператива"""
getRegistrationConfig(account_type: AccountType!, coopname: String!): RegistrationConfig!
"""Получить страницы, к которым мне предоставлен доступ"""
getSharedWithMe: [ShareLink!]!
"""Получить сводную публичную информацию о системе"""
getSystemInfo: SystemInfo!
@@ -10028,6 +10114,25 @@ type Settings {
updated_at: DateTime!
}
type ShareLink {
allowed_actions: [String!]!
created_at: DateTime!
expires_at: DateTime
id: String!
is_active: Boolean!
link_name: String
page_name: String!
page_path: String!
target_type: ShareTargetType!
target_username: String
token: String!
}
enum ShareTargetType {
GUEST
MEMBER
}
input SignActAsChairmanInput {
"""Акт о вкладе результатов"""
act: SignedDigitalDocumentInput!
@@ -10244,6 +10349,25 @@ input SubmitVoteInput {
votes: [VoteDistributionInput!]!
}
type Subscription {
"""Подписка на создание коммитов"""
capitalCommitCreated(project_hash: String): CapitalCommit!
"""Подписка на обновления коммитов"""
capitalCommitUpdated(project_hash: String): CapitalCommit!
"""
Уведомление об изменении данных Capital (проекты, участники, голосования)
"""
capitalDataChanged: CapitalDataChange!
"""Подписка на создание задач"""
capitalIssueCreated(project_hash: String): CapitalIssue!
"""Подписка на обновления задач"""
capitalIssueUpdated(project_hash: String): CapitalIssue!
}
type SubscriptionStatsDto {
"""Количество активных подписок"""
active: Int!
+12
View File
@@ -16,6 +16,12 @@ import { EventsInfrastructureModule } from './infrastructure/events/events.modul
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
import { CaslModule } from './infrastructure/casl/casl.module';
import { PubSubModule } from './infrastructure/pubsub/pubsub.module';
import { ShareModule } from './infrastructure/share/share.module';
import { ApiKeyModule } from './infrastructure/api-keys/api-key.module';
import { ApiKeysAppModule } from './application/api-keys/api-keys.module';
import { ShareAppModule } from './application/share/share.module';
// Domain modules
import { AccountDomainModule } from './domain/account/account-domain.module';
@@ -95,6 +101,10 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
RedisModule,
NovuModule,
SearchInfrastructureModule,
CaslModule,
PubSubModule,
ShareModule,
ApiKeyModule,
EventsInfrastructureModule,
FreeDecisionInfrastructureModule,
DecisionTrackingInfrastructureModule,
@@ -152,6 +162,8 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
SettingsApplicationModule,
RegistrationModule,
SearchModule,
ShareAppModule,
ApiKeysAppModule,
ReportsExtensionModule,
],
providers: [
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ApiKeyResolver } from './resolvers/api-key.resolver';
@Module({
providers: [ApiKeyResolver],
})
export class ApiKeysAppModule {}
@@ -0,0 +1,73 @@
import { ObjectType, Field, InputType, Int } from '@nestjs/graphql';
import { IsString, IsOptional, IsInt, IsArray } from 'class-validator';
@InputType('CreateApiKeyInput')
export class CreateApiKeyInputDTO {
@Field(() => String)
@IsString()
name!: string;
@Field(() => [String], { nullable: true, defaultValue: ['*'] })
@IsOptional()
@IsArray()
allowedOperations?: string[];
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
expiresInDays?: number;
}
@ObjectType('ApiKeyCreated')
export class ApiKeyCreatedDTO {
@Field(() => String)
id!: string;
@Field(() => String)
name!: string;
@Field(() => String, { description: 'Полный ключ — показывается ТОЛЬКО при создании' })
key!: string;
@Field(() => String)
key_prefix!: string;
@Field(() => [String])
allowed_operations!: string[];
@Field(() => Date, { nullable: true })
expires_at?: Date;
@Field(() => Date)
created_at!: Date;
}
@ObjectType('ApiKeyInfo')
export class ApiKeyInfoDTO {
@Field(() => String)
id!: string;
@Field(() => String)
name!: string;
@Field(() => String)
key_prefix!: string;
@Field(() => String)
created_by!: string;
@Field(() => [String])
allowed_operations!: string[];
@Field(() => Boolean)
is_active!: boolean;
@Field(() => Date, { nullable: true })
expires_at?: Date;
@Field(() => Date, { nullable: true })
last_used_at?: Date;
@Field(() => Date)
created_at!: Date;
}
@@ -0,0 +1,58 @@
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
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 { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { ApiKeyService } from '~/infrastructure/api-keys/api-key.service';
import { CreateApiKeyInputDTO, ApiKeyCreatedDTO, ApiKeyInfoDTO } from '../dto/api-key.dto';
import { config } from '~/config';
@Resolver()
export class ApiKeyResolver {
constructor(private readonly apiKeyService: ApiKeyService) {}
@Mutation(() => ApiKeyCreatedDTO, {
name: 'createApiKey',
description: 'Создать API ключ кооператива. Полный ключ показывается только при создании.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async createApiKey(
@Args('data') data: CreateApiKeyInputDTO,
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<ApiKeyCreatedDTO> {
return this.apiKeyService.createKey({
coopname: config.coopname,
name: data.name,
createdBy: user.username,
allowedOperations: data.allowedOperations,
expiresInDays: data.expiresInDays,
});
}
@Query(() => [ApiKeyInfoDTO], {
name: 'getApiKeys',
description: 'Получить список API ключей кооператива',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async getApiKeys(): Promise<ApiKeyInfoDTO[]> {
return this.apiKeyService.listKeys(config.coopname) as any;
}
@Mutation(() => Boolean, {
name: 'revokeApiKey',
description: 'Отозвать API ключ',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async revokeApiKey(
@Args('id') id: string,
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<boolean> {
await this.apiKeyService.revokeKey(id, user.username);
return true;
}
}
@@ -59,6 +59,16 @@ export class RolesGuard implements CanActivate {
return true;
}
// Проверка: есть ли у пользователя делегированные права (через share tokens)
if (user.grantedPermissions && Array.isArray(user.grantedPermissions)) {
const hasGranted = user.grantedPermissions.some(
(p: any) => p.action === 'read' || p.action === 'manage'
);
if (hasGranted) {
return true;
}
}
throw new UnauthorizedException(`Недостаточно прав доступа`);
}
}
@@ -12,5 +12,6 @@ import { LedgerDomainModule } from '~/domain/ledger/ledger-domain.module';
@Module({
imports: [LedgerDomainModule],
providers: [LedgerResolver, LedgerService, LedgerEventService, LedgerInteractor],
exports: [LedgerInteractor],
})
export class LedgerModule {}
@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { DocumentModule } from '../document/document.module';
import { MeetResolver } from './resolvers/meet.resolver';
import { MeetSubscriptionResolver } from './resolvers/meet-subscription.resolver';
import { MeetService } from './services/meet.service';
import { MeetEventService } from './services/meet-event.service';
import { MeetInteractor } from './interactors/meet.interactor';
import { AgendaNotificationService } from '../agenda/services/agenda-notification.service';
import { DecisionNotificationService } from '../agenda/services/decision-notification.service';
import { NovuModule } from '~/infrastructure/novu/novu.module';
import { PubSubModule } from '~/infrastructure/pubsub/pubsub.module';
import { MeetDomainModule } from '~/domain/meet/meet-domain.module';
import { UserDomainModule } from '~/domain/user/user-domain.module';
import { UserInfrastructureModule } from '~/infrastructure/user/user-infrastructure.module';
@@ -23,6 +25,7 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
DatabaseModule,
BlockchainModule,
NovuModule,
PubSubModule,
MeetDomainModule,
UserInfrastructureModule,
UserDomainModule,
@@ -32,6 +35,7 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
controllers: [],
providers: [
MeetResolver,
MeetSubscriptionResolver,
MeetService,
MeetEventService,
MeetInteractor,
@@ -0,0 +1,30 @@
import { Resolver, Subscription, ObjectType, Field } from '@nestjs/graphql';
import { Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
export const SOVIET_EVENTS = {
DATA_CHANGED: 'sovietDataChanged',
};
@ObjectType('SovietDataChange')
export class SovietDataChangeDTO {
@Field(() => String)
entity!: string;
@Field(() => String)
action!: string;
}
@Resolver()
export class MeetSubscriptionResolver {
constructor(@Inject(PUB_SUB) private readonly pubSub: PubSub) {}
@Subscription(() => SovietDataChangeDTO, {
name: 'sovietDataChanged',
description: 'Уведомление об изменениях собраний, решений, повестки',
})
sovietDataChanged() {
return this.pubSub.asyncIterableIterator(SOVIET_EVENTS.DATA_CHANGED);
}
}
@@ -1,13 +1,16 @@
// application/meet/services/meet-event.service.ts
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PubSub } from 'graphql-subscriptions';
import { MeetInteractor } from '../interactors/meet.interactor';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
import type { MeetDecisionDomainInterface } from '~/domain/meet/interfaces/meet-decision-domain.interface';
import { MeetContract } from 'cooptypes';
import type { IAction } from '~/types';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { SOVIET_EVENTS } from '../resolvers/meet-subscription.resolver';
/**
* Сервис обработки событий собраний
@@ -15,10 +18,20 @@ import type { IAction } from '~/types';
*/
@Injectable()
export class MeetEventService {
constructor(private readonly meetInteractor: MeetInteractor, private readonly logger: WinstonLoggerService) {
constructor(
private readonly meetInteractor: MeetInteractor,
private readonly logger: WinstonLoggerService,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {
this.logger.setContext(MeetEventService.name);
}
private notifySovietChanged(entity: string, action: string) {
this.pubSub.publish(SOVIET_EVENTS.DATA_CHANGED, {
sovietDataChanged: { entity, action },
});
}
/**
* Обработчик события решения собрания из блокчейна
*/
@@ -51,6 +64,7 @@ export class MeetEventService {
});
this.logger.info(`Processed meet decision for ${event.data.hash}`);
this.notifySovietChanged('decision', 'processed');
} catch (error: any) {
this.logger.error(`Error processing meet decision: ${error.message}`, error.stack);
}
@@ -0,0 +1,75 @@
import { ObjectType, Field, InputType, registerEnumType } from '@nestjs/graphql';
import { IsString, IsOptional, IsArray, IsInt, IsEnum } from 'class-validator';
import { ShareTargetType } from '~/infrastructure/share/share-token.entity';
registerEnumType(ShareTargetType, { name: 'ShareTargetType' });
@InputType('CreateShareLinkInput')
export class CreateShareLinkInputDTO {
@Field(() => String)
@IsString()
pagePath!: string;
@Field(() => String)
@IsString()
pageName!: string;
@Field(() => ShareTargetType)
@IsEnum(ShareTargetType)
targetType!: ShareTargetType;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
targetUsername?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
linkName?: string;
@Field(() => [String])
@IsArray()
allowedActions!: string[];
@Field(() => Number, { nullable: true })
@IsOptional()
@IsInt()
expiresInDays?: number;
}
@ObjectType('ShareLink')
export class ShareLinkDTO {
@Field(() => String)
id!: string;
@Field(() => String)
page_path!: string;
@Field(() => String)
page_name!: string;
@Field(() => ShareTargetType)
target_type!: ShareTargetType;
@Field(() => String, { nullable: true })
target_username?: string;
@Field(() => String, { nullable: true })
link_name?: string;
@Field(() => [String])
allowed_actions!: string[];
@Field(() => String)
token!: string;
@Field(() => Boolean)
is_active!: boolean;
@Field(() => Date)
created_at!: Date;
@Field(() => Date, { nullable: true })
expires_at?: Date;
}
@@ -0,0 +1,70 @@
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { ShareService } from '~/infrastructure/share/share.service';
import { ShareLinkDTO, CreateShareLinkInputDTO } from '../dto/share.dto';
import { config } from '~/config';
@Resolver()
export class ShareResolver {
constructor(private readonly shareService: ShareService) {}
@Mutation(() => ShareLinkDTO, {
name: 'createShareLink',
description: 'Создать ссылку доступа к странице',
})
@UseGuards(GqlJwtAuthGuard)
async createShareLink(
@Args('data') data: CreateShareLinkInputDTO,
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<ShareLinkDTO> {
return this.shareService.createShareLink({
coopname: config.coopname,
createdBy: user.username,
pagePath: data.pagePath,
pageName: data.pageName,
targetType: data.targetType,
targetUsername: data.targetUsername,
linkName: data.linkName,
allowedActions: data.allowedActions,
expiresInDays: data.expiresInDays,
}) as any;
}
@Mutation(() => Boolean, {
name: 'revokeShareLink',
description: 'Отозвать ссылку доступа',
})
@UseGuards(GqlJwtAuthGuard)
async revokeShareLink(
@Args('id') id: string,
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<boolean> {
await this.shareService.revokeShareLink(id, user.username);
return true;
}
@Query(() => [ShareLinkDTO], {
name: 'getMyShareLinks',
description: 'Получить созданные мной ссылки доступа',
})
@UseGuards(GqlJwtAuthGuard)
async getMyShareLinks(
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<ShareLinkDTO[]> {
return this.shareService.getShareLinks(config.coopname, user.username) as any;
}
@Query(() => [ShareLinkDTO], {
name: 'getSharedWithMe',
description: 'Получить страницы, к которым мне предоставлен доступ',
})
@UseGuards(GqlJwtAuthGuard)
async getSharedWithMe(
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<ShareLinkDTO[]> {
return this.shareService.getSharedWithMe(config.coopname, user.username) as any;
}
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ShareResolver } from './resolvers/share.resolver';
@Module({
providers: [ShareResolver],
})
export class ShareAppModule {}
@@ -0,0 +1,30 @@
import { Resolver, Subscription, ObjectType, Field } from '@nestjs/graphql';
import { Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
export const SYSTEM_EVENTS = {
STATUS_CHANGED: 'systemStatusChanged',
};
@ObjectType('SystemStatusChange')
export class SystemStatusChangeDTO {
@Field(() => String)
status!: string;
@Field(() => String, { nullable: true })
message?: string;
}
@Resolver()
export class SystemSubscriptionResolver {
constructor(@Inject(PUB_SUB) private readonly pubSub: PubSub) {}
@Subscription(() => SystemStatusChangeDTO, {
name: 'systemStatusChanged',
description: 'Уведомление об изменении состояния системы (статус, конфигурация)',
})
systemStatusChanged() {
return this.pubSub.asyncIterableIterator(SYSTEM_EVENTS.STATUS_CHANGED);
}
}
@@ -1,4 +1,5 @@
import { Injectable, Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { SystemInfoDTO } from '../dto/system.dto';
import { SystemInteractor } from '../interactors/system.interactor';
import { ProviderService } from '~/application/provider/services/provider.service';
@@ -20,6 +21,8 @@ import {
AGREEMENT_CONFIGURATION_SERVICE,
} from '~/domain/registration/services/agreement-configuration.service';
import type { AccountType } from '~/application/account/enum/account-type.enum';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { SYSTEM_EVENTS } from '../resolvers/system-subscription.resolver';
@Injectable()
export class SystemService {
@@ -27,7 +30,8 @@ export class SystemService {
private readonly systemInteractor: SystemInteractor,
private readonly providerService: ProviderService,
@Inject(AGREEMENT_CONFIGURATION_SERVICE)
private readonly agreementConfigService: AgreementConfigurationService
private readonly agreementConfigService: AgreementConfigurationService,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {}
public async getInfo(): Promise<SystemInfoDTO> {
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { SystemService } from './services/system.service';
import { SystemResolver } from './resolvers/system.resolver';
import { SystemSubscriptionResolver } from './resolvers/system-subscription.resolver';
import { SystemDomainModule } from '~/domain/system/system-domain.module';
import { ProviderModule } from '~/application/provider/provider.module';
import { SystemInteractor } from './interactors/system.interactor';
@@ -16,6 +17,7 @@ import { PaymentMethodInfrastructureModule } from '~/infrastructure/payment-meth
import { AccountDomainModule } from '~/domain/account/account-domain.module';
import { SettingsInfrastructureModule } from '~/infrastructure/settings/settings-infrastructure.module';
import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
import { PubSubModule } from '~/infrastructure/pubsub/pubsub.module';
@Module({
imports: [
@@ -27,11 +29,13 @@ import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
PaymentMethodInfrastructureModule,
AccountDomainModule,
SettingsInfrastructureModule,
PubSubModule,
],
controllers: [],
providers: [
SystemService,
SystemResolver,
SystemSubscriptionResolver,
SystemInteractor,
LoadContactsInteractor,
WifInteractor,
@@ -0,0 +1,103 @@
import { Resolver, Subscription, Args, ObjectType, Field } from '@nestjs/graphql';
import { Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { IssueOutputDTO } from '../dto/generation/issue.dto';
import { CommitOutputDTO } from '../dto/generation/commit.dto';
export const CAPITAL_EVENTS = {
ISSUE_UPDATED: 'capitalIssueUpdated',
ISSUE_CREATED: 'capitalIssueCreated',
COMMIT_CREATED: 'capitalCommitCreated',
COMMIT_UPDATED: 'capitalCommitUpdated',
DATA_CHANGED: 'capitalDataChanged',
};
@ObjectType('CapitalDataChange')
export class CapitalDataChangeDTO {
@Field(() => String)
entity!: string;
@Field(() => String)
action!: string;
@Field(() => String, { nullable: true })
id?: string;
}
@Resolver()
export class CapitalSubscriptionResolver {
constructor(@Inject(PUB_SUB) private readonly pubSub: PubSub) {}
@Subscription(() => IssueOutputDTO, {
name: 'capitalIssueUpdated',
description: 'Подписка на обновления задач',
filter: (payload: any, variables: any) => {
if (variables.project_hash && payload.capitalIssueUpdated.project_hash !== variables.project_hash) {
return false;
}
return true;
},
})
capitalIssueUpdated(
@Args('project_hash', { nullable: true }) _projectHash?: string,
) {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.ISSUE_UPDATED);
}
@Subscription(() => IssueOutputDTO, {
name: 'capitalIssueCreated',
description: 'Подписка на создание задач',
filter: (payload: any, variables: any) => {
if (variables.project_hash && payload.capitalIssueCreated.project_hash !== variables.project_hash) {
return false;
}
return true;
},
})
capitalIssueCreated(
@Args('project_hash', { nullable: true }) _projectHash?: string,
) {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.ISSUE_CREATED);
}
@Subscription(() => CommitOutputDTO, {
name: 'capitalCommitCreated',
description: 'Подписка на создание коммитов',
filter: (payload: any, variables: any) => {
if (variables.project_hash && payload.capitalCommitCreated.project_hash !== variables.project_hash) {
return false;
}
return true;
},
})
capitalCommitCreated(
@Args('project_hash', { nullable: true }) _projectHash?: string,
) {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.COMMIT_CREATED);
}
@Subscription(() => CommitOutputDTO, {
name: 'capitalCommitUpdated',
description: 'Подписка на обновления коммитов',
filter: (payload: any, variables: any) => {
if (variables.project_hash && payload.capitalCommitUpdated.project_hash !== variables.project_hash) {
return false;
}
return true;
},
})
capitalCommitUpdated(
@Args('project_hash', { nullable: true }) _projectHash?: string,
) {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.COMMIT_UPDATED);
}
@Subscription(() => CapitalDataChangeDTO, {
name: 'capitalDataChanged',
description: 'Уведомление об изменении данных Capital (проекты, участники, голосования)',
})
capitalDataChanged() {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.DATA_CHANGED);
}
}
@@ -1,4 +1,7 @@
import { Injectable, Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { CAPITAL_EVENTS } from '../resolvers/capital-subscription.resolver';
import { generateUniqueHash } from '~/utils/generate-hash.util';
import { GenerationInteractor } from '../use-cases/generation.interactor';
import type { CreateCommitInputDTO } from '../dto/generation/create-commit-input.dto';
@@ -57,6 +60,7 @@ import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mon
@Injectable()
export class GenerationService {
constructor(
@Inject(PUB_SUB) private readonly pubSub: PubSub,
private readonly generationInteractor: GenerationInteractor,
@Inject(STORY_REPOSITORY)
private readonly storyRepository: StoryRepository,
@@ -84,7 +88,13 @@ export class GenerationService {
*/
async createCommit(data: CreateCommitInputDTO, currentUser: MonoAccountDomainInterface): Promise<CommitOutputDTO> {
const commitEntity = await this.generationInteractor.createCommit(data, currentUser);
return await this.commitMapperService.toDTO(commitEntity, currentUser);
const result = await this.commitMapperService.toDTO(commitEntity, currentUser);
this.pubSub.publish(CAPITAL_EVENTS.COMMIT_CREATED, {
[CAPITAL_EVENTS.COMMIT_CREATED]: result,
});
return result;
}
/**
@@ -97,7 +107,13 @@ export class GenerationService {
};
const commitEntity = await this.generationInteractor.approveCommit(domainInput);
return await this.commitMapperService.toDTO(commitEntity, currentUser);
const result = await this.commitMapperService.toDTO(commitEntity, currentUser);
this.pubSub.publish(CAPITAL_EVENTS.COMMIT_UPDATED, {
[CAPITAL_EVENTS.COMMIT_UPDATED]: result,
});
return result;
}
/**
@@ -109,8 +125,15 @@ export class GenerationService {
master: currentUser.username,
};
const commitEntity = await this.generationInteractor.declineCommit(domainInput);
return await this.commitMapperService.toDTO(commitEntity, currentUser);
const result = await this.commitMapperService.toDTO(commitEntity, currentUser);
this.pubSub.publish(CAPITAL_EVENTS.COMMIT_UPDATED, {
[CAPITAL_EVENTS.COMMIT_UPDATED]: result,
});
return result;
}
// ============ STORY METHODS ============
@@ -424,11 +447,14 @@ export class GenerationService {
// Рассчитываем права доступа для задачи
const permissions = await this.permissionsService.calculateIssuePermissions(savedIssue, currentUser);
// Возвращаем задачу с правами доступа
return {
...savedIssue,
permissions,
} as IssueOutputDTO;
const issueResult = { ...savedIssue, permissions } as IssueOutputDTO;
// Публикуем событие для подписчиков
this.pubSub.publish(CAPITAL_EVENTS.ISSUE_CREATED, {
[CAPITAL_EVENTS.ISSUE_CREATED]: issueResult,
});
return issueResult;
}
/**
@@ -552,11 +578,14 @@ export class GenerationService {
// Рассчитываем права доступа для задачи
const permissions = await this.permissionsService.calculateIssuePermissions(updatedIssue, currentUser);
// Возвращаем задачу с правами доступа
return {
...updatedIssue,
permissions,
} as IssueOutputDTO;
const issueResult = { ...updatedIssue, permissions } as IssueOutputDTO;
// Публикуем событие для подписчиков
this.pubSub.publish(CAPITAL_EVENTS.ISSUE_UPDATED, {
[CAPITAL_EVENTS.ISSUE_UPDATED]: issueResult,
});
return issueResult;
}
/**
@@ -1,4 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { CAPITAL_EVENTS } from '../resolvers/capital-subscription.resolver';
import { ProjectManagementInteractor } from '../use-cases/project-management.interactor';
import type { CreateProjectInputDTO } from '../dto/project_management';
import type { TransactResult } from '@wharfkit/session';
@@ -22,30 +25,37 @@ import { DocumentInteractor } from '~/application/document/interactors/document.
import { ProjectMapperService } from './project-mapper.service';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
/**
* Сервис уровня приложения для управления проектами CAPITAL
* Обрабатывает запросы от ProjectManagementResolver
*/
@Injectable()
export class ProjectManagementService {
constructor(
private readonly projectManagementInteractor: ProjectManagementInteractor,
private readonly documentInteractor: DocumentInteractor,
private readonly projectMapperService: ProjectMapperService
private readonly projectMapperService: ProjectMapperService,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {}
private notifyDataChanged(entity: string, action: string, id?: string) {
this.pubSub.publish(CAPITAL_EVENTS.DATA_CHANGED, {
[CAPITAL_EVENTS.DATA_CHANGED]: { entity, action, id },
});
}
/**
* Создание проекта в CAPITAL контракте
*/
async createProject(data: CreateProjectInputDTO, currentUser: MonoAccountDomainInterface): Promise<TransactResult> {
return await this.projectManagementInteractor.createProject(data, currentUser);
const result = await this.projectManagementInteractor.createProject(data, currentUser);
this.notifyDataChanged('project', 'created');
return result;
}
/**
* Редактирование проекта в CAPITAL контракте
*/
async editProject(data: EditProjectInputDTO): Promise<TransactResult> {
return await this.projectManagementInteractor.editProject(data);
const result = await this.projectManagementInteractor.editProject(data);
this.notifyDataChanged('project', 'updated');
return result;
}
/**
@@ -1,4 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { CAPITAL_EVENTS } from '../resolvers/capital-subscription.resolver';
import { VotingInteractor } from '../use-cases/voting.interactor';
import type { StartVotingInputDTO } from '../dto/voting/start-voting-input.dto';
import type { SubmitVoteInputDTO } from '../dto/voting/submit-vote-input.dto';
@@ -12,33 +15,45 @@ import type { PaginationInputDomainInterface } from '~/domain/common/interfaces/
import { SegmentMapper } from '../../infrastructure/mappers/segment.mapper';
import { SegmentOutputDTO } from '../dto/segments/segment.dto';
/**
* Сервис уровня приложения для голосования в CAPITAL
* Обрабатывает запросы от VotingResolver
*/
@Injectable()
export class VotingService {
constructor(private readonly votingInteractor: VotingInteractor, private readonly segmentMapper: SegmentMapper) {}
constructor(
private readonly votingInteractor: VotingInteractor,
private readonly segmentMapper: SegmentMapper,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {}
private notifyDataChanged(entity: string, action: string) {
this.pubSub.publish(CAPITAL_EVENTS.DATA_CHANGED, {
[CAPITAL_EVENTS.DATA_CHANGED]: { entity, action },
});
}
/**
* Запуск голосования в CAPITAL контракте
*/
async startVoting(data: StartVotingInputDTO): Promise<TransactResult> {
return await this.votingInteractor.startVoting(data);
const result = await this.votingInteractor.startVoting(data);
this.notifyDataChanged('voting', 'started');
return result;
}
/**
* Голосование в CAPITAL контракте
*/
async submitVote(data: SubmitVoteInputDTO, username: string): Promise<TransactResult> {
return await this.votingInteractor.submitVote({ ...data, voter: username });
const result = await this.votingInteractor.submitVote({ ...data, voter: username });
this.notifyDataChanged('voting', 'voted');
return result;
}
/**
* Завершение голосования в CAPITAL контракте
*/
async completeVoting(data: CompleteVotingInputDTO): Promise<TransactResult> {
return await this.votingInteractor.completeVoting(data);
const result = await this.votingInteractor.completeVoting(data);
this.notifyDataChanged('voting', 'completed');
return result;
}
/**
@@ -1,5 +1,6 @@
import { forwardRef, Module } from '@nestjs/common';
import { BaseExtModule } from '../base.extension.module';
import { PubSubModule } from '~/infrastructure/pubsub/pubsub.module';
import { CapitalDatabaseModule } from './infrastructure/database/capital-database.module';
import { RegistrationInfrastructureModule } from '~/infrastructure/registration/registration-infrastructure.module';
import { Injectable } from '@nestjs/common';
@@ -218,6 +219,7 @@ import { ProcessInstanceTypeormRepository } from './infrastructure/repositories/
import { PROCESS_TEMPLATE_REPOSITORY, PROCESS_INSTANCE_REPOSITORY } from './domain/repositories/process.repository';
import { ProcessService } from './application/services/process.service';
import { ProcessResolver } from './application/resolvers/process.resolver';
import { CapitalSubscriptionResolver } from './application/resolvers/capital-subscription.resolver';
import { ProcessTemplateTypeormEntity } from './infrastructure/entities/process-template.entity';
import { ProcessInstanceTypeormEntity } from './infrastructure/entities/process-instance.entity';
import { CommentTypeormRepository } from './infrastructure/repositories/comment.typeorm-repository';
@@ -520,6 +522,7 @@ export class CapitalPlugin extends BaseExtModule {
forwardRef(() => RegistrationModule),
RegistrationInfrastructureModule,
EventEmitterModule.forRoot(),
PubSubModule,
],
providers: [
// Plugin
@@ -673,6 +676,7 @@ IssueIdGenerationService,
},
ProcessService,
ProcessResolver,
CapitalSubscriptionResolver,
{
provide: COMMENT_REPOSITORY,
useClass: CommentTypeormRepository,
@@ -13,6 +13,7 @@ import { ParticipantPluginModule } from './participant/participant-extension.mod
import { Schema as ParticipantSchema } from './participant/types';
import { OneCoopPluginModule, OneCoopPlugin, Schema as OneCoopSchema } from './1ccoop/oneccoop-extension.module';
import { CapitalPluginModule, CapitalPlugin, Schema as CapitalSchema } from './capital/capital-extension.module';
import { ReportsExtensionModule } from './reports/reports-extension.module';
/**
* Конфигурация рабочего стола (workspace), который предоставляет расширение
@@ -305,6 +306,30 @@ export const AppRegistry: INamedExtension = {
return !!this.desktops && this.desktops.length > 0;
},
},
reports: {
is_builtin: false,
is_internal: true,
is_available: true,
desktops: [
{
name: 'reports',
title: 'Отчёты ФНС',
icon: 'fa-solid fa-file-invoice',
},
],
title: 'Отчёты ФНС',
description: 'Генерация налоговых отчётов для ФНС: бухбаланс, 6-НДФЛ, РСВ, ПСВ, декларация УСН и уведомления.',
image: 'https://i.ibb.co/6C5F3kD/Chat-GPT-Image-10-2025-20-42-42.png',
class: ReportsExtensionModule,
pluginClass: BuiltinPlugin,
schema: BuiltinSchema,
tags: ['бухгалтерия', 'отчётность', 'ФНС'],
readme: getReadmeContent('./reports'),
instructions: getInstructionsContent('./reports'),
get is_desktop() {
return !!this.desktops && this.desktops.length > 0;
},
},
orders: {
is_builtin: false,
is_internal: true,
@@ -36,6 +36,71 @@ export class GenerateReportInputDTO {
period?: number;
}
@InputType('OrganizationDataInput')
export class OrganizationDataInputDTO {
@Field(() => String)
@IsString()
inn!: string;
@Field(() => String)
@IsString()
kpp!: string;
@Field(() => String)
@IsString()
orgName!: string;
@Field(() => String)
@IsString()
ogrn!: string;
@Field(() => String)
@IsString()
okved!: string;
@Field(() => String)
@IsString()
oktmo!: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
okfs?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
okopf?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
address?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
phone?: string;
@Field(() => String)
@IsString()
signerLastName!: string;
@Field(() => String)
@IsString()
signerFirstName!: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
signerMiddleName?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
signerSnils?: string;
}
@ObjectType('GeneratedReport')
export class GeneratedReportDTO {
@Field(() => ReportType)
@@ -1,16 +1,22 @@
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { UseGuards, Inject, Logger } 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 { ReportRegistryService } from '../../domain/services/report-registry.service';
import { AvailableReportDTO, GenerateReportInputDTO, GeneratedReportDTO } from '../dto/report.dto';
import { AvailableReportDTO, GenerateReportInputDTO, GeneratedReportDTO, OrganizationDataInputDTO } from '../dto/report.dto';
import { config } from '~/config';
import type { ReportInput, LedgerAccountData } from '../../domain/interfaces/report-generator.interface';
import { LedgerInteractor } from '~/application/ledger/interactors/ledger.interactor';
@Resolver()
export class ReportResolver {
constructor(private readonly reportRegistry: ReportRegistryService) {}
private readonly logger = new Logger(ReportResolver.name);
constructor(
private readonly reportRegistry: ReportRegistryService,
private readonly ledgerInteractor: LedgerInteractor,
) {}
@Query(() => [AvailableReportDTO], {
name: 'getAvailableReports',
@@ -29,30 +35,55 @@ export class ReportResolver {
@AuthRoles(['chairman'])
async generateReport(
@Args('data') data: GenerateReportInputDTO,
@Args('organization') org: OrganizationDataInputDTO,
): Promise<GeneratedReportDTO> {
const coopname = config.coopname;
let ledgerData: LedgerAccountData[] = [];
try {
const ledgerState = await this.ledgerInteractor.getLedger({ coopname });
ledgerData = ledgerState.chartOfAccounts.map(acc => {
const amount = this.parseAmount(acc.available);
return {
accountId: acc.id,
name: acc.name,
balanceCurrent: amount,
balancePrevious: 0,
balancePrePrevious: 0,
};
});
} catch (e: any) {
this.logger.warn(`Не удалось загрузить данные ledger: ${e.message}`);
}
const input: ReportInput = {
reportType: data.reportType,
year: data.year,
period: data.period,
inn: '9728130611',
kpp: '772801001',
orgName: 'Потребительский Кооператив "ВОСХОД"',
ogrn: '1247700283346',
okved: '94.99',
okfs: '16',
okopf: '20200',
signerFio: { lastName: 'Иванов', firstName: 'Иван', middleName: 'Иванович' },
ledgerData: [],
inn: org.inn,
kpp: org.kpp,
orgName: org.orgName,
ogrn: org.ogrn,
okved: org.okved,
okfs: org.okfs || '16',
okopf: org.okopf || '20200',
oktmo: org.oktmo,
address: org.address,
phone: org.phone,
signerFio: {
lastName: org.signerLastName,
firstName: org.signerFirstName,
middleName: org.signerMiddleName,
},
signerSnils: org.signerSnils,
ledgerData,
};
// TODO: загрузить реальные данные из ledger
// Пока используем тестовые данные
input.ledgerData = [
{ accountId: 51, name: 'Расчётный счёт', available: 100000, blocked: 0 },
{ accountId: 80, name: 'Паевой фонд', available: 50000, blocked: 0 },
{ accountId: 86, name: 'Целевое финансирование', available: 30000, blocked: 0 },
];
return this.reportRegistry.generate(input);
}
private parseAmount(amountStr: string): number {
const match = amountStr.match(/([\d.]+)/);
return match ? parseFloat(match[1]) : 0;
}
}
@@ -1,78 +1,55 @@
import type { ReportType } from '../enums/report-type.enum';
/**
* Входные данные для генерации отчёта
*/
export interface ReportInput {
/** Тип отчёта */
reportType: ReportType;
/** Год отчётного периода */
year: number;
/** Квартал (1-4) или месяц (1-12) */
period?: number;
/** ИНН организации */
inn: string;
/** КПП организации */
kpp: string;
/** Наименование организации */
orgName: string;
/** ОГРН */
ogrn: string;
/** ОКВЭД */
okved: string;
/** ОКПО */
okpo?: string;
/** ОКФС — форма собственности */
okfs: string;
/** ОКОПФ — организационно-правовая форма */
okopf: string;
/** ОКТМО — код муниципального образования */
oktmo: string;
/** Адрес места нахождения */
address?: string;
/** Номер телефона */
phone?: string;
/** ФИО подписанта */
signerFio: { lastName: string; firstName: string; middleName?: string };
/** Данные из ledger (балансы по счетам) */
ledgerData?: LedgerAccountData[];
/** СНИЛС председателя (для ПСВ) */
signerSnils?: string;
}
/**
* Данные по бухгалтерскому счёту из ledger
*/
export interface LedgerAccountData {
/** ID счёта (51, 80, 86, etc.) */
accountId: number;
/** Наименование счёта */
name: string;
/** Доступные средства */
available: number;
/** Заблокированные средства */
blocked: number;
/** Сальдо на отчётную дату текущего периода */
balanceCurrent: number;
/** Сальдо на отчётную дату предыдущего периода */
balancePrevious: number;
/** Сальдо на отчётную дату года, предшествующего предыдущему */
balancePrePrevious: number;
}
/**
* Результат генерации отчёта
*/
export interface ReportOutput {
/** Тип отчёта */
reportType: ReportType;
/** XML содержимое (windows-1251 encoded) */
xml: string;
/** Имя файла для ФНС */
fileName: string;
/** Ошибки валидации (если есть) */
errors: string[];
/** Успешна ли валидация */
isValid: boolean;
}
/**
* Интерфейс генератора отчёта.
* Каждый тип отчёта реализует этот интерфейс.
*/
export interface IReportGenerator {
/** Тип отчёта */
readonly reportType: ReportType;
/** Генерация XML */
generate(input: ReportInput): ReportOutput;
/** Генерация имени файла по стандарту ФНС */
generateFileName(input: ReportInput): string;
}
@@ -1,12 +1,21 @@
import { create } from 'xmlbuilder2';
import * as iconv from 'iconv-lite';
import { ReportType } from '../../domain/enums/report-type.enum';
import type { IReportGenerator, ReportInput, ReportOutput } from '../../domain/interfaces/report-generator.interface';
import { createXmlDoc, formatDate, generateUuid, addFio } from './xml-utils';
/**
* Генератор бухгалтерского баланса (NO_BUHOTCH)
* Формат: XML в кодировке windows-1251 с кириллическими тегами
* Стандарт: Приказ ФНС КЧ-17-18/692 от 11.03.2025
* Генератор бухгалтерской отчётности (NO_BUHOTCH)
* Формат: Приказ ФНС КЧ-17-18/692 от 11.03.2025
* XSD: NO_BUHOTCH_1_105_00_05_09_01.xsd
*
* Кооператив обязан сдавать:
* - Баланс (форма 0710001)
* - Отчёт о целевом использовании средств (форма 0710006)
* - Отчёт о финансовых результатах — только при коммерческой деятельности
*
* Счета ledger:
* 51 — Расчётный счёт (Актив → ОбА → Денежные средства, строка 1250)
* 80 — Паевой фонд (Пассив → Капитал, строка 1310)
* 86 — Целевое финансирование (Пассив → Капитал, строка 1350)
*/
export class BuhotchGenerator implements IReportGenerator {
readonly reportType = ReportType.BUHOTCH;
@@ -17,9 +26,9 @@ export class BuhotchGenerator implements IReportGenerator {
try {
const xml = this.buildXml(input);
return { reportType: this.reportType, xml, fileName, errors, isValid: errors.length === 0 };
return { reportType: this.reportType, xml, fileName, errors, isValid: true };
} catch (e: any) {
errors.push(`Ошибка генерации: ${e.message}`);
errors.push(`Ошибка генерации бухбаланса: ${e.message}`);
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
}
}
@@ -27,128 +36,154 @@ export class BuhotchGenerator implements IReportGenerator {
generateFileName(input: ReportInput): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
return `NO_BUHOTCH_${input.inn}_${input.kpp}_${dateStr}_${randomId()}.xml`;
const uuid = generateUuid();
return `NO_BUHOTCH_${input.inn}_${input.kpp}_${dateStr}_${uuid}`;
}
private getLedgerBalance(input: ReportInput, accountId: number) {
const acc = input.ledgerData?.find(a => a.accountId === accountId);
return {
current: acc ? Math.round(acc.balanceCurrent / 1000) : 0,
previous: acc ? Math.round(acc.balancePrevious / 1000) : 0,
prePrevious: acc ? Math.round(acc.balancePrePrevious / 1000) : 0,
};
}
private buildXml(input: ReportInput): string {
const account51 = input.ledgerData?.find(a => a.accountId === 51);
const account80 = input.ledgerData?.find(a => a.accountId === 80);
const account86 = input.ledgerData?.find(a => a.accountId === 86);
const acc51 = this.getLedgerBalance(input, 51);
const acc80 = this.getLedgerBalance(input, 80);
const acc86 = this.getLedgerBalance(input, 86);
const cashAmount = account51 ? Math.round(account51.available / 1000) : 0;
const shareCapital = account80 ? Math.round(account80.available / 1000) : 0;
const targetFunding = account86 ? Math.round(account86.available / 1000) : 0;
const totalOba = { current: acc51.current, previous: acc51.previous, prePrevious: acc51.prePrevious };
const totalAssets = { current: totalOba.current, previous: totalOba.previous, prePrevious: totalOba.prePrevious };
const totalCapital = {
current: acc80.current + acc86.current,
previous: acc80.previous + acc86.previous,
prePrevious: acc80.prePrevious + acc86.prePrevious,
};
const totalPassive = totalCapital;
const totalCurrentAssets = cashAmount;
const totalAssets = totalCurrentAssets;
const totalCapital = shareCapital + targetFunding;
const totalLiabilities = totalCapital;
const idFile = this.generateFileName(input);
const idFile = this.generateFileName(input).replace('.xml', '');
const doc = create({ version: '1.0', encoding: 'windows-1251' })
const doc = createXmlDoc()
.ele('Файл')
.att('ИдФайл', idFile)
.att('ВерсПрог', 'CoopReports 1.0')
.att('ВерсФорм', '5.09')
.ele('Документ')
.att('КНД', '0710099')
.att('ДатаДок', formatDate(new Date()))
.att('Период', '34')
.att('ОтчетГод', String(input.year))
.att('НомКорр', '0')
.att('ОКЕИ', '384')
.att('ПрАудит', '0')
.ele('СвНП')
.ele('НПЮЛ')
.att('НаимОрг', input.orgName)
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.att('АдрМН', '-')
.up()
.att('ОКВЭД2', input.okved)
.att('ОКФС', input.okfs)
.att('ОКОПФ', input.okopf)
.up()
.ele('Подписант')
.att('ПрПодп', '1')
.ele('ФИО')
.att('Фамилия', input.signerFio.lastName)
.att('Имя', input.signerFio.firstName)
.att('Отчество', input.signerFio.middleName || '')
.up()
.up()
.ele('Баланс')
.ele('Актив')
.ele('ВнеОбА')
.att('Пояснения', '')
.att('СумОтч', '0')
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.ele('ОбА')
.ele('ДенежнСр')
.att('Пояснения', '')
.att('СумОтч', String(cashAmount))
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.att('Пояснения', '')
.att('СумОтч', String(totalCurrentAssets))
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.att('СумОтч', String(totalAssets))
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.ele('Пассив')
.ele('Капитал')
.ele('УстКап')
.att('Пояснения', '')
.att('СумОтч', String(shareCapital))
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.ele('ЦелФинанс')
.att('Пояснения', '')
.att('СумОтч', String(targetFunding))
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.att('Пояснения', '')
.att('СумОтч', String(totalCapital))
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.ele('ДолгОбяз')
.att('Пояснения', '')
.att('СумОтч', '0')
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.ele('КрОбяз')
.att('Пояснения', '')
.att('СумОтч', '0')
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.att('СумОтч', String(totalLiabilities))
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up()
.up()
.up()
.up();
.att('ВерсФорм', '5.09');
const dokument = doc
.ele('Документ')
.att('КНД', '0710099')
.att('ДатаДок', formatDate(new Date()))
.att('Период', '34')
.att('ОтчетГод', String(input.year))
.att('НомКорр', '0')
.att('ОКЕИ', '384')
.att('ПрАудит', '0');
const svnp = dokument.ele('СвНП')
.att('ОКВЭД2', input.okved)
.att('ОКФС', input.okfs)
.att('ОКОПФ', input.okopf);
if (input.okpo) {
svnp.att('ОКПО', input.okpo);
}
svnp.ele('НПЮЛ')
.att('НаимОрг', input.orgName)
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.att('АдрМН', input.address || '-')
.up();
svnp.up();
const signer = dokument.ele('Подписант').att('ПрПодп', '1');
addFio(signer, input.signerFio);
signer.up();
// === Баланс (форма 0710001) ===
const balans = dokument.ele('Баланс').att('ОКУД', '0710001');
const aktiv = balans.ele('Актив');
aktiv.ele('ВнеОбА')
.att('СумОтч', '0')
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up();
aktiv.ele('ОбА')
.att('СумОтч', String(totalOba.current))
.att('СумПрдщ', String(totalOba.previous))
.att('СумПрдшв', String(totalOba.prePrevious))
.up();
aktiv
.att('СумОтч', String(totalAssets.current))
.att('СумПрдщ', String(totalAssets.previous))
.att('СумПрдшв', String(totalAssets.prePrevious));
aktiv.up();
const passiv = balans.ele('Пассив');
passiv.ele('ДолгосрОбяз')
.att('СумОтч', '0')
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up();
passiv.ele('КраткосрОбяз')
.att('СумОтч', '0')
.att('СумПрдщ', '0')
.att('СумПрдшв', '0')
.up();
passiv
.att('ОКУД', '0710001')
.att('СумОтч', String(totalPassive.current))
.att('СумПрдщ', String(totalPassive.previous))
.att('СумПрдшв', String(totalPassive.prePrevious));
passiv.up();
balans.up();
// === Отчёт о целевом использовании средств (форма 0710006) ===
const celIsp = dokument.ele('ЦелИсп').att('ОКУД', '0710006');
celIsp.ele('ОстатНачОтч')
.att('СумОтч', String(acc86.previous))
.att('СумПред', String(acc86.prePrevious))
.up();
const postup = celIsp.ele('Поступило');
postup.ele('ВступВзнос').att('СумОтч', '0').att('СумПред', '0').up();
postup.ele('ЧленВзнос').att('СумОтч', '0').att('СумПред', '0').up();
postup.ele('ЦелевВзнос').att('СумОтч', '0').att('СумПред', '0').up();
postup.ele('ДобрИмВзнос').att('СумОтч', '0').att('СумПред', '0').up();
postup.ele('ПрибПредДеят').att('СумОтч', '0').att('СумПред', '0').up();
postup.ele('Прочие').att('СумОтч', '0').att('СумПред', '0').up();
postup.att('СумОтч', '0').att('СумПред', '0');
postup.up();
const ispolz = celIsp.ele('Использовано');
const rashCel = ispolz.ele('РасхЦелМер');
rashCel.att('СумОтч', '0').att('СумПред', '0');
rashCel.up();
const rashAU = ispolz.ele('РасхСодАУ');
rashAU.att('СумОтч', '0').att('СумПред', '0');
rashAU.up();
ispolz.ele('ПриобОСИн').att('СумОтч', '0').att('СумПред', '0').up();
ispolz.ele('Прочие').att('СумОтч', '0').att('СумПред', '0').up();
ispolz.att('СумОтч', '0').att('СумПред', '0');
ispolz.up();
celIsp.ele('ОстатКонОтч')
.att('СумОтч', String(acc86.current))
.att('СумПред', String(acc86.previous))
.up();
celIsp.up();
dokument.up();
doc.up();
return doc.end({ prettyPrint: true });
}
}
function formatDate(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
}
function randomId(): string {
return Math.random().toString(36).substring(2, 10).toUpperCase();
}
@@ -0,0 +1,96 @@
import { ReportType } from '../../domain/enums/report-type.enum';
import type { IReportGenerator, ReportInput, ReportOutput } from '../../domain/interfaces/report-generator.interface';
import { createXmlDoc, formatDate, generateUuid, addSigner, getTaxOfficeCode } from './xml-utils';
/**
* Генератор ДУСН — Декларация по УСН (нулевая)
* XSD: NO_USN_1_030_00_05_09_01.xsd
* КНД: 1152017
*
* Структура: Файл → Документ → [СвНП, Подписант, УСН]
* УСН содержит @ОбНал (1=доходы, 2=доходы-расходы)
* Для кооператива: ОбНал=1 (доходы)
* Раздел 1.1 — СумНалПУ_НП с нулями
* Раздел 2.1.1 — РасчНал1 с нулями
*/
export class DusnGenerator implements IReportGenerator {
readonly reportType = ReportType.DUSN;
generate(input: ReportInput): ReportOutput {
const fileName = this.generateFileName(input);
const errors: string[] = [];
try {
const xml = this.buildXml(input);
return { reportType: this.reportType, xml, fileName, errors, isValid: true };
} catch (e: any) {
errors.push(`Ошибка генерации ДУСН: ${e.message}`);
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
}
}
generateFileName(input: ReportInput): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
return `NO_USN_${input.inn}_${input.kpp}_${dateStr}_${generateUuid()}`;
}
private buildXml(input: ReportInput): string {
const idFile = this.generateFileName(input);
const kodNO = getTaxOfficeCode(input.kpp);
const doc = createXmlDoc()
.ele('Файл')
.att('ИдФайл', idFile)
.att('ВерсПрог', 'CoopReports 1.0')
.att('ВерсФорм', '5.09');
const dokument = doc.ele('Документ')
.att('КНД', '1152017')
.att('ДатаДок', formatDate(new Date()))
.att('Период', '34')
.att('ОтчетГод', String(input.year))
.att('КодНО', kodNO)
.att('НомКорр', '0')
.att('ПоМесту', '120');
dokument.ele('СвНП')
.ele('НПЮЛ')
.att('НаимОрг', input.orgName)
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.up()
.up();
addSigner(dokument, input.signerFio);
const usn = dokument.ele('УСН').att('ОбНал', '1');
const section1 = usn.ele('СумНалПУ_НП')
.att('ОКТМО', input.oktmo)
.att('НалПУУменПер', '0');
section1.ele('РасчНал1')
.att('ПризНП', '2')
.ele('Доход')
.att('СумЗаНалПер', '0')
.up()
.ele('Ставка')
.att('СтавкаНалПер', '6.0')
.up()
.ele('Исчисл')
.att('СумЗаНалПер', '0')
.up()
.ele('УменНал')
.att('СумЗаНалПер', '0')
.up()
.up();
section1.up();
usn.up();
dokument.up();
doc.up();
return doc.end({ prettyPrint: true });
}
}
@@ -0,0 +1,76 @@
import { ReportType } from '../../domain/enums/report-type.enum';
import type { IReportGenerator, ReportInput, ReportOutput } from '../../domain/interfaces/report-generator.interface';
import { createXmlDoc, formatDate, generateUuid, addSigner, getQuarterPeriodCode, getTaxOfficeCode } from './xml-utils';
/**
* Генератор 4-ФСС (ЕФС-1) — нулевой
* XSD не предоставлен, формат приближённый к стандарту СФР
* КНД: 1111111 (условный)
*
* С 2023 года 4-ФСС заменён на ЕФС-1 (подраздел 2.1)
* Подаётся в Социальный фонд России ежеквартально
*/
export class Fss4Generator implements IReportGenerator {
readonly reportType = ReportType.FSS4;
generate(input: ReportInput): ReportOutput {
const fileName = this.generateFileName(input);
const errors: string[] = [];
try {
const xml = this.buildXml(input);
return { reportType: this.reportType, xml, fileName, errors, isValid: true };
} catch (e: any) {
errors.push(`Ошибка генерации 4-ФСС: ${e.message}`);
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
}
}
generateFileName(input: ReportInput): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
return `EFS1_${input.inn}_${input.kpp}_${dateStr}_${generateUuid()}`;
}
private buildXml(input: ReportInput): string {
const periodCode = getQuarterPeriodCode(input.period);
const idFile = this.generateFileName(input);
const kodNO = getTaxOfficeCode(input.kpp);
const doc = createXmlDoc()
.ele('Файл')
.att('ИдФайл', idFile)
.att('ВерсПрог', 'CoopReports 1.0')
.att('ВерсФорм', '1.00');
const dokument = doc.ele('Документ')
.att('КНД', '1111111')
.att('ДатаДок', formatDate(new Date()))
.att('Период', periodCode)
.att('ОтчетГод', String(input.year))
.att('КодНО', kodNO)
.att('НомКорр', '0')
.att('ПоМесту', '214');
dokument.ele('СвНП')
.ele('НПЮЛ')
.att('НаимОрг', input.orgName)
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.up()
.up();
addSigner(dokument, input.signerFio);
dokument.ele('ЕФС1')
.att('СрЧисл', '1')
.att('СумНачислВзнос', '0')
.att('СумУплВзнос', '0')
.up();
dokument.up();
doc.up();
return doc.end({ prettyPrint: true });
}
}
@@ -1,9 +1,17 @@
import { create } from 'xmlbuilder2';
import { ReportType } from '../../domain/enums/report-type.enum';
import type { IReportGenerator, ReportInput, ReportOutput } from '../../domain/interfaces/report-generator.interface';
import { createXmlDoc, formatDate, generateUuid, addSigner, getQuarterPeriodCode, getTaxOfficeCode } from './xml-utils';
/**
* Генератор 6-НДФЛ — нулевая отчётность
* XSD: NO_NDFL6.2_1_231_00_05_05_02.xsd
* КНД: 1151100
*
* Структура:
* Файл → Документ → [СвНП, Подписант, НДФЛ6.2]
* СвНП содержит @ОКТМО и выбор НПЮЛ/НПФЛ
* НДФЛ6.2 содержит ОбязНА[], РасчСумНал[], СправДох[]
* Для нулевого отчёта: РасчСумНал со ставкой 13% и нулями
*/
export class Ndfl6Generator implements IReportGenerator {
readonly reportType = ReportType.NDFL6;
@@ -13,48 +21,7 @@ export class Ndfl6Generator implements IReportGenerator {
const errors: string[] = [];
try {
const periodCode = input.period === 1 ? '21' : input.period === 2 ? '31' : input.period === 3 ? '33' : '34';
const idFile = fileName.replace('.xml', '');
const doc = create({ version: '1.0', encoding: 'windows-1251' })
.ele('Файл')
.att('ИдФайл', idFile)
.att('ВерсПрог', 'CoopReports 1.0')
.att('ВерсФорм', '5.05')
.ele('Документ')
.att('КНД', '1151100')
.att('ДатаДок', formatDate(new Date()))
.att('Период', periodCode)
.att('ОтчетГод', String(input.year))
.att('КодНО', input.kpp.substring(0, 4))
.att('НомКорр', '0')
.att('ПоМесту', '214')
.ele('СвНП')
.att('ОКВЭД', input.okved)
.ele('НПЮЛ')
.att('НаимОрг', input.orgName)
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.up()
.up()
.ele('Подписант')
.att('ПрПодп', '1')
.ele('ФИО')
.att('Фамилия', input.signerFio.lastName)
.att('Имя', input.signerFio.firstName)
.att('Отчество', input.signerFio.middleName || '')
.up()
.up()
.ele('НДФЛ6')
.ele('ОбобщПоказ')
.att('КолФЛДоход', '0')
.att('КолФЛВыч', '0')
.up()
.up()
.up()
.up();
const xml = doc.end({ prettyPrint: true });
const xml = this.buildXml(input);
return { reportType: this.reportType, xml, fileName, errors, isValid: true };
} catch (e: any) {
errors.push(`Ошибка генерации 6-НДФЛ: ${e.message}`);
@@ -65,10 +32,79 @@ export class Ndfl6Generator implements IReportGenerator {
generateFileName(input: ReportInput): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
return `NO_NDFL6.2_${input.inn}_${input.kpp}_${dateStr}.xml`;
const uuid = generateUuid();
return `NO_NDFL6.2_${input.inn}_${input.kpp}_${dateStr}_${uuid}`;
}
private buildXml(input: ReportInput): string {
const periodCode = getQuarterPeriodCode(input.period);
const idFile = this.generateFileName(input);
const kodNO = getTaxOfficeCode(input.kpp);
const doc = createXmlDoc()
.ele('Файл')
.att('ИдФайл', idFile)
.att('ВерсПрог', 'CoopReports 1.0')
.att('ВерсФорм', '5.05');
const dokument = doc.ele('Документ')
.att('КНД', '1151100')
.att('ДатаДок', formatDate(new Date()))
.att('Период', periodCode)
.att('ОтчетГод', String(input.year))
.att('КодНО', kodNO)
.att('НомКорр', '0')
.att('ПоМесту', '214');
dokument.ele('СвНП')
.att('ОКТМО', input.oktmo)
.ele('НПЮЛ')
.att('НаимОрг', input.orgName)
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.up()
.up();
addSigner(dokument, input.signerFio);
const ndfl = dokument.ele('НДФЛ6.2');
ndfl.ele('РасчСумНал')
.att('Ставка', '13')
.att('КБК', '18210102010011000110')
.att('КолФЛ', '0')
.att('КолКвал', '0')
.att('СумНачислНач', '0')
.att('СумНачислКвал', '0')
.att('СумВыч', '0')
.att('НалБаза', '0')
.att('СумНалИсч', '0')
.att('СумНалИсчКвал', '0')
.att('СумФикс', '0')
.att('СумНалПриб', '0')
.att('СумНалИнГос', '0')
.att('СумНалУдерж', '0')
.att('СумНалУдерж1Мес', '0')
.att('СумНалУдерж23_1Мес', '0')
.att('СумНалУдерж2Мес', '0')
.att('СумНалУдерж23_2Мес', '0')
.att('СумНалУдерж3Мес', '0')
.att('СумНалУдерж23_3Мес', '0')
.att('СумНалНеУдерж', '0')
.att('СумНалИзлУдерж', '0')
.att('СумНалВозвр', '0')
.att('СумНалВозвр1Мес', '0')
.att('СумНалВозвр23_1Мес', '0')
.att('СумНалВозвр2Мес', '0')
.att('СумНалВозвр23_2Мес', '0')
.att('СумНалВозвр3Мес', '0')
.att('СумНалВозвр23_3Мес', '0')
.up();
ndfl.up();
dokument.up();
doc.up();
return doc.end({ prettyPrint: true });
}
}
function formatDate(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
}
@@ -0,0 +1,78 @@
import { ReportType } from '../../domain/enums/report-type.enum';
import type { IReportGenerator, ReportInput, ReportOutput } from '../../domain/interfaces/report-generator.interface';
import { createXmlDoc, formatDate, generateUuid, addFio, addSigner, getMonthPeriodCode, getTaxOfficeCode } from './xml-utils';
/**
* Генератор ПСВ — Персонифицированные сведения (нулевой)
* XSD: NO_PERSSVFL_1_297_00_05_01_02.xsd
* КНД: 1151162
*
* Структура: Файл → Документ → [СвНП, Подписант, ПерсСвФЛ[]]
* ПерсСвФЛ обязателен (minOccurs=1), содержит ФИО, @СНИЛС, @СумВыпл
* Для нулевого отчёта: одна запись председателя с СумВыпл=0
*/
export class PsvGenerator implements IReportGenerator {
readonly reportType = ReportType.PSV;
generate(input: ReportInput): ReportOutput {
const fileName = this.generateFileName(input);
const errors: string[] = [];
try {
const xml = this.buildXml(input);
return { reportType: this.reportType, xml, fileName, errors, isValid: true };
} catch (e: any) {
errors.push(`Ошибка генерации ПСВ: ${e.message}`);
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
}
}
generateFileName(input: ReportInput): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
return `NO_PERSSVFL_${input.inn}_${input.kpp}_${dateStr}_${generateUuid()}`;
}
private buildXml(input: ReportInput): string {
const periodCode = getMonthPeriodCode(input.period);
const idFile = this.generateFileName(input);
const kodNO = getTaxOfficeCode(input.kpp);
const doc = createXmlDoc()
.ele('Файл')
.att('ИдФайл', idFile)
.att('ВерсПрог', 'CoopReports 1.0')
.att('ВерсФорм', '5.01');
const dokument = doc.ele('Документ')
.att('КНД', '1151162')
.att('ДатаДок', formatDate(new Date()))
.att('НомКорр', '0')
.att('Период', periodCode)
.att('ОтчетГод', String(input.year))
.att('КодНО', kodNO)
.att('ПоМесту', '214');
dokument.ele('СвНП')
.ele('НПЮЛ')
.att('НаимОрг', input.orgName)
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.up()
.up();
addSigner(dokument, input.signerFio);
const persSv = dokument.ele('ПерсСвФЛ')
.att('СНИЛС', input.signerSnils || '000-000-000 00')
.att('СумВыпл', '0');
addFio(persSv, input.signerFio);
persSv.up();
dokument.up();
doc.up();
return doc.end({ prettyPrint: true });
}
}
@@ -0,0 +1,87 @@
import { ReportType } from '../../domain/enums/report-type.enum';
import type { IReportGenerator, ReportInput, ReportOutput } from '../../domain/interfaces/report-generator.interface';
import { createXmlDoc, formatDate, generateUuid, addSigner, getQuarterPeriodCode, getTaxOfficeCode } from './xml-utils';
/**
* Генератор РСВ — Расчёт по страховым взносам (нулевой)
* XSD: NO_RASCHSV_1_162_00_05_08_02.xsd
* КНД: 1151111
*
* Структура: Файл → Документ → [СвНП, Подписант, РасчетСВ]
* СвНП: @СрЧисл, @Тлф, НПЮЛ(@НаимОрг, @ИННЮЛ, @КПП)
* РасчетСВ: ОбязПлатСВ(@ТипПлат, @ОКТМО, ...) для нулевого
*/
export class RsvGenerator implements IReportGenerator {
readonly reportType = ReportType.RSV;
generate(input: ReportInput): ReportOutput {
const fileName = this.generateFileName(input);
const errors: string[] = [];
try {
const xml = this.buildXml(input);
return { reportType: this.reportType, xml, fileName, errors, isValid: true };
} catch (e: any) {
errors.push(`Ошибка генерации РСВ: ${e.message}`);
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
}
}
generateFileName(input: ReportInput): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
return `NO_RASCHSV_${input.inn}_${input.kpp}_${dateStr}_${generateUuid()}`;
}
private buildXml(input: ReportInput): string {
const periodCode = getQuarterPeriodCode(input.period);
const idFile = this.generateFileName(input);
const kodNO = getTaxOfficeCode(input.kpp);
const doc = createXmlDoc()
.ele('Файл')
.att('ИдФайл', idFile)
.att('ВерсПрог', 'CoopReports 1.0')
.att('ВерсФорм', '5.08');
const dokument = doc.ele('Документ')
.att('КНД', '1151111')
.att('ДатаДок', formatDate(new Date()))
.att('НомКорр', '0')
.att('Период', periodCode)
.att('ОтчетГод', String(input.year))
.att('КодНО', kodNO)
.att('ПоМесту', '214');
dokument.ele('СвНП')
.att('СрЧисл', '1')
.ele('НПЮЛ')
.att('НаимОрг', input.orgName)
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.up()
.up();
addSigner(dokument, input.signerFio);
const raschSV = dokument.ele('РасчетСВ');
const obyaz = raschSV.ele('ОбязПлатСВ')
.att('ТипПлат', '1')
.att('ОКТМО', input.oktmo);
obyaz.ele('УплПерОПС')
.att('КБК', '18210202010061010160')
.att('СумСВУплПер', '0')
.att('СумСВУпл1М', '0')
.att('СумСВУпл2М', '0')
.att('СумСВУпл3М', '0')
.up();
obyaz.up();
raschSV.up();
dokument.up();
doc.up();
return doc.end({ prettyPrint: true });
}
}
@@ -0,0 +1,75 @@
import { ReportType } from '../../domain/enums/report-type.enum';
import type { IReportGenerator, ReportInput, ReportOutput } from '../../domain/interfaces/report-generator.interface';
import { createXmlDoc, formatDate, generateUuid, addSigner, getQuarterPeriodCode, getTaxOfficeCode } from './xml-utils';
/**
* Генератор Уведомления по УСН (нулевое)
* XSD: UT_UVISCHSUMNAL_1_263_00_05_03_01.xsd (общий с UV_VZNOSY)
* КНД: 1110355
*
* Уведомление об исчисленных суммах авансовых платежей по УСН
* Подаётся ежеквартально до 25-го числа
*/
export class UusnGenerator implements IReportGenerator {
readonly reportType = ReportType.UUSN;
generate(input: ReportInput): ReportOutput {
const fileName = this.generateFileName(input);
const errors: string[] = [];
try {
const xml = this.buildXml(input);
return { reportType: this.reportType, xml, fileName, errors, isValid: true };
} catch (e: any) {
errors.push(`Ошибка генерации уведомления по УСН: ${e.message}`);
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
}
}
generateFileName(input: ReportInput): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
return `UT_UVISCHSUMNAL_${input.inn}_${input.kpp}_${dateStr}_${generateUuid()}`;
}
private buildXml(input: ReportInput): string {
const idFile = this.generateFileName(input);
const kodNO = getTaxOfficeCode(input.kpp);
const quarter = input.period || 1;
const doc = createXmlDoc()
.ele('Файл')
.att('ИдФайл', idFile)
.att('ВерсПрог', 'CoopReports 1.0')
.att('ВерсФорм', '5.03');
const dokument = doc.ele('Документ')
.att('КНД', '1110355')
.att('ДатаДок', formatDate(new Date()))
.att('КодНО', kodNO);
dokument.ele('СвНП')
.ele('НПЮЛ')
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.up()
.up();
addSigner(dokument, input.signerFio);
dokument.ele('УвИсчСумНалог')
.att('КППДекл', input.kpp)
.att('ОКТМО', input.oktmo)
.att('КБК', '18210501021011000110')
.att('СумНалогАванс', '0')
.att('Период', '21/01')
.att('НомерМесКварт', String(quarter))
.att('Год', String(input.year))
.up();
dokument.up();
doc.up();
return doc.end({ prettyPrint: true });
}
}
@@ -0,0 +1,76 @@
import { ReportType } from '../../domain/enums/report-type.enum';
import type { IReportGenerator, ReportInput, ReportOutput } from '../../domain/interfaces/report-generator.interface';
import { createXmlDoc, formatDate, generateUuid, addSigner, getTaxOfficeCode } from './xml-utils';
/**
* Генератор Уведомления об исчисленных страховых взносах (нулевое)
* XSD: UT_UVISCHSUMNAL_1_263_00_05_03_01.xsd
* КНД: 1110355
*
* Структура: Файл → Документ → [СвНП, Подписант, УвИсчСумНалог[]]
* СвНП: НПЮЛ(@ИННЮЛ, @КПП)
* УвИсчСумНалог: @ОКТМО, @КБК, @СумНалогАванс, @Период, @НомерМесКварт, @Год
*/
export class UvVznosyGenerator implements IReportGenerator {
readonly reportType = ReportType.UV_VZNOSY;
generate(input: ReportInput): ReportOutput {
const fileName = this.generateFileName(input);
const errors: string[] = [];
try {
const xml = this.buildXml(input);
return { reportType: this.reportType, xml, fileName, errors, isValid: true };
} catch (e: any) {
errors.push(`Ошибка генерации уведомления о взносах: ${e.message}`);
return { reportType: this.reportType, xml: '', fileName, errors, isValid: false };
}
}
generateFileName(input: ReportInput): string {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
return `UT_UVISCHSUMNAL_${input.inn}_${input.kpp}_${dateStr}_${generateUuid()}`;
}
private buildXml(input: ReportInput): string {
const idFile = this.generateFileName(input);
const kodNO = getTaxOfficeCode(input.kpp);
const month = input.period || 1;
const doc = createXmlDoc()
.ele('Файл')
.att('ИдФайл', idFile)
.att('ВерсПрог', 'CoopReports 1.0')
.att('ВерсФорм', '5.03');
const dokument = doc.ele('Документ')
.att('КНД', '1110355')
.att('ДатаДок', formatDate(new Date()))
.att('КодНО', kodNO);
dokument.ele('СвНП')
.ele('НПЮЛ')
.att('ИННЮЛ', input.inn)
.att('КПП', input.kpp)
.up()
.up();
addSigner(dokument, input.signerFio);
dokument.ele('УвИсчСумНалог')
.att('КППДекл', input.kpp)
.att('ОКТМО', input.oktmo)
.att('КБК', '18210202000011000160')
.att('СумНалогАванс', '0')
.att('Период', '21/01')
.att('НомерМесКварт', String(month))
.att('Год', String(input.year))
.up();
dokument.up();
doc.up();
return doc.end({ prettyPrint: true });
}
}
@@ -0,0 +1,53 @@
import { create } from 'xmlbuilder2';
export function formatDate(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
}
export function generateUuid(): string {
return Math.random().toString(36).substring(2, 10).toUpperCase()
+ Math.random().toString(36).substring(2, 6).toUpperCase();
}
export function createXmlDoc(): any {
return create({ version: '1.0', encoding: 'windows-1251' });
}
export function addFio(
parent: any,
fio: { lastName: string; firstName: string; middleName?: string },
): any {
const el = parent.ele('ФИО')
.att('Фамилия', fio.lastName)
.att('Имя', fio.firstName);
if (fio.middleName) {
el.att('Отчество', fio.middleName);
}
return el.up();
}
export function addSigner(
parent: any,
fio: { lastName: string; firstName: string; middleName?: string },
): any {
const signer = parent.ele('Подписант').att('ПрПодп', '1');
addFio(signer, fio);
return signer.up();
}
export function getQuarterPeriodCode(quarter?: number): string {
switch (quarter) {
case 1: return '21';
case 2: return '31';
case 3: return '33';
default: return '34';
}
}
export function getMonthPeriodCode(month?: number): string {
return month ? String(month).padStart(2, '0') : '01';
}
export function getTaxOfficeCode(kpp: string): string {
return kpp.substring(0, 4);
}
@@ -1,13 +1,14 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ReportRegistryService } from '../../domain/services/report-registry.service';
import { ReportType } from '../../domain/enums/report-type.enum';
import { BuhotchGenerator } from '../generators/buhotch.generator';
import { Ndfl6Generator } from '../generators/ndfl6.generator';
import { createZeroReportGenerator } from '../generators/zero-report.generator';
import { RsvGenerator } from '../generators/rsv.generator';
import { PsvGenerator } from '../generators/psv.generator';
import { DusnGenerator } from '../generators/dusn.generator';
import { Fss4Generator } from '../generators/fss4.generator';
import { UvVznosyGenerator } from '../generators/uv-vznosy.generator';
import { UusnGenerator } from '../generators/uusn.generator';
/**
* Сервис инициализации — регистрирует все генераторы при старте модуля
*/
@Injectable()
export class ReportInitService implements OnModuleInit {
constructor(private readonly registry: ReportRegistryService) {}
@@ -15,11 +16,11 @@ export class ReportInitService implements OnModuleInit {
onModuleInit() {
this.registry.register(new BuhotchGenerator());
this.registry.register(new Ndfl6Generator());
this.registry.register(createZeroReportGenerator(ReportType.RSV, '1151111', '5.08'));
this.registry.register(createZeroReportGenerator(ReportType.PSV, '1151162', '5.01'));
this.registry.register(createZeroReportGenerator(ReportType.DUSN, '1152017', '5.09'));
this.registry.register(createZeroReportGenerator(ReportType.FSS4, '1111111', '1.00'));
this.registry.register(createZeroReportGenerator(ReportType.UV_VZNOSY, '1110355', '5.03'));
this.registry.register(createZeroReportGenerator(ReportType.UUSN, '1110355', '5.03'));
this.registry.register(new RsvGenerator());
this.registry.register(new PsvGenerator());
this.registry.register(new DusnGenerator());
this.registry.register(new Fss4Generator());
this.registry.register(new UvVznosyGenerator());
this.registry.register(new UusnGenerator());
}
}
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { ReportRegistryService } from './domain/services/report-registry.service';
import { ReportInitService } from './infrastructure/services/report-init.service';
import { ReportResolver } from './application/resolvers/report.resolver';
import { LedgerModule } from '~/application/ledger/ledger.module';
@Module({
imports: [LedgerModule],
providers: [
ReportRegistryService,
ReportInitService,
@@ -0,0 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('api_keys')
export class ApiKeyEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 12 })
coopname!: string;
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'varchar', length: 64, unique: true })
key_hash!: string;
@Column({ type: 'varchar', length: 8 })
key_prefix!: string;
@Column({ type: 'varchar', length: 50 })
created_by!: string;
@Column({ type: 'jsonb', default: '["*"]' })
allowed_operations!: string[];
@Column({ type: 'timestamp', nullable: true })
expires_at?: Date;
@Column({ type: 'boolean', default: true })
is_active!: boolean;
@Column({ type: 'timestamp', nullable: true })
last_used_at?: Date;
@CreateDateColumn()
created_at!: Date;
}
@@ -0,0 +1,37 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { ApiKeyService } from './api-key.service';
/**
* Guard для аутентификации через API ключ.
* Проверяет заголовок x-api-key. Если присутствует — валидирует.
* Работает как альтернатива JWT — не заменяет его.
*/
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private readonly apiKeyService: ApiKeyService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
const apiKey = request.headers['x-api-key'];
if (!apiKey) return true;
const entity = await this.apiKeyService.validateKey(apiKey);
if (!entity) {
throw new UnauthorizedException('Невалидный или истёкший API ключ');
}
request.apiKeyEntity = entity;
if (!request.user) {
request.user = {
username: entity.created_by,
role: 'chairman',
isApiKey: true,
};
}
return true;
}
}
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKeyEntity } from './api-key.entity';
import { ApiKeyService } from './api-key.service';
import { ApiKeyGuard } from './api-key.guard';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ApiKeyEntity])],
providers: [ApiKeyService, ApiKeyGuard],
exports: [ApiKeyService, ApiKeyGuard],
})
export class ApiKeyModule {}
@@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as crypto from 'crypto';
import { ApiKeyEntity } from './api-key.entity';
export interface CreateApiKeyInput {
coopname: string;
name: string;
createdBy: string;
allowedOperations?: string[];
expiresInDays?: number;
}
export interface ApiKeyCreateResult {
id: string;
name: string;
key: string;
key_prefix: string;
allowed_operations: string[];
expires_at?: Date;
created_at: Date;
}
@Injectable()
export class ApiKeyService {
private readonly logger = new Logger(ApiKeyService.name);
constructor(
@InjectRepository(ApiKeyEntity)
private readonly repo: Repository<ApiKeyEntity>,
) {}
async createKey(input: CreateApiKeyInput): Promise<ApiKeyCreateResult> {
const rawKey = `ck_${crypto.randomBytes(32).toString('hex')}`;
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
const keyPrefix = rawKey.substring(0, 10);
const expiresAt = input.expiresInDays
? new Date(Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000)
: undefined;
const entity = this.repo.create({
coopname: input.coopname,
name: input.name,
key_hash: keyHash,
key_prefix: keyPrefix,
created_by: input.createdBy,
allowed_operations: input.allowedOperations || ['*'],
expires_at: expiresAt,
is_active: true,
});
const saved = await this.repo.save(entity);
return {
id: saved.id,
name: saved.name,
key: rawKey,
key_prefix: keyPrefix,
allowed_operations: saved.allowed_operations,
expires_at: saved.expires_at,
created_at: saved.created_at,
};
}
async validateKey(rawKey: string): Promise<ApiKeyEntity | null> {
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
const entity = await this.repo.findOneBy({ key_hash: keyHash, is_active: true });
if (!entity) return null;
if (entity.expires_at && entity.expires_at < new Date()) return null;
entity.last_used_at = new Date();
await this.repo.save(entity);
return entity;
}
async listKeys(coopname: string): Promise<ApiKeyEntity[]> {
return this.repo.find({
where: { coopname },
order: { created_at: 'DESC' },
});
}
async revokeKey(id: string, revokedBy: string): Promise<void> {
const entity = await this.repo.findOneBy({ id, created_by: revokedBy });
if (entity) {
entity.is_active = false;
await this.repo.save(entity);
}
}
isOperationAllowed(entity: ApiKeyEntity, operation: string): boolean {
if (entity.allowed_operations.includes('*')) return true;
return entity.allowed_operations.includes(operation);
}
}
@@ -0,0 +1,12 @@
/**
* Стандартные действия CASL
*/
export enum Action {
Manage = 'manage', // Полный доступ
Read = 'read', // Чтение
Create = 'create', // Создание
Update = 'update', // Обновление
Delete = 'delete', // Удаление
Execute = 'execute', // Выполнение действий (approve, decline, etc.)
Share = 'share', // Поделиться ссылкой
}
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { AbilityBuilder, createMongoAbility, type MongoAbility } from '@casl/ability';
import { Action } from './actions';
import { Subject } from './subjects';
export type AppAbility = MongoAbility<[Action, Subject]>;
export interface UserForAbility {
username: string;
role: 'chairman' | 'member' | 'user';
/** Дополнительные гранулированные права (future) */
permissions?: GrantedPermission[];
}
export interface GrantedPermission {
action: Action;
subject: Subject;
conditions?: Record<string, any>;
}
/**
* Фабрика CASL abilities.
* Создаёт набор прав на основе роли + гранулированных разрешений.
* Обратная совместимость: chairman/member/user сохраняют текущие права.
*/
@Injectable()
export class CaslAbilityFactory {
createForUser(user: UserForAbility): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
switch (user.role) {
case 'chairman':
can(Action.Manage, Subject.All);
break;
case 'member':
can(Action.Read, Subject.All);
can(Action.Create, Subject.Issue);
can(Action.Update, Subject.Issue);
can(Action.Create, Subject.Commit);
can(Action.Execute, Subject.Decision);
can(Action.Read, Subject.Payment);
can(Action.Read, Subject.Participant);
can(Action.Read, Subject.Document);
can(Action.Read, Subject.Meet);
can(Action.Read, Subject.Ledger);
can(Action.Create, Subject.Process);
can(Action.Update, Subject.Process);
can(Action.Read, Subject.Report);
can(Action.Share, Subject.All);
// member не может управлять системой и расширениями
cannot(Action.Manage, Subject.System);
cannot(Action.Manage, Subject.Extension);
break;
case 'user':
// user может читать свои данные
can(Action.Read, Subject.Wallet);
can(Action.Read, Subject.Profile);
can(Action.Read, Subject.UserDocument);
can(Action.Read, Subject.UserPayment);
can(Action.Read, Subject.Meet);
can(Action.Read, Subject.Search);
// user может обновлять свой профиль
can(Action.Update, Subject.Profile);
// user может работать с задачами
can(Action.Read, Subject.Issue);
can(Action.Update, Subject.Issue);
break;
}
// Применяем гранулированные права (из share tokens и т.д.)
if (user.permissions) {
for (const perm of user.permissions) {
can(perm.action, perm.subject, perm.conditions);
}
}
return build();
}
}
@@ -0,0 +1,72 @@
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import { CaslAbilityFactory, type UserForAbility } from './casl-ability.factory';
import { Action } from './actions';
import { Subject } from './subjects';
import config from '~/config/config';
export const CHECK_ABILITY_KEY = 'check_ability';
/**
* Декоратор для указания требуемых прав
*/
export function CheckAbility(action: Action, subject: Subject) {
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
Reflect.defineMetadata(CHECK_ABILITY_KEY, { action, subject }, descriptor?.value || target);
};
}
/**
* CASL Guard — проверяет granular permissions.
* Работает ПАРАЛЛЕЛЬНО с RolesGuard для обратной совместимости.
* Если @CheckAbility не установлен, пропускает (как и раньше).
*/
@Injectable()
export class CaslGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly caslAbilityFactory: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
// server-secret bypass
if (request.headers['server-secret'] === config.server_secret) {
return true;
}
const requirement = this.reflector.get<{ action: Action; subject: Subject }>(
CHECK_ABILITY_KEY,
context.getHandler(),
);
// Если @CheckAbility не установлен, пропускаем (обратная совместимость)
if (!requirement) {
return true;
}
const { user } = request;
if (!user) {
throw new ForbiddenException('Пользователь не авторизован');
}
const userForAbility: UserForAbility = {
username: user.username,
role: user.role,
permissions: user.grantedPermissions || [],
};
const ability = this.caslAbilityFactory.createForUser(userForAbility);
if (!ability.can(requirement.action, requirement.subject)) {
throw new ForbiddenException(
`Недостаточно прав: ${requirement.action} ${requirement.subject}`,
);
}
return true;
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { CaslAbilityFactory } from './casl-ability.factory';
import { CaslGuard } from './casl.guard';
@Global()
@Module({
providers: [CaslAbilityFactory, CaslGuard],
exports: [CaslAbilityFactory, CaslGuard],
})
export class CaslModule {}
@@ -0,0 +1,42 @@
/**
* Субъекты (ресурсы) для проверки прав.
* Каждая страница/раздел = отдельный subject.
*/
export enum Subject {
// Системные
All = 'all',
System = 'System',
Extension = 'Extension',
// Кооператив
Participant = 'Participant',
Agreement = 'Agreement',
Document = 'Document',
Payment = 'Payment',
Meet = 'Meet',
Branch = 'Branch',
BoardMember = 'BoardMember',
Decision = 'Decision',
// Пользовательские
Wallet = 'Wallet',
Profile = 'Profile',
UserDocument = 'UserDocument',
UserPayment = 'UserPayment',
// Capital
Project = 'Project',
Component = 'Component',
Issue = 'Issue',
Process = 'Process',
Commit = 'Commit',
// Отчёты
Report = 'Report',
// Поиск
Search = 'Search',
// Ledger
Ledger = 'Ledger',
}
@@ -3,6 +3,7 @@ import { Global, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import config from '~/config/config';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import * as jwt from 'jsonwebtoken';
import { docDirectiveTransformer } from './directives/doc.directive';
import { GraphQLError, GraphQLFormattedError } from 'graphql';
import { fieldAuthDirectiveTransformer } from './directives/fieldAuth.directive';
@@ -17,9 +18,32 @@ import logger from '~/config/logger';
autoSchemaFile: 'schema.gql',
sortSchema: true,
debug: config.env !== 'production',
// context: ({ req }) => req,
playground: { endpoint: '/v1/graphql', settings: { 'request.credentials': 'same-origin' } },
path: '/v1/graphql', // здесь можно задать другой путь, когда потребуется,
path: '/v1/graphql',
subscriptions: {
'graphql-ws': {
path: '/v1/graphql',
onConnect: (context: any) => {
const { connectionParams } = context;
const token = connectionParams?.token || connectionParams?.authorization;
if (!token) {
throw new Error('Необходима аутентификация для WebSocket подписок');
}
try {
const decoded = jwt.verify(token, config.jwt.secret);
return { user: decoded, token };
} catch {
throw new Error('Невалидный токен аутентификации');
}
},
},
},
context: ({ req, connection }: any) => {
if (connection) {
return { req: { headers: {}, user: connection.context?.user } };
}
return { req };
},
transformSchema: (schema) => {
schema = docDirectiveTransformer(schema, 'auth');
schema = fieldAuthDirectiveTransformer(schema, 'auth');
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
export const PUB_SUB = Symbol('PUB_SUB');
@Global()
@Module({
providers: [
{
provide: PUB_SUB,
useValue: new PubSub(),
},
],
exports: [PUB_SUB],
})
export class PubSubModule {}
@@ -0,0 +1,48 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
export enum ShareTargetType {
GUEST = 'guest',
MEMBER = 'member',
}
@Entity('share_tokens')
export class ShareTokenEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 12 })
coopname!: string;
@Column({ type: 'varchar', length: 50 })
created_by!: string;
@Column({ type: 'varchar', length: 255 })
page_path!: string;
@Column({ type: 'varchar', length: 255 })
page_name!: string;
@Column({ type: 'enum', enum: ShareTargetType })
target_type!: ShareTargetType;
@Column({ type: 'varchar', length: 255, nullable: true })
target_username?: string;
@Column({ type: 'varchar', length: 255, nullable: true })
link_name?: string;
@Column({ type: 'jsonb', default: '[]' })
allowed_actions!: string[];
@Column({ type: 'text' })
token!: string;
@Column({ type: 'timestamp', nullable: true })
expires_at?: Date;
@Column({ type: 'boolean', default: true })
is_active!: boolean;
@CreateDateColumn()
created_at!: Date;
}
@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ShareTokenEntity } from './share-token.entity';
import { ShareService } from './share.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ShareTokenEntity])],
providers: [ShareService],
exports: [ShareService],
})
export class ShareModule {}
@@ -0,0 +1,113 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as jwt from 'jsonwebtoken';
import { ShareTokenEntity, ShareTargetType } from './share-token.entity';
import config from '~/config/config';
export interface CreateShareLinkInput {
coopname: string;
createdBy: string;
pagePath: string;
pageName: string;
targetType: ShareTargetType;
targetUsername?: string;
linkName?: string;
allowedActions: string[];
expiresInDays?: number;
}
export interface ShareTokenPayload {
shareId: string;
coopname: string;
pagePath: string;
allowedActions: string[];
targetType: ShareTargetType;
targetUsername?: string;
}
@Injectable()
export class ShareService {
private readonly logger = new Logger(ShareService.name);
constructor(
@InjectRepository(ShareTokenEntity)
private readonly repo: Repository<ShareTokenEntity>,
) {}
async createShareLink(input: CreateShareLinkInput): Promise<ShareTokenEntity> {
const expiresAt = input.expiresInDays
? new Date(Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000)
: undefined;
const entity = this.repo.create({
coopname: input.coopname,
created_by: input.createdBy,
page_path: input.pagePath,
page_name: input.pageName,
target_type: input.targetType,
target_username: input.targetUsername,
link_name: input.linkName,
allowed_actions: input.allowedActions,
expires_at: expiresAt,
is_active: true,
token: '',
});
const saved = await this.repo.save(entity);
const payload: ShareTokenPayload = {
shareId: saved.id,
coopname: input.coopname,
pagePath: input.pagePath,
allowedActions: input.allowedActions,
targetType: input.targetType,
targetUsername: input.targetUsername,
};
saved.token = jwt.sign(payload, config.jwt.secret, {
expiresIn: input.expiresInDays ? `${input.expiresInDays}d` : '365d',
});
return this.repo.save(saved);
}
async verifyShareToken(token: string): Promise<ShareTokenPayload | null> {
try {
const payload = jwt.verify(token, config.jwt.secret) as ShareTokenPayload;
const entity = await this.repo.findOneBy({ id: payload.shareId, is_active: true });
if (!entity) return null;
if (entity.expires_at && entity.expires_at < new Date()) {
return null;
}
return payload;
} catch {
return null;
}
}
async getShareLinks(coopname: string, createdBy: string): Promise<ShareTokenEntity[]> {
return this.repo.find({
where: { coopname, created_by: createdBy, is_active: true },
order: { created_at: 'DESC' },
});
}
async getSharedWithMe(coopname: string, username: string): Promise<ShareTokenEntity[]> {
return this.repo.find({
where: { coopname, target_username: username, is_active: true },
order: { created_at: 'DESC' },
});
}
async revokeShareLink(id: string, revokedBy: string): Promise<void> {
const entity = await this.repo.findOneBy({ id, created_by: revokedBy });
if (entity) {
entity.is_active = false;
await this.repo.save(entity);
}
}
}
@@ -0,0 +1,88 @@
import * as crypto from 'crypto';
// Тестируем логику API ключей без реальной БД
describe('ApiKey Security', () => {
describe('key generation', () => {
it('generates key with ck_ prefix', () => {
const rawKey = `ck_${crypto.randomBytes(32).toString('hex')}`;
expect(rawKey.startsWith('ck_')).toBe(true);
expect(rawKey.length).toBe(67); // ck_ + 64 hex
});
it('hash is irreversible — different keys produce different hashes', () => {
const key1 = `ck_${crypto.randomBytes(32).toString('hex')}`;
const key2 = `ck_${crypto.randomBytes(32).toString('hex')}`;
const hash1 = crypto.createHash('sha256').update(key1).digest('hex');
const hash2 = crypto.createHash('sha256').update(key2).digest('hex');
expect(hash1).not.toBe(hash2);
});
it('same key always produces same hash', () => {
const key = 'ck_abc123';
const hash1 = crypto.createHash('sha256').update(key).digest('hex');
const hash2 = crypto.createHash('sha256').update(key).digest('hex');
expect(hash1).toBe(hash2);
});
it('prefix is first 10 chars of key', () => {
const rawKey = `ck_${crypto.randomBytes(32).toString('hex')}`;
const prefix = rawKey.substring(0, 10);
expect(prefix.startsWith('ck_')).toBe(true);
expect(prefix.length).toBe(10);
});
});
describe('expiry validation', () => {
it('expired key should be rejected', () => {
const expiresAt = new Date(Date.now() - 1000); // 1 second ago
expect(expiresAt < new Date()).toBe(true);
});
it('future key should be accepted', () => {
const expiresAt = new Date(Date.now() + 86400000); // 1 day from now
expect(expiresAt < new Date()).toBe(false);
});
it('null expiry means unlimited', () => {
const expiresAt: Date | undefined = undefined;
const isExpired = expiresAt ? (expiresAt as Date) < new Date() : false;
expect(isExpired).toBe(false);
});
});
describe('operation checking', () => {
it('wildcard allows everything', () => {
const ops = ['*'];
expect(ops.includes('*') || ops.includes('createApiKey')).toBe(true);
});
it('specific operations restrict access', () => {
const ops = ['getApiKeys', 'revokeApiKey'];
expect(ops.includes('createApiKey')).toBe(false);
expect(ops.includes('getApiKeys')).toBe(true);
});
it('empty operations deny everything', () => {
const ops: string[] = [];
expect(ops.includes('*')).toBe(false);
expect(ops.includes('anything')).toBe(false);
});
});
describe('security invariants', () => {
it('raw key is never stored — only hash', () => {
const rawKey = `ck_${crypto.randomBytes(32).toString('hex')}`;
const hash = crypto.createHash('sha256').update(rawKey).digest('hex');
// hash doesn't contain the raw key
expect(hash.includes('ck_')).toBe(false);
expect(hash.length).toBe(64); // SHA256 = 64 hex chars
});
it('cannot reconstruct key from hash', () => {
const hash = crypto.createHash('sha256').update('ck_test123').digest('hex');
// There's no way to get 'ck_test123' back from hash
expect(typeof hash).toBe('string');
expect(hash).not.toBe('ck_test123');
});
});
});
@@ -0,0 +1,138 @@
import { CaslAbilityFactory, type UserForAbility } from '../../../src/infrastructure/casl/casl-ability.factory';
import { Action } from '../../../src/infrastructure/casl/actions';
import { Subject } from '../../../src/infrastructure/casl/subjects';
describe('CaslAbilityFactory', () => {
const factory = new CaslAbilityFactory();
describe('chairman role', () => {
const chairman: UserForAbility = { username: 'ant', role: 'chairman' };
const ability = factory.createForUser(chairman);
it('can manage all resources', () => {
expect(ability.can(Action.Manage, Subject.All)).toBe(true);
});
it('can read system', () => {
expect(ability.can(Action.Read, Subject.System)).toBe(true);
});
it('can manage extensions', () => {
expect(ability.can(Action.Manage, Subject.Extension)).toBe(true);
});
it('can generate reports', () => {
expect(ability.can(Action.Create, Subject.Report)).toBe(true);
});
it('can share pages', () => {
expect(ability.can(Action.Share, Subject.All)).toBe(true);
});
});
describe('member role', () => {
const member: UserForAbility = { username: 'petr', role: 'member' };
const ability = factory.createForUser(member);
it('can read all resources', () => {
expect(ability.can(Action.Read, Subject.Payment)).toBe(true);
expect(ability.can(Action.Read, Subject.Participant)).toBe(true);
expect(ability.can(Action.Read, Subject.Document)).toBe(true);
});
it('can create/update issues', () => {
expect(ability.can(Action.Create, Subject.Issue)).toBe(true);
expect(ability.can(Action.Update, Subject.Issue)).toBe(true);
});
it('can execute decisions', () => {
expect(ability.can(Action.Execute, Subject.Decision)).toBe(true);
});
it('cannot manage system', () => {
expect(ability.can(Action.Manage, Subject.System)).toBe(false);
});
it('cannot manage extensions', () => {
expect(ability.can(Action.Manage, Subject.Extension)).toBe(false);
});
it('can share pages', () => {
expect(ability.can(Action.Share, Subject.Payment)).toBe(true);
});
it('can read reports', () => {
expect(ability.can(Action.Read, Subject.Report)).toBe(true);
});
});
describe('user role', () => {
const user: UserForAbility = { username: 'ivan', role: 'user' };
const ability = factory.createForUser(user);
it('can read own wallet', () => {
expect(ability.can(Action.Read, Subject.Wallet)).toBe(true);
});
it('can read own profile', () => {
expect(ability.can(Action.Read, Subject.Profile)).toBe(true);
});
it('can update own profile', () => {
expect(ability.can(Action.Update, Subject.Profile)).toBe(true);
});
it('can read/update issues', () => {
expect(ability.can(Action.Read, Subject.Issue)).toBe(true);
expect(ability.can(Action.Update, Subject.Issue)).toBe(true);
});
it('cannot read payments (cooperative level)', () => {
expect(ability.can(Action.Read, Subject.Payment)).toBe(false);
});
it('cannot create decisions', () => {
expect(ability.can(Action.Create, Subject.Decision)).toBe(false);
});
it('cannot share pages', () => {
expect(ability.can(Action.Share, Subject.Payment)).toBe(false);
});
it('cannot manage system', () => {
expect(ability.can(Action.Manage, Subject.System)).toBe(false);
});
});
describe('granular permissions', () => {
it('user with extra permissions can access granted resources', () => {
const user: UserForAbility = {
username: 'olga',
role: 'user',
permissions: [
{ action: Action.Read, subject: Subject.Payment },
{ action: Action.Read, subject: Subject.Ledger },
],
};
const ability = factory.createForUser(user);
expect(ability.can(Action.Read, Subject.Payment)).toBe(true);
expect(ability.can(Action.Read, Subject.Ledger)).toBe(true);
expect(ability.can(Action.Create, Subject.Payment)).toBe(false);
});
it('granted execute permission works', () => {
const user: UserForAbility = {
username: 'test',
role: 'user',
permissions: [
{ action: Action.Execute, subject: Subject.Payment },
],
};
const ability = factory.createForUser(user);
expect(ability.can(Action.Execute, Subject.Payment)).toBe(true);
expect(ability.can(Action.Delete, Subject.Payment)).toBe(false);
});
});
});
@@ -0,0 +1,396 @@
import { BuhotchGenerator } from '../../../src/extensions/reports/infrastructure/generators/buhotch.generator';
import { Ndfl6Generator } from '../../../src/extensions/reports/infrastructure/generators/ndfl6.generator';
import { RsvGenerator } from '../../../src/extensions/reports/infrastructure/generators/rsv.generator';
import { PsvGenerator } from '../../../src/extensions/reports/infrastructure/generators/psv.generator';
import { DusnGenerator } from '../../../src/extensions/reports/infrastructure/generators/dusn.generator';
import { Fss4Generator } from '../../../src/extensions/reports/infrastructure/generators/fss4.generator';
import { UvVznosyGenerator } from '../../../src/extensions/reports/infrastructure/generators/uv-vznosy.generator';
import { UusnGenerator } from '../../../src/extensions/reports/infrastructure/generators/uusn.generator';
import { ReportType } from '../../../src/extensions/reports/domain/enums/report-type.enum';
import type { ReportInput } from '../../../src/extensions/reports/domain/interfaces/report-generator.interface';
const baseInput: ReportInput = {
reportType: ReportType.BUHOTCH,
year: 2025,
inn: '9728130611',
kpp: '772801001',
orgName: 'Потребительский Кооператив "ВОСХОД"',
ogrn: '1247700283346',
okved: '94.99',
okfs: '16',
okopf: '20200',
oktmo: '45382000',
address: 'г. Москва, ул. Тестовая, д.1',
signerFio: { lastName: 'Иванов', firstName: 'Иван', middleName: 'Иванович' },
signerSnils: '123-456-789 00',
ledgerData: [
{ accountId: 51, name: 'Расчётный счёт', balanceCurrent: 150000, balancePrevious: 120000, balancePrePrevious: 80000 },
{ accountId: 80, name: 'Паевой фонд', balanceCurrent: 50000, balancePrevious: 40000, balancePrePrevious: 30000 },
{ accountId: 86, name: 'Целевое финансирование', balanceCurrent: 100000, balancePrevious: 80000, balancePrePrevious: 50000 },
],
};
function assertValidXml(xml: string) {
expect(xml).toBeTruthy();
expect(xml).toContain('<?xml');
expect(xml).toContain('windows-1251');
expect(xml).toContain('<Файл');
expect(xml).toContain('ИдФайл=');
expect(xml).toContain('ВерсПрог=');
expect(xml).toContain('ВерсФорм=');
expect(xml).toContain('<Документ');
expect(xml).toContain('</Файл>');
}
function assertHasSigner(xml: string) {
expect(xml).toContain('<Подписант');
expect(xml).toContain('ПрПодп="1"');
expect(xml).toContain('<ФИО');
expect(xml).toContain('Фамилия="Иванов"');
expect(xml).toContain('Имя="Иван"');
}
describe('Бухгалтерский баланс (BuhotchGenerator)', () => {
const gen = new BuhotchGenerator();
it('reportType = BUHOTCH', () => {
expect(gen.reportType).toBe(ReportType.BUHOTCH);
});
it('генерирует валидный XML', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.BUHOTCH });
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
assertValidXml(result.xml);
});
it('содержит корректные атрибуты Документ', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.BUHOTCH });
expect(result.xml).toContain('КНД="0710099"');
expect(result.xml).toContain('ОтчетГод="2025"');
expect(result.xml).toContain('Период="34"');
expect(result.xml).toContain('НомКорр="0"');
expect(result.xml).toContain('ОКЕИ="384"');
});
it('содержит блок СвНП с НПЮЛ', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.BUHOTCH });
expect(result.xml).toContain('<СвНП');
expect(result.xml).toContain('ОКВЭД2="94.99"');
expect(result.xml).toContain('ОКФС="16"');
expect(result.xml).toContain('ОКОПФ="20200"');
expect(result.xml).toContain('<НПЮЛ');
expect(result.xml).toContain('ИННЮЛ="9728130611"');
expect(result.xml).toContain('КПП="772801001"');
});
it('содержит подписанта', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.BUHOTCH });
assertHasSigner(result.xml);
});
it('содержит Баланс с Активом и Пассивом', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.BUHOTCH });
expect(result.xml).toContain('<Баланс');
expect(result.xml).toContain('<Актив');
expect(result.xml).toContain('<ВнеОбА');
expect(result.xml).toContain('<ОбА');
expect(result.xml).toContain('<Пассив');
expect(result.xml).toContain('<ДолгосрОбяз');
expect(result.xml).toContain('<КраткосрОбяз');
});
it('рассчитывает суммы из ledger (в тыс.руб.)', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.BUHOTCH });
expect(result.xml).toContain('СумОтч="150"');
expect(result.xml).toContain('СумПрдщ="120"');
});
it('содержит ЦелИсп', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.BUHOTCH });
expect(result.xml).toContain('<ЦелИсп');
expect(result.xml).toContain('<ОстатНачОтч');
expect(result.xml).toContain('<Поступило');
expect(result.xml).toContain('<Использовано');
expect(result.xml).toContain('<ОстатКонОтч');
});
it('генерирует корректное имя файла', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.BUHOTCH });
expect(result.fileName).toMatch(/^NO_BUHOTCH_9728130611_772801001_\d{8}_/);
});
it('работает с нулевыми данными ledger', () => {
const input = { ...baseInput, reportType: ReportType.BUHOTCH as const, ledgerData: [] };
const result = gen.generate(input);
expect(result.isValid).toBe(true);
expect(result.xml).toContain('СумОтч="0"');
});
});
describe('6-НДФЛ (Ndfl6Generator)', () => {
const gen = new Ndfl6Generator();
it('reportType = NDFL6', () => {
expect(gen.reportType).toBe(ReportType.NDFL6);
});
it('генерирует нулевой отчёт', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.NDFL6, period: 1 });
expect(result.isValid).toBe(true);
assertValidXml(result.xml);
});
it('атрибуты Документ', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.NDFL6, period: 2 });
expect(result.xml).toContain('КНД="1151100"');
expect(result.xml).toContain('ВерсФорм="5.05"');
expect(result.xml).toContain('Период="31"');
expect(result.xml).toContain('КодНО="7728"');
expect(result.xml).toContain('ПоМесту="214"');
});
it('ОКТМО в СвНП', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.NDFL6, period: 1 });
expect(result.xml).toContain('ОКТМО="45382000"');
});
it('содержит РасчСумНал с нулями', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.NDFL6, period: 1 });
expect(result.xml).toContain('<НДФЛ6.2');
expect(result.xml).toContain('<РасчСумНал');
expect(result.xml).toContain('Ставка="13"');
expect(result.xml).toContain('КолФЛ="0"');
expect(result.xml).toContain('НалБаза="0"');
});
it('подписант', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.NDFL6, period: 1 });
assertHasSigner(result.xml);
});
it('корректные коды периодов', () => {
const r1 = gen.generate({ ...baseInput, reportType: ReportType.NDFL6, period: 1 });
expect(r1.xml).toContain('Период="21"');
const r3 = gen.generate({ ...baseInput, reportType: ReportType.NDFL6, period: 3 });
expect(r3.xml).toContain('Период="33"');
const r4 = gen.generate({ ...baseInput, reportType: ReportType.NDFL6, period: 4 });
expect(r4.xml).toContain('Период="34"');
});
});
describe('РСВ (RsvGenerator)', () => {
const gen = new RsvGenerator();
it('reportType = RSV', () => {
expect(gen.reportType).toBe(ReportType.RSV);
});
it('генерирует нулевой отчёт', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.RSV, period: 1 });
expect(result.isValid).toBe(true);
assertValidXml(result.xml);
});
it('атрибуты Документ', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.RSV, period: 1 });
expect(result.xml).toContain('КНД="1151111"');
expect(result.xml).toContain('ВерсФорм="5.08"');
expect(result.xml).toContain('ПоМесту="214"');
});
it('содержит РасчетСВ с ОбязПлатСВ', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.RSV, period: 1 });
expect(result.xml).toContain('<РасчетСВ');
expect(result.xml).toContain('<ОбязПлатСВ');
expect(result.xml).toContain('ТипПлат="1"');
expect(result.xml).toContain('ОКТМО="45382000"');
});
it('СвНП с СрЧисл', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.RSV, period: 1 });
expect(result.xml).toContain('СрЧисл="1"');
});
});
describe('ПСВ (PsvGenerator)', () => {
const gen = new PsvGenerator();
it('reportType = PSV', () => {
expect(gen.reportType).toBe(ReportType.PSV);
});
it('генерирует нулевой отчёт с ПерсСвФЛ', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.PSV, period: 1 });
expect(result.isValid).toBe(true);
assertValidXml(result.xml);
});
it('атрибуты Документ', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.PSV, period: 3 });
expect(result.xml).toContain('КНД="1151162"');
expect(result.xml).toContain('ВерсФорм="5.01"');
expect(result.xml).toContain('Период="03"');
});
it('содержит ПерсСвФЛ с СНИЛС', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.PSV, period: 1 });
expect(result.xml).toContain('<ПерсСвФЛ');
expect(result.xml).toContain('СНИЛС="123-456-789 00"');
expect(result.xml).toContain('СумВыпл="0"');
});
it('имя файла NO_PERSSVFL', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.PSV, period: 1 });
expect(result.fileName).toMatch(/^NO_PERSSVFL_/);
});
});
describe('ДУСН (DusnGenerator)', () => {
const gen = new DusnGenerator();
it('reportType = DUSN', () => {
expect(gen.reportType).toBe(ReportType.DUSN);
});
it('генерирует нулевую декларацию', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.DUSN });
expect(result.isValid).toBe(true);
assertValidXml(result.xml);
});
it('атрибуты Документ', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.DUSN });
expect(result.xml).toContain('КНД="1152017"');
expect(result.xml).toContain('ВерсФорм="5.09"');
expect(result.xml).toContain('Период="34"');
expect(result.xml).toContain('ПоМесту="120"');
});
it('содержит УСН с ОбНал=1', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.DUSN });
expect(result.xml).toContain('<УСН');
expect(result.xml).toContain('ОбНал="1"');
expect(result.xml).toContain('<СумНалПУ_НП');
expect(result.xml).toContain('ОКТМО="45382000"');
});
it('РасчНал1 с нулевыми суммами', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.DUSN });
expect(result.xml).toContain('<РасчНал1');
expect(result.xml).toContain('ПризНП="2"');
expect(result.xml).toContain('СтавкаНалПер="6.0"');
});
});
describe('4-ФСС (Fss4Generator)', () => {
const gen = new Fss4Generator();
it('reportType = FSS4', () => {
expect(gen.reportType).toBe(ReportType.FSS4);
});
it('генерирует XML', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.FSS4, period: 1 });
expect(result.isValid).toBe(true);
assertValidXml(result.xml);
});
it('имя файла EFS1', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.FSS4, period: 1 });
expect(result.fileName).toMatch(/^EFS1_/);
});
});
describe('Уведомление о взносах (UvVznosyGenerator)', () => {
const gen = new UvVznosyGenerator();
it('reportType = UV_VZNOSY', () => {
expect(gen.reportType).toBe(ReportType.UV_VZNOSY);
});
it('генерирует нулевое уведомление', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.UV_VZNOSY, period: 3 });
expect(result.isValid).toBe(true);
assertValidXml(result.xml);
});
it('атрибуты Документ', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.UV_VZNOSY, period: 3 });
expect(result.xml).toContain('КНД="1110355"');
expect(result.xml).toContain('ВерсФорм="5.03"');
});
it('содержит УвИсчСумНалог', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.UV_VZNOSY, period: 5 });
expect(result.xml).toContain('<УвИсчСумНалог');
expect(result.xml).toContain('ОКТМО="45382000"');
expect(result.xml).toContain('СумНалогАванс="0"');
expect(result.xml).toContain('Год="2025"');
});
it('СвНП с НПЮЛ (без НаимОрг)', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.UV_VZNOSY, period: 1 });
expect(result.xml).toContain('<НПЮЛ');
expect(result.xml).toContain('ИННЮЛ="9728130611"');
});
});
describe('Уведомление по УСН (UusnGenerator)', () => {
const gen = new UusnGenerator();
it('reportType = UUSN', () => {
expect(gen.reportType).toBe(ReportType.UUSN);
});
it('генерирует нулевое уведомление', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.UUSN, period: 2 });
expect(result.isValid).toBe(true);
assertValidXml(result.xml);
});
it('КБК для УСН', () => {
const result = gen.generate({ ...baseInput, reportType: ReportType.UUSN, period: 1 });
expect(result.xml).toContain('КБК="18210501021011000110"');
});
});
describe('ReportRegistryService', () => {
const { ReportRegistryService } = require('../../../src/extensions/reports/domain/services/report-registry.service');
const registry = new ReportRegistryService();
beforeAll(() => {
registry.register(new BuhotchGenerator());
registry.register(new Ndfl6Generator());
registry.register(new RsvGenerator());
registry.register(new PsvGenerator());
registry.register(new DusnGenerator());
registry.register(new Fss4Generator());
registry.register(new UvVznosyGenerator());
registry.register(new UusnGenerator());
});
it('регистрирует все 8 генераторов', () => {
const reports = registry.getAvailableReports();
expect(reports).toHaveLength(8);
});
it('генерирует отчёт по типу', () => {
const result = registry.generate({ ...baseInput, reportType: ReportType.BUHOTCH });
expect(result.isValid).toBe(true);
expect(result.xml).toContain('<Баланс');
});
it('выбрасывает исключение для незарегистрированного типа', () => {
expect(() => registry.generate({ ...baseInput, reportType: 'unknown' as any })).toThrow();
});
it('isGenerationAvailable для ежегодного отчёта', () => {
expect(registry.isGenerationAvailable(ReportType.BUHOTCH, 2024)).toBe(true);
expect(registry.isGenerationAvailable(ReportType.BUHOTCH, 2030)).toBe(false);
});
it('isGenerationAvailable для ежеквартального', () => {
expect(registry.isGenerationAvailable(ReportType.NDFL6, 2020, 1)).toBe(true);
});
});
@@ -64,11 +64,10 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, onBeforeUnmount } from 'vue';
import { computed, onMounted } from 'vue';
import { ContributorGamificationWidget } from 'app/extensions/capital/widgets/ContributorGamificationWidget';
import { useContributorStore } from 'app/extensions/capital/entities/Contributor/model';
import { useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
import { useSessionStore } from 'src/entities/Session/model/store';
import { useSystemStore } from 'src/entities/System/model';
import { EditAboutInput, EditHoursPerDayInput, EditRatePerHourInput } from 'app/extensions/capital/features/Contributor/EditContributor';
@@ -178,42 +177,25 @@ const roleContributions = computed(() => {
];
});
/**
* Функция для перезагрузки данных профиля
* Используется для poll обновлений
*/
const reloadProfileData = async () => {
try {
// Для профиля перезагружаем данные участника
await contributorStore.loadContributor({ username });
} catch (error) {
console.warn('Ошибка при перезагрузке данных профиля в poll:', error);
console.warn('Ошибка при перезагрузке данных профиля:', error);
}
};
// Обработчик обновления любого поля профиля
const handleFieldUpdated = () => {
// Поле профиля обновлено, данные перезагрузятся автоматически через poll
reloadProfileData();
};
// Настраиваем poll обновление данных
const { start: startProfilePoll, stop: stopProfilePoll } = useDataPoller(
reloadProfileData,
{ interval: POLL_INTERVALS.SLOW, immediate: false }
);
// Проверяем при монтировании
onMounted(async () => {
// Загружаем данные текущего участника аналогично CapitalBase
await contributorStore.loadSelf({ username });
// Запускаем poll обновление данных
startProfilePoll();
useGraphqlSubscription({
query: buildSubscriptionQuery('capitalDataChanged', null, ['entity', 'action']),
onData: () => { reloadProfileData(); },
});
// Останавливаем poll при уходе со страницы
onBeforeUnmount(() => {
stopProfilePoll();
onMounted(async () => {
await contributorStore.loadSelf({ username });
});
</script>
@@ -239,7 +239,7 @@ div(v-if="shouldShowTemporaryStub").q-pt-md
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue';
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useGenerateCapitalRegistrationDocuments } from 'app/extensions/capital/features/Contributor/GenerateCapitalRegistrationDocuments/model';
import { useCompleteCapitalRegistration } from 'app/extensions/capital/features/Contributor/CompleteCapitalRegistration/model';
@@ -248,8 +248,7 @@ import { DocumentHtmlReader } from 'src/shared/ui/DocumentHtmlReader';
import { RoleSelector } from 'app/extensions/capital/shared/ui';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { useSystemStore } from 'src/entities/System/model';
import { useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
import { useSessionStore } from 'src/entities/Session';
const router = useRouter();
@@ -438,54 +437,36 @@ const updateCurrentStep = () => {
// Следим за изменениями статуса регистрации
watch(() => contributorStore.isContributorActiveOrPending, updateCurrentStep);
/**
* Функция для перезагрузки данных регистрации
* Используется для poll обновлений
*/
const reloadRegistrationData = async () => {
try {
// Для страницы регистрации обновляем статус участника
await contributorStore.loadContributor({ username: session.username });
} catch (error) {
console.warn('Ошибка при перезагрузке данных регистрации в poll:', error);
console.warn('Ошибка при перезагрузке данных регистрации:', error);
}
};
// Настраиваем poll обновление данных
const { start: startRegistrationPoll, stop: stopRegistrationPoll } = useDataPoller(
reloadRegistrationData,
{ interval: POLL_INTERVALS.SLOW, immediate: false }
);
useGraphqlSubscription({
query: buildSubscriptionQuery('capitalDataChanged', null, ['entity', 'action']),
onData: () => { reloadRegistrationData(); },
});
// Инициализация при монтировании
onMounted(() => {
console.log('🎯 CapitalRegistrationPage mounted');
updateCurrentStep();
// Запускаем poll обновление данных
startRegistrationPoll();
// Генерация пачки документов Capital при монтировании (только если не показывается временный заглушка)
if (!shouldShowTemporaryStub.value) {
generateCapitalDocuments()
.then((documents) => {
console.log('Пачка документов сгенерирована:', {
console.log('Пачка документов сгенерирована:', {
generation_contract: documents?.generation_contract?.hash,
storage_agreement: documents?.storage_agreement?.hash,
blagorost_agreement: documents?.blagorost_agreement?.hash,
});
})
.catch((error) => {
console.error('Ошибка при генерации пачки документов:', error);
console.error('Ошибка при генерации пачки документов:', error);
FailAlert('Не удалось сгенерировать документы регистрации');
});
}
});
// Останавливаем poll при уходе со страницы
onBeforeUnmount(() => {
stopRegistrationPoll();
});
// Отслеживание размонтирования
@@ -54,10 +54,10 @@ div
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount, ref, computed } from 'vue';
import { onMounted, ref, computed } from 'vue';
import { useSystemStore } from 'src/entities/System/model';
import { useExpandableState, useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import { useExpandableState } from 'src/shared/lib/composables';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
import 'src/shared/ui/TitleStyles';
import { WindowLoader } from 'src/shared/ui/Loader';
import { ColorCard } from 'src/shared/ui/ColorCard/ui';
@@ -134,50 +134,27 @@ const handleVotesChanged = (data: { projectHash: string; voter: string }) => {
segmentsToReload.value[data.voter] = Date.now();
};
/**
* Функция для перезагрузки данных голосования
* Используется для poll обновлений
*/
const reloadVotingData = async () => {
try {
// Для голосований используем принудительную перезагрузку через timestamp
// Это заставит виджеты перезагрузить данные
const timestamp = Date.now();
// Обновляем все сегменты для перезагрузки
Object.keys(segmentsToReload.value).forEach(key => {
segmentsToReload.value[key] = timestamp;
});
// Если нет сегментов, добавляем специальный ключ для принудительной перезагрузки
if (Object.keys(segmentsToReload.value).length === 0) {
segmentsToReload.value['__force_reload__'] = timestamp;
}
} catch (error) {
console.warn('Ошибка при перезагрузке данных голосования в poll:', error);
const reloadVotingData = () => {
const timestamp = Date.now();
Object.keys(segmentsToReload.value).forEach(key => {
segmentsToReload.value[key] = timestamp;
});
if (Object.keys(segmentsToReload.value).length === 0) {
segmentsToReload.value['__force_reload__'] = timestamp;
}
};
// Настраиваем poll обновление данных
const { start: startVotingPoll, stop: stopVotingPoll } = useDataPoller(
reloadVotingData,
{ interval: POLL_INTERVALS.FAST, immediate: false }
);
// Инициализация
onMounted(async () => {
// Загружаем проект явно
await loadProject();
// Загружаем сохраненное состояние expanded из LocalStorage
loadSegmentsExpandedState();
// Запускаем poll обновление данных
startVotingPoll();
useGraphqlSubscription({
query: buildSubscriptionQuery('capitalDataChanged', null, ['entity', 'action']),
onData: (data: any) => {
if (data?.capitalDataChanged?.entity === 'voting') {
reloadVotingData();
}
},
});
// Останавливаем poll при уходе со страницы
onBeforeUnmount(() => {
stopVotingPoll();
onMounted(async () => {
await loadProject();
loadSegmentsExpandedState();
});
</script>
@@ -21,7 +21,7 @@ div
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, markRaw, computed } from 'vue';
import { ref, onMounted, markRaw, computed, onBeforeUnmount } from 'vue';
import { useSystemStore } from 'src/entities/System/model';
import { useSessionStore } from 'src/entities/Session';
import { FailAlert } from 'src/shared/api';
@@ -29,8 +29,7 @@ import { ContributorsListWidget } from 'app/extensions/capital/widgets/Contribut
import { ImportContributorsButton } from 'app/extensions/capital/widgets/ImportContributorsButton';
import { ImportContributorButton } from 'app/extensions/capital/features/Contributor/ImportContributor';
import { useContributorStore } from 'app/extensions/capital/entities/Contributor/model';
import { useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
import { useHeaderActions } from 'src/shared/hooks';
const contributorStore = useContributorStore();
@@ -110,53 +109,22 @@ const onPageChange = async (page: number) => {
await loadContributors();
};
/**
* Функция для перезагрузки данных участников
* Используется для poll обновлений
*/
const reloadContributors = async () => {
try {
await contributorStore.loadContributors({
filter: {
coopname: info.coopname,
},
pagination: {
page: pagination.value.page,
limit: pagination.value.rowsPerPage,
sortBy: pagination.value.sortBy,
descending: pagination.value.descending,
},
});
} catch (error) {
console.warn('Ошибка при перезагрузке участников в poll:', error);
}
};
useGraphqlSubscription({
query: buildSubscriptionQuery('capitalDataChanged', null, ['entity', 'action']),
onData: () => { loadContributors(); },
});
// Настраиваем poll обновление данных
const { start: startContributorsPoll, stop: stopContributorsPoll } = useDataPoller(
reloadContributors,
{ interval: POLL_INTERVALS.MEDIUM, immediate: false }
);
// Регистрируем кнопки меню в header
const { registerAction: registerHeaderAction, clearActions } = useHeaderActions();
// Инициализация
onMounted(async () => {
await loadContributors();
// Запускаем poll обновление данных
startContributorsPoll();
// Регистрируем кнопки меню в header
menuButtons.value.forEach(button => {
registerHeaderAction(button);
});
});
// Останавливаем poll и очищаем действия header при уходе со страницы
onBeforeUnmount(() => {
stopContributorsPoll();
clearActions();
});
</script>
@@ -16,9 +16,9 @@ div
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { useExpandableState, useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import { ref, onMounted } from 'vue';
import { useExpandableState } from 'src/shared/lib/composables';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
import { WindowLoader } from 'src/shared/ui/Loader';
import { CommitsListWidget } from 'app/extensions/capital/widgets';
import { useSystemStore } from 'src/entities/System/model';
@@ -75,24 +75,16 @@ const handleCommitsDataLoaded = (commitHashes: string[]) => {
};
const handlePaginationChanged = (paginationData: { page: number; rowsPerPage: number; sortBy: string; descending: boolean }) => {
// Сохраняем текущую пагинацию для poll обновлений
currentPage.value = paginationData.page;
currentRowsPerPage.value = paginationData.rowsPerPage;
currentSortBy.value = paginationData.sortBy;
currentDescending.value = paginationData.descending;
};
/**
* Функция для перезагрузки данных коммитов
* Используется для poll обновлений
*/
const reloadCommitsData = async () => {
try {
// Используем store для загрузки данных с текущими параметрами пагинации
await commitStore.loadCommits({
filter: {
coopname: info.coopname,
},
filter: { coopname: info.coopname },
options: {
page: currentPage.value,
limit: currentRowsPerPage.value,
@@ -101,28 +93,22 @@ const reloadCommitsData = async () => {
},
});
} catch (error) {
console.warn('Ошибка при перезагрузке данных коммитов в poll:', error);
console.warn('Ошибка при перезагрузке коммитов:', error);
}
};
// Настраиваем poll обновление данных
const { start: startCommitsPoll, stop: stopCommitsPoll } = useDataPoller(
reloadCommitsData,
{ interval: POLL_INTERVALS.MEDIUM, immediate: false }
);
// Инициализация состояния при монтировании
onMounted(async () => {
// Загружаем сохраненное состояние expanded из LocalStorage
loadCommitsExpandedState();
// Запускаем poll обновление данных
startCommitsPoll();
useGraphqlSubscription({
query: buildSubscriptionQuery('capitalCommitCreated', null, ['id', 'commit_hash', 'status']),
onData: () => { forceReload.value++; reloadCommitsData(); },
});
// Останавливаем poll при уходе со страницы
onBeforeUnmount(() => {
stopCommitsPoll();
useGraphqlSubscription({
query: buildSubscriptionQuery('capitalCommitUpdated', null, ['id', 'commit_hash', 'status']),
onData: () => { forceReload.value++; reloadCommitsData(); },
});
onMounted(async () => {
loadCommitsExpandedState();
});
</script>
@@ -5,45 +5,20 @@ div
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount } from 'vue';
import { onMounted } from 'vue';
import { useProjectLoader } from 'app/extensions/capital/entities/Project/model';
import ProjectContributorsList from 'app/extensions/capital/widgets/ProjectInfoSelectorWidget/ProjectContributorsList.vue';
import { useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
// Используем composable для загрузки проекта
const { project, loadProject } = useProjectLoader();
/**
* Функция для перезагрузки данных проекта
* Используется для poll обновлений
*/
const reloadProjectData = async () => {
try {
// Перезагружаем данные текущего проекта
await loadProject();
} catch (error) {
console.warn('Ошибка при перезагрузке данных проекта в poll:', error);
}
};
// Настраиваем poll обновление данных
const { start: startProjectPoll, stop: stopProjectPoll } = useDataPoller(
reloadProjectData,
{ interval: POLL_INTERVALS.MEDIUM, immediate: false }
);
// Инициализация
onMounted(async () => {
await loadProject();
// Запускаем poll обновление данных
startProjectPoll();
useGraphqlSubscription({
query: buildSubscriptionQuery('capitalDataChanged', null, ['entity', 'action']),
onData: () => { loadProject(); },
});
// Останавливаем poll при уходе со страницы
onBeforeUnmount(() => {
stopProjectPoll();
onMounted(async () => {
await loadProject();
});
</script>
@@ -38,10 +38,10 @@ div
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useSystemStore } from 'src/entities/System/model';
import { useExpandableState, useDataPoller } from 'src/shared/lib/composables';
import { POLL_INTERVALS } from 'src/shared/lib/consts';
import { useExpandableState } from 'src/shared/lib/composables';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
import 'src/shared/ui/TitleStyles';
import { WindowLoader } from 'src/shared/ui/Loader';
import { ListVotingProjectWidget, ProjectVotingSegmentsWidget, SegmentVotesWidget } from 'app/extensions/capital/widgets';
@@ -123,48 +123,27 @@ const handleVotesChanged = (data: { projectHash: string; voter: string }) => {
segmentsToReload.value[data.voter] = Date.now();
};
/**
* Функция для перезагрузки данных голосования
* Используется для poll обновлений
*/
const reloadVotingData = async () => {
try {
// Для голосований используем принудительную перезагрузку через timestamp
// Это заставит виджеты перезагрузить данные
const timestamp = Date.now();
// Обновляем все сегменты для перезагрузки
Object.keys(segmentsToReload.value).forEach(key => {
segmentsToReload.value[key] = timestamp;
});
// Если нет сегментов, добавляем специальный ключ для принудительной перезагрузки
if (Object.keys(segmentsToReload.value).length === 0) {
segmentsToReload.value['__force_reload__'] = timestamp;
}
} catch (error) {
console.warn('Ошибка при перезагрузке данных голосования в poll:', error);
const reloadVotingData = () => {
const timestamp = Date.now();
Object.keys(segmentsToReload.value).forEach(key => {
segmentsToReload.value[key] = timestamp;
});
if (Object.keys(segmentsToReload.value).length === 0) {
segmentsToReload.value['__force_reload__'] = timestamp;
}
};
// Настраиваем poll обновление данных
const { start: startVotingPoll, stop: stopVotingPoll } = useDataPoller(
reloadVotingData,
{ interval: POLL_INTERVALS.FAST, immediate: false }
);
useGraphqlSubscription({
query: buildSubscriptionQuery('capitalDataChanged', null, ['entity', 'action']),
onData: (data: any) => {
if (data?.capitalDataChanged?.entity === 'voting') {
reloadVotingData();
}
},
});
// Инициализация
onMounted(async () => {
// Загружаем сохраненное состояние expanded из LocalStorage
loadProjectsExpandedState();
loadSegmentsExpandedState();
// Запускаем poll обновление данных
startVotingPoll();
});
// Останавливаем poll при уходе со страницы
onBeforeUnmount(() => {
stopVotingPoll();
});
</script>
@@ -12,6 +12,7 @@ import { ApprovalsPage } from 'app/extensions/chairman/pages/ApprovalsPage';
import { SystemSettingsPage } from 'app/extensions/chairman/pages/SystemSettingsPage';
import { PaymentProviderPage } from 'app/extensions/chairman/pages/PaymentProviderPage';
import { ConnectPage } from 'app/extensions/chairman/pages/ConnectPage';
import { ApiKeysPage } from 'app/extensions/chairman/pages/ApiKeysPage';
import { AgendaPresetsPage } from 'app/extensions/chairman/pages/AgendaPresetsPage';
import { agreementsBase } from 'src/shared/lib/consts/workspaces';
@@ -234,6 +235,19 @@ export default async function (): Promise<IWorkspaceConfig[]> {
},
children: [],
},
{
path: 'settings/api-keys',
name: 'api-keys',
component: markRaw(ApiKeysPage),
meta: {
title: 'API ключи',
icon: 'fa-solid fa-key',
roles: ['chairman'],
agreements: agreementsBase,
requiresAuth: true,
},
children: [],
},
],
},
],
@@ -0,0 +1 @@
export { ApiKeysPage } from './ui'
@@ -0,0 +1,162 @@
<template lang="pug">
div.page-shell
q-card.hero-card(flat)
.hero-title API Ключи
.hero-subtitle Управление ключами доступа к API кооператива
q-card.q-mt-md(flat)
q-card-section
.row.items-center
.text-h6.col Ключи доступа
q-btn(color='primary' icon='add' label='Создать ключ' no-caps @click='showCreate = true')
q-separator
q-card-section
q-table(
:rows='keys'
:columns='columns'
row-key='id'
flat
:loading='loading'
)
template(#body-cell-status='props')
q-td(:props='props')
q-chip(
:color='props.row.is_active ? "positive" : "negative"'
text-color='white'
dense
) {{ props.row.is_active ? 'Активен' : 'Отозван' }}
template(#body-cell-actions='props')
q-td(:props='props')
q-btn(
v-if='props.row.is_active'
flat
dense
icon='block'
color='negative'
@click='revoke(props.row.id)'
)
q-tooltip Отозвать ключ
.text-center.text-grey-5.q-pa-lg(v-if='keys.length === 0 && !loading')
q-icon(name='vpn_key' size='48px')
.q-mt-sm Нет API ключей
//- Диалог создания
q-dialog(v-model='showCreate')
q-card(style='min-width: 450px')
q-card-section
.text-h6 Новый API ключ
q-card-section
q-input(v-model='newName' label='Название ключа' autofocus dense)
q-input.q-mt-sm(
v-model.number='newExpiresInDays'
label='Срок действия (дней, пусто = бессрочно)'
type='number'
dense
)
q-card-actions(align='right')
q-btn(flat label='Отмена' @click='showCreate = false')
q-btn(flat label='Создать' color='primary' @click='create' :loading='creating')
//- Диалог с созданным ключом
q-dialog(v-model='showCreatedKey' persistent)
q-card(style='min-width: 500px')
q-card-section
.text-h6 🔑 Ключ создан
.text-body2.text-negative.q-mt-sm Скопируйте ключ сейчас он больше не будет показан!
q-card-section
q-input(
:modelValue='createdKey'
readonly
outlined
dense
type='textarea'
rows='2'
)
template(#append)
q-btn(flat dense icon='content_copy' @click='copyKey')
q-card-actions(align='right')
q-btn(flat label='Закрыть' color='primary' @click='showCreatedKey = false')
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { client } from 'src/shared/api/client'
import { copyToClipboard, Notify } from 'quasar'
const keys = ref<any[]>([])
const loading = ref(false)
const showCreate = ref(false)
const creating = ref(false)
const newName = ref('')
const newExpiresInDays = ref<number | undefined>(undefined)
const showCreatedKey = ref(false)
const createdKey = ref('')
const columns = [
{ name: 'name', label: 'Название', field: 'name', align: 'left' as const },
{ name: 'key_prefix', label: 'Префикс', field: 'key_prefix', align: 'left' as const },
{ name: 'created_by', label: 'Создал', field: 'created_by', align: 'left' as const },
{ name: 'status', label: 'Статус', field: 'is_active', align: 'center' as const },
{ name: 'expires_at', label: 'Истекает', field: (r: any) => r.expires_at ? new Date(r.expires_at).toLocaleDateString('ru') : '∞', align: 'center' as const },
{ name: 'last_used_at', label: 'Последнее использование', field: (r: any) => r.last_used_at ? new Date(r.last_used_at).toLocaleString('ru') : '—', align: 'center' as const },
{ name: 'actions', label: '', field: 'id', align: 'right' as const },
]
onMounted(() => loadKeys())
async function loadKeys() {
loading.value = true
try {
const { getApiKeys } = await client.Query({
getApiKeys: {
id: true, name: true, key_prefix: true, created_by: true,
allowed_operations: true, is_active: true,
expires_at: true, last_used_at: true, created_at: true,
},
})
keys.value = getApiKeys || []
} catch {}
loading.value = false
}
async function create() {
if (!newName.value) return
creating.value = true
try {
const { createApiKey } = await client.Mutation({
createApiKey: [{
data: {
name: newName.value,
expiresInDays: newExpiresInDays.value || undefined,
},
}, { id: true, key: true, name: true }],
})
createdKey.value = createApiKey.key
showCreate.value = false
showCreatedKey.value = true
newName.value = ''
newExpiresInDays.value = undefined
await loadKeys()
} catch (e: any) {
Notify.create({ type: 'negative', message: e?.message || 'Ошибка' })
}
creating.value = false
}
async function revoke(id: string) {
try {
await client.Mutation({ revokeApiKey: [{ id }, true] })
Notify.create({ type: 'info', message: 'Ключ отозван' })
await loadKeys()
} catch {}
}
function copyKey() {
copyToClipboard(createdKey.value)
Notify.create({ type: 'positive', message: 'Ключ скопирован' })
}
</script>
@@ -0,0 +1 @@
export { default as ApiKeysPage } from './ApiKeysPage.vue'
@@ -8,6 +8,7 @@ import { MeetDetailsPage } from 'src/pages/Cooperative/MeetDetails';
import { UserDocumentsPage } from 'src/pages/User/DocumentsPage';
import { UserPaymentsPage } from 'src/pages/User/PaymentsPage';
import { SupportTrigger } from 'src/pages/Support';
import { SharedWithMePage } from './pages/SharedWithMePage';
import { agreementsBase } from 'src/shared/lib/consts/workspaces';
import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace';
import { markRaw } from 'vue';
@@ -138,6 +139,19 @@ export default async function (): Promise<IWorkspaceConfig[]> {
},
],
},
{
meta: {
title: 'Доступные мне',
icon: 'fa-solid fa-share-nodes',
roles: ['user', 'member', 'chairman'],
agreements: agreementsBase,
requiresAuth: true,
},
path: 'shared-with-me',
name: 'shared-with-me',
component: markRaw(SharedWithMePage),
children: [],
},
{
path: '/:coopname/contacts',
name: 'contacts',
@@ -0,0 +1 @@
export { SharedWithMePage } from './ui'
@@ -0,0 +1,132 @@
<template lang="pug">
div.page-shell
q-card.hero-card(flat)
.hero-title Доступные мне
.hero-subtitle Страницы, к которым вам предоставлен доступ
q-card.q-mt-md(flat)
q-card-section
q-table(
:rows='sharedLinks'
:columns='columns'
row-key='id'
flat
:loading='loading'
)
template(#body-cell-page='props')
q-td(:props='props')
.text-weight-medium {{ props.row.page_name }}
.text-caption.text-grey {{ props.row.page_path }}
template(#body-cell-from='props')
q-td(:props='props')
q-chip(dense color='blue-2' text-color='blue-9')
| {{ props.row.link_name || 'Без имени' }}
template(#body-cell-actions_list='props')
q-td(:props='props')
q-chip(
v-for='action in props.row.allowed_actions'
:key='action'
dense
color='grey-3'
text-color='grey-8'
).q-mr-xs {{ actionLabel(action) }}
template(#body-cell-expires='props')
q-td(:props='props')
span(v-if='props.row.expires_at') {{ formatDate(props.row.expires_at) }}
q-chip(v-else dense color='green-2' text-color='green-9') Бессрочно
template(#body-cell-go='props')
q-td(:props='props')
q-btn(
flat
dense
icon='fa-solid fa-arrow-right'
color='primary'
@click='navigateTo(props.row)'
)
q-tooltip Перейти
.text-center.text-grey-5.q-pa-lg(v-if='sharedLinks.length === 0 && !loading')
q-icon(name='fa-solid fa-share-nodes' size='48px')
.q-mt-sm Нет предоставленных страниц
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { client } from 'src/shared/api/client'
import { Notify } from 'quasar'
interface SharedLink {
id: string
page_path: string
page_name: string
target_type: string
link_name?: string
allowed_actions: string[]
token: string
is_active: boolean
expires_at?: string
}
const router = useRouter()
const sharedLinks = ref<SharedLink[]>([])
const loading = ref(false)
const columns = [
{ name: 'page', label: 'Страница', field: 'page_name', align: 'left' as const },
{ name: 'from', label: 'Название', field: 'link_name', align: 'left' as const },
{ name: 'actions_list', label: 'Разрешения', field: 'allowed_actions', align: 'left' as const },
{ name: 'expires', label: 'Истекает', field: 'expires_at', align: 'center' as const },
{ name: 'go', label: '', field: 'id', align: 'right' as const },
]
function actionLabel(action: string) {
const map: Record<string, string> = {
read: 'Чтение',
create: 'Создание',
update: 'Изменение',
delete: 'Удаление',
execute: 'Выполнение',
manage: 'Управление',
}
return map[action] || action
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('ru-RU')
}
function navigateTo(link: SharedLink) {
const url = link.page_path.includes('?')
? `${link.page_path}&share_token=${link.token}`
: `${link.page_path}?share_token=${link.token}`
router.push(url)
}
onMounted(async () => {
loading.value = true
try {
const { getSharedWithMe } = await client.Query({
getSharedWithMe: {
id: true,
page_path: true,
page_name: true,
target_type: true,
link_name: true,
allowed_actions: true,
token: true,
is_active: true,
expires_at: true,
},
})
sharedLinks.value = (getSharedWithMe || []).filter((l: any) => l.is_active)
} catch (e: any) {
Notify.create({ type: 'negative', message: e?.message || 'Ошибка загрузки' })
}
loading.value = false
})
</script>
@@ -0,0 +1 @@
export { default as SharedWithMePage } from './SharedWithMePage.vue'
@@ -0,0 +1 @@
export { default as install } from './install'
@@ -0,0 +1,40 @@
import { markRaw } from 'vue';
import { agreementsBase } from 'src/shared/lib/consts/workspaces';
import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace';
import { ReportsPage } from './pages';
export default async function (): Promise<IWorkspaceConfig[]> {
return [{
workspace: 'reports',
extension_name: 'reports',
title: 'Отчёты ФНС',
icon: 'fa-solid fa-file-invoice',
defaultRoute: 'reports-main',
routes: [
{
meta: {
title: 'Отчёты ФНС',
icon: 'fa-solid fa-file-invoice',
roles: ['chairman'],
},
path: '/:coopname/reports',
name: 'reports',
children: [
{
path: '',
name: 'reports-main',
component: markRaw(ReportsPage),
meta: {
title: 'Расписание отчётов',
icon: 'fa-solid fa-calendar-days',
roles: ['chairman'],
agreements: agreementsBase,
requiresAuth: true,
},
children: [],
},
],
},
],
}];
}
@@ -0,0 +1 @@
export { ReportsPage } from './ui'
@@ -0,0 +1,281 @@
<template lang="pug">
div.page-shell
q-card.hero-card(flat)
.hero-title Отчёты ФНС
.hero-subtitle Генерация и управление налоговыми отчётами кооператива
//- Расписание отчётов
q-card.q-mt-md(flat)
q-card-section
.row.items-center
.text-h6.col Расписание отчётов
q-separator
q-card-section
q-table(
:rows='reports'
:columns='columns'
row-key='type'
flat
:loading='loading'
)
template(#body-cell-period='props')
q-td(:props='props')
q-chip(
:color='periodColor(props.row.period)'
text-color='white'
dense
) {{ periodLabel(props.row.period) }}
template(#body-cell-actions='props')
q-td(:props='props')
q-btn(
flat
dense
icon='fa-solid fa-file-export'
color='primary'
@click='openGenerate(props.row)'
:disable='generating'
)
q-tooltip Сгенерировать отчёт
.text-center.text-grey-5.q-pa-lg(v-if='reports.length === 0 && !loading')
q-icon(name='fa-solid fa-file-lines' size='48px')
.q-mt-sm Загрузка расписания...
//- Диалог генерации
q-dialog(v-model='showGenerate')
q-card(style='min-width: 550px')
q-card-section
.text-h6 Генерация: {{ selectedReport?.name }}
q-card-section
.row.q-gutter-md
.col-6
q-input(v-model.number='genYear' label='Год' type='number' dense outlined)
.col-6
q-input(
v-if='selectedReport?.period !== "yearly"'
v-model.number='genPeriod'
:label='selectedReport?.period === "monthly" ? "Месяц (1-12)" : "Квартал (1-4)"'
type='number'
dense
outlined
)
q-separator.q-my-md
.text-subtitle2.q-mb-sm Данные организации
.row.q-gutter-sm
.col-6
q-input(v-model='orgData.inn' label='ИНН' dense outlined)
.col-6
q-input(v-model='orgData.kpp' label='КПП' dense outlined)
.col-12
q-input(v-model='orgData.orgName' label='Наименование' dense outlined)
.col-6
q-input(v-model='orgData.ogrn' label='ОГРН' dense outlined)
.col-6
q-input(v-model='orgData.okved' label='ОКВЭД' dense outlined)
.col-6
q-input(v-model='orgData.oktmo' label='ОКТМО' dense outlined)
.col-6
q-input(v-model='orgData.okfs' label='ОКФС' dense outlined)
q-separator.q-my-md
.text-subtitle2.q-mb-sm Подписант
.row.q-gutter-sm
.col-4
q-input(v-model='orgData.signerLastName' label='Фамилия' dense outlined)
.col-4
q-input(v-model='orgData.signerFirstName' label='Имя' dense outlined)
.col-4
q-input(v-model='orgData.signerMiddleName' label='Отчество' dense outlined)
q-card-actions(align='right')
q-btn(flat label='Отмена' @click='showGenerate = false')
q-btn(flat label='Сгенерировать' color='primary' @click='generate' :loading='generating')
//- Диалог результата
q-dialog(v-model='showResult')
q-card(style='min-width: 600px; max-width: 90vw')
q-card-section
.row.items-center
.text-h6.col Результат генерации
q-chip(
:color='result?.isValid ? "positive" : "negative"'
text-color='white'
) {{ result?.isValid ? 'Валиден' : 'Ошибки' }}
q-card-section(v-if='result?.errors?.length')
q-banner.bg-negative.text-white(v-for='err in result.errors' :key='err')
| {{ err }}
q-card-section
.text-caption.text-grey Файл: {{ result?.fileName }}
q-input.q-mt-sm(
:modelValue='result?.xml'
readonly
outlined
type='textarea'
rows='12'
)
q-card-actions(align='right')
q-btn(flat label='Скачать XML' icon='download' color='primary' @click='downloadXml')
q-btn(flat label='Закрыть' @click='showResult = false')
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue'
import { client } from 'src/shared/api/client'
import { Notify } from 'quasar'
interface AvailableReport {
type: string
name: string
period: string
deadline: string
}
interface GeneratedReport {
reportType: string
xml: string
fileName: string
errors: string[]
isValid: boolean
}
const reports = ref<AvailableReport[]>([])
const loading = ref(false)
const generating = ref(false)
const showGenerate = ref(false)
const showResult = ref(false)
const selectedReport = ref<AvailableReport | null>(null)
const result = ref<GeneratedReport | null>(null)
const genYear = ref(new Date().getFullYear() - 1)
const genPeriod = ref(1)
const orgData = reactive({
inn: '',
kpp: '',
orgName: '',
ogrn: '',
okved: '94.99',
oktmo: '',
okfs: '16',
okopf: '20200',
signerLastName: '',
signerFirstName: '',
signerMiddleName: '',
})
const columns = [
{ name: 'name', label: 'Отчёт', field: 'name', align: 'left' as const, sortable: true },
{ name: 'period', label: 'Периодичность', field: 'period', align: 'center' as const },
{ name: 'deadline', label: 'Срок сдачи', field: 'deadline', align: 'left' as const },
{ name: 'actions', label: '', field: 'type', align: 'right' as const },
]
function periodLabel(p: string) {
if (p === 'yearly') return 'Ежегодно'
if (p === 'quarterly') return 'Ежеквартально'
if (p === 'monthly') return 'Ежемесячно'
return p
}
function periodColor(p: string) {
if (p === 'yearly') return 'deep-purple'
if (p === 'quarterly') return 'blue'
if (p === 'monthly') return 'teal'
return 'grey'
}
onMounted(async () => {
await loadReports()
})
async function loadReports() {
loading.value = true
try {
const { getAvailableReports } = await client.Query({
getAvailableReports: {
type: true,
name: true,
period: true,
deadline: true,
},
})
reports.value = getAvailableReports || []
} catch (e: any) {
Notify.create({ type: 'negative', message: 'Ошибка загрузки отчётов: ' + (e?.message || '') })
}
loading.value = false
}
function openGenerate(report: AvailableReport) {
selectedReport.value = report
showGenerate.value = true
}
async function generate() {
if (!selectedReport.value) return
generating.value = true
try {
const { generateReport } = await client.Mutation({
generateReport: [{
data: {
reportType: selectedReport.value.type as any,
year: genYear.value,
period: genPeriod.value,
},
organization: {
inn: orgData.inn,
kpp: orgData.kpp,
orgName: orgData.orgName,
ogrn: orgData.ogrn,
okved: orgData.okved,
oktmo: orgData.oktmo,
okfs: orgData.okfs,
okopf: orgData.okopf,
signerLastName: orgData.signerLastName,
signerFirstName: orgData.signerFirstName,
signerMiddleName: orgData.signerMiddleName,
},
}, {
reportType: true,
xml: true,
fileName: true,
errors: true,
isValid: true,
}],
})
result.value = generateReport
showGenerate.value = false
showResult.value = true
if (generateReport.isValid) {
Notify.create({ type: 'positive', message: 'Отчёт сгенерирован успешно' })
}
} catch (e: any) {
Notify.create({ type: 'negative', message: 'Ошибка генерации: ' + (e?.message || '') })
}
generating.value = false
}
function downloadXml() {
if (!result.value) return
const blob = new Blob([result.value.xml], { type: 'application/xml;charset=windows-1251' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = result.value.fileName + '.xml'
a.click()
URL.revokeObjectURL(url)
}
</script>
@@ -0,0 +1 @@
export { default as ReportsPage } from './ReportsPage.vue'
@@ -0,0 +1 @@
export { ReportsPage } from './ReportsPage'
@@ -170,17 +170,9 @@ export const useConnectionAgreementStore = defineStore(namespace, () => {
}
}
const startInstanceAutoRefresh = async (intervalMs = 30000) => { // 30 секунд по умолчанию
await loadCurrentInstance() // Первая загрузка
const interval = setInterval(() => {
loadCurrentInstance()
}, intervalMs)
// Функция для остановки автообновления
const stop = () => clearInterval(interval)
return stop
const startInstanceAutoRefresh = async () => {
await loadCurrentInstance()
return () => {}
}
const reset = () => {
@@ -3,14 +3,11 @@ import { ref, Ref, triggerRef, computed, ComputedRef } from 'vue';
import { api } from '../api';
import type { ISystemInfo } from '../types';
import { Zeus } from '@coopenomics/sdk';
import { createClient } from 'graphql-ws';
import { env } from 'src/shared/config';
const namespace = 'systemStore';
// Константы для экспоненциального backoff
const BASE_INTERVAL_MS = 30000; // Базовый интервал 30 секунд (было 10)
const MAX_INTERVAL_MS = 300000; // Максимальный интервал 5 минут
const BACKOFF_MULTIPLIER = 2; // Множитель для backoff
interface ISystemStore {
info: Ref<ISystemInfo>;
backendAvailable: Ref<boolean>;
@@ -27,104 +24,100 @@ interface ISystemStore {
export const useSystemStore = defineStore(namespace, (): ISystemStore => {
const info = ref<ISystemInfo>({
system_status: 'active', // Начальное значение
system_status: 'active',
} as ISystemInfo);
const backendAvailable = ref<boolean>(true);
const maintenanceCounter = ref<number>(0); // Счетчик для принудительного обновления
const maintenanceCounter = ref<number>(0);
let monitoringTimeout: ReturnType<typeof setTimeout> | null = null;
let isLoading = false; // Защита от конкурентных запросов
let currentInterval = BASE_INTERVAL_MS; // Текущий интервал (для backoff)
let consecutiveErrors = 0; // Счетчик последовательных ошибок
let isLoading = false;
let wsUnsubscribe: (() => void) | null = null;
const loadSystemInfo = async () => {
// Защита от конкурентных запросов
if (isLoading) {
console.debug('loadSystemInfo: запрос уже выполняется, пропускаем');
return;
}
if (isLoading) return;
isLoading = true;
try {
info.value = await api.loadSystemInfo();
backendAvailable.value = true;
triggerRef(info); // Принудительно триггерим реактивность
// При успехе сбрасываем backoff
consecutiveErrors = 0;
currentInterval = BASE_INTERVAL_MS;
triggerRef(info);
} catch (error) {
console.warn('Failed to load system info, backend might be unavailable:', error);
console.warn('Failed to load system info:', error);
backendAvailable.value = false;
// При недоступности бэкенда устанавливаем статус обслуживания
info.value.system_status = Zeus.SystemStatus.maintenance;
// Увеличиваем интервал при ошибках (экспоненциальный backoff)
consecutiveErrors++;
currentInterval = Math.min(
BASE_INTERVAL_MS * Math.pow(BACKOFF_MULTIPLIER, consecutiveErrors),
MAX_INTERVAL_MS
);
console.debug(`Backoff: следующая попытка через ${currentInterval / 1000} секунд`);
throw error; // Перебрасываем ошибку для обработки выше
throw error;
} finally {
isLoading = false;
}
};
const scheduleNextCheck = () => {
// КРИТИЧНО: Не запускаем мониторинг на сервере (SSR)
// В SSR setInterval/setTimeout создают утечки памяти и накапливают запросы
if (typeof window === 'undefined') {
return;
}
stopSystemMonitoring();
monitoringTimeout = setTimeout(async () => {
try {
await loadSystemInfo();
} catch (error) {
console.warn('Failed to update system info during monitoring:', error);
// При недоступности бэкенда устанавливаем статус обслуживания
backendAvailable.value = false;
info.value.system_status = Zeus.SystemStatus.maintenance;
maintenanceCounter.value++; // Увеличиваем счетчик для триггера watch
}
// Планируем следующую проверку с учетом возможного backoff
scheduleNextCheck();
}, currentInterval);
};
const startSystemMonitoring = () => {
// КРИТИЧНО: Не запускаем мониторинг на сервере (SSR)
// Это предотвращает утечку таймеров и самоDDoS
if (typeof window === 'undefined') {
console.debug('startSystemMonitoring: пропускаем на сервере (SSR)');
return;
}
// Останавливаем существующий мониторинг, если он есть
if (typeof window === 'undefined') return;
stopSystemMonitoring();
// Сбрасываем backoff при явном старте мониторинга
consecutiveErrors = 0;
currentInterval = BASE_INTERVAL_MS;
try {
const wsUrl = (env.BACKEND_URL + '/v1/graphql').replace(/^http/, 'ws');
// Используем setTimeout вместо setInterval для гибкого управления интервалом
scheduleNextCheck();
const wsClient = createClient({
url: wsUrl,
connectionParams: () => {
try {
const globalStore = (window as any).__pinia?.state?.value?.global;
const token = globalStore?.tokens?.access?.token;
return token ? { token } : {};
} catch {
return {};
}
},
retryAttempts: Infinity,
shouldRetry: () => true,
retryWait: async (retries) => {
const delay = Math.min(1000 * Math.pow(2, retries), 60000);
await new Promise(resolve => setTimeout(resolve, delay));
},
on: {
connected: () => {
if (!backendAvailable.value) {
backendAvailable.value = true;
loadSystemInfo().catch(() => {});
}
},
closed: () => {
backendAvailable.value = false;
info.value.system_status = Zeus.SystemStatus.maintenance;
maintenanceCounter.value++;
},
},
});
wsUnsubscribe = wsClient.subscribe(
{ query: 'subscription { systemStatusChanged { status message } }' },
{
next(value) {
if (value.data) {
backendAvailable.value = true;
loadSystemInfo().catch(() => {});
}
},
error() {
backendAvailable.value = false;
info.value.system_status = Zeus.SystemStatus.maintenance;
maintenanceCounter.value++;
},
complete() {},
},
);
} catch (e) {
console.warn('Failed to start system monitoring subscription:', e);
}
};
const stopSystemMonitoring = () => {
if (monitoringTimeout) {
clearTimeout(monitoringTimeout);
monitoringTimeout = null;
if (wsUnsubscribe) {
wsUnsubscribe();
wsUnsubscribe = null;
}
};
// Человеко-читаемое название кооператива
const cooperativeDisplayName = computed(() => {
const vars = info.value?.vars;
if (vars?.short_abbr && vars?.name) {
@@ -133,16 +126,9 @@ export const useSystemStore = defineStore(namespace, (): ISystemStore => {
return info.value.contacts?.full_name || '';
});
// Символ управления (govern)
const governSymbol = computed(() => info.value.symbols?.root_govern_symbol || '₽');
// Точность символа управления
const governPrecision = computed(() => info.value.symbols?.root_govern_precision || 2);
// Системный символ
const systemSymbol = computed(() => info.value.symbols?.root_symbol || '₽');
// Точность системного символа
const systemPrecision = computed(() => info.value.symbols?.root_precision || 2);
return {
@@ -0,0 +1 @@
export { ShareHeaderAction, ShareDialog } from './ui'
@@ -0,0 +1,184 @@
<template lang="pug">
q-dialog(v-model='isOpen' persistent)
q-card(style='width: 550px; max-width: 90vw')
q-card-section
.row.items-center
q-icon(name='share' size='24px' color='primary')
.text-h6.q-ml-sm Поделиться страницей
q-space
q-btn(flat round dense icon='close' @click='close')
q-separator
q-tabs(v-model='tab' dense class='text-primary')
q-tab(name='create' label='Создать ссылку')
q-tab(name='active' label='Активные ссылки')
q-tab-panels(v-model='tab')
q-tab-panel(name='create')
q-form(@submit.prevent='createLink')
q-select(
v-model='targetType'
:options='targetOptions'
label='Тип доступа'
emit-value
map-options
dense
)
q-input(
v-if='targetType === "guest"'
v-model='linkName'
label='Название ссылки'
dense
class='q-mt-sm'
)
q-input(
v-if='targetType === "member"'
v-model='targetUsername'
label='Имя пайщика'
dense
class='q-mt-sm'
)
q-select(
v-model='selectedActions'
:options='actionOptions'
label='Разрешённые действия'
multiple
emit-value
map-options
dense
class='q-mt-sm'
)
q-input(
v-model.number='expiresInDays'
label='Срок действия (дней, пусто = бессрочно)'
type='number'
dense
class='q-mt-sm'
)
q-btn.q-mt-md(
type='submit'
color='primary'
label='Создать ссылку'
:loading='creating'
no-caps
)
q-tab-panel(name='active')
q-list(v-if='links.length > 0' separator)
q-item(v-for='link in links' :key='link.id')
q-item-section
q-item-label {{ link.link_name || link.target_username || 'Гость' }}
q-item-label(caption) {{ link.page_name }} · {{ link.allowed_actions.join(', ') }}
q-item-section(side)
q-btn(flat dense icon='content_copy' @click='copyToken(link.token)')
q-btn(flat dense icon='delete' color='negative' @click='revoke(link.id)')
.text-center.text-grey-5.q-pa-lg(v-else)
| Нет активных ссылок
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { client } from 'src/shared/api/client'
import { copyToClipboard, Notify } from 'quasar'
const props = defineProps<{ modelValue: boolean; pagePath: string; pageName: string }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
const isOpen = ref(props.modelValue)
watch(() => props.modelValue, v => { isOpen.value = v })
watch(isOpen, v => { emit('update:modelValue', v) })
const tab = ref('create')
const targetType = ref('guest')
const linkName = ref('')
const targetUsername = ref('')
const selectedActions = ref(['read'])
const expiresInDays = ref<number | undefined>(undefined)
const creating = ref(false)
const links = ref<any[]>([])
const targetOptions = [
{ label: 'Гость (по ссылке)', value: 'guest' },
{ label: 'Пайщик (по имени)', value: 'member' },
]
const actionOptions = [
{ label: 'Просмотр', value: 'read' },
{ label: 'Создание', value: 'create' },
{ label: 'Редактирование', value: 'update' },
{ label: 'Удаление', value: 'delete' },
{ label: 'Выполнение действий', value: 'execute' },
]
onMounted(() => loadLinks())
async function loadLinks() {
try {
const { getMyShareLinks } = await client.Query({
getMyShareLinks: {
id: true,
page_path: true,
page_name: true,
target_type: true,
target_username: true,
link_name: true,
allowed_actions: true,
token: true,
is_active: true,
created_at: true,
},
})
links.value = (getMyShareLinks || []).filter((l: any) => l.page_path === props.pagePath)
} catch {}
}
async function createLink() {
creating.value = true
try {
await client.Mutation({
createShareLink: [{
data: {
pagePath: props.pagePath,
pageName: props.pageName,
targetType: targetType.value as any,
targetUsername: targetType.value === 'member' ? targetUsername.value : undefined,
linkName: targetType.value === 'guest' ? linkName.value : undefined,
allowedActions: selectedActions.value,
expiresInDays: expiresInDays.value || undefined,
},
}, { id: true, token: true }],
})
Notify.create({ type: 'positive', message: 'Ссылка создана' })
await loadLinks()
linkName.value = ''
targetUsername.value = ''
} catch (e: any) {
Notify.create({ type: 'negative', message: e?.message || 'Ошибка' })
} finally {
creating.value = false
}
}
async function revoke(id: string) {
try {
await client.Mutation({ revokeShareLink: [{ id }, true] })
Notify.create({ type: 'info', message: 'Ссылка отозвана' })
await loadLinks()
} catch {}
}
function copyToken(token: string) {
const url = `${window.location.origin}${props.pagePath}?share=${token}`
copyToClipboard(url)
Notify.create({ type: 'positive', message: 'Ссылка скопирована' })
}
function close() { isOpen.value = false }
</script>
@@ -0,0 +1,28 @@
<template lang="pug">
q-btn(
v-if='canShare'
flat
dense
no-caps
icon='share'
label='Поделиться'
@click='showDialog = true'
)
ShareDialog(v-model='showDialog' :pagePath='currentPath' :pageName='currentPageName')
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useSessionStore } from 'src/entities/Session'
import ShareDialog from './ShareDialog.vue'
const session = useSessionStore()
const route = useRoute()
const showDialog = ref(false)
const canShare = computed(() => session.isChairman || session.isMember)
const currentPath = computed(() => route.fullPath)
const currentPageName = computed(() => (route.meta?.title as string) || route.name?.toString() || '')
</script>
@@ -0,0 +1,2 @@
export { default as ShareHeaderAction } from './ShareHeaderAction.vue'
export { default as ShareDialog } from './ShareDialog.vue'
@@ -88,20 +88,9 @@ export function useProviderSubscriptions() {
}
};
/**
* Автоматическая загрузка с интервалом
*/
const startAutoRefresh = (intervalMs = 60000) => { // 1 минута по умолчанию
loadSubscriptions(); // Первая загрузка
const interval = setInterval(() => {
loadSubscriptions();
}, intervalMs);
// Функция для остановки автообновления
const stop = () => clearInterval(interval);
return stop;
const startAutoRefresh = () => {
loadSubscriptions();
return () => {};
};
return {
@@ -16,7 +16,7 @@ q-card(flat)
</template>
<script setup lang="ts">
import { onBeforeUnmount, computed, ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useSessionStore } from 'src/entities/Session';
import { CreateProjectButton } from 'src/features/Decision/CreateProject';
@@ -24,6 +24,7 @@ import { useDecisionProcessor } from 'src/processes/process-decisions';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { QuestionsTable } from 'src/widgets/Questions';
import { useHeaderActions } from 'src/shared/hooks';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
const route = useRoute();
const session = useSessionStore();
@@ -120,13 +121,10 @@ const onVoteAgainst = async (row) => {
}
};
// Инициализация
loadDecisions(route.params.coopname as string);
// Периодическое обновление данных
const interval = setInterval(
() => loadDecisions(route.params.coopname as string, true),
10000,
);
onBeforeUnmount(() => clearInterval(interval));
useGraphqlSubscription({
query: buildSubscriptionQuery('sovietDataChanged', null, ['entity', 'action']),
onData: () => { loadDecisions(route.params.coopname as string, true); },
});
</script>
@@ -38,7 +38,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref, computed, onUnmounted } from 'vue';
import { onMounted, ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { MeetDetailsInfo } from 'src/widgets/Meets/MeetDetailsInfo';
import { MeetDetailsActions } from 'src/widgets/Meets/MeetDetailsActions';
@@ -51,6 +51,7 @@ import { useBackButton } from 'src/shared/lib/navigation';
import { useVoteOnMeet } from 'src/features/Meet/VoteOnMeet';
import { Zeus } from '@coopenomics/sdk';
import { FailAlert } from 'src/shared/api';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
const route = useRoute();
const meetStore = useMeetStore();
@@ -72,8 +73,6 @@ const showAgenda = computed(
const { isVotingNow, setMeet } = useVoteOnMeet();
let intervalId: ReturnType<typeof setInterval> | null = null;
const loadMeetDetails = async () => {
try {
await meetStore.loadMeet({
@@ -103,16 +102,13 @@ useBackButton({
componentId: 'meet-details-' + meetHash.value,
});
onMounted(() => {
loadMeetDetails();
intervalId = setInterval(loadMeetDetails, 15000);
useGraphqlSubscription({
query: buildSubscriptionQuery('sovietDataChanged', null, ['entity', 'action']),
onData: () => { loadMeetDetails(); },
});
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
onMounted(() => {
loadMeetDetails();
});
</script>
@@ -14,48 +14,28 @@ div
</template>
<script lang="ts" setup>
import { ref, computed, watch, onBeforeUnmount, onMounted } from 'vue';
import { computed, onMounted } from 'vue';
import { useSessionStore } from 'src/entities/Session';
import { Loader } from 'src/shared/ui/Loader';
import { useRegistratorStore } from 'src/entities/Registrator';
import { useGraphqlSubscription, buildSubscriptionQuery } from 'src/shared/lib/composables';
const store = useRegistratorStore();
const currentStep = store.steps.WaitingRegistration;
const step = computed(() => store.state.step);
const interval = ref();
watch(step, (newValue) => {
if (newValue === currentStep) {
interval.value = setInterval(() => update(), 10000);
update();
}
});
const session = useSessionStore();
const participantAccount = computed(() => session.participantAccount);
onMounted(() => {
if (participantAccount.value && step.value === currentStep) store.next();
});
onBeforeUnmount(() => {
if (interval.value) {
clearInterval(interval.value);
if (participantAccount.value && store.state.step === store.steps.WaitingRegistration) {
store.next();
}
});
const update = async () => {
if (store.state.account.username && !participantAccount.value) {
// Убираю loadProfile - данные уже загружены
// await currentUser.loadProfile(
// store.state.account.username,
// info.coopname,
// );
} else {
clearInterval(interval.value);
}
};
useGraphqlSubscription({
query: buildSubscriptionQuery('sovietDataChanged', null, ['entity', 'action']),
onData: () => {
if (participantAccount.value) {
store.next();
}
},
});
</script>
@@ -68,4 +68,9 @@ export async function useInitAppProcess(router: Router) {
await useInitExtensionsProcess(router);
// Автоматическая кнопка "Поделиться" на всех страницах
if (typeof window !== 'undefined') {
const { useShareButtonProcess } = await import('src/processes/share-button-setup');
useShareButtonProcess();
}
}
@@ -9,6 +9,7 @@ import marketAdminInstall from '../../../extensions/market-admin/install';
import participantInstall from '../../../extensions/participant/install';
import powerupInstall from '../../../extensions/powerup/install';
import sovietInstall from '../../../extensions/soviet/install';
import reportsInstall from '../../../extensions/reports/install';
/**
* Единый регистр всех доступных расширений
@@ -23,6 +24,7 @@ export const extensionsRegistry: Record<string, () => Promise<IWorkspaceConfig[]
participant: participantInstall,
powerup: powerupInstall,
soviet: sovietInstall,
reports: reportsInstall,
};
/**
@@ -10,6 +10,10 @@ function hasAccess(to, userRole) {
return userRole && to.meta?.roles.includes(userRole);
}
function hasShareToken(to): boolean {
return !!(to.query?.share_token && typeof to.query.share_token === 'string' && to.query.share_token.length > 10);
}
// Функция для получения URL для редиректа
function getRedirectUrl(router: Router, to: any): string {
if (process.env.CLIENT) {
@@ -106,8 +110,8 @@ export function setupNavigationGuard(router: Router) {
return;
}
// проверка по ролям
if (hasAccess(to, userRole)) {
// проверка по ролям — при наличии share_token пропускаем проверку ролей
if (hasAccess(to, userRole) || hasShareToken(to)) {
next();
} else {
next({ name: 'permissionDenied', query: to.query });
@@ -0,0 +1,35 @@
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useSessionStore } from 'src/entities/Session'
import { useHeaderActions } from 'src/shared/hooks'
import { ShareHeaderAction } from 'src/features/PageShare'
/**
* Автоматически регистрирует кнопку "Поделиться" в header
* на страницах где meta.shareable === true.
* Доступна только chairman и member.
*/
export function useShareButtonProcess() {
const route = useRoute()
const session = useSessionStore()
const { registerAction, unregisterAction } = useHeaderActions()
watch(
() => route.fullPath,
() => {
const canShare = session.isChairman || session.isMember
const isShareable = route.meta?.shareable !== false
if (canShare && isShareable && session.isAuth) {
registerAction({
id: 'page-share',
component: ShareHeaderAction,
order: 100,
})
} else {
unregisterAction('page-share')
}
},
{ immediate: true },
)
}
@@ -3,3 +3,4 @@ export { useExpandableState } from './useExpandableState';
export { useDataPoller } from './useDataPoller';
export { useMobileDrawer } from './useMobileDrawer';
export { useReferralLink } from './useReferralLink';
export { useGraphqlSubscription, buildSubscriptionQuery } from './useGraphqlSubscription';
@@ -0,0 +1,124 @@
import { ref, onUnmounted, type Ref } from 'vue';
import { createClient, type Client } from 'graphql-ws';
import { useGlobalStore } from 'src/shared/store';
import { env } from 'src/shared/config';
let sharedClient: Client | null = null;
let activeSubscribers = 0;
function getWsUrl(): string {
const httpUrl = env.BACKEND_URL + '/v1/graphql';
return httpUrl.replace(/^http/, 'ws');
}
function getOrCreateClient(): Client {
if (sharedClient) return sharedClient;
const globalStore = useGlobalStore();
sharedClient = createClient({
url: getWsUrl(),
connectionParams: () => ({
token: globalStore.tokens?.access?.token || '',
}),
retryAttempts: 5,
shouldRetry: () => true,
on: {
closed: () => {
if (activeSubscribers === 0) {
sharedClient?.dispose();
sharedClient = null;
}
},
},
});
return sharedClient;
}
export interface UseSubscriptionOptions<T> {
query: string;
variables?: Record<string, any>;
onData: (data: T) => void;
onError?: (error: any) => void;
enabled?: Ref<boolean>;
}
/**
* Composable для GraphQL подписок через graphql-ws.
* Автоматически подключается к WebSocket с JWT токеном.
* Автоматически отписывается при уничтожении компонента.
*/
export function useGraphqlSubscription<T = any>(options: UseSubscriptionOptions<T>) {
const isConnected = ref(false);
let unsubscribe: (() => void) | null = null;
function subscribe() {
if (unsubscribe) return;
if (options.enabled && !options.enabled.value) return;
const client = getOrCreateClient();
activeSubscribers++;
isConnected.value = true;
unsubscribe = client.subscribe(
{
query: options.query,
variables: options.variables,
},
{
next(value) {
if (value.data) {
options.onData(value.data as T);
}
},
error(error) {
console.warn('[Subscription] Error:', error);
options.onError?.(error);
},
complete() {
isConnected.value = false;
},
},
);
}
function stop() {
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
activeSubscribers = Math.max(0, activeSubscribers - 1);
isConnected.value = false;
}
}
subscribe();
onUnmounted(() => {
stop();
});
return {
isConnected,
stop,
restart: () => {
stop();
subscribe();
},
};
}
/**
* Утилита для формирования GraphQL subscription query строки
*/
export function buildSubscriptionQuery(
name: string,
args: Record<string, any> | null,
fields: string[],
): string {
const argsStr = args
? `(${Object.entries(args).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')})`
: '';
return `subscription { ${name}${argsStr} { ${fields.join(' ')} } }`;
}
+1
View File
@@ -12,6 +12,7 @@ export * as Classes from './classes'
export * as Mutations from './mutations'
export * as Queries from './queries'
export * as Selectors from './selectors'
export * as Subscriptions from './subscriptions'
export * as Types from './types'
@@ -0,0 +1,77 @@
export const IssueUpdated = {
name: 'capitalIssueUpdated' as const,
subscription: {
capitalIssueUpdated: [
{ project_hash: '' },
{
id: true,
issue_hash: true,
title: true,
status: true,
priority: true,
estimate: true,
created_by: true,
submaster: true,
creators: true,
project_hash: true,
},
],
},
}
export const IssueCreated = {
name: 'capitalIssueCreated' as const,
subscription: {
capitalIssueCreated: [
{ project_hash: '' },
{
id: true,
issue_hash: true,
title: true,
status: true,
priority: true,
estimate: true,
created_by: true,
submaster: true,
creators: true,
project_hash: true,
},
],
},
}
export const CommitCreated = {
name: 'capitalCommitCreated' as const,
subscription: {
capitalCommitCreated: [
{ project_hash: '' },
{
id: true,
commit_hash: true,
hours: true,
description: true,
status: true,
created_by: true,
project_hash: true,
},
],
},
}
export const CommitUpdated = {
name: 'capitalCommitUpdated' as const,
subscription: {
capitalCommitUpdated: [
{ project_hash: '' },
{
id: true,
commit_hash: true,
hours: true,
description: true,
status: true,
created_by: true,
project_hash: true,
},
],
},
}
@@ -0,0 +1 @@
export * as Capital from './capital'
+103 -1
View File
@@ -256,6 +256,9 @@ export const AllTypesProps: Record<string,any> = {
close_at:"DateTime",
open_at:"DateTime",
proposal:"AnnualGeneralMeetingAgendaSignedDocumentInput"
},
CreateApiKeyInput:{
},
CreateBranchInput:{
@@ -328,6 +331,9 @@ export const AllTypesProps: Record<string,any> = {
},
CreateProjectPropertyInput:{
},
CreateShareLinkInput:{
targetType:"ShareTargetType"
},
CreateSovietIndividualDataInput:{
passport:"PassportInput"
@@ -437,6 +443,9 @@ export const AllTypesProps: Record<string,any> = {
GenerateRegistrationDocumentsInput:{
account_type:"AccountType"
},
GenerateReportInput:{
reportType:"ReportType"
},
GenerationContractGenerateDocumentInput:{
},
@@ -874,6 +883,9 @@ export const AllTypesProps: Record<string,any> = {
createAnnualGeneralMeet:{
data:"CreateAnnualGeneralMeetInput"
},
createApiKey:{
data:"CreateApiKeyInput"
},
createBranch:{
data:"CreateBranchInput"
},
@@ -892,6 +904,9 @@ export const AllTypesProps: Record<string,any> = {
createProjectOfFreeDecision:{
data:"CreateProjectFreeDecisionInput"
},
createShareLink:{
data:"CreateShareLinkInput"
},
createWebPushSubscription:{
data:"CreateSubscriptionInput"
},
@@ -983,6 +998,9 @@ export const AllTypesProps: Record<string,any> = {
generateRegistrationDocuments:{
data:"GenerateRegistrationDocumentsInput"
},
generateReport:{
data:"GenerateReportInput"
},
generateReturnByAssetAct:{
data:"ReturnByAssetActGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -1073,6 +1091,12 @@ export const AllTypesProps: Record<string,any> = {
},
restartAnnualGeneralMeet:{
data:"RestartAnnualGeneralMeetInput"
},
revokeApiKey:{
},
revokeShareLink:{
},
selectBranch:{
data:"SelectBranchInput"
@@ -1487,6 +1511,7 @@ export const AllTypesProps: Record<string,any> = {
user_agreement:"SignedDigitalDocumentInput",
wallet_agreement:"SignedDigitalDocumentInput"
},
ReportType: "enum" as const,
RepresentedByInput:{
},
@@ -1596,6 +1621,7 @@ export const AllTypesProps: Record<string,any> = {
SetWifInput:{
},
ShareTargetType: "enum" as const,
SignActAsChairmanInput:{
act:"SignedDigitalDocumentInput"
},
@@ -1640,6 +1666,20 @@ export const AllTypesProps: Record<string,any> = {
SubmitVoteInput:{
votes:"VoteDistributionInput"
},
Subscription:{
capitalCommitCreated:{
},
capitalCommitUpdated:{
},
capitalIssueCreated:{
},
capitalIssueUpdated:{
}
},
SupplyOnRequestInput:{
document:"AssetContributionActSignedDocumentInput"
},
@@ -1803,6 +1843,26 @@ export const ReturnTypes: Record<string,any> = {
protocol_day_month_year:"String",
protocol_number:"String"
},
ApiKeyCreated:{
allowed_operations:"String",
created_at:"DateTime",
expires_at:"DateTime",
id:"String",
key:"String",
key_prefix:"String",
name:"String"
},
ApiKeyInfo:{
allowed_operations:"String",
created_at:"DateTime",
created_by:"String",
expires_at:"DateTime",
id:"String",
is_active:"Boolean",
key_prefix:"String",
last_used_at:"DateTime",
name:"String"
},
Approval:{
_created_at:"DateTime",
_id:"String",
@@ -1832,6 +1892,12 @@ export const ReturnTypes: Record<string,any> = {
threshold:"Int",
waits:"WaitWeight"
},
AvailableReport:{
deadline:"String",
name:"String",
period:"String",
type:"ReportType"
},
BankAccount:{
account_number:"String",
bank_name:"String",
@@ -2909,6 +2975,13 @@ export const ReturnTypes: Record<string,any> = {
order:"Int",
title:"String"
},
GeneratedReport:{
errors:"String",
fileName:"String",
isValid:"Boolean",
reportType:"ReportType",
xml:"String"
},
Individual:{
birthdate:"String",
email:"String",
@@ -3148,12 +3221,14 @@ export const ReturnTypes: Record<string,any> = {
confirmReceiveOnRequest:"Transaction",
confirmSupplyOnRequest:"Transaction",
createAnnualGeneralMeet:"MeetAggregate",
createApiKey:"ApiKeyCreated",
createBranch:"Branch",
createChildOrder:"Transaction",
createDepositPayment:"GatewayPayment",
createInitialPayment:"GatewayPayment",
createParentOffer:"Transaction",
createProjectOfFreeDecision:"CreatedProjectFreeDecision",
createShareLink:"ShareLink",
createWebPushSubscription:"CreateSubscriptionResponse",
createWithdraw:"CreateWithdrawResponse",
deactivateWebPushSubscriptionById:"Boolean",
@@ -3180,6 +3255,7 @@ export const ReturnTypes: Record<string,any> = {
generatePrivacyAgreement:"GeneratedDocument",
generateProjectOfFreeDecision:"GeneratedDocument",
generateRegistrationDocuments:"GenerateRegistrationDocumentsOutput",
generateReport:"GeneratedReport",
generateReturnByAssetAct:"GeneratedDocument",
generateReturnByAssetDecision:"GeneratedDocument",
generateReturnByAssetStatement:"GeneratedDocument",
@@ -3207,6 +3283,8 @@ export const ReturnTypes: Record<string,any> = {
registerParticipant:"Account",
resetKey:"Boolean",
restartAnnualGeneralMeet:"MeetAggregate",
revokeApiKey:"Boolean",
revokeShareLink:"Boolean",
selectBranch:"Boolean",
sendAgreement:"Transaction",
setPaymentStatus:"GatewayPayment",
@@ -3648,6 +3726,8 @@ export const ReturnTypes: Record<string,any> = {
getAccounts:"AccountsPaginationResult",
getActions:"PaginatedActionsPaginationResult",
getAgenda:"AgendaWithDocuments",
getApiKeys:"ApiKeyInfo",
getAvailableReports:"AvailableReport",
getBranches:"Branch",
getCapitalIssueLogs:"PaginatedCapitalLogsPaginationResult",
getCapitalOnboardingState:"CapitalOnboardingState",
@@ -3665,6 +3745,7 @@ export const ReturnTypes: Record<string,any> = {
getLedgerHistory:"LedgerHistoryResponse",
getMeet:"MeetAggregate",
getMeets:"MeetAggregate",
getMyShareLinks:"ShareLink",
getPaymentMethods:"PaymentMethodPaginationResult",
getPayments:"PaginatedGatewayPaymentsPaginationResult",
getProgramWallet:"ProgramWallet",
@@ -3672,6 +3753,7 @@ export const ReturnTypes: Record<string,any> = {
getProviderSubscriptionById:"ProviderSubscription",
getProviderSubscriptions:"ProviderSubscription",
getRegistrationConfig:"RegistrationConfig",
getSharedWithMe:"ShareLink",
getSystemInfo:"SystemInfo",
getUserWebPushSubscriptions:"WebPushSubscriptionDto",
getWebPushSubscriptionStats:"SubscriptionStatsDto",
@@ -3764,6 +3846,19 @@ export const ReturnTypes: Record<string,any> = {
provider_name:"String",
updated_at:"DateTime"
},
ShareLink:{
allowed_actions:"String",
created_at:"DateTime",
expires_at:"DateTime",
id:"String",
is_active:"Boolean",
link_name:"String",
page_name:"String",
page_path:"String",
target_type:"ShareTargetType",
target_username:"String",
token:"String"
},
SignatureInfo:{
id:"Float",
is_valid:"Boolean",
@@ -3799,6 +3894,12 @@ export const ReturnTypes: Record<string,any> = {
action:"ExtendedBlockchainAction",
documentAggregate:"DocumentAggregate"
},
Subscription:{
capitalCommitCreated:"CapitalCommit",
capitalCommitUpdated:"CapitalCommit",
capitalIssueCreated:"CapitalIssue",
capitalIssueUpdated:"CapitalIssue"
},
SubscriptionStatsDto:{
active:"Int",
inactive:"Int",
@@ -3921,5 +4022,6 @@ export const ReturnTypes: Record<string,any> = {
export const Ops = {
query: "Query" as const,
mutation: "Mutation" as const
mutation: "Mutation" as const,
subscription: "Subscription" as const
}
+406 -1
View File
@@ -1463,6 +1463,29 @@ export type ValueTypes = {
/** Голос (за/против/воздержался) */
vote: string | Variable<any, string>
};
["ApiKeyCreated"]: AliasType<{
allowed_operations?:boolean | `@${string}`,
created_at?:boolean | `@${string}`,
expires_at?:boolean | `@${string}`,
id?:boolean | `@${string}`,
/** Полный ключ — показывается ТОЛЬКО при создании */
key?:boolean | `@${string}`,
key_prefix?:boolean | `@${string}`,
name?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["ApiKeyInfo"]: AliasType<{
allowed_operations?:boolean | `@${string}`,
created_at?:boolean | `@${string}`,
created_by?:boolean | `@${string}`,
expires_at?:boolean | `@${string}`,
id?:boolean | `@${string}`,
is_active?:boolean | `@${string}`,
key_prefix?:boolean | `@${string}`,
last_used_at?:boolean | `@${string}`,
name?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
/** Одобрение документа председателем совета */
["Approval"]: AliasType<{
/** Дата создания записи */
@@ -1703,6 +1726,13 @@ export type ValueTypes = {
/** Вес ожидания */
waits?:ValueTypes["WaitWeight"],
__typename?: boolean | `@${string}`
}>;
["AvailableReport"]: AliasType<{
deadline?:boolean | `@${string}`,
name?:boolean | `@${string}`,
period?:boolean | `@${string}`,
type?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["BankAccount"]: AliasType<{
/** Номер банковского счета */
@@ -3794,6 +3824,11 @@ export type ValueTypes = {
proposal: ValueTypes["AnnualGeneralMeetingAgendaSignedDocumentInput"] | Variable<any, string>,
/** Имя аккаунта секретаря */
secretary: string | Variable<any, string>
};
["CreateApiKeyInput"]: {
allowedOperations?: Array<string> | undefined | null | Variable<any, string>,
expiresInDays?: number | undefined | null | Variable<any, string>,
name: string | Variable<any, string>
};
["CreateBranchInput"]: {
/** Документ, на основании которого действует Уполномоченный (решение совета №СС-.. от ..) */
@@ -4117,6 +4152,15 @@ export type ValueTypes = {
property_hash: string | Variable<any, string>,
/** Имя пользователя */
username: string | Variable<any, string>
};
["CreateShareLinkInput"]: {
allowedActions: Array<string> | Variable<any, string>,
expiresInDays?: number | undefined | null | Variable<any, string>,
linkName?: string | undefined | null | Variable<any, string>,
pageName: string | Variable<any, string>,
pagePath: string | Variable<any, string>,
targetType: ValueTypes["ShareTargetType"] | Variable<any, string>,
targetUsername?: string | undefined | null | Variable<any, string>
};
["CreateSovietIndividualDataInput"]: {
/** Дата рождения */
@@ -4854,6 +4898,11 @@ export type ValueTypes = {
username?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["GenerateReportInput"]: {
period?: number | undefined | null | Variable<any, string>,
reportType: ValueTypes["ReportType"] | Variable<any, string>,
year: number | Variable<any, string>
};
["GeneratedDocument"]: AliasType<{
/** Бинарное содержимое документа (base64) */
binary?:boolean | `@${string}`,
@@ -4887,6 +4936,14 @@ export type ValueTypes = {
/** Название документа */
title?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["GeneratedReport"]: AliasType<{
errors?:boolean | `@${string}`,
fileName?:boolean | `@${string}`,
isValid?:boolean | `@${string}`,
reportType?:boolean | `@${string}`,
xml?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["GenerationContractGenerateDocumentInput"]: {
/** Номер блока, на котором был создан документ */
@@ -5691,12 +5748,14 @@ confirmAgreement?: [{ data: ValueTypes["ConfirmAgreementInput"] | Variable<any,
confirmReceiveOnRequest?: [{ data: ValueTypes["ConfirmReceiveOnRequestInput"] | Variable<any, string>},ValueTypes["Transaction"]],
confirmSupplyOnRequest?: [{ data: ValueTypes["ConfirmSupplyOnRequestInput"] | Variable<any, string>},ValueTypes["Transaction"]],
createAnnualGeneralMeet?: [{ data: ValueTypes["CreateAnnualGeneralMeetInput"] | Variable<any, string>},ValueTypes["MeetAggregate"]],
createApiKey?: [{ data: ValueTypes["CreateApiKeyInput"] | Variable<any, string>},ValueTypes["ApiKeyCreated"]],
createBranch?: [{ data: ValueTypes["CreateBranchInput"] | Variable<any, string>},ValueTypes["Branch"]],
createChildOrder?: [{ data: ValueTypes["CreateChildOrderInput"] | Variable<any, string>},ValueTypes["Transaction"]],
createDepositPayment?: [{ data: ValueTypes["CreateDepositPaymentInput"] | Variable<any, string>},ValueTypes["GatewayPayment"]],
createInitialPayment?: [{ data: ValueTypes["CreateInitialPaymentInput"] | Variable<any, string>},ValueTypes["GatewayPayment"]],
createParentOffer?: [{ data: ValueTypes["CreateParentOfferInput"] | Variable<any, string>},ValueTypes["Transaction"]],
createProjectOfFreeDecision?: [{ data: ValueTypes["CreateProjectFreeDecisionInput"] | Variable<any, string>},ValueTypes["CreatedProjectFreeDecision"]],
createShareLink?: [{ data: ValueTypes["CreateShareLinkInput"] | Variable<any, string>},ValueTypes["ShareLink"]],
createWebPushSubscription?: [{ data: ValueTypes["CreateSubscriptionInput"] | Variable<any, string>},ValueTypes["CreateSubscriptionResponse"]],
createWithdraw?: [{ data: ValueTypes["CreateWithdrawInput"] | Variable<any, string>},ValueTypes["CreateWithdrawResponse"]],
deactivateWebPushSubscriptionById?: [{ data: ValueTypes["DeactivateSubscriptionInput"] | Variable<any, string>},boolean | `@${string}`],
@@ -5723,6 +5782,7 @@ generateParticipantApplicationDecision?: [{ data: ValueTypes["ParticipantApplica
generatePrivacyAgreement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateProjectOfFreeDecision?: [{ data: ValueTypes["ProjectFreeDecisionGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateRegistrationDocuments?: [{ data: ValueTypes["GenerateRegistrationDocumentsInput"] | Variable<any, string>},ValueTypes["GenerateRegistrationDocumentsOutput"]],
generateReport?: [{ data: ValueTypes["GenerateReportInput"] | Variable<any, string>},ValueTypes["GeneratedReport"]],
generateReturnByAssetAct?: [{ data: ValueTypes["ReturnByAssetActGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateReturnByAssetDecision?: [{ data: ValueTypes["ReturnByAssetDecisionGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
generateReturnByAssetStatement?: [{ data: ValueTypes["ReturnByAssetStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
@@ -5750,6 +5810,8 @@ registerAccount?: [{ data: ValueTypes["RegisterAccountInput"] | Variable<any, st
registerParticipant?: [{ data: ValueTypes["RegisterParticipantInput"] | Variable<any, string>},ValueTypes["Account"]],
resetKey?: [{ data: ValueTypes["ResetKeyInput"] | Variable<any, string>},boolean | `@${string}`],
restartAnnualGeneralMeet?: [{ data: ValueTypes["RestartAnnualGeneralMeetInput"] | Variable<any, string>},ValueTypes["MeetAggregate"]],
revokeApiKey?: [{ id: string | Variable<any, string>},boolean | `@${string}`],
revokeShareLink?: [{ id: string | Variable<any, string>},boolean | `@${string}`],
selectBranch?: [{ data: ValueTypes["SelectBranchInput"] | Variable<any, string>},boolean | `@${string}`],
sendAgreement?: [{ data: ValueTypes["SendAgreementInput"] | Variable<any, string>},ValueTypes["Transaction"]],
setPaymentStatus?: [{ data: ValueTypes["SetPaymentStatusInput"] | Variable<any, string>},ValueTypes["GatewayPayment"]],
@@ -6792,6 +6854,10 @@ getAccounts?: [{ data?: ValueTypes["GetAccountsInput"] | undefined | null | Vari
getActions?: [{ filters?: ValueTypes["ActionFiltersInput"] | undefined | null | Variable<any, string>, pagination?: ValueTypes["PaginationInput"] | undefined | null | Variable<any, string>},ValueTypes["PaginatedActionsPaginationResult"]],
/** Получить список вопросов совета кооператива для голосования */
getAgenda?:ValueTypes["AgendaWithDocuments"],
/** Получить список API ключей кооператива */
getApiKeys?:ValueTypes["ApiKeyInfo"],
/** Получить список доступных типов отчётов */
getAvailableReports?:ValueTypes["AvailableReport"],
getBranches?: [{ data: ValueTypes["GetBranchesInput"] | Variable<any, string>},ValueTypes["Branch"]],
getCapitalIssueLogs?: [{ data: ValueTypes["GetCapitalIssueLogsInput"] | Variable<any, string>, options?: ValueTypes["PaginationInput"] | undefined | null | Variable<any, string>},ValueTypes["PaginatedCapitalLogsPaginationResult"]],
/** Получить состояние онбординга capital */
@@ -6813,6 +6879,8 @@ getLedger?: [{ data: ValueTypes["GetLedgerInput"] | Variable<any, string>},Value
getLedgerHistory?: [{ data: ValueTypes["GetLedgerHistoryInput"] | Variable<any, string>},ValueTypes["LedgerHistoryResponse"]],
getMeet?: [{ data: ValueTypes["GetMeetInput"] | Variable<any, string>},ValueTypes["MeetAggregate"]],
getMeets?: [{ data: ValueTypes["GetMeetsInput"] | Variable<any, string>},ValueTypes["MeetAggregate"]],
/** Получить созданные мной ссылки доступа */
getMyShareLinks?:ValueTypes["ShareLink"],
getPaymentMethods?: [{ data?: ValueTypes["GetPaymentMethodsInput"] | undefined | null | Variable<any, string>},ValueTypes["PaymentMethodPaginationResult"]],
getPayments?: [{ data?: ValueTypes["PaymentFiltersInput"] | undefined | null | Variable<any, string>, options?: ValueTypes["PaginationInput"] | undefined | null | Variable<any, string>},ValueTypes["PaginatedGatewayPaymentsPaginationResult"]],
getProgramWallet?: [{ filter: ValueTypes["ProgramWalletFilterInput"] | Variable<any, string>},ValueTypes["ProgramWallet"]],
@@ -6821,6 +6889,8 @@ getProviderSubscriptionById?: [{ id: number | Variable<any, string>},ValueTypes[
/** Получить подписки пользователя у провайдера */
getProviderSubscriptions?:ValueTypes["ProviderSubscription"],
getRegistrationConfig?: [{ account_type: ValueTypes["AccountType"] | Variable<any, string>, coopname: string | Variable<any, string>},ValueTypes["RegistrationConfig"]],
/** Получить страницы, к которым мне предоставлен доступ */
getSharedWithMe?:ValueTypes["ShareLink"],
/** Получить сводную публичную информацию о системе */
getSystemInfo?:ValueTypes["SystemInfo"],
getUserWebPushSubscriptions?: [{ data: ValueTypes["GetUserSubscriptionsInput"] | Variable<any, string>},ValueTypes["WebPushSubscriptionDto"]],
@@ -6989,6 +7059,7 @@ searchPrivateAccounts?: [{ data: ValueTypes["SearchPrivateAccountsInput"] | Vari
title?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["ReportType"]:ReportType;
["RepresentedBy"]: AliasType<{
/** На основании чего действует */
based_on?:boolean | `@${string}`,
@@ -7572,6 +7643,21 @@ searchPrivateAccounts?: [{ data: ValueTypes["SearchPrivateAccountsInput"] | Vari
updated_at?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["ShareLink"]: AliasType<{
allowed_actions?:boolean | `@${string}`,
created_at?:boolean | `@${string}`,
expires_at?:boolean | `@${string}`,
id?:boolean | `@${string}`,
is_active?:boolean | `@${string}`,
link_name?:boolean | `@${string}`,
page_name?:boolean | `@${string}`,
page_path?:boolean | `@${string}`,
target_type?:boolean | `@${string}`,
target_username?:boolean | `@${string}`,
token?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["ShareTargetType"]:ShareTargetType;
["SignActAsChairmanInput"]: {
/** Акт о вкладе результатов */
act: ValueTypes["SignedDigitalDocumentInput"] | Variable<any, string>,
@@ -7734,6 +7820,13 @@ searchPrivateAccounts?: [{ data: ValueTypes["SearchPrivateAccountsInput"] | Vari
/** Распределение голосов */
votes: Array<ValueTypes["VoteDistributionInput"]> | Variable<any, string>
};
["Subscription"]: AliasType<{
capitalCommitCreated?: [{ project_hash?: string | undefined | null | Variable<any, string>},ValueTypes["CapitalCommit"]],
capitalCommitUpdated?: [{ project_hash?: string | undefined | null | Variable<any, string>},ValueTypes["CapitalCommit"]],
capitalIssueCreated?: [{ project_hash?: string | undefined | null | Variable<any, string>},ValueTypes["CapitalIssue"]],
capitalIssueUpdated?: [{ project_hash?: string | undefined | null | Variable<any, string>},ValueTypes["CapitalIssue"]],
__typename?: boolean | `@${string}`
}>;
["SubscriptionStatsDto"]: AliasType<{
/** Количество активных подписок */
active?:boolean | `@${string}`,
@@ -8766,6 +8859,29 @@ export type ResolverInputTypes = {
/** Голос (за/против/воздержался) */
vote: string
};
["ApiKeyCreated"]: AliasType<{
allowed_operations?:boolean | `@${string}`,
created_at?:boolean | `@${string}`,
expires_at?:boolean | `@${string}`,
id?:boolean | `@${string}`,
/** Полный ключ — показывается ТОЛЬКО при создании */
key?:boolean | `@${string}`,
key_prefix?:boolean | `@${string}`,
name?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["ApiKeyInfo"]: AliasType<{
allowed_operations?:boolean | `@${string}`,
created_at?:boolean | `@${string}`,
created_by?:boolean | `@${string}`,
expires_at?:boolean | `@${string}`,
id?:boolean | `@${string}`,
is_active?:boolean | `@${string}`,
key_prefix?:boolean | `@${string}`,
last_used_at?:boolean | `@${string}`,
name?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
/** Одобрение документа председателем совета */
["Approval"]: AliasType<{
/** Дата создания записи */
@@ -9006,6 +9122,13 @@ export type ResolverInputTypes = {
/** Вес ожидания */
waits?:ResolverInputTypes["WaitWeight"],
__typename?: boolean | `@${string}`
}>;
["AvailableReport"]: AliasType<{
deadline?:boolean | `@${string}`,
name?:boolean | `@${string}`,
period?:boolean | `@${string}`,
type?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["BankAccount"]: AliasType<{
/** Номер банковского счета */
@@ -11097,6 +11220,11 @@ export type ResolverInputTypes = {
proposal: ResolverInputTypes["AnnualGeneralMeetingAgendaSignedDocumentInput"],
/** Имя аккаунта секретаря */
secretary: string
};
["CreateApiKeyInput"]: {
allowedOperations?: Array<string> | undefined | null,
expiresInDays?: number | undefined | null,
name: string
};
["CreateBranchInput"]: {
/** Документ, на основании которого действует Уполномоченный (решение совета №СС-.. от ..) */
@@ -11420,6 +11548,15 @@ export type ResolverInputTypes = {
property_hash: string,
/** Имя пользователя */
username: string
};
["CreateShareLinkInput"]: {
allowedActions: Array<string>,
expiresInDays?: number | undefined | null,
linkName?: string | undefined | null,
pageName: string,
pagePath: string,
targetType: ResolverInputTypes["ShareTargetType"],
targetUsername?: string | undefined | null
};
["CreateSovietIndividualDataInput"]: {
/** Дата рождения */
@@ -12157,6 +12294,11 @@ export type ResolverInputTypes = {
username?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["GenerateReportInput"]: {
period?: number | undefined | null,
reportType: ResolverInputTypes["ReportType"],
year: number
};
["GeneratedDocument"]: AliasType<{
/** Бинарное содержимое документа (base64) */
binary?:boolean | `@${string}`,
@@ -12190,6 +12332,14 @@ export type ResolverInputTypes = {
/** Название документа */
title?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["GeneratedReport"]: AliasType<{
errors?:boolean | `@${string}`,
fileName?:boolean | `@${string}`,
isValid?:boolean | `@${string}`,
reportType?:boolean | `@${string}`,
xml?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["GenerationContractGenerateDocumentInput"]: {
/** Номер блока, на котором был создан документ */
@@ -12994,12 +13144,14 @@ confirmAgreement?: [{ data: ResolverInputTypes["ConfirmAgreementInput"]},Resolve
confirmReceiveOnRequest?: [{ data: ResolverInputTypes["ConfirmReceiveOnRequestInput"]},ResolverInputTypes["Transaction"]],
confirmSupplyOnRequest?: [{ data: ResolverInputTypes["ConfirmSupplyOnRequestInput"]},ResolverInputTypes["Transaction"]],
createAnnualGeneralMeet?: [{ data: ResolverInputTypes["CreateAnnualGeneralMeetInput"]},ResolverInputTypes["MeetAggregate"]],
createApiKey?: [{ data: ResolverInputTypes["CreateApiKeyInput"]},ResolverInputTypes["ApiKeyCreated"]],
createBranch?: [{ data: ResolverInputTypes["CreateBranchInput"]},ResolverInputTypes["Branch"]],
createChildOrder?: [{ data: ResolverInputTypes["CreateChildOrderInput"]},ResolverInputTypes["Transaction"]],
createDepositPayment?: [{ data: ResolverInputTypes["CreateDepositPaymentInput"]},ResolverInputTypes["GatewayPayment"]],
createInitialPayment?: [{ data: ResolverInputTypes["CreateInitialPaymentInput"]},ResolverInputTypes["GatewayPayment"]],
createParentOffer?: [{ data: ResolverInputTypes["CreateParentOfferInput"]},ResolverInputTypes["Transaction"]],
createProjectOfFreeDecision?: [{ data: ResolverInputTypes["CreateProjectFreeDecisionInput"]},ResolverInputTypes["CreatedProjectFreeDecision"]],
createShareLink?: [{ data: ResolverInputTypes["CreateShareLinkInput"]},ResolverInputTypes["ShareLink"]],
createWebPushSubscription?: [{ data: ResolverInputTypes["CreateSubscriptionInput"]},ResolverInputTypes["CreateSubscriptionResponse"]],
createWithdraw?: [{ data: ResolverInputTypes["CreateWithdrawInput"]},ResolverInputTypes["CreateWithdrawResponse"]],
deactivateWebPushSubscriptionById?: [{ data: ResolverInputTypes["DeactivateSubscriptionInput"]},boolean | `@${string}`],
@@ -13026,6 +13178,7 @@ generateParticipantApplicationDecision?: [{ data: ResolverInputTypes["Participan
generatePrivacyAgreement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
generateProjectOfFreeDecision?: [{ data: ResolverInputTypes["ProjectFreeDecisionGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
generateRegistrationDocuments?: [{ data: ResolverInputTypes["GenerateRegistrationDocumentsInput"]},ResolverInputTypes["GenerateRegistrationDocumentsOutput"]],
generateReport?: [{ data: ResolverInputTypes["GenerateReportInput"]},ResolverInputTypes["GeneratedReport"]],
generateReturnByAssetAct?: [{ data: ResolverInputTypes["ReturnByAssetActGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
generateReturnByAssetDecision?: [{ data: ResolverInputTypes["ReturnByAssetDecisionGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
generateReturnByAssetStatement?: [{ data: ResolverInputTypes["ReturnByAssetStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
@@ -13053,6 +13206,8 @@ registerAccount?: [{ data: ResolverInputTypes["RegisterAccountInput"]},ResolverI
registerParticipant?: [{ data: ResolverInputTypes["RegisterParticipantInput"]},ResolverInputTypes["Account"]],
resetKey?: [{ data: ResolverInputTypes["ResetKeyInput"]},boolean | `@${string}`],
restartAnnualGeneralMeet?: [{ data: ResolverInputTypes["RestartAnnualGeneralMeetInput"]},ResolverInputTypes["MeetAggregate"]],
revokeApiKey?: [{ id: string},boolean | `@${string}`],
revokeShareLink?: [{ id: string},boolean | `@${string}`],
selectBranch?: [{ data: ResolverInputTypes["SelectBranchInput"]},boolean | `@${string}`],
sendAgreement?: [{ data: ResolverInputTypes["SendAgreementInput"]},ResolverInputTypes["Transaction"]],
setPaymentStatus?: [{ data: ResolverInputTypes["SetPaymentStatusInput"]},ResolverInputTypes["GatewayPayment"]],
@@ -14097,6 +14252,10 @@ getAccounts?: [{ data?: ResolverInputTypes["GetAccountsInput"] | undefined | nul
getActions?: [{ filters?: ResolverInputTypes["ActionFiltersInput"] | undefined | null, pagination?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["PaginatedActionsPaginationResult"]],
/** Получить список вопросов совета кооператива для голосования */
getAgenda?:ResolverInputTypes["AgendaWithDocuments"],
/** Получить список API ключей кооператива */
getApiKeys?:ResolverInputTypes["ApiKeyInfo"],
/** Получить список доступных типов отчётов */
getAvailableReports?:ResolverInputTypes["AvailableReport"],
getBranches?: [{ data: ResolverInputTypes["GetBranchesInput"]},ResolverInputTypes["Branch"]],
getCapitalIssueLogs?: [{ data: ResolverInputTypes["GetCapitalIssueLogsInput"], options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["PaginatedCapitalLogsPaginationResult"]],
/** Получить состояние онбординга capital */
@@ -14118,6 +14277,8 @@ getLedger?: [{ data: ResolverInputTypes["GetLedgerInput"]},ResolverInputTypes["L
getLedgerHistory?: [{ data: ResolverInputTypes["GetLedgerHistoryInput"]},ResolverInputTypes["LedgerHistoryResponse"]],
getMeet?: [{ data: ResolverInputTypes["GetMeetInput"]},ResolverInputTypes["MeetAggregate"]],
getMeets?: [{ data: ResolverInputTypes["GetMeetsInput"]},ResolverInputTypes["MeetAggregate"]],
/** Получить созданные мной ссылки доступа */
getMyShareLinks?:ResolverInputTypes["ShareLink"],
getPaymentMethods?: [{ data?: ResolverInputTypes["GetPaymentMethodsInput"] | undefined | null},ResolverInputTypes["PaymentMethodPaginationResult"]],
getPayments?: [{ data?: ResolverInputTypes["PaymentFiltersInput"] | undefined | null, options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["PaginatedGatewayPaymentsPaginationResult"]],
getProgramWallet?: [{ filter: ResolverInputTypes["ProgramWalletFilterInput"]},ResolverInputTypes["ProgramWallet"]],
@@ -14126,6 +14287,8 @@ getProviderSubscriptionById?: [{ id: number},ResolverInputTypes["ProviderSubscri
/** Получить подписки пользователя у провайдера */
getProviderSubscriptions?:ResolverInputTypes["ProviderSubscription"],
getRegistrationConfig?: [{ account_type: ResolverInputTypes["AccountType"], coopname: string},ResolverInputTypes["RegistrationConfig"]],
/** Получить страницы, к которым мне предоставлен доступ */
getSharedWithMe?:ResolverInputTypes["ShareLink"],
/** Получить сводную публичную информацию о системе */
getSystemInfo?:ResolverInputTypes["SystemInfo"],
getUserWebPushSubscriptions?: [{ data: ResolverInputTypes["GetUserSubscriptionsInput"]},ResolverInputTypes["WebPushSubscriptionDto"]],
@@ -14294,6 +14457,7 @@ searchPrivateAccounts?: [{ data: ResolverInputTypes["SearchPrivateAccountsInput"
title?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["ReportType"]:ReportType;
["RepresentedBy"]: AliasType<{
/** На основании чего действует */
based_on?:boolean | `@${string}`,
@@ -14877,6 +15041,21 @@ searchPrivateAccounts?: [{ data: ResolverInputTypes["SearchPrivateAccountsInput"
updated_at?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["ShareLink"]: AliasType<{
allowed_actions?:boolean | `@${string}`,
created_at?:boolean | `@${string}`,
expires_at?:boolean | `@${string}`,
id?:boolean | `@${string}`,
is_active?:boolean | `@${string}`,
link_name?:boolean | `@${string}`,
page_name?:boolean | `@${string}`,
page_path?:boolean | `@${string}`,
target_type?:boolean | `@${string}`,
target_username?:boolean | `@${string}`,
token?:boolean | `@${string}`,
__typename?: boolean | `@${string}`
}>;
["ShareTargetType"]:ShareTargetType;
["SignActAsChairmanInput"]: {
/** Акт о вкладе результатов */
act: ResolverInputTypes["SignedDigitalDocumentInput"],
@@ -15039,6 +15218,13 @@ searchPrivateAccounts?: [{ data: ResolverInputTypes["SearchPrivateAccountsInput"
/** Распределение голосов */
votes: Array<ResolverInputTypes["VoteDistributionInput"]>
};
["Subscription"]: AliasType<{
capitalCommitCreated?: [{ project_hash?: string | undefined | null},ResolverInputTypes["CapitalCommit"]],
capitalCommitUpdated?: [{ project_hash?: string | undefined | null},ResolverInputTypes["CapitalCommit"]],
capitalIssueCreated?: [{ project_hash?: string | undefined | null},ResolverInputTypes["CapitalIssue"]],
capitalIssueUpdated?: [{ project_hash?: string | undefined | null},ResolverInputTypes["CapitalIssue"]],
__typename?: boolean | `@${string}`
}>;
["SubscriptionStatsDto"]: AliasType<{
/** Количество активных подписок */
active?:boolean | `@${string}`,
@@ -15523,6 +15709,7 @@ searchPrivateAccounts?: [{ data: ResolverInputTypes["SearchPrivateAccountsInput"
["schema"]: AliasType<{
query?:ResolverInputTypes["Query"],
mutation?:ResolverInputTypes["Mutation"],
subscription?:ResolverInputTypes["Subscription"],
__typename?: boolean | `@${string}`
}>
}
@@ -16063,6 +16250,27 @@ export type ModelTypes = {
number: string,
/** Голос (за/против/воздержался) */
vote: string
};
["ApiKeyCreated"]: {
allowed_operations: Array<string>,
created_at: ModelTypes["DateTime"],
expires_at?: ModelTypes["DateTime"] | undefined | null,
id: string,
/** Полный ключ — показывается ТОЛЬКО при создании */
key: string,
key_prefix: string,
name: string
};
["ApiKeyInfo"]: {
allowed_operations: Array<string>,
created_at: ModelTypes["DateTime"],
created_by: string,
expires_at?: ModelTypes["DateTime"] | undefined | null,
id: string,
is_active: boolean,
key_prefix: string,
last_used_at?: ModelTypes["DateTime"] | undefined | null,
name: string
};
/** Одобрение документа председателем совета */
["Approval"]: {
@@ -16300,6 +16508,12 @@ export type ModelTypes = {
threshold: number,
/** Вес ожидания */
waits: Array<ModelTypes["WaitWeight"]>
};
["AvailableReport"]: {
deadline: string,
name: string,
period: string,
type: ModelTypes["ReportType"]
};
["BankAccount"]: {
/** Номер банковского счета */
@@ -18338,6 +18552,11 @@ export type ModelTypes = {
proposal: ModelTypes["AnnualGeneralMeetingAgendaSignedDocumentInput"],
/** Имя аккаунта секретаря */
secretary: string
};
["CreateApiKeyInput"]: {
allowedOperations?: Array<string> | undefined | null,
expiresInDays?: number | undefined | null,
name: string
};
["CreateBranchInput"]: {
/** Документ, на основании которого действует Уполномоченный (решение совета №СС-.. от ..) */
@@ -18661,6 +18880,15 @@ export type ModelTypes = {
property_hash: string,
/** Имя пользователя */
username: string
};
["CreateShareLinkInput"]: {
allowedActions: Array<string>,
expiresInDays?: number | undefined | null,
linkName?: string | undefined | null,
pageName: string,
pagePath: string,
targetType: ModelTypes["ShareTargetType"],
targetUsername?: string | undefined | null
};
["CreateSovietIndividualDataInput"]: {
/** Дата рождения */
@@ -19369,6 +19597,11 @@ export type ModelTypes = {
documents: Array<ModelTypes["GeneratedRegistrationDocument"]>,
/** Имя пользователя */
username: string
};
["GenerateReportInput"]: {
period?: number | undefined | null,
reportType: ModelTypes["ReportType"],
year: number
};
["GeneratedDocument"]: {
/** Бинарное содержимое документа (base64) */
@@ -19401,6 +19634,13 @@ export type ModelTypes = {
order: number,
/** Название документа */
title: string
};
["GeneratedReport"]: {
errors: Array<string>,
fileName: string,
isValid: boolean,
reportType: ModelTypes["ReportType"],
xml: string
};
["GenerationContractGenerateDocumentInput"]: {
/** Номер блока, на котором был создан документ */
@@ -20272,6 +20512,8 @@ export type ModelTypes = {
confirmSupplyOnRequest: ModelTypes["Transaction"],
/** Сгенерировать документ предложения повестки очередного общего собрания пайщиков */
createAnnualGeneralMeet: ModelTypes["MeetAggregate"],
/** Создать API ключ кооператива. Полный ключ показывается только при создании. */
createApiKey: ModelTypes["ApiKeyCreated"],
/** Создать кооперативный участок */
createBranch: ModelTypes["Branch"],
/** Создать заявку на поставку имущества по предложению Поставщика */
@@ -20284,6 +20526,8 @@ export type ModelTypes = {
createParentOffer: ModelTypes["Transaction"],
/** Создать повестку дня и проект решения, и сохранить в хранилище для дальнейшей генерации документа и его публикации */
createProjectOfFreeDecision: ModelTypes["CreatedProjectFreeDecision"],
/** Создать ссылку доступа к странице */
createShareLink: ModelTypes["ShareLink"],
/** Создать веб-пуш подписку для пользователя */
createWebPushSubscription: ModelTypes["CreateSubscriptionResponse"],
/** Создать заявку на вывод средств */
@@ -20336,6 +20580,8 @@ export type ModelTypes = {
generateProjectOfFreeDecision: ModelTypes["GeneratedDocument"],
/** Генерирует пакет документов для регистрации пайщика. Возвращает список документов с метаданными для отображения на фронтенде. */
generateRegistrationDocuments: ModelTypes["GenerateRegistrationDocumentsOutput"],
/** Генерация отчёта для ФНС/ФСС */
generateReport: ModelTypes["GeneratedReport"],
/** Сгенерировать документ акта возврата имущества. */
generateReturnByAssetAct: ModelTypes["GeneratedDocument"],
/** Сгенерировать документ решения о возврате имущества. */
@@ -20390,6 +20636,10 @@ export type ModelTypes = {
resetKey: boolean,
/** Перезапуск общего собрания пайщиков */
restartAnnualGeneralMeet: ModelTypes["MeetAggregate"],
/** Отозвать API ключ */
revokeApiKey: boolean,
/** Отозвать ссылку доступа */
revokeShareLink: boolean,
/** Выбрать кооперативный участок */
selectBranch: boolean,
/** Отправить соглашение */
@@ -21431,6 +21681,10 @@ export type ModelTypes = {
getActions: ModelTypes["PaginatedActionsPaginationResult"],
/** Получить список вопросов совета кооператива для голосования */
getAgenda: Array<ModelTypes["AgendaWithDocuments"]>,
/** Получить список API ключей кооператива */
getApiKeys: Array<ModelTypes["ApiKeyInfo"]>,
/** Получить список доступных типов отчётов */
getAvailableReports: Array<ModelTypes["AvailableReport"]>,
/** Получить список кооперативных участков */
getBranches: Array<ModelTypes["Branch"]>,
/** Получить логи событий по задаче */
@@ -21464,6 +21718,8 @@ export type ModelTypes = {
getMeet: ModelTypes["MeetAggregate"],
/** Получить список всех собраний кооператива */
getMeets: Array<ModelTypes["MeetAggregate"]>,
/** Получить созданные мной ссылки доступа */
getMyShareLinks: Array<ModelTypes["ShareLink"]>,
/** Получить список методов оплаты */
getPaymentMethods: ModelTypes["PaymentMethodPaginationResult"],
/** Получить список платежей с возможностью фильтрации по типу, статусу и направлению. */
@@ -21478,6 +21734,8 @@ export type ModelTypes = {
getProviderSubscriptions: Array<ModelTypes["ProviderSubscription"]>,
/** Получить конфигурацию программ регистрации для кооператива */
getRegistrationConfig: ModelTypes["RegistrationConfig"],
/** Получить страницы, к которым мне предоставлен доступ */
getSharedWithMe: Array<ModelTypes["ShareLink"]>,
/** Получить сводную публичную информацию о системе */
getSystemInfo: ModelTypes["SystemInfo"],
/** Получить веб-пуш подписки пользователя */
@@ -21644,6 +21902,7 @@ export type ModelTypes = {
/** Название программы для отображения */
title: string
};
["ReportType"]:ReportType;
["RepresentedBy"]: {
/** На основании чего действует */
based_on: string,
@@ -22218,6 +22477,20 @@ export type ModelTypes = {
/** Дата последнего обновления */
updated_at: ModelTypes["DateTime"]
};
["ShareLink"]: {
allowed_actions: Array<string>,
created_at: ModelTypes["DateTime"],
expires_at?: ModelTypes["DateTime"] | undefined | null,
id: string,
is_active: boolean,
link_name?: string | undefined | null,
page_name: string,
page_path: string,
target_type: ModelTypes["ShareTargetType"],
target_username?: string | undefined | null,
token: string
};
["ShareTargetType"]:ShareTargetType;
["SignActAsChairmanInput"]: {
/** Акт о вкладе результатов */
act: ModelTypes["SignedDigitalDocumentInput"],
@@ -22373,6 +22646,16 @@ export type ModelTypes = {
project_hash: string,
/** Распределение голосов */
votes: Array<ModelTypes["VoteDistributionInput"]>
};
["Subscription"]: {
/** Подписка на создание коммитов */
capitalCommitCreated: ModelTypes["CapitalCommit"],
/** Подписка на обновления коммитов */
capitalCommitUpdated: ModelTypes["CapitalCommit"],
/** Подписка на создание задач */
capitalIssueCreated: ModelTypes["CapitalIssue"],
/** Подписка на обновления задач */
capitalIssueUpdated: ModelTypes["CapitalIssue"]
};
["SubscriptionStatsDto"]: {
/** Количество активных подписок */
@@ -22836,7 +23119,8 @@ export type ModelTypes = {
};
["schema"]: {
query?: ModelTypes["Query"] | undefined | null,
mutation?: ModelTypes["Mutation"] | undefined | null
mutation?: ModelTypes["Mutation"] | undefined | null,
subscription?: ModelTypes["Subscription"] | undefined | null
}
}
@@ -23392,6 +23676,29 @@ export type GraphQLTypes = {
number: string,
/** Голос (за/против/воздержался) */
vote: string
};
["ApiKeyCreated"]: {
__typename: "ApiKeyCreated",
allowed_operations: Array<string>,
created_at: GraphQLTypes["DateTime"],
expires_at?: GraphQLTypes["DateTime"] | undefined | null,
id: string,
/** Полный ключ — показывается ТОЛЬКО при создании */
key: string,
key_prefix: string,
name: string
};
["ApiKeyInfo"]: {
__typename: "ApiKeyInfo",
allowed_operations: Array<string>,
created_at: GraphQLTypes["DateTime"],
created_by: string,
expires_at?: GraphQLTypes["DateTime"] | undefined | null,
id: string,
is_active: boolean,
key_prefix: string,
last_used_at?: GraphQLTypes["DateTime"] | undefined | null,
name: string
};
/** Одобрение документа председателем совета */
["Approval"]: {
@@ -23633,6 +23940,13 @@ export type GraphQLTypes = {
threshold: number,
/** Вес ожидания */
waits: Array<GraphQLTypes["WaitWeight"]>
};
["AvailableReport"]: {
__typename: "AvailableReport",
deadline: string,
name: string,
period: string,
type: GraphQLTypes["ReportType"]
};
["BankAccount"]: {
__typename: "BankAccount",
@@ -25724,6 +26038,11 @@ export type GraphQLTypes = {
proposal: GraphQLTypes["AnnualGeneralMeetingAgendaSignedDocumentInput"],
/** Имя аккаунта секретаря */
secretary: string
};
["CreateApiKeyInput"]: {
allowedOperations?: Array<string> | undefined | null,
expiresInDays?: number | undefined | null,
name: string
};
["CreateBranchInput"]: {
/** Документ, на основании которого действует Уполномоченный (решение совета №СС-.. от ..) */
@@ -26047,6 +26366,15 @@ export type GraphQLTypes = {
property_hash: string,
/** Имя пользователя */
username: string
};
["CreateShareLinkInput"]: {
allowedActions: Array<string>,
expiresInDays?: number | undefined | null,
linkName?: string | undefined | null,
pageName: string,
pagePath: string,
targetType: GraphQLTypes["ShareTargetType"],
targetUsername?: string | undefined | null
};
["CreateSovietIndividualDataInput"]: {
/** Дата рождения */
@@ -26783,6 +27111,11 @@ export type GraphQLTypes = {
documents: Array<GraphQLTypes["GeneratedRegistrationDocument"]>,
/** Имя пользователя */
username: string
};
["GenerateReportInput"]: {
period?: number | undefined | null,
reportType: GraphQLTypes["ReportType"],
year: number
};
["GeneratedDocument"]: {
__typename: "GeneratedDocument",
@@ -26817,6 +27150,14 @@ export type GraphQLTypes = {
order: number,
/** Название документа */
title: string
};
["GeneratedReport"]: {
__typename: "GeneratedReport",
errors: Array<string>,
fileName: string,
isValid: boolean,
reportType: GraphQLTypes["ReportType"],
xml: string
};
["GenerationContractGenerateDocumentInput"]: {
/** Номер блока, на котором был создан документ */
@@ -27710,6 +28051,8 @@ export type GraphQLTypes = {
confirmSupplyOnRequest: GraphQLTypes["Transaction"],
/** Сгенерировать документ предложения повестки очередного общего собрания пайщиков */
createAnnualGeneralMeet: GraphQLTypes["MeetAggregate"],
/** Создать API ключ кооператива. Полный ключ показывается только при создании. */
createApiKey: GraphQLTypes["ApiKeyCreated"],
/** Создать кооперативный участок */
createBranch: GraphQLTypes["Branch"],
/** Создать заявку на поставку имущества по предложению Поставщика */
@@ -27722,6 +28065,8 @@ export type GraphQLTypes = {
createParentOffer: GraphQLTypes["Transaction"],
/** Создать повестку дня и проект решения, и сохранить в хранилище для дальнейшей генерации документа и его публикации */
createProjectOfFreeDecision: GraphQLTypes["CreatedProjectFreeDecision"],
/** Создать ссылку доступа к странице */
createShareLink: GraphQLTypes["ShareLink"],
/** Создать веб-пуш подписку для пользователя */
createWebPushSubscription: GraphQLTypes["CreateSubscriptionResponse"],
/** Создать заявку на вывод средств */
@@ -27774,6 +28119,8 @@ export type GraphQLTypes = {
generateProjectOfFreeDecision: GraphQLTypes["GeneratedDocument"],
/** Генерирует пакет документов для регистрации пайщика. Возвращает список документов с метаданными для отображения на фронтенде. */
generateRegistrationDocuments: GraphQLTypes["GenerateRegistrationDocumentsOutput"],
/** Генерация отчёта для ФНС/ФСС */
generateReport: GraphQLTypes["GeneratedReport"],
/** Сгенерировать документ акта возврата имущества. */
generateReturnByAssetAct: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать документ решения о возврате имущества. */
@@ -27828,6 +28175,10 @@ export type GraphQLTypes = {
resetKey: boolean,
/** Перезапуск общего собрания пайщиков */
restartAnnualGeneralMeet: GraphQLTypes["MeetAggregate"],
/** Отозвать API ключ */
revokeApiKey: boolean,
/** Отозвать ссылку доступа */
revokeShareLink: boolean,
/** Выбрать кооперативный участок */
selectBranch: boolean,
/** Отправить соглашение */
@@ -28937,6 +29288,10 @@ export type GraphQLTypes = {
getActions: GraphQLTypes["PaginatedActionsPaginationResult"],
/** Получить список вопросов совета кооператива для голосования */
getAgenda: Array<GraphQLTypes["AgendaWithDocuments"]>,
/** Получить список API ключей кооператива */
getApiKeys: Array<GraphQLTypes["ApiKeyInfo"]>,
/** Получить список доступных типов отчётов */
getAvailableReports: Array<GraphQLTypes["AvailableReport"]>,
/** Получить список кооперативных участков */
getBranches: Array<GraphQLTypes["Branch"]>,
/** Получить логи событий по задаче */
@@ -28970,6 +29325,8 @@ export type GraphQLTypes = {
getMeet: GraphQLTypes["MeetAggregate"],
/** Получить список всех собраний кооператива */
getMeets: Array<GraphQLTypes["MeetAggregate"]>,
/** Получить созданные мной ссылки доступа */
getMyShareLinks: Array<GraphQLTypes["ShareLink"]>,
/** Получить список методов оплаты */
getPaymentMethods: GraphQLTypes["PaymentMethodPaginationResult"],
/** Получить список платежей с возможностью фильтрации по типу, статусу и направлению. */
@@ -28984,6 +29341,8 @@ export type GraphQLTypes = {
getProviderSubscriptions: Array<GraphQLTypes["ProviderSubscription"]>,
/** Получить конфигурацию программ регистрации для кооператива */
getRegistrationConfig: GraphQLTypes["RegistrationConfig"],
/** Получить страницы, к которым мне предоставлен доступ */
getSharedWithMe: Array<GraphQLTypes["ShareLink"]>,
/** Получить сводную публичную информацию о системе */
getSystemInfo: GraphQLTypes["SystemInfo"],
/** Получить веб-пуш подписки пользователя */
@@ -29155,6 +29514,7 @@ export type GraphQLTypes = {
/** Название программы для отображения */
title: string
};
["ReportType"]: ReportType;
["RepresentedBy"]: {
__typename: "RepresentedBy",
/** На основании чего действует */
@@ -29738,6 +30098,21 @@ export type GraphQLTypes = {
/** Дата последнего обновления */
updated_at: GraphQLTypes["DateTime"]
};
["ShareLink"]: {
__typename: "ShareLink",
allowed_actions: Array<string>,
created_at: GraphQLTypes["DateTime"],
expires_at?: GraphQLTypes["DateTime"] | undefined | null,
id: string,
is_active: boolean,
link_name?: string | undefined | null,
page_name: string,
page_path: string,
target_type: GraphQLTypes["ShareTargetType"],
target_username?: string | undefined | null,
token: string
};
["ShareTargetType"]: ShareTargetType;
["SignActAsChairmanInput"]: {
/** Акт о вкладе результатов */
act: GraphQLTypes["SignedDigitalDocumentInput"],
@@ -29899,6 +30274,17 @@ export type GraphQLTypes = {
project_hash: string,
/** Распределение голосов */
votes: Array<GraphQLTypes["VoteDistributionInput"]>
};
["Subscription"]: {
__typename: "Subscription",
/** Подписка на создание коммитов */
capitalCommitCreated: GraphQLTypes["CapitalCommit"],
/** Подписка на обновления коммитов */
capitalCommitUpdated: GraphQLTypes["CapitalCommit"],
/** Подписка на создание задач */
capitalIssueCreated: GraphQLTypes["CapitalIssue"],
/** Подписка на обновления задач */
capitalIssueUpdated: GraphQLTypes["CapitalIssue"]
};
["SubscriptionStatsDto"]: {
__typename: "SubscriptionStatsDto",
@@ -30653,6 +31039,16 @@ export enum ProjectStatus {
UNDEFINED = "UNDEFINED",
VOTING = "VOTING"
}
export enum ReportType {
BUHOTCH = "BUHOTCH",
DUSN = "DUSN",
FSS4 = "FSS4",
NDFL6 = "NDFL6",
PSV = "PSV",
RSV = "RSV",
UUSN = "UUSN",
UV_VZNOSY = "UV_VZNOSY"
}
/** Статус результата в системе CAPITAL */
export enum ResultStatus {
ACT1 = "ACT1",
@@ -30677,6 +31073,10 @@ export enum SegmentStatus {
STATEMENT = "STATEMENT",
UNDEFINED = "UNDEFINED"
}
export enum ShareTargetType {
GUEST = "GUEST",
MEMBER = "MEMBER"
}
/** Статус истории в системе CAPITAL */
export enum StoryStatus {
CANCELLED = "CANCELLED",
@@ -30791,6 +31191,7 @@ type ZEUS_VARIABLES = {
["ConvertToAxonStatementSignedMetaDocumentInput"]: ValueTypes["ConvertToAxonStatementSignedMetaDocumentInput"];
["Country"]: ValueTypes["Country"];
["CreateAnnualGeneralMeetInput"]: ValueTypes["CreateAnnualGeneralMeetInput"];
["CreateApiKeyInput"]: ValueTypes["CreateApiKeyInput"];
["CreateBranchInput"]: ValueTypes["CreateBranchInput"];
["CreateChildOrderInput"]: ValueTypes["CreateChildOrderInput"];
["CreateCommitInput"]: ValueTypes["CreateCommitInput"];
@@ -30812,6 +31213,7 @@ type ZEUS_VARIABLES = {
["CreateProjectInput"]: ValueTypes["CreateProjectInput"];
["CreateProjectInvestInput"]: ValueTypes["CreateProjectInvestInput"];
["CreateProjectPropertyInput"]: ValueTypes["CreateProjectPropertyInput"];
["CreateShareLinkInput"]: ValueTypes["CreateShareLinkInput"];
["CreateSovietIndividualDataInput"]: ValueTypes["CreateSovietIndividualDataInput"];
["CreateStoryInput"]: ValueTypes["CreateStoryInput"];
["CreateSubscriptionInput"]: ValueTypes["CreateSubscriptionInput"];
@@ -30851,6 +31253,7 @@ type ZEUS_VARIABLES = {
["GenerateDocumentInput"]: ValueTypes["GenerateDocumentInput"];
["GenerateDocumentOptionsInput"]: ValueTypes["GenerateDocumentOptionsInput"];
["GenerateRegistrationDocumentsInput"]: ValueTypes["GenerateRegistrationDocumentsInput"];
["GenerateReportInput"]: ValueTypes["GenerateReportInput"];
["GenerationContractGenerateDocumentInput"]: ValueTypes["GenerationContractGenerateDocumentInput"];
["GenerationContractSignedDocumentInput"]: ValueTypes["GenerationContractSignedDocumentInput"];
["GenerationContractSignedMetaDocumentInput"]: ValueTypes["GenerationContractSignedMetaDocumentInput"];
@@ -30946,6 +31349,7 @@ type ZEUS_VARIABLES = {
["RegisterAccountInput"]: ValueTypes["RegisterAccountInput"];
["RegisterContributorInput"]: ValueTypes["RegisterContributorInput"];
["RegisterParticipantInput"]: ValueTypes["RegisterParticipantInput"];
["ReportType"]: ValueTypes["ReportType"];
["RepresentedByInput"]: ValueTypes["RepresentedByInput"];
["ResetKeyInput"]: ValueTypes["ResetKeyInput"];
["RestartAnnualGeneralMeetInput"]: ValueTypes["RestartAnnualGeneralMeetInput"];
@@ -30980,6 +31384,7 @@ type ZEUS_VARIABLES = {
["SetPlanInput"]: ValueTypes["SetPlanInput"];
["SetVarsInput"]: ValueTypes["SetVarsInput"];
["SetWifInput"]: ValueTypes["SetWifInput"];
["ShareTargetType"]: ValueTypes["ShareTargetType"];
["SignActAsChairmanInput"]: ValueTypes["SignActAsChairmanInput"];
["SignActAsContributorInput"]: ValueTypes["SignActAsContributorInput"];
["SignByPresiderOnAnnualGeneralMeetInput"]: ValueTypes["SignByPresiderOnAnnualGeneralMeetInput"];
+50
View File
@@ -167,6 +167,9 @@ importers:
'@a2seven/yoo-checkout':
specifier: ^1.1.4
version: 1.1.4
'@casl/ability':
specifier: ^6.8.0
version: 6.8.0
'@coopenomics/factory':
specifier: workspace:*
version: link:../factory
@@ -338,6 +341,9 @@ importers:
graphql-scalars:
specifier: ^1.24.0
version: 1.24.0(graphql@16.9.0)
graphql-subscriptions:
specifier: ^3.0.0
version: 3.0.0(graphql@16.9.0)
graphql-tools:
specifier: ^9.0.2
version: 9.0.2(@types/react@18.3.20)(graphql-ws@5.16.0(graphql@16.9.0))(graphql@16.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.9.0))
@@ -2329,6 +2335,9 @@ packages:
'@bufbuild/protobuf@1.10.1':
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
'@casl/ability@6.8.0':
resolution: {integrity: sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA==}
'@cfaester/enzyme-adapter-react-18@0.8.0':
resolution: {integrity: sha512-3Z3ThTUouHwz8oIyhTYQljEMNRFtlVyc3VOOHCbxs47U6cnXs8K9ygi/c1tv49s7MBlTXeIcuN+Ttd9aPtILFQ==}
peerDependencies:
@@ -6465,6 +6474,18 @@ packages:
resolution: {integrity: sha512-PChz8UaKQAVNHghsHcPyx1OMHoFRUEA7rJSK/mDhdq85bk+PLsUHUBqTQTFt18VJZbmxBovM65fezlheQRsSDA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ucast/core@1.10.2':
resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==}
'@ucast/js@3.1.0':
resolution: {integrity: sha512-eJ7yQeYtMK85UZjxoxBEbTWx6UMxEXKbjVyp+NlzrT5oMKV5Gpo/9bjTl3r7msaXTVC8iD9NJacqJ8yp7joX+Q==}
'@ucast/mongo2js@1.4.1':
resolution: {integrity: sha512-9aeg5cmqwRQnKCXHN6I17wk83Rcm487bHelaG8T4vfpWneAI469wSI3Srnbu+PuZ5znWRbnwtVq9RgPL+bN6CA==}
'@ucast/mongo@2.4.3':
resolution: {integrity: sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==}
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
@@ -10441,6 +10462,11 @@ packages:
peerDependencies:
graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
graphql-subscriptions@3.0.0:
resolution: {integrity: sha512-kZCdevgmzDjGAOqH7GlDmQXYAkuHoKpMlJrqF40HMPhUhM5ZWSFSxCwD/nSi6AkaijmMfsFhoJRGJ27UseCvRA==}
peerDependencies:
graphql: ^15.7.2 || ^16.0.0
graphql-tag@2.12.6:
resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==}
engines: {node: '>=10'}
@@ -18697,6 +18723,10 @@ snapshots:
'@bufbuild/protobuf@1.10.1': {}
'@casl/ability@6.8.0':
dependencies:
'@ucast/mongo2js': 1.4.1
'@cfaester/enzyme-adapter-react-18@0.8.0(enzyme@3.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
enzyme: 3.11.0
@@ -23632,6 +23662,22 @@ snapshots:
'@typescript-eslint/types': 8.12.2
eslint-visitor-keys: 3.4.3
'@ucast/core@1.10.2': {}
'@ucast/js@3.1.0':
dependencies:
'@ucast/core': 1.10.2
'@ucast/mongo2js@1.4.1':
dependencies:
'@ucast/core': 1.10.2
'@ucast/js': 3.1.0
'@ucast/mongo': 2.4.3
'@ucast/mongo@2.4.3':
dependencies:
'@ucast/core': 1.10.2
'@ungap/structured-clone@1.2.0': {}
'@vitejs/plugin-vue@2.3.4(vite@2.9.16(sass@1.80.5))(vue@3.5.12(typescript@4.9.5))':
@@ -29154,6 +29200,10 @@ snapshots:
graphql: 16.9.0
tslib: 2.8.0
graphql-subscriptions@3.0.0(graphql@16.9.0):
dependencies:
graphql: 16.9.0
graphql-tag@2.12.6(graphql@16.9.0):
dependencies:
graphql: 16.9.0