Compare commits

...

59 Commits

Author SHA1 Message Date
Alex Ant 8a59d3e83e update
Typecheck / desktop (pull_request) Successful in 29m32s
Typecheck / controller (pull_request) Successful in 29m56s
2026-06-26 10:44:49 +05:00
Alex Ant de10e16bae fix(registration): закрывать signup до готовности vars и сериализовать их patch
Typecheck / desktop (pull_request) Successful in 32m7s
Typecheck / controller (pull_request) Successful in 29m46s
Добавляем расчётный флаг settings.is_registration_open в getSystemInfo и заглушку на странице signup, чтобы пайщик не попадал в ошибку генерации документов. Параллельные утверждения решений больше не затирают vars при read-modify-write.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 18:25:10 +05:00
Alex Ant 987600dd38 fix(cooptypes): увеличить прочерк номера счёта в доверенности КУ
Typecheck / desktop (pull_request) Failing after 10m33s
Typecheck / controller (pull_request) Successful in 28m18s
Поле для ручного вписывания банковского счёта в шаблоне 329 расширено в 3 раза.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:10:43 +05:00
Alex Ant ff1965376d fix(desktop): не показывать ложное несовпадение контрольной суммы документа
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
Пересчёт хэша при появлении binary и состояние загрузки вместо предупреждения, пока PDF ещё не готов.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:09:11 +05:00
Alex Ant 07fb4599e6 fix(ku): скрыть кнопки собрания по итогам голосования и ограничить председателя ФЛ
Typecheck / desktop (pull_request) Successful in 30m18s
Typecheck / controller (pull_request) Has been cancelled
Кнопки «Отменить» и «Завершить протокол» показываются только после окончания
голосования — в зависимости от принятия повестки. Председатель участка
выбирается только среди физических лиц, с проверкой на backend.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 23:37:03 +05:00
Alex Ant 686cd0cff2 fix(desktop): добавить поле Email в форму редактирования ИП в реестре пайщиков
Typecheck / desktop (pull_request) Failing after 25m53s
Typecheck / controller (pull_request) Successful in 28m11s
У физлица и организации поле уже было в Editable*Card; для ИП пропустили при создании компонента.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 17:08:18 +05:00
Alex Ant ef96b4c81c feat(ku): упростить joindec и доработать документы участка
Typecheck / desktop (pull_request) Successful in 40m44s
Typecheck / controller (pull_request) Has been cancelled
Удалён шаблон 321 и заявление при присоединении к собранию; joindec без document.
В протокол 325 добавлено приложение с положением участка, factory-тесты 320–330.
Экспорт getPaymentMethods.name в SDK; пункт меню «Выйти из кооператива».

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:23:26 +05:00
Alex Ant f8b190bf53 merge: dev into feat/ku-selforg
Resolve conflict in process-decisions handlers — keep both
Ku establishment and membership exit decision generators.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 13:23:33 +05:00
coopops 7f5f9867d9 fix(ku): прочерки в доверенностях — подчёркивание под рукописное заполнение
Typecheck / desktop (pull_request) Successful in 13m26s
Typecheck / controller (pull_request) Successful in 13m3s
Поля под ручное заполнение (банковский счёт в 329, № протокола/доверенности
в 329 и 330) — линия подчёркивания «______________» вместо em-dash, чтобы
вписать значение ручкой на распечатанном экземпляре.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:30:45 +00:00
coopops c4fa1fecc2 fix(ku): документы учреждения КУ — правки методолога (Смуров, 16.06)
Typecheck / desktop (pull_request) Successful in 13m17s
Typecheck / controller (pull_request) Successful in 12m51s
Текст генерируемых документов учреждения кооперативного участка по
замечаниям методолога. Структура он-чейн пакетов не затронута.

- 323 Протокол собрания: убрано «кооперативного участка» из названия и
  meeting_name (просто «собрание пайщиков»); литерация «Протокол № КУ-N»
  сохранена.
- Повестка собрания (desktop, KuMeetingDetails/onStart + KuMeetings):
  в каждый вопрос добавлена принадлежность «...потребительского
  кооператива «Восход»»; полномочие обратиться в совет вшито во второй
  вопрос (третий вопрос не нужен — методолог «три в два»). Имя кооператива
  из System store (vars.name / full_abbr_genitive).
- 322 Бюллетень: в текст добавлено «потребительского кооператива «{name}»».
- 324 Заявление в совет: «прошу Совет согласовать создание участка ... и
  согласовать кандидатуру избранного председателя».
- 325 Решение совета: повестка разложена на 2 вопроса (учреждение +
  утверждение председателя и поручение Совету организовать сопровождение —
  банковский счёт, доверенность).
- 327/328 Договоры матответственности: из названия убрано «ПОЛНОЙ
  ИНДИВИДУАЛЬНОЙ» (тело без изменений).
- 329 Доверенность председателю КУ: переработана по эталону —
  реквизиты ПК в шапку, паспорт+регистрация инлайн в первый абзац,
  банковский счёт «№ —» (прочерк до открытия счёта в банке), ссылка на
  «Протокол № —» совета (прочерк), убран нижний блок «Уполномоченный: ФИО»,
  подпись доверителя + М.П.
- 330 Доверенность оператору: переработана по эталону — шапка
  «Кооперативный участок ... ПК ...», доверитель — председатель КУ,
  паспорт оператора инлайн, без банковского счёта, подпись + М.П. (КУ).

Эталоны: ~/blago/production/shared/ШАБЛОН_Доверенность_*.pdf,
ШАБЛОН_ДОГОВОР_О_МАТЕРИАЛЬНОЙ_ОТВЕТСТВЕННОСТИ_*.pdf

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:04:29 +00:00
coopops fbf6d98abc fix(branch): учреждение КУ — разделить пакет собрания и пакет совета по якорю
Typecheck / desktop (pull_request) Successful in 13m15s
Typecheck / controller (pull_request) Successful in 12m58s
Раньше всё (протокол, бюллетени, заявление в совет, договор, доверенность,
решение совета) висело на одном якоре hash → получались две почти одинаковые
карточки одного пакета (протокол и заявление), документы перетекали между ними.

Разводим на два процесса по разным package-хэшам:
- Пакет СОБРАНИЯ пайщиков (якорь = proposal.hash): протокол (closedec) + бюллетени
  (votedec). Появляется в реестре сразу при закрытии собрания.
- Пакет СОВЕТА (якорь = hash процесса): заявление в совет + договор матответственности
  + доверенность + решение совета. Советская часть (createagenda/confirmdec/declinedec/
  одобрения) НЕ тронута — нулевой риск.

Меняются только package-аргументы в closedec (make_complete_document) и votedec (newlink).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:03:19 +00:00
coopops 6d970bc0fe fix(branch): учреждение КУ — один пакет документов вместо двух
Typecheck / desktop (pull_request) Successful in 13m23s
Typecheck / controller (pull_request) Failing after 6m41s
closedec при типе createbranch заводил отдельный complete-document (newsubmitted)
на протокол собрания пайщиков, а exec/create_agenda — второй на заявление в совет.
Один и тот же package=hash, но два newsubmitted → две почти одинаковые карточки
в реестре документов.

Теперь для createbranch протокол линкуется (newlink) по якорному хэшу к пакету
заявления председателя в совет: заявление — единственный statement пакета, внутри
него протокол собрания пайщиков, бюллетени, договор матответственности, доверенность
и решение совета. Для free-решений протокол остаётся самостоятельным документом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:30:21 +00:00
coopops 58d7bc52bf [C28-38][@ant] merge: dev → feat/ku-selforg — разрешение конфликтов + фикс desktop typecheck
Typecheck / desktop (pull_request) Successful in 13m56s
Typecheck / controller (pull_request) Successful in 13m29s
Конфликты:
- extensions.module.ts: оба модуля (Ku + Expenses)
- notifications/workflows/index.ts: оба набора воркфлоу (branch + expense)
- UserSearchSelector.vue: оба пропа (exclude + types)
- zeus (sdk+controller): перегенерирован из объединённой схемы
  (rebuild stale dist: cooptypes, factory, inter, notifications)

Фикс vue-tsc (упавший desktop-typecheck на PR #130):
- BaseInput: добавлен type 'datetime-local' (q-input его поддерживает)
- KuMeetingDetailsPage: AgendaNumberAvatar number-коэрс (?? ""),
  docTarget/openMeetingDoc типизированы IDocumentAggregate,
  preview-агрегат неподписанного документа — каст
- KuBranchDetailsWidget: documentTarget/authorityTarget типизированы
  IDocumentAggregate, preview-агрегат — каст

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:48:26 +00:00
coopops 72bd5d96d0 [C28-38][@ant] правка: белый список КУ — любой пайщик, без аккаунтов участков
Typecheck / desktop (pull_request) Failing after 8m50s
Typecheck / controller (pull_request) Successful in 14m0s
contract addwhite: убрано ограничение «только физлицо» — в белый список можно любого пайщика. desktop: UserSearchSelector получил проп exclude, BranchPrivacyManager исключает аккаунты КУ (branames) из поиска пайщиков — КУ не является пайщиком.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:05:14 +00:00
coopops b22fbe1845 [C28-38][@ant] правка: управление приватностью КУ — на стол председателя
Typecheck / desktop (pull_request) Failing after 8m46s
Typecheck / controller (pull_request) Successful in 13m32s
Перенёс управление приватностью+белым списком в MemberBranchListPage (стол председателя «Кооперативные участки», развёрнутая строка) через компонент BranchPrivacyManager. Откатил KuBranchesPage (стол «Кооперативный участок») к виду без управления — там оно было не к месту.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:18:08 +00:00
coopops 93239d71f0 [C28-38][@ant] docs: guard приватного участка в стандарте sov.selectbranch
Typecheck / desktop (pull_request) Failing after 8m47s
Typecheck / controller (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:59:33 +00:00
coopops 3b85a2b783 [C28-38][@ant] фича: UI приватных КУ — управление белым списком + фильтр выбора
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
SDK: операции setBranchPrivate/addBranchWhitelist/deleteBranchWhitelist. desktop: на странице КУ председателю — разворот строки с галочкой «Приватный» и белым списком (добавление по ФИО/удаление); значок «Приватный» в списке; фильтр недоступных приватных участков (is_available) при регистрации и смене КУ.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:58:42 +00:00
coopops e55622636d [C28-38][@ant] фича: приватные КУ с белым списком — контракт+cooptypes+backend+sdk
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
branch: поля is_private/whitelist (binary_extension) + actions setprivate/addwhite/delwhite (auth председателя совета); soviet::selectbranch запрещает выбор приватного КУ вне белого списка. backend: is_private/is_available(computed per-user)/whitelist_certificates(role-gated) в Branch DTO, 3 мутации, фильтр-готовность. codegen: zeus+селекторы.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:48:59 +00:00
coopops 1ccb72c75f [C28-38][@ant] правка: подсказка карточки пайщиков — про делегирование голоса председателю КУ
Typecheck / desktop (pull_request) Failing after 8m40s
Typecheck / controller (pull_request) Successful in 13m27s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:07:42 +00:00
coopops 74a19d7e0e [C28-38][@ant] фича: карточка «Пайщики участка» на странице КУ + единый вид карточек лиц
Typecheck / desktop (pull_request) Failing after 8m48s
Typecheck / controller (pull_request) Has been cancelled
Добавлена карточка с количеством пайщиков участка (participants_count из
публичного payload ветки). Карточка председателя переведена на PersonCard
density=compact — убрана рамка, чтобы совпадала с доверенными лицами.
2026-06-15 07:46:13 +00:00
coopops 78822f3d6d [C28-38][@ant] фича: колонка «Пайщики» (число участников КУ) в списке участков
Typecheck / desktop (pull_request) Failing after 9m37s
Typecheck / controller (pull_request) Successful in 13m20s
Бэкенд считает пайщиков участка из таблицы soviet participants
(status accepted + braname) и отдаёт participants_count в payload веток
(getBranch/getBranches, одно чтение таблицы на список). Поле добавлено в
DTO Branch, оба SDK-селектора, регенерирован zeus. На фронте — колонка
в таблице КУ. Председатель и доверенные входят в число пайщиков участка.
2026-06-15 07:07:31 +00:00
coopops 6d89d96ad3 [C28-38][@ant] фича: панель состояния мажоритарной системы управления на странице КУ
Typecheck / desktop (pull_request) Failing after 8m30s
Typecheck / controller (pull_request) Successful in 13m20s
Отображает флаг is_branched (system.info.cooperator_account) — включается
контрактом при 3+ кооперативных участках. Read-only индикатор + пояснение
о мажоритарной модели общего собрания через участки.
2026-06-15 06:28:51 +00:00
coopops 8b4a8063bb [C28-38][@ant] правка: у неподписанного превью прячем весь блок документа (рамка + контрольная сумма + скачать)
Typecheck / desktop (pull_request) Failing after 8m21s
Typecheck / controller (pull_request) Successful in 13m17s
По просьбе — не только красную плашку, а весь блок DocumentSignatures целиком
(рамка, контрольная сумма, кнопка «скачать») скрываем у документов без
канонического doc_hash (неподписанное превью). Гейт перенесён на уровень
BaseDocument (v-if='hasDocHash' на всём блоке); проп hideChecksum откатан как
лишний — DocumentSignatures вернулся к исходному виду. Подписанные документы
(doc_hash есть) отображаются с полным блоком как раньше.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:03:01 +00:00
coopops f1a0555056 [C28-38][@ant] правка: в превью неподписанного документа скрываем блок контрольной суммы
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Превью-перед-подписанием строит агрегат с пустым doc_hash (канонического
хэша ещё нет), а BaseDocument пересчитывал хэш из бинарника и DocumentSignatures
показывал красную «Локально пересчитанный хеш не совпадает» — ложная ошибка,
документ просто ещё не подписан. Теперь BaseDocument при пустом doc_hash не
запускает пересчёт и прячет блок контрольной суммы (проп hideChecksum). Для
реальных подписанных документов doc_hash есть → сверка работает как раньше.
Вёрстка и кнопка «скачать» сохранены.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:59:14 +00:00
coopops 37069af6ca [C28-38][@ant] безопасность: ФИО должностных лиц не публикуем в meta документов КУ — в Action только username, ФИО резолвит фабрика
Typecheck / desktop (pull_request) Failing after 8m21s
Typecheck / controller (pull_request) Successful in 13m13s
Документы 323/324/325 (chairman) и 327/330 (trustee) хранили ФИО
председателя в Action → оно попадало в document2.meta и публиковалось
on-chain plaintext. Теперь в Action идёт только username; фабрика через
getUser(username, block_num) резолвит ФИО на момент генерации и вставляет
в рендер PDF (канон 702.AssetContributionAct.receiver). Model/шаблоны не
тронуты — ФИО по-прежнему в документе, но не в meta.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:12:06 +00:00
coopops 11f1270eb0 [C28-38][@ant] правка: лейбл доверенности председателю КУ в реестре одобрений + неотклоняемость
Typecheck / desktop (pull_request) Failing after 8m23s
Typecheck / controller (pull_request) Successful in 13m18s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:45:05 +00:00
coopops 359643f4ce [C28-38][@ant] правка: документы заявки — отдельная колонка со ссылками
Typecheck / desktop (pull_request) Failing after 8m19s
Typecheck / controller (pull_request) Successful in 13m25s
Договор/Доверенность вынесены из «Действия» в колонку «Документы» —
ссылки с иконкой (не ghost-кнопки); «Действия» оставлены под Одобрить/Отклонить

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:37:46 +00:00
coopops 90b2caf6cb [C28-38][@ant] фича: линковка договора/доверенности в пакет совета + предпросмотр перед подписью
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
- контракт exec: newlink договора (328) и доверенности (329) к пакету повестки
  совета (как votedec линкует бюллетень); branchliab/branchauth в soviet_actions —
  совет читает их вместе с заявлением (нужен redeploy branch)
- предпросмотр перед подписанием: данные → генерация документов с подставленными
  данными → показ на прочтение → подпись и отправка. Для exec (председатель: заявление+
  договор+доверенность) и requestTrusted (доверенный: договор+доверенность)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:36:10 +00:00
coopops b30cf4cdcc [C28-38][@ant] правка: маски паспортных полей (строки, без спиннеров)
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Has been cancelled
- BaseInput: проп mask (проброс в q-input)
- форма паспорта: серия #### / номер ###### / код ###-### (только цифры,
  без стрелок number), плейсхолдеры + валидация полноты перед сохранением

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:23:10 +00:00
coopops 248375a1bf [C28-38][@ant] фикс: устойчивость стола КУ + протоколы собрания на странице
Typecheck / desktop (pull_request) Failing after 8m12s
Typecheck / controller (pull_request) Successful in 13m11s
- getBranches больше не роняет весь список 400-й «Организация не найдена»,
  когда карточка участка ещё не материализована обработчиком confirmdec
  после одобрения советом — не-готовый участок пропускается до след. загрузки
- на странице собрания публикуются протокол собрания пайщиков (323) и решение
  совета (325) агрегатами (html+подписи+бюллетени); договор матответственности
  (328) и доверенность (329) с паспортными данными туда НЕ выносятся
- KuDecision: +protocol_document/+authorization_document (агрегаты), codegen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:14:07 +00:00
coopops 1fcaa242bc [C28-38][@ant] правка: повестка КУ — канон-вёрстка, понятный термин адреса, авто-обновление
Typecheck / desktop (pull_request) Failing after 8m35s
Typecheck / controller (pull_request) Has been cancelled
- «адрес привязки» → «по адресу, определённому собранием пайщиков» (термин убран)
- «Повестка дня» → «Повестка собрания»; карточка повестки по канону MeetDetailsAgenda
  (шапка с иконкой, проект решения в обособленном surface-2 блоке, подпись «Ваш голос»)
- фоновый поллинг состояния (15с) на странице собрания и в списке, пока нет websocket

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:52:47 +00:00
coopops 93dec39cd0 [C28-38][@ant] фича: доверенности КУ он-чейн (329 председателю участка / 330 доверенному-оператору)
Typecheck / desktop (pull_request) Failing after 8m19s
Typecheck / controller (pull_request) Successful in 13m35s
Все документы идут он-чейн: доверенности едут вторым document2 рядом с договорами
матответственности и визируются теми же подписантами, после одобрения комплитятся
(make_complete_document) в реестр документов.

Контракт branch:
- exec(+authority), reqtrusted(+authority), apprtrusted(+countersigned_authority);
  поля authority в coodecision/trustreq.
- доверенность №1 визирует председатель совета — отдельное одобрение branchauth в
  confirmdec (approval_hash = хэш документа) + новые apprauth/declauth.
- доверенность №2 и договор 327 комплитятся в apprtrusted встречной подписью
  председателя участка.

cooptypes: реестр 329 BranchTrusteePowerOfAttorney, 330 BranchTrustedPowerOfAttorney;
interfaces/branch + actions apprAuth/declAuth. factory: Actions/Templates 329/330.
controller: DTO/service/resolver генерации, проброс authority в адаптере, проекция
trustreq.authority + agregate authority_document для встречной подписи. sdk: селектор
заявки + zeus reген. desktop: генерация+подпись доверенностей в DecisionFlow/TrustedFlow,
встречная подпись обоих документов в approveTrusted, просмотр доверенности в виджете.

Контракт пересобран (branch.wasm/.abi), требуется редеплой + рестарт чейна/парсера.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 07:10:10 +00:00
coopops c0e818e44c [C28-38][@ant] фича: сбор паспорта при подписании договора матответственности — диалог показывается только если паспорта в реестре нет
Typecheck / desktop (pull_request) Failing after 8m30s
Typecheck / controller (pull_request) Successful in 13m23s
Feature User/CollectPassport: BaseDialog (канон) с паспортными полями + useRequirePassport. Председатель участка собирает паспорт при направлении в совет (execDecision, договор 328), доверенное лицо — при подаче заявки (requestTrusted, договор 327). Если паспорт уже в профиле (session.privateAccount.individual_data.passport) — окно не показывается, действие идёт сразу. Сохранение через saveMyPassport + обновление session. entities/Account/api.saveMyPassport. ESLint чист.
2026-06-13 14:15:45 +00:00
coopops ba9d192622 [C28-38][@ant] фича: self-service saveMyPassport — пайщик сам сохраняет свои паспортные данные в реестр (только если пусто, без перезатирания)
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
Мутация saveMyPassport (guard только аутентификация, @CurrentUser, пишет в свой individual через IndividualRepository; reject если паспорт уже установлен — обновление это отдельный процесс). Нужна для ролей, которым паспорт обязателен (председатель участка, доверенное лицо) в кооперативах без сбора паспорта при регистрации. Договоры 327/328 теперь показывают паспортный блок по наличию individual.passport (а не по кооп-флагу vars.passport_request) — паспорт этих ролей обязателен независимо от настройки кооператива. Backend + cooptypes + SDK; codegen обновлён, controller компилируется.
2026-06-13 14:08:12 +00:00
coopops 577975e47c [C28-38][@ant] рефакторинг: канон trustee/trusted/chairman — раздельные договоры матответственности председателя участка (328 trustee) и доверенного лица (327 trusted, полный текст + individual/паспорт)
Typecheck / desktop (pull_request) Failing after 8m29s
Typecheck / controller (pull_request) Has been cancelled
Канон ролей зафиксирован: chairman=председатель совета, trustee=председатель участка, trusted=доверенное лицо. Документ 328 BranchChairmanLiabilityAgreement переименован в BranchTrusteeLiabilityAgreement (имя Chairman теперь врало). Документ 327 переписан: вместо короткого огрызка на ICommonUser — полный текст договора (как 328) для доверенного лица на individual (с паспортом), вторая сторона = председатель участка (trustee_full_name). Контракт не трогался: trusted-флоу generic над document2. Сквозь cooptypes/factory/controller/sdk + desktop (DecisionFlow/TrustedFlow). Codegen+SDK пересобраны, 14/14 ku-тестов, ESLint чист.
2026-06-13 13:29:43 +00:00
coopops a11c9135ed [C28-38][@ant] feat: договор матответственности председателя КУ в пакете в совет + встречная подпись председателя совета
Typecheck / desktop (pull_request) Failing after 8m21s
Typecheck / controller (pull_request) Successful in 13m6s
Председатель участка подписывает договор о полной индивидуальной материальной
ответственности (новый документ реестра 328) при подаче заявления о создании КУ —
оба документа уходят в совет одним пакетом действием exec. После решения совета об
учреждении участка договор автоматически уходит на встречную подпись председателю
совета через механизм одобрений (soviet::createapprv → confirmapprv → branch::apprliab).
Отклонить договор нельзя: решение об учреждении уже принято — контракт блокирует
declliab (check(false)), а в UI кнопка «Отклонить» для этого типа скрыта.

- cooptypes: реестровый документ 328.BranchChairmanLiabilityAgreement (юр-текст,
  паспортный блок исполнителя под passport_request, «Общество» = председатель совета);
  интерфейсы branch (IExec.liability, ICoodecision.liability, IApprliab/IDeclliab).
- factory: Template+Action 328 (individual через getUser).
- contract branch: exec(...,liability) хранит договор; confirmdec запускает
  create_approval; apprliab финализирует (make_complete_document); declliab блокирует отказ.
- controller: DTO 328 + liability в ExecKuDecisionInput; execDecision теперь требует
  избранного председателя (assertIsDecisionChairman); генератор + резолвер; адаптер;
  проекция решения расширена полем liability.
- desktop: execDecision генерит и подписывает договор 328 в пакете; метка действия
  и скрытие кнопки «Отклонить» для недопустимого к отказу договора в /chairman/approvals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:05:54 +00:00
coopops ffa71e4b80 [C28-38][@ant] фикс: встречная подпись договора доверенного без регенерации + кнопка в совет у избранного председателя + уведомления КУ
Typecheck / desktop (pull_request) Failing after 8m32s
Typecheck / controller (pull_request) Successful in 13m18s
Одобрение заявки доверенного падало «Недостаточно прав доступа для генерации
документа»: председатель регенерировал договор 327 заявителя, а генерация
разрешена только автору. Теперь заявка отдаёт агрегат документа (сырой документ
+ подписи через DocumentAggregator) и ФИО заявителя; председатель накладывает
встречную подпись на документ первого подписанта (канон 2-й подписи как в
собраниях), документ открывается на просмотр из таблицы заявок.

«Направить в совет» показывается избранному председателю участка
(decision.chairman), а не организатору собрания — заявление в совет
подписывает он.

Уведомления: напоминание участникам за час до собрания (cron по meet_at,
маркер meet_reminder_sent), председателю участка о новой заявке доверенного,
заявителю о решении по заявке; workflow «голосование началось» включён в
реестр синхронизации (не был зарегистрирован).
2026-06-12 18:34:44 +00:00
coopops 67eeac5563 [C28-38][@ant] фикс: заявка доверенного принимает договор 327 с ФИО председателя
Typecheck / desktop (pull_request) Failing after 8m32s
Typecheck / controller (pull_request) Successful in 13m17s
requestTrusted был типизирован под заявление 326 (мета без chairman_full_name),
а фронт по канону подписывает договор о полной материальной ответственности 327 —
GraphQL отклонял мету. Тип application заменён на DTO договора 327 (как у
встречной подписи председателя). Формулировка пустого «Моего участка» смягчена:
«Кооперативный участок пока не выбран».
2026-06-12 15:19:16 +00:00
coopops 57f2ef43e0 [C28-38][@ant] фича: автопривязка председателя КУ к своему участку
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Successful in 14m11s
soviet::setbranch (auth _branch) — системная установка/сброс braname без
заявления. Председатель привязывается автоматически при учреждении участка
(confirmdec/createbranch) и при смене (editbranch: новому — установка,
прежнему — сброс, выбирает участок заново). Председатель может возглавлять
только один участок и не может сменить участок заявлением (selectbranch),
пока исполняет обязанности — попап выбора КУ его не касается.
«Мой участок» показывает участок по braname пайщика (выбранный или
автопривязанный), fallback — по председательству.
2026-06-12 12:43:48 +00:00
coopops e35e74cd11 [C28-38][@ant] фикс: публичные сертификаты участка для пайщиков + доверенные фамилиями
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Successful in 13m11s
Поля trustee/trusted остаются под guard chairman/member (паспортные данные);
для пайщиков добавлены trustee_certificate/trusted_certificates (ФИО+username)
без guard. Страницы КУ переведены на getPublicBranches, в таблице участков
доверенные показываются фамилиями, прочерк при отсутствии.
2026-06-12 11:57:35 +00:00
coopops 2e54f7a9ac [C28-38][@ant] фича: перестройка стола КУ + статус «Отменено» + гейт отмены принятого решения
Typecheck / desktop (pull_request) Failing after 8m28s
Typecheck / controller (pull_request) Successful in 13m11s
- статус CANCELLED: контракт стирает запись одинаково при любом исходе, факт отмены фиксируется в БД при canceldec; в списках и карточке показывается «Отменено» вместо «Завершено»
- отмена собрания недоступна после успешного голосования (все бюллетени поданы или окно истекло и по каждому вопросу «за» больше «против») и после утверждения протокола — только направить в совет; при сборе участников и неуспешном голосовании отмена остаётся
- стол КУ: страница «Кооперативные участки» (таблица с председателем и адресом, переход на участок), страница участка (адрес/контакты, ФИО председателя, доверенные лица, заявки на доверенных с одобрением председателем, кнопка «Стать доверенным»), «Мой участок» — тот же виджет для участка, где пайщик председатель
- страница «Доверенные» удалена — её функционал переехал на страницу участка
2026-06-12 11:33:29 +00:00
coopops 68db00e8d4 [C28-38][@ant] фикс: дубли повестки после открытия голосования + контакты участка на собрании
Typecheck / desktop (pull_request) Failing after 8m18s
Typecheck / controller (pull_request) Successful in 13m12s
- стёртые контрактом вопросы (erase при replace повестки) оставались в выдаче — фильтр по present в getDecision; бюллетень снова проходит валидацию контракта
- секция повестки скрывается, когда вопросов нет (после завершения собрания повестка живёт в протоколе)
- email и телефон участка запрашиваются при открытии голосования (нужны для добавления участка как подразделения после решения совета), хранятся приватно в БД и видны в карточке собрания
2026-06-12 08:35:07 +00:00
coopops b2286167f0 [C28-38][@ant] фича: тип собрания, спец-повестка и документ 325 в decision-factory — полный флоу собраний КУ по канону
Typecheck / desktop (pull_request) Failing after 8m30s
Typecheck / controller (pull_request) Successful in 13m20s
- решение совета branchdec: документ 325 переписан (реквизиты явно через Action), мутация kuGenerateEstablishmentDecision, обработчик в decision-factory desktop
- кнопки действий собрания в шапке страницы, canCloseNow-гейт (все проголосовали или 15 минут истекли), busy-лоадеры до обновления проекции
- перевёрстка повестки/результатов по канону meet, back-link, статус в карточке
- диалог объявления: тип собрания (создание КУ / произвольные вопросы), повестка организатора для free, доп.вопросы при открытии голосования
- фикс импортов Int (DTO) и formatDateToLocalTimezone (DecisionFlow)
2026-06-12 07:31:28 +00:00
coopops 051a56c3d3 [C28-38][@ant] feat: тип собрания (участок/произвольные вопросы), доп. вопросы при открытии голосования, линковка бюллетеней и протокола к якорному хэшу в реестре документов, кнопка Назад и перевёрстка карточки собрания
Typecheck / desktop (pull_request) Failing after 8m36s
Typecheck / controller (pull_request) Successful in 13m17s
2026-06-12 06:04:07 +00:00
coopops 6cc408fd52 [C28-38][@ant] fix: разделение ролей — организатор=председатель собрания, председатель участка избирается отдельно; петиция 324 с наименованием участка и ФИО избранного председателя вместо braname; кворум 3 в startdec
Typecheck / desktop (pull_request) Failing after 8m25s
Typecheck / controller (pull_request) Successful in 13m26s
2026-06-11 20:13:42 +00:00
coopops cabe4a8ed8 [C28-38][@ant] fix: список собраний — только участок/дата/статус/участники, явная кнопка перехода и cursor pointer на строке
Typecheck / desktop (pull_request) Failing after 8m40s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 19:54:22 +00:00
coopops f8ea5eed91 [C28-38][@ant] fix: материализация placeholder-собрания, кворум 3 на открытие голосования, время по TIMEZONE платформы — устранение пустой повестки/участников и путаницы часовых поясов
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-11 19:46:46 +00:00
coopops 64006e89c8 [C28-38][@ant] feat: ревизия флоу собрания КУ по замечаниям — голосование открывает организатор, назначая председателя из участников (по ФИО) и фиксируя наименование+адрес участка, определённые собранием; окно голосования 15 минут отмеряет контракт (startdec без дат, setchair удалён); место/время собрания и наименование участка — приватные db-поля ku_decisions, в блокчейн и в документ 320 не публикуются; при старте голосования участники получают уведомления (workflow branch-voting-started); при утверждении советом создаётся карточка организации участка с человекочитаемым именем (braname — служебный username); unit-тесты KuService 12 passed
Typecheck / desktop (pull_request) Failing after 8m17s
Typecheck / controller (pull_request) Successful in 13m16s
2026-06-11 18:51:26 +00:00
coopops afcdea2770 [C28-38][@ant] fix(controller): duplicate global_sequence при сохранении действия = идемпотентный успех, а не вечный pending — повторно доставленные сообщения (ресинк парсера, XCLAIM re-delivery) раньше не ACK'ались и recoverOwnPending бесконечно долбил лог duplicate key
Typecheck / desktop (pull_request) Successful in 13m55s
Typecheck / controller (pull_request) Successful in 13m7s
2026-06-11 17:48:49 +00:00
coopops c43f501d00 [C28-38][@ant] fix: подписать parser на контракт branch (дельты не парсились — проекции ku_* оставались пустыми, отсюда «успех + собрание не найдено»); объявление собрания упрощено: braname генерируется автоматически и в UI/документе не показывается, кандидат в председатели не вводится — председатель выбирается на собрании из присоединившихся; повестка без username; шаблон 320 — строка кандидата условная; страницы ku ждут PG-проекцию поллингом после транзакций вместо мгновенного запроса
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 17:44:18 +00:00
coopops c1da60bb37 [C28-38][@ant] fix(desktop): typecheck ku-страниц — статусы/тип решения через Zeus-enum'ы (UPPERCASE с сервера, lowercase-map не матчился бы и в рантайме), TableSkeleton получает массив колонок по шапкам таблиц, id вопроса нормализован в number для индексации голосов
Typecheck / desktop (pull_request) Successful in 13m42s
Typecheck / controller (pull_request) Successful in 13m19s
2026-06-11 13:37:42 +00:00
coopops dd76c5587d [C28-38][@ant] feat: добавить frontend-workspace «Кооперативный участок» в desktop (слот trustee) — страницы Мой участок / Собрания / Собрание / Доверенные, циклы generate→sign→submit документов 320–324 и 327 (встречная подпись договора без регенерации), канон MONO v2 (.table-wrap, Base-обёртки, useHeaderActions, useDismissibleBanner)
Typecheck / desktop (pull_request) Failing after 8m37s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 13:18:58 +00:00
coopops 769d1db5a0 [C28-38][@ant] feat: codegen ku — ZEUS-клиент с 21 операцией кооперативных участков, SDK-домен Mutations/Queries.Ku с селекторами, unit-тесты KuService (11 passed) — типизированный end-to-end доступ для desktop; попутно типовые фиксы BigInt(BN)/UInt128.from под текущие типы wharfkit
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 13:05:41 +00:00
coopops 2fc99acde2 [C28-38][@ant] feat: добавить backend-расширение «Кооперативный участок» в controller (extensions/ku) — слот trustee активирован; blockchain-адаптер 11 действий branch с подписью кооператива, PG-проекции ku_decisions/ku_decision_questions/ku_trust_requests с синком и fork-обработкой, GraphQL: 11 мутаций + 7 генераторов документов 320–327 + 3 запроса с enum-статусами
Typecheck / desktop (pull_request) Successful in 13m47s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 12:47:30 +00:00
coopops b3e308b0c4 [C28-38][@ant] feat: добавить фабрики документов 320–327 самоорганизации участков в factory (+data-driven Action 322/323/325 в cooptypes) — генерация PDF повестки, заявлений, бюллетеня, протокола и решений по реестру
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-11 12:31:06 +00:00
coopops ec582d7d1a [C28-38][@ant] feat: зеркалировать контракт самоорганизации участков в cooptypes (интерфейсы branch, 13 actions, 3 таблицы, шаблоны 320–327) — типизированный слой документов и действий собрания/доверенных для backend и SDK
Typecheck / desktop (pull_request) Successful in 13m42s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 12:08:05 +00:00
coopops 28f28ae9d4 [C28-38][@ant] feat: добавить приём доверенных по заявлению в branch (trustreqs, reqtrusted/apprtrusted/decltrusted) и YAML-стандарты собрания и доверенных — оформление членства доверенных лиц кооперативного участка
Typecheck / desktop (pull_request) Successful in 13m45s
Typecheck / controller (pull_request) Has been cancelled
2026-06-11 11:46:45 +00:00
coopops 36dcd59428 [C28-38][@ant] feat: добавить L2-исполнение решения о создании КУ в branch (exec→совет, callbacks confirmdec/declinedec, canceldec, тип повестки branchdec) — автоматическое учреждение участка по утверждению совета
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-11 11:42:58 +00:00
coopops 5335ff3049 [C28-38][@ant] feat: добавить L1-механизм «собрание→решение» в контракт branch (таблицы decisions/decisionq, действия createdec/joindec/setchair/startdec/votedec/closedec) — основа самоорганизации кооперативных участков пайщиками
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled
2026-06-11 11:39:03 +00:00
271 changed files with 23984 additions and 81 deletions
+1
View File
@@ -1,4 +1,5 @@
{
"cSpell.enabled": false,
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"editor.tabCompletion": "onlySnippets",
@@ -5,6 +5,25 @@
#include "src/deletebranch.cpp"
#include "src/deltrusted.cpp"
#include "src/editbranch.cpp"
#include "src/setprivate.cpp"
#include "src/addwhite.cpp"
#include "src/delwhite.cpp"
#include "src/createdec.cpp"
#include "src/joindec.cpp"
#include "src/startdec.cpp"
#include "src/votedec.cpp"
#include "src/closedec.cpp"
#include "src/exec.cpp"
#include "src/confirmdec.cpp"
#include "src/declinedec.cpp"
#include "src/canceldec.cpp"
#include "src/apprliab.cpp"
#include "src/declliab.cpp"
#include "src/apprauth.cpp"
#include "src/declauth.cpp"
#include "src/reqtrusted.cpp"
#include "src/apprtrusted.cpp"
#include "src/decltrusted.cpp"
using namespace eosio;
+39 -2
View File
@@ -63,6 +63,43 @@ public:
[[eosio::action]] void deletebranch(eosio::name coopname, eosio::name braname);
[[eosio::action]] void addtrusted(eosio::name coopname, eosio::name braname, eosio::name trusted);
[[eosio::action]] void deltrusted(eosio::name coopname, eosio::name braname, eosio::name trusted);
// Приватность кооперативного участка и управление белым списком (председатель совета).
// Приватный участок нельзя выбрать (selectbranch), если аккаунт не в белом списке участка.
[[eosio::action]] void setprivate(eosio::name coopname, eosio::name braname, bool is_private);
[[eosio::action]] void addwhite(eosio::name coopname, eosio::name braname, eosio::name account);
[[eosio::action]] void delwhite(eosio::name coopname, eosio::name braname, eosio::name account);
// Универсальный механизм «собрание → решение» (L1)
[[eosio::action]] void createdec(eosio::name coopname, eosio::checksum256 hash, eosio::name type, eosio::name initiator, document2 proposal, eosio::name braname, std::vector<decision_point> agenda);
[[eosio::action]] void joindec(eosio::name coopname, eosio::checksum256 hash, eosio::name username);
[[eosio::action]] void startdec(eosio::name coopname, eosio::checksum256 hash, eosio::name chairman, std::string address, std::vector<decision_point> agenda);
[[eosio::action]] void votedec(eosio::name coopname, eosio::checksum256 hash, eosio::name username, document2 ballot, std::vector<decision_vote_point> votes);
[[eosio::action]] void closedec(eosio::name coopname, eosio::checksum256 hash, document2 protocol);
// Расширение автоматизируемого решения (L2): создание кооперативного участка через совет.
// Председатель участка подписывает пакетом заявление, договор о полной материальной
// ответственности (liability) и доверенность председателю участка (authority).
[[eosio::action]] void exec(eosio::name coopname, eosio::checksum256 hash, document2 petition, document2 liability, document2 authority);
[[eosio::action]] void confirmdec(eosio::name coopname, eosio::checksum256 hash, document2 authorization);
[[eosio::action]] void declinedec(eosio::name coopname, eosio::checksum256 hash, std::string reason);
[[eosio::action]] void canceldec(eosio::name coopname, eosio::checksum256 hash, std::string reason);
// Договор о полной материальной ответственности председателя КУ: встречная подпись председателя совета
// (callback'и одобрения совета; отклонение заблокировано — решение об учреждении участка уже принято)
[[eosio::action]] void apprliab(eosio::name coopname, eosio::name username, eosio::checksum256 approval_hash, document2 approved_document);
[[eosio::action]] void declliab(eosio::name coopname, eosio::name username, eosio::checksum256 approval_hash, std::string reason);
// Доверенность председателя КУ: встречная подпись председателя совета
// (отдельное одобрение совета рядом с договором матответственности; отклонение заблокировано)
[[eosio::action]] void apprauth(eosio::name coopname, eosio::name username, eosio::checksum256 approval_hash, document2 approved_document);
[[eosio::action]] void declauth(eosio::name coopname, eosio::name username, eosio::checksum256 approval_hash, std::string reason);
// Доверенные лица по заявлению: договор о полной материальной ответственности (application)
// и доверенность доверенному лицу (authority) — оба со встречной подписью председателя участка
[[eosio::action]] void reqtrusted(eosio::name coopname, eosio::name braname, eosio::name username, eosio::checksum256 hash, document2 application, document2 authority);
[[eosio::action]] void apprtrusted(eosio::name coopname, eosio::checksum256 hash, document2 countersigned, document2 countersigned_authority);
[[eosio::action]] void decltrusted(eosio::name coopname, eosio::checksum256 hash, std::string reason);
};
@@ -0,0 +1,135 @@
# ─────────────────────────────────────────────────────────────────────────────
# Стандарт «Собрание пайщиков и решение участка».
#
# Контракт branch ведёт универсальный механизм собраний пайщиков: от объявления
# собрания и присоединения участников до голосования бюллетенями и утверждения
# протокола председателем собрания. Свободное решение фиксируется протоколом и
# на этом завершается. Решение об учреждении кооперативного участка дополнительно
# выносится на рассмотрение совета и по его утверждению создаёт участок.
#
# Источники в коде:
# • cpp/branch/src/createdec.cpp — объявление собрания и повестки
# • cpp/branch/src/joindec.cpp — присоединение участника
# • cpp/branch/src/startdec.cpp — открытие голосования (с назначением председателя)
# • cpp/branch/src/votedec.cpp — бюллетень участника
# • cpp/branch/src/closedec.cpp — утверждение протокола председателем
# • cpp/branch/src/exec.cpp — заявление в совет (для учреждения участка)
# • cpp/branch/src/confirmdec.cpp — учреждение участка по решению совета
# ─────────────────────────────────────────────────────────────────────────────
process_type: brn.decide
id: public_branch_decide_process
title: Собрание пайщиков и решение участка
slug: decide
status: proposed
contract: branch
summary: >
От объявления собрания пайщиков и присоединения участников до голосования
бюллетенями и утверждения протокола председателем. Свободное решение
фиксируется протоколом; решение об учреждении кооперативного участка
выносится на совет и по его утверждению создаёт участок.
purpose: >
«Собрание пайщиков и решение участка» — способ для пайщиков собраться в
любой момент и принять решение по своей повестке. Инициатор объявляет
собрание, желающие присоединяются, председатель собрания открывает
голосование, участники голосуют бюллетенями (за / против / воздержался по
каждому вопросу), председатель утверждает протокол. Свободное решение на
этом завершается как документ. Решение об учреждении кооперативного участка
председатель направляет на рассмотрение совета; по утверждению советом
участок создаётся с указанными реквизитами.
roles:
- participant # Пайщик-инициатор / участник / голосующий
- chairman # Председатель собрания (для учреждения участка — будущий председатель участка)
- soviet # Совет — утверждение учреждения участка
# ── Действия контракта ───────────────────────────────────────────────────────
actions:
- name: branch::createdec
human: Объявить собрание
actor: Пайщик
role: opener
purpose: >
Инициатор формирует повестку дня и подписывает предложение о проведении
собрания. Для учреждения кооперативного участка повестка включает вопросы
об организации участка, адресе привязки и избрании председателя. Собрание
открывается для присоединения участников.
- name: branch::joindec
human: Присоединиться к собранию
actor: Пайщик
role: progress
purpose: >
Пайщик входит в состав участников собрания.
- name: branch::startdec
human: Открыть голосование
actor: Пайщик
role: progress
purpose: >
Организатор собрания — он же председатель собрания по праву его создания —
открывает голосование, указывая избираемого собранием председателя
кооперативного участка из числа присоединившихся участников, а также
адрес привязки участка, определённый собранием. Повестку в этот момент
можно дополнить вопросами, внесёнными участниками на собрании. Открыть
голосование можно только при наличии не менее трёх участников собрания —
иначе кворум недостижим и собрание можно лишь отменить. Окно голосования
отмеряется автоматически и составляет 15 минут — голосование проходит прямо
на собрании.
- name: branch::votedec
human: Подать бюллетень
actor: Пайщик
role: progress
purpose: >
Участник подаёт подписанный бюллетень с волеизъявлением (за / против /
воздержался) по каждому вопросу повестки. Подсчёт голосов ведёт система.
Бюллетень публикуется в реестре документов и привязывается к собранию.
- name: branch::closedec
human: Утвердить протокол
actor: Пайщик
role: closer
purpose: >
Председатель собрания закрывает голосование и утверждает протокол решения
своей подписью. Протокол публикуется в реестре документов и завершает
пакет документов собрания (бюллетени и протокол собираются вместе).
Свободное решение на этом завершается. Решение об учреждении участка
переходит к исполнению.
- name: branch::exec
human: Направить заявление в совет
actor: Пайщик
role: progress
purpose: >
Председатель собрания формирует заявление в совет об учреждении
кооперативного участка; к нему прилагаются протокол собрания и бюллетени.
Вопрос выносится на рассмотрение совета.
links:
- process_type: sov.authpkg
label: Типовой процесс решения совета
- name: branch::confirmdec
human: Учредить участок по решению совета
actor: Совет
role: closer
purpose: >
По утверждению советом кооперативный участок создаётся с указанными
реквизитами (наименование, адрес привязки, избранный председатель).
- name: branch::declinedec
human: Отклонить решением совета
actor: Совет
role: rollback
purpose: >
При отказе совета вопрос об учреждении участка снимается; причина
фиксируется в журнале.
- name: branch::canceldec
human: Отменить собрание
actor: Пайщик
role: rollback
purpose: >
Инициатор сворачивает собрание до вынесения вопроса на совет.
operations: []
@@ -0,0 +1,61 @@
# ─────────────────────────────────────────────────────────────────────────────
# Стандарт «Приём доверенного лица кооперативного участка».
#
# Контракт branch ведёт приём доверенных лиц участка по заявлению: пайщик
# подаёт заявление и договор о полной материальной ответственности, председатель
# участка одобряет встречной подписью, и пайщик становится доверенным лицом.
#
# Источники в коде:
# • cpp/branch/src/reqtrusted.cpp — подача заявки
# • cpp/branch/src/apprtrusted.cpp — одобрение председателем участка
# • cpp/branch/src/decltrusted.cpp — отклонение председателем участка
# ─────────────────────────────────────────────────────────────────────────────
process_type: brn.trust
id: public_branch_trust_process
title: Приём доверенного лица кооперативного участка
slug: trust
status: proposed
contract: branch
summary: >
Пайщик подаёт заявление и договор о полной материальной ответственности;
председатель кооперативного участка одобряет встречной подписью, и пайщик
входит в состав доверенных лиц участка (не более трёх).
purpose: >
«Приём доверенного лица кооперативного участка» позволяет пайщику стать
доверенным лицом участка по заявлению. Заявитель подписывает заявление и
договор о полной материальной ответственности; председатель участка
рассматривает заявку и одобряет её встречной подписью на договоре, после
чего пайщик получает права доверенного лица.
roles:
- participant # Пайщик-заявитель
- chairman # Председатель кооперативного участка
# ── Действия контракта ───────────────────────────────────────────────────────
actions:
- name: branch::reqtrusted
human: Подать заявку доверенного
actor: Пайщик
role: opener
purpose: >
Пайщик подаёт заявку на приём доверенным лицом выбранного участка, прилагая
подписанные заявление и договор о полной материальной ответственности.
- name: branch::apprtrusted
human: Одобрить доверенного
actor: Председатель участка
role: closer
purpose: >
Председатель участка одобряет заявку встречной подписью на договоре
материальной ответственности; пайщик входит в состав доверенных лиц
участка.
- name: branch::decltrusted
human: Отклонить заявку доверенного
actor: Председатель участка
role: rollback
purpose: >
Председатель участка отклоняет заявку; причина фиксируется в журнале.
operations: []
@@ -0,0 +1,25 @@
/**
* @brief Добавление пайщика в белый список кооперативного участка.
* Пайщики из белого списка могут выбрать приватный кооперативный участок при вступлении
* или смене участка. В белый список можно добавить любого пайщика кооператива.
* @param coopname Наименование кооператива
* @param braname Наименование кооперативного участка
* @param account Аккаунт пайщика для добавления
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::addwhite(eosio::name coopname, eosio::name braname, eosio::name account) {
check_auth_or_fail(_branch, coopname, coopname, "addwhite"_n);
branch_index branches(_branch, coopname.value);
auto branch = branches.find(braname.value);
eosio::check(branch != branches.end(), "Кооперативный участок не найден");
get_account_or_fail(account);
branches.modify(branch, coopname, [&](auto &b) {
b.add_account_to_whitelist(account);
});
}
@@ -0,0 +1,22 @@
/**
* @brief Встречная подпись председателя совета на доверенности председателя
* кооперативного участка.
* Callback одобрения совета: председатель совета подписал доверенность второй
* подписью (механизм soviet::confirmapprv проверил его подпись). Фиксируем
* полностью подписанную доверенность в реестре документов по якорю одобрения.
* @param coopname Наименование кооператива
* @param username Председатель кооперативного участка — уполномоченный по доверенности
* @param approval_hash Якорь одобрения (хэш документа доверенности)
* @param approved_document Доверенность с встречной подписью председателя совета
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p soviet
*/
[[eosio::action]] void branch::apprauth(eosio::name coopname, eosio::name username, eosio::checksum256 approval_hash, document2 approved_document) {
require_auth(_soviet);
// Доверенность подписана обеими сторонами (председатель участка + председатель совета) —
// фиксируем её в реестре документов как принятую.
::Soviet::make_complete_document(_branch, coopname, username, "branchauth"_n, approval_hash, approved_document);
}
@@ -0,0 +1,22 @@
/**
* @brief Встречная подпись председателя совета на договоре о полной материальной
* ответственности председателя кооперативного участка.
* Callback одобрения совета: председатель совета подписал договор второй подписью
* (механизм soviet::confirmapprv проверил его подпись). Фиксируем полностью
* подписанный договор в реестре документов по якорю процесса.
* @param coopname Наименование кооператива
* @param username Председатель кооперативного участка — сторона «Исполнитель»
* @param approval_hash Якорь процесса (хэш одобрения)
* @param approved_document Договор с встречной подписью председателя совета
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p soviet
*/
[[eosio::action]] void branch::apprliab(eosio::name coopname, eosio::name username, eosio::checksum256 approval_hash, document2 approved_document) {
require_auth(_soviet);
// Договор подписан обеими сторонами (председатель участка + председатель совета) —
// фиксируем его в реестре документов как принятый.
::Soviet::make_complete_document(_branch, coopname, username, "branchliab"_n, approval_hash, approved_document);
}
@@ -0,0 +1,42 @@
/**
* @brief Одобрение заявки доверенного лица председателем участка.
* Председатель кооперативного участка накладывает встречную подпись на договор
* материальной ответственности и доверенность заявителя; оба полностью подписанных
* документа фиксируются в реестре документов, пайщик добавляется в список доверенных
* лиц участка, заявка стирается.
* @param coopname Наименование кооператива
* @param hash Внешний идентификатор заявки
* @param countersigned Договор материальной ответственности со встречной подписью председателя участка
* @param countersigned_authority Доверенность доверенному лицу со встречной подписью председателя участка
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::apprtrusted(eosio::name coopname, eosio::checksum256 hash, document2 countersigned, document2 countersigned_authority) {
check_auth_or_fail(_branch, coopname, coopname, "apprtrusted"_n);
verify_document_or_fail(countersigned);
verify_document_or_fail(countersigned_authority);
auto req = get_trustreq_or_fail(coopname, hash);
auto br = get_branch_or_fail(coopname, req.braname);
eosio::check(!br.is_account_in_trusted(req.username), "Пайщик уже является доверенным лицом участка");
eosio::check(br.trusted.size() < 3, "Достигнут предел доверенных лиц участка (не более трёх)");
branch_index branches(_branch, coopname.value);
auto bitr = branches.find(req.braname.value);
branches.modify(bitr, coopname, [&](auto &row) {
row.add_account_to_trusted(req.username);
});
// Оба документа подписаны обеими сторонами (доверенное лицо + председатель участка) —
// фиксируем их в реестре документов как принятые.
::Soviet::make_complete_document(_branch, coopname, req.username, "trustliab"_n, hash, countersigned);
::Soviet::make_complete_document(_branch, coopname, req.username, "trustauth"_n, hash, countersigned_authority);
trustreq_index trustreqs(_branch, coopname.value);
auto ritr = trustreqs.find(req.id);
trustreqs.erase(ritr);
}
@@ -0,0 +1,23 @@
/**
* @brief Отмена собрания инициатором до вывода на совет.
* Инициатор сворачивает собрание, пока решение не ушло в совет; запись и вопросы
* повестки стираются, причина передаётся аргументом (история — в журнале действий).
* @param coopname Наименование кооператива
* @param hash Якорь процесса
* @param reason Причина отмены
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::canceldec(eosio::name coopname, eosio::checksum256 hash, std::string reason) {
check_auth_or_fail(_branch, coopname, coopname, "canceldec"_n);
auto dec = get_decision_or_fail(coopname, hash);
eosio::check(dec.status != "onapproval"_n, "Решение уже на рассмотрении совета — отмена недоступна");
decision_index decisions(_branch, coopname.value);
auto itr = decisions.find(dec.id);
erase_coodecquests(coopname, dec.id);
decisions.erase(itr);
}
@@ -0,0 +1,56 @@
/**
* @brief Закрытие собрания и утверждение протокола председателем.
* Председатель закрывает голосование и утверждает протокол одной своей подписью
* (секретарь не нужен — подсчёт ведёт контракт). Проверяется кворум и итог
* голосования. Для типа "free" — терминал: запись стирается (история — в журнале).
* Для "createbranch" — решение переходит в статус "approved" в ожидании исполнения
* действием exec (вывод вопроса на совет).
* @param coopname Наименование кооператива
* @param hash Якорь процесса
* @param protocol Подписанный председателем протокол решения
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::closedec(eosio::name coopname, eosio::checksum256 hash, document2 protocol) {
check_auth_or_fail(_branch, coopname, coopname, "closedec"_n);
verify_document_or_fail(protocol);
auto dec = get_decision_or_fail(coopname, hash);
eosio::check(dec.status == "voting"_n, "Решение не находится в стадии голосования");
// Кворум: число поданных бюллетеней не меньше минимального
eosio::check(dec.signed_ballots >= MIN_DECISION_QUORUM, "Не набран минимальный кворум собрания");
// Закрыть может только по истечении окна голосования либо когда проголосовали все участники
auto now = current_time_point();
bool window_passed = now.sec_since_epoch() > dec.close_at.sec_since_epoch();
bool all_voted = dec.signed_ballots >= dec.participants.size();
eosio::check(window_passed || all_voted, "Голосование ещё идёт");
// Протокол собрания пайщиков завершает ПАКЕТ СОБРАНИЯ в реестре — его якорь = хэш
// предложения/повестки (proposal.hash), к нему же привязаны бюллетени (см. votedec).
// Пакет появляется в реестре сразу при закрытии собрания. Отправка в совет (exec) —
// ОТДЕЛЬНЫЙ пакет на хэше процесса (hash), чтобы документы собрания и документы совета
// не сваливались в одну кашу под общим якорем.
Soviet::make_complete_document(_branch, coopname, dec.initiator, get_valid_soviet_action("branchdec"_n), dec.proposal.hash, protocol);
if (dec.type == "free"_n) {
// Свободное решение зафиксировано протоколом и завершается (история — в журнале действий)
decision_index decisions(_branch, coopname.value);
auto itr = decisions.find(dec.id);
erase_coodecquests(coopname, dec.id);
decisions.erase(itr);
return;
}
// Автоматизируемое решение — переходит в ожидание исполнения (exec → совет)
decision_index decisions(_branch, coopname.value);
auto itr = decisions.find(dec.id);
decisions.modify(itr, coopname, [&](auto &d) {
d.status = "approved"_n;
d.protocol = protocol;
});
}
@@ -0,0 +1,101 @@
/**
* @brief Подтверждение решения советом — создание кооперативного участка.
* Callback совета: по утверждению учреждается кооперативный участок с реквизитами
* из решения (аккаунт участка, избранный председатель), после чего запись решения
* стирается (история — в журнале действий).
* @param coopname Наименование кооператива
* @param hash Якорь процесса
* @param authorization Документ решения совета
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p soviet
*/
[[eosio::action]] void branch::confirmdec(eosio::name coopname, eosio::checksum256 hash, document2 authorization) {
require_auth(_soviet);
auto dec = get_decision_or_fail(coopname, hash);
eosio::check(dec.type == "createbranch"_n, "Решение не относится к созданию участка");
eosio::check(dec.status == "onapproval"_n, "Решение не находится на утверждении совета");
auto coop = get_cooperative_or_fail(coopname);
auto authorizer_account = get_account_or_fail(dec.chairman);
eosio::check(authorizer_account.type == "individual"_n, "Только физическое лицо может быть назначено председателем кооперативного участка");
// Создаём кооперативный участок с избранным председателем
branch_index branches(_branch, coopname.value);
// председатель может возглавлять только один кооперативный участок
auto branches_by_trustee = branches.get_index<"bytrustee"_n>();
eosio::check(branches_by_trustee.find(dec.chairman.value) == branches_by_trustee.end(),
"Избранный председатель уже является председателем другого кооперативного участка");
branches.emplace(coopname, [&](auto &row) {
row.braname = dec.braname;
row.trustee = dec.chairman;
});
action(
permission_level{_branch, "active"_n},
_registrator,
"createbranch"_n,
std::make_tuple(coopname, dec.braname))
.send();
// председатель привязывается к собственному участку и не выбирает его заявлением
action(
permission_level{_branch, "active"_n},
_soviet,
"setbranch"_n,
std::make_tuple(coopname, dec.chairman, dec.braname))
.send();
uint64_t branch_count = add_branch_count(coopname);
if (!coop.is_branched && branch_count >= 3) { // переход на режим кооперативных участков
action(
permission_level{_branch, "active"_n},
_registrator,
"enabranches"_n,
std::make_tuple(coopname))
.send();
}
// Участок учреждён. Договор о полной материальной ответственности председателя участка
// (подписан председателем участка при подаче заявления) уходит на встречную подпись
// председателю совета: единоличное одобрение без повторного голосования. Отклонить нельзя —
// решение об учреждении уже принято (см. branch::declliab).
::Soviet::create_approval(
_branch,
coopname,
dec.chairman,
dec.liability,
"branchliab"_n,
hash,
_branch,
"apprliab"_n,
"declliab"_n,
std::string(""));
// Доверенность председателю участка уходит на встречную подпись председателю совета
// отдельным одобрением рядом с договором. Якорь одобрения — хэш самого документа
// доверенности (hash процесса уже занят одобрением договора). Отклонить нельзя.
::Soviet::create_approval(
_branch,
coopname,
dec.chairman,
dec.authority,
"branchauth"_n,
dec.authority.hash,
_branch,
"apprauth"_n,
"declauth"_n,
std::string(""));
// Решение исполнено — стираем запись и вопросы повестки (история в журнале действий)
decision_index decisions(_branch, coopname.value);
auto itr = decisions.find(dec.id);
erase_coodecquests(coopname, dec.id);
decisions.erase(itr);
}
@@ -20,18 +20,31 @@
auto authorizer_account = get_account_or_fail(trustee);
eosio::check(authorizer_account.type == "individual"_n, "Только физическое лицо может быть назначено председателем кооперативного участка");
// председатель может возглавлять только один кооперативный участок
auto branches_by_trustee = branches.get_index<"bytrustee"_n>();
eosio::check(branches_by_trustee.find(trustee.value) == branches_by_trustee.end(),
"Пайщик уже является председателем другого кооперативного участка");
branches.emplace(coopname, [&](auto &row) {
row.braname = braname;
row.trustee = trustee;
});
action(
permission_level{ _branch, "active"_n},
_registrator,
"createbranch"_n,
std::make_tuple(coopname, braname)
).send();
// председатель привязывается к собственному участку и не выбирает его заявлением
action(
permission_level{ _branch, "active"_n},
_soviet,
"setbranch"_n,
std::make_tuple(coopname, trustee, braname)
).send();
uint64_t branch_count = add_branch_count(coopname);
if (!coop.is_branched && branch_count >= 3) { //регистрируем переход на кооперативные участки
@@ -0,0 +1,77 @@
/**
* @brief Объявление собрания пайщиков с повесткой (создание решения).
* Универсальный механизм: тип "free" — свободное решение (фиксируется протоколом),
* тип "createbranch" — автоматизируемое (после утверждения уходит в совет и создаёт КУ).
* @param coopname Наименование кооператива
* @param hash Якорь процесса (внешний идентификатор решения)
* @param type Тип решения: "free" | "createbranch"
* @param initiator Организатор собрания
* @param proposal Подписанное предложение/повестка
* @param braname Заранее сгенерированный аккаунт будущего КУ (для "createbranch", иначе пустое)
* @param agenda Вопросы повестки дня
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::createdec(eosio::name coopname, eosio::checksum256 hash, eosio::name type, eosio::name initiator, document2 proposal, eosio::name braname, std::vector<decision_point> agenda) {
check_auth_or_fail(_branch, coopname, coopname, "createdec"_n);
verify_document_or_fail(proposal);
eosio::check(type == "free"_n || type == "createbranch"_n, "Недопустимый тип решения");
get_cooperative_or_fail(coopname);
get_participant_or_fail(coopname, initiator);
decision_index decisions(_branch, coopname.value);
auto idx = decisions.get_index<"byhash"_n>();
eosio::check(idx.find(hash) == idx.end(), "Решение с указанным идентификатором уже существует");
eosio::check(!agenda.empty(), "Повестка дня не может быть пустой");
if (type == "createbranch"_n) {
eosio::check(braname != ""_n, "Для создания кооперативного участка требуется аккаунт участка");
}
uint64_t decision_id = get_global_id_in_scope(_branch, coopname, "decisions"_n);
decisions.emplace(coopname, [&](auto &d) {
d.id = decision_id;
d.hash = hash;
d.coopname = coopname;
d.type = type;
d.initiator = initiator;
d.chairman = ""_n; // председатель выбирается организатором из участников при открытии голосования
d.status = "opened"_n;
d.proposal = proposal;
d.signed_ballots = 0;
d.braname = braname;
d.participants = {initiator}; // организатор сразу участник
d.created_at = current_time_point();
});
coodecquest_index questions(_branch, coopname.value);
uint64_t number = 0;
for (const auto &point : agenda) {
eosio::check(!point.title.empty(), "Вопрос должен содержать заголовок");
eosio::check(!point.decision.empty(), "Вопрос должен содержать проект решения");
number++;
eosio::check(number <= 10, "Не больше 10 вопросов на повестке собрания");
questions.emplace(coopname, [&](auto &q) {
q.id = get_global_id_in_scope(_branch, coopname, "decisionq"_n);
q.decision_id = decision_id;
q.number = number;
q.coopname = coopname;
q.title = point.title;
q.decision = point.decision;
q.context = point.context;
q.counter_votes_for = 0;
q.counter_votes_against = 0;
q.counter_votes_abstained = 0;
});
}
}
@@ -0,0 +1,20 @@
/**
* @brief Запрет отклонения доверенности председателя кооперативного участка.
* Callback отклонения совета: к этому моменту решение об учреждении кооперативного
* участка уже принято советом и участок создан, поэтому доверенность председателю
* участка отклонить нельзя — председателю совета остаётся только подписать её
* встречной подписью (см. branch::apprauth). Действие всегда завершается ошибкой.
* @param coopname Наименование кооператива
* @param username Председатель кооперативного участка — уполномоченный по доверенности
* @param approval_hash Якорь одобрения (хэш документа доверенности)
* @param reason Причина отклонения (не используется)
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p soviet
*/
[[eosio::action]] void branch::declauth(eosio::name coopname, eosio::name username, eosio::checksum256 approval_hash, std::string reason) {
require_auth(_soviet);
eosio::check(false, "Доверенность председателя участка нельзя отклонить: решение совета об учреждении участка уже принято — требуется только встречная подпись председателя совета");
}
@@ -0,0 +1,24 @@
/**
* @brief Отклонение решения советом.
* Callback совета: при отказе совета запись решения и вопросы повестки стираются,
* причина передаётся аргументом (история — в журнале действий).
* @param coopname Наименование кооператива
* @param hash Якорь процесса
* @param reason Причина отклонения
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p soviet
*/
[[eosio::action]] void branch::declinedec(eosio::name coopname, eosio::checksum256 hash, std::string reason) {
require_auth(_soviet);
decision_index decisions(_branch, coopname.value);
auto idx = decisions.get_index<"byhash"_n>();
auto itr = idx.find(hash);
if (itr != idx.end()) {
erase_coodecquests(coopname, itr->id);
idx.erase(itr);
}
}
@@ -0,0 +1,21 @@
/**
* @brief Запрет отклонения договора о материальной ответственности председателя участка.
* Callback отклонения совета: к этому моменту решение об учреждении кооперативного
* участка уже принято советом и участок создан, поэтому договор о материальной
* ответственности отклонить нельзя — председателю совета остаётся только подписать его
* встречной подписью (см. branch::apprliab). Действие всегда завершается ошибкой, тем
* самым гарантируя, что процесс не уйдёт в неопределённое состояние.
* @param coopname Наименование кооператива
* @param username Председатель кооперативного участка — сторона «Исполнитель»
* @param approval_hash Якорь процесса (хэш одобрения)
* @param reason Причина отклонения (не используется)
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p soviet
*/
[[eosio::action]] void branch::declliab(eosio::name coopname, eosio::name username, eosio::checksum256 approval_hash, std::string reason) {
require_auth(_soviet);
eosio::check(false, "Договор о материальной ответственности председателя участка нельзя отклонить: решение совета об учреждении участка уже принято — требуется только встречная подпись председателя совета");
}
@@ -0,0 +1,20 @@
/**
* @brief Отклонение заявки доверенного лица председателем участка.
* Заявка стирается, причина передаётся аргументом (история — в журнале действий).
* @param coopname Наименование кооператива
* @param hash Внешний идентификатор заявки
* @param reason Причина отклонения
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::decltrusted(eosio::name coopname, eosio::checksum256 hash, std::string reason) {
check_auth_or_fail(_branch, coopname, coopname, "decltrusted"_n);
auto req = get_trustreq_or_fail(coopname, hash);
trustreq_index trustreqs(_branch, coopname.value);
auto ritr = trustreqs.find(req.id);
trustreqs.erase(ritr);
}
@@ -0,0 +1,22 @@
/**
* @brief Удаление аккаунта из белого списка кооперативного участка.
* Удаляет аккаунт пайщика из белого списка приватного кооперативного участка.
* @param coopname Наименование кооператива
* @param braname Наименование кооперативного участка
* @param account Аккаунт пайщика для удаления
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::delwhite(eosio::name coopname, eosio::name braname, eosio::name account) {
check_auth_or_fail(_branch, coopname, coopname, "delwhite"_n);
branch_index branches(_branch, coopname.value);
auto branch = branches.find(braname.value);
eosio::check(branch != branches.end(), "Кооперативный участок не найден");
branches.modify(branch, coopname, [&](auto &b) {
b.remove_account_from_whitelist(account);
});
}
@@ -20,8 +20,34 @@
auto authorizer_account = get_account_or_fail(trustee);
eosio::check(authorizer_account.type == "individual"_n, "Только физическое лицо может быть назначено председателем кооперативного участка");
eosio::name previous_trustee = branch->trustee;
if (previous_trustee != trustee) {
// председатель может возглавлять только один кооперативный участок
auto branches_by_trustee = branches.get_index<"bytrustee"_n>();
eosio::check(branches_by_trustee.find(trustee.value) == branches_by_trustee.end(),
"Пайщик уже является председателем другого кооперативного участка");
}
branches.modify(branch, coopname, [&](auto &b) {
b.trustee = trustee;
});
if (previous_trustee != trustee) {
// прежний председатель освобождается от привязки и выбирает участок заново заявлением
action(
permission_level{ _branch, "active"_n},
_soviet,
"setbranch"_n,
std::make_tuple(coopname, previous_trustee, ""_n)
).send();
// новый председатель привязывается к собственному участку
action(
permission_level{ _branch, "active"_n},
_soviet,
"setbranch"_n,
std::make_tuple(coopname, trustee, braname)
).send();
}
}
@@ -0,0 +1,71 @@
/**
* @brief Исполнение утверждённого решения о создании кооперативного участка.
* Председатель собрания формирует заявление в совет и выводит вопрос об учреждении
* кооперативного участка на рассмотрение совета. К повестке совета прилагаются
* протокол собрания и бюллетени (по тому же якорю процесса).
* @param coopname Наименование кооператива
* @param hash Якорь процесса
* @param petition Подписанное заявление председателя в совет
* @param liability Подписанный председателем участка договор о полной материальной ответственности
* @param authority Подписанная председателем участка доверенность председателю кооперативного участка
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::exec(eosio::name coopname, eosio::checksum256 hash, document2 petition, document2 liability, document2 authority) {
check_auth_or_fail(_branch, coopname, coopname, "exec"_n);
verify_document_or_fail(petition);
verify_document_or_fail(liability);
verify_document_or_fail(authority);
auto dec = get_decision_or_fail(coopname, hash);
eosio::check(dec.type == "createbranch"_n, "Исполнение доступно только для решения о создании участка");
eosio::check(dec.status == "approved"_n, "Решение не утверждено председателем");
decision_index decisions(_branch, coopname.value);
auto itr = decisions.find(dec.id);
decisions.modify(itr, coopname, [&](auto &d) {
d.status = "onapproval"_n;
d.petition = petition;
d.liability = liability;
d.authority = authority;
});
::Soviet::create_agenda(
_branch,
coopname,
dec.chairman,
get_valid_soviet_action("branchdec"_n),
hash,
_branch,
"confirmdec"_n,
"declinedec"_n,
petition,
std::string(""));
// К пакету повестки совета линкуются договор о материальной ответственности и доверенность
// председателя участка — совет читает их вместе с заявлением до утверждения (по якорю процесса).
Action::send<newlink_interface>(
_soviet,
"newlink"_n,
_branch,
coopname,
dec.chairman,
get_valid_soviet_action("branchliab"_n),
hash,
liability
);
Action::send<newlink_interface>(
_soviet,
"newlink"_n,
_branch,
coopname,
dec.chairman,
get_valid_soviet_action("branchauth"_n),
hash,
authority
);
}
@@ -0,0 +1,26 @@
/**
* @brief Присоединение пайщика к собранию.
* Пайщик фиксируется в списке участников собрания.
* @param coopname Наименование кооператива
* @param hash Якорь процесса
* @param username Имя присоединяющегося пайщика
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::joindec(eosio::name coopname, eosio::checksum256 hash, eosio::name username) {
check_auth_or_fail(_branch, coopname, coopname, "joindec"_n);
get_participant_or_fail(coopname, username);
auto dec = get_decision_or_fail(coopname, hash);
eosio::check(dec.status == "opened"_n, "Присоединиться можно только до начала голосования");
eosio::check(!dec.is_participant(username), "Пайщик уже присоединился к собранию");
decision_index decisions(_branch, coopname.value);
auto itr = decisions.find(dec.id);
decisions.modify(itr, coopname, [&](auto &d) {
d.participants.push_back(username);
});
}
@@ -0,0 +1,42 @@
/**
* @brief Подача заявки на приём доверенным лицом кооперативного участка.
* Пайщик прикладывает подписанные заявление и договор о полной материальной
* ответственности. Заявка ожидает одобрения председателя участка.
* @param coopname Наименование кооператива
* @param braname Кооперативный участок
* @param username Заявитель
* @param hash Внешний идентификатор заявки
* @param application Подписанный заявителем договор о полной материальной ответственности
* @param authority Подписанная заявителем доверенность доверенному лицу/оператору участка
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::reqtrusted(eosio::name coopname, eosio::name braname, eosio::name username, eosio::checksum256 hash, document2 application, document2 authority) {
check_auth_or_fail(_branch, coopname, coopname, "reqtrusted"_n);
verify_document_or_fail(application);
verify_document_or_fail(authority);
get_participant_or_fail(coopname, username);
auto br = get_branch_or_fail(coopname, braname);
eosio::check(br.trustee != username, "Председатель участка не может быть доверенным лицом");
eosio::check(!br.is_account_in_trusted(username), "Пайщик уже является доверенным лицом участка");
eosio::check(br.trusted.size() < 3, "Достигнут предел доверенных лиц участка (не более трёх)");
trustreq_index trustreqs(_branch, coopname.value);
auto idx = trustreqs.get_index<"byhash"_n>();
eosio::check(idx.find(hash) == idx.end(), "Заявка с указанным идентификатором уже существует");
trustreqs.emplace(coopname, [&](auto &r) {
r.id = get_global_id_in_scope(_branch, coopname, "trustreqs"_n);
r.hash = hash;
r.coopname = coopname;
r.braname = braname;
r.username = username;
r.application = application;
r.authority = authority;
});
}
@@ -0,0 +1,23 @@
/**
* @brief Установка признака приватности кооперативного участка.
* Приватный кооперативный участок нельзя выбрать при вступлении или смене участка,
* если аккаунт пайщика не добавлен в белый список этого участка.
* @param coopname Наименование кооператива
* @param braname Наименование кооперативного участка
* @param is_private Признак приватности (true — приватный, false — публичный)
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::setprivate(eosio::name coopname, eosio::name braname, bool is_private) {
check_auth_or_fail(_branch, coopname, coopname, "setprivate"_n);
branch_index branches(_branch, coopname.value);
auto branch = branches.find(braname.value);
eosio::check(branch != branches.end(), "Кооперативный участок не найден");
branches.modify(branch, coopname, [&](auto &b) {
b.set_private(is_private);
});
}
@@ -0,0 +1,72 @@
/**
* @brief Открытие голосования по решению.
* Организатор собрания (он же председатель собрания) открывает голосование,
* указывая избираемого председателя кооперативного участка (из числа
* присоединившихся участников) и — для решения "createbranch" —
* адрес привязки кооперативного участка, определённый собранием.
* Окно голосования отмеряется автоматически (DECISION_VOTING_WINDOW_SECONDS).
* @param coopname Наименование кооператива
* @param hash Якорь процесса
* @param chairman Избираемый председатель кооперативного участка (из участников)
* @param address Адрес привязки кооперативного участка (для "createbranch")
* @param agenda Итоговая повестка собрания: если непуста — заменяет предварительную
* (уточнённые на собрании формулировки и дополнительные вопросы); пустая — повестка без изменений
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::startdec(eosio::name coopname, eosio::checksum256 hash, eosio::name chairman, std::string address, std::vector<decision_point> agenda) {
check_auth_or_fail(_branch, coopname, coopname, "startdec"_n);
auto dec = get_decision_or_fail(coopname, hash);
eosio::check(dec.status == "opened"_n, "Голосование уже начато или собрание закрыто");
eosio::check(dec.participants.size() >= MIN_DECISION_QUORUM,
"Для открытия голосования требуется не менее 3 участников собрания");
eosio::check(dec.is_participant(chairman), "Председатель участка должен быть участником собрания");
if (dec.type == "createbranch"_n) {
eosio::check(!address.empty(), "Для создания кооперативного участка требуется адрес привязки");
}
// Итоговая повестка собрания: непустой список заменяет предварительную
// (формулировки уточняются на собрании — наименование участка, председатель, новые вопросы)
coodecquest_index questions(_branch, coopname.value);
uint64_t number = 0;
if (!agenda.empty()) {
erase_coodecquests(coopname, dec.id);
}
for (const auto &point : agenda) {
eosio::check(!point.title.empty(), "Вопрос должен содержать заголовок");
eosio::check(!point.decision.empty(), "Вопрос должен содержать проект решения");
number++;
eosio::check(number <= 10, "Не больше 10 вопросов на повестке собрания");
questions.emplace(coopname, [&](auto &q) {
q.id = get_global_id_in_scope(_branch, coopname, "decisionq"_n);
q.decision_id = dec.id;
q.number = number;
q.coopname = coopname;
q.title = point.title;
q.decision = point.decision;
q.context = point.context;
q.counter_votes_for = 0;
q.counter_votes_against = 0;
q.counter_votes_abstained = 0;
});
}
auto now = current_time_point();
decision_index decisions(_branch, coopname.value);
auto itr = decisions.find(dec.id);
decisions.modify(itr, coopname, [&](auto &d) {
d.status = "voting"_n;
d.chairman = chairman;
d.address = address;
d.open_at = eosio::time_point_sec(now.sec_since_epoch());
d.close_at = eosio::time_point_sec(now.sec_since_epoch() + DECISION_VOTING_WINDOW_SECONDS);
});
}
@@ -0,0 +1,95 @@
/**
* @brief Голосование по вопросам повестки бюллетенем.
* Участник подаёт бюллетень-заявление с волеизъявлением (за/против/воздержался)
* по каждому вопросу повестки. Подсчёт ведёт контракт. Адаптация meet::vote.
* @param coopname Наименование кооператива
* @param hash Якорь процесса
* @param username Имя голосующего участника
* @param ballot Подписанный бюллетень
* @param votes Массив голосов по вопросам повестки
* @ingroup public_actions
* @ingroup public_branch_actions
* @note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void branch::votedec(eosio::name coopname, eosio::checksum256 hash, eosio::name username, document2 ballot, std::vector<decision_vote_point> votes) {
check_auth_or_fail(_branch, coopname, coopname, "votedec"_n);
verify_document_or_fail(ballot);
auto dec = get_decision_or_fail(coopname, hash);
eosio::check(dec.status == "voting"_n, "Голосование не открыто");
eosio::check(dec.is_participant(username), "Голосовать может только участник собрания");
auto now = current_time_point();
eosio::check(now.sec_since_epoch() >= dec.open_at.sec_since_epoch(), "Голосование ещё не началось");
eosio::check(now.sec_since_epoch() <= dec.close_at.sec_since_epoch(), "Голосование завершено");
// Собираем вопросы данного решения
coodecquest_index questions(_branch, coopname.value);
auto by_dec = questions.get_index<"bydecision"_n>();
std::vector<coodecquest> decision_questions;
for (auto itr = by_dec.lower_bound(dec.id); itr != by_dec.end() && itr->decision_id == dec.id; ++itr) {
decision_questions.push_back(*itr);
}
eosio::check(votes.size() == decision_questions.size(), "Бюллетень должен содержать голоса по всем вопросам");
// Бюллетень покрывает все вопросы без дублирования
std::set<uint64_t> question_ids;
for (const auto &q : decision_questions) {
question_ids.insert(q.id);
}
std::set<uint64_t> voted_ids;
for (const auto &v : votes) {
eosio::check(question_ids.find(v.question_id) != question_ids.end(), "В бюллетене указан неизвестный вопрос");
eosio::check(voted_ids.insert(v.question_id).second, "Дублирование голосов по одному вопросу недопустимо");
}
// Участник не голосовал ранее
for (const auto &q : decision_questions) {
auto already = [&](const std::vector<eosio::name> &voters) {
return std::find(voters.begin(), voters.end(), username) != voters.end();
};
eosio::check(!already(q.voters_for) && !already(q.voters_against) && !already(q.voters_abstained), "Ваш голос уже учтён");
}
// Учитываем голоса
for (const auto &v : votes) {
auto qitr = questions.find(v.question_id);
eosio::check(qitr != questions.end(), "Вопрос не найден");
questions.modify(qitr, coopname, [&](auto &q) {
if (v.vote == "for"_n) {
q.counter_votes_for++;
q.voters_for.push_back(username);
} else if (v.vote == "against"_n) {
q.counter_votes_against++;
q.voters_against.push_back(username);
} else if (v.vote == "abstained"_n) {
q.counter_votes_abstained++;
q.voters_abstained.push_back(username);
} else {
eosio::check(false, "Недопустимое значение голоса");
}
});
}
decision_index decisions(_branch, coopname.value);
auto ditr = decisions.find(dec.id);
decisions.modify(ditr, coopname, [&](auto &d) {
d.signed_ballots++;
});
// Бюллетень линкуется к ПАКЕТУ СОБРАНИЯ (якорь = хэш предложения proposal.hash),
// туда же закрывающим документом ляжет протокол (см. closedec). Не на хэш процесса,
// иначе бюллетени попадут в пакет заявления в совет.
Action::send<newlink_interface>(
_soviet,
"newlink"_n,
_branch,
coopname,
username,
get_valid_soviet_action("ballot"_n),
dec.proposal.hash,
ballot
);
}
+4
View File
@@ -92,6 +92,10 @@ static const std::set<eosio::name> soviet_actions = {
"completegm"_n, //решение общего собрания пайщиков
"ballot"_n, //бюллетень участника общего собрания пайщиков
"gmnotify"_n, //уведомление участника общего собрания пайщиков
//BRANCH
"branchdec"_n, //решение собрания пайщиков об учреждении кооперативного участка
"branchliab"_n, //договор о полной материальной ответственности председателя участка (линк в пакет совета)
"branchauth"_n, //доверенность председателю кооперативного участка (линк в пакет совета)
//CAPITAL
"capitalinvst"_n, //заявление на инвестиции по договору УХД
"createresult"_n, //клайм прироста благороста из задания
@@ -48,6 +48,8 @@
#include "table_branch_branches.hpp"
#include "table_branch_branchstat.hpp"
#include "table_branch_decisions.hpp"
#include "table_branch_trustreqs.hpp"
// wallet / gateway / ledger / loan / marketplace
#include "table_wallet_deposits.hpp"
@@ -1,6 +1,7 @@
#pragma once
#include <algorithm>
#include <eosio/binary_extension.hpp>
#include <eosio/eosio.hpp>
#include <vector>
@@ -16,6 +17,12 @@ struct [[eosio::table, eosio::contract(BRANCH)]] coobranch {
eosio::name trustee;
std::vector<eosio::name> trusted;
// Поля приватности добавлены как binary_extension: у действующих кооперативов
// в таблице уже могут быть строки, и расширение в конце struct сохраняет
// обратную совместимость десериализации (старые записи читаются как публичные с пустым списком).
eosio::binary_extension<bool> is_private; // приватный участок: выбрать его могут только из белого списка
eosio::binary_extension<std::vector<eosio::name>> whitelist; // белый список аккаунтов, допущенных к выбору приватного участка
uint64_t primary_key() const { return braname.value; }
uint64_t by_trustee() const { return trustee.value; }
@@ -37,6 +44,38 @@ struct [[eosio::table, eosio::contract(BRANCH)]] coobranch {
}
return is_account_in_trusted(username);
}
// признак приватности с учётом отсутствующего расширения у старых записей
// (const-перегрузка value_or() без аргумента возвращает bool() == false, если значения нет)
bool is_branch_private() const { return is_private.value_or(); }
bool is_account_in_whitelist(const eosio::name &account) const {
if (!whitelist.has_value()) {
return false;
}
const auto &wl = whitelist.value();
return std::find(wl.begin(), wl.end(), account) != wl.end();
}
void set_private(bool value) { is_private.emplace(value); }
void add_account_to_whitelist(const eosio::name &account) {
if (!whitelist.has_value()) {
whitelist.emplace();
}
auto &wl = whitelist.value();
eosio::check(std::find(wl.begin(), wl.end(), account) == wl.end(),
"Аккаунт уже добавлен в белый список кооперативного участка");
wl.push_back(account);
}
void remove_account_from_whitelist(const eosio::name &account) {
eosio::check(whitelist.has_value(), "Белый список кооперативного участка пуст");
auto &wl = whitelist.value();
auto it = std::find(wl.begin(), wl.end(), account);
eosio::check(it != wl.end(), "Аккаунт не найден в белом списке кооперативного участка");
wl.erase(it);
}
};
typedef eosio::multi_index<
@@ -0,0 +1,138 @@
#pragma once
#include <algorithm>
#include <eosio/eosio.hpp>
#include <string>
#include <vector>
#include "../consts.hpp"
#include "document_core.hpp"
/**
* @file
* Универсальный механизм «собрание пайщиков → решение» на контракте BRANCH.
* Зеркало Совета: тип решения `free` (свободное — просто фиксируется протоколом)
* либо автоматизируемый (`createbranch` — после утверждения председателем уходит
* в совет действием exec и по решению совета создаёт кооперативный участок).
* Якорь записи — hash (byhash). Голос фиксируется бюллетенем-заявлением;
* протокол утверждает один председатель собрания (секретарь не нужен).
* Жизненный цикл — Chain-RAM: терминал = erase, история — в журнале действий парсера.
*/
/// Минимальный кворум учредительного собрания (число подписанных бюллетеней).
static constexpr uint64_t MIN_DECISION_QUORUM = 3;
/// Длительность окна голосования собрания пайщиков участка (секунд).
/// Голосование короткое — проходит прямо на собрании: организатор открывает
/// его кнопкой, дедлайн отмеряется автоматически.
static constexpr uint32_t DECISION_VOTING_WINDOW_SECONDS = 15 * 60;
/// Точка повестки дня собрания (вход действия createdec).
struct decision_point {
std::string title; ///< Текст вопроса
std::string decision; ///< Проект решения по вопросу
std::string context; ///< Контекст вопроса (может быть пустым)
};
/// Голос участника по одному вопросу повестки (вход действия votedec).
struct decision_vote_point {
uint64_t question_id; ///< Идентификатор вопроса
eosio::name vote; ///< Голос: "for"_n | "against"_n | "abstained"_n
};
/**
* @ingroup public_tables
* @ingroup public_branch_tables
* @par table: decisions
*/
struct [[eosio::table, eosio::contract(BRANCH)]] coodecision {
uint64_t id; ///< Идентификатор решения
eosio::checksum256 hash; ///< Якорь процесса (внешний идентификатор)
eosio::name coopname; ///< Имя кооператива
eosio::name type; ///< Тип решения: "free" | "createbranch"
eosio::name initiator; ///< Организатор собрания
eosio::name chairman; ///< Председатель собрания — выбирается организатором из участников при открытии голосования (для createbranch — он же кандидат в председатели КУ)
eosio::name status; ///< opened | voting | approved | onapproval
document2 proposal; ///< Подписанное предложение/повестка инициатора
document2 protocol; ///< Протокол решения (утверждает председатель)
document2 petition; ///< Заявление председателя в совет (для createbranch)
document2 liability; ///< Договор о полной индивидуальной материальной ответственности председателя КУ (подписан председателем участка, ждёт встречной подписи председателя совета)
document2 authority; ///< Доверенность председателю КУ (подписана председателем участка, ждёт встречной подписи председателя совета)
document2 authorization; ///< Решение совета (callback)
eosio::time_point_sec open_at; ///< Начало голосования
eosio::time_point_sec close_at; ///< Плановое закрытие голосования
uint64_t signed_ballots; ///< Число поданных бюллетеней
eosio::name braname; ///< Заранее сгенерированный аккаунт будущего КУ (createbranch)
std::string address; ///< Адрес привязки КУ (createbranch)
std::vector<eosio::name> participants; ///< Присоединившиеся участники собрания
eosio::time_point_sec created_at; ///< Дата создания
// Место и время проведения собрания НЕ публикуются в блокчейн —
// это приватные данные пайщиков, живут в БД платформы (composite db-слой).
uint64_t primary_key() const { return id; }
eosio::checksum256 by_hash() const { return hash; }
bool is_participant(const eosio::name &username) const {
return std::find(participants.begin(), participants.end(), username) != participants.end();
}
};
typedef eosio::multi_index<
"decisions"_n, coodecision,
eosio::indexed_by<"byhash"_n, eosio::const_mem_fun<coodecision, eosio::checksum256, &coodecision::by_hash>>>
decision_index;
/**
* @ingroup public_tables
* @ingroup public_branch_tables
* @par table: decisionq
*/
struct [[eosio::table, eosio::contract(BRANCH)]] coodecquest {
uint64_t id; ///< Идентификатор вопроса
uint64_t decision_id; ///< Идентификатор решения
uint64_t number; ///< Номер вопроса в повестке
eosio::name coopname; ///< Имя кооператива
std::string title; ///< Текст вопроса
std::string decision; ///< Проект решения по вопросу
std::string context; ///< Контекст вопроса
uint64_t counter_votes_for; ///< Счётчик голосов за
uint64_t counter_votes_against; ///< Счётчик голосов против
uint64_t counter_votes_abstained; ///< Счётчик воздержавшихся
std::vector<eosio::name> voters_for; ///< Проголосовавшие за
std::vector<eosio::name> voters_against; ///< Проголосовавшие против
std::vector<eosio::name> voters_abstained; ///< Воздержавшиеся
uint64_t primary_key() const { return id; }
uint64_t by_decision() const { return decision_id; }
};
typedef eosio::multi_index<
"decisionq"_n, coodecquest,
eosio::indexed_by<"bydecision"_n, eosio::const_mem_fun<coodecquest, uint64_t, &coodecquest::by_decision>>>
coodecquest_index;
/// Получить решение по якорю или упасть.
inline coodecision get_decision_or_fail(eosio::name coopname, eosio::checksum256 hash) {
decision_index decisions(_branch, coopname.value);
auto idx = decisions.get_index<"byhash"_n>();
auto itr = idx.find(hash);
eosio::check(itr != idx.end(), "Решение собрания не найдено");
return *itr;
}
/// Удалить все вопросы повестки данного решения (при erase решения).
inline void erase_coodecquests(eosio::name coopname, uint64_t decision_id) {
coodecquest_index questions(_branch, coopname.value);
auto by_dec = questions.get_index<"bydecision"_n>();
auto itr = by_dec.lower_bound(decision_id);
while (itr != by_dec.end() && itr->decision_id == decision_id) {
itr = by_dec.erase(itr);
}
}
@@ -0,0 +1,45 @@
#pragma once
#include <eosio/eosio.hpp>
#include "../consts.hpp"
#include "document_core.hpp"
/**
* @file
* Заявки на приём доверенным лицом кооперативного участка.
* Pending-таблица: заявитель подаёт подписанные заявление и договор о полной
* материальной ответственности; председатель участка одобряет встречной подписью —
* пайщик добавляется в trusted участка, заявка стирается (история — в журнале действий).
*/
/**
* @ingroup public_tables
* @ingroup public_branch_tables
* @par table: trustreqs
*/
struct [[eosio::table, eosio::contract(BRANCH)]] trustreq {
uint64_t id; ///< Идентификатор заявки
eosio::checksum256 hash; ///< Внешний идентификатор заявки
eosio::name coopname; ///< Имя кооператива
eosio::name braname; ///< Кооперативный участок
eosio::name username; ///< Заявитель
document2 application; ///< Договор о полной материальной ответственности доверенного лица (подписан заявителем)
document2 authority; ///< Доверенность доверенному лицу/оператору участка (подписана заявителем, ждёт встречной подписи председателя участка)
uint64_t primary_key() const { return id; }
eosio::checksum256 by_hash() const { return hash; }
};
typedef eosio::multi_index<
"trustreqs"_n, trustreq,
eosio::indexed_by<"byhash"_n, eosio::const_mem_fun<trustreq, eosio::checksum256, &trustreq::by_hash>>>
trustreq_index;
inline trustreq get_trustreq_or_fail(eosio::name coopname, eosio::checksum256 hash) {
trustreq_index trustreqs(_branch, coopname.value);
auto idx = trustreqs.get_index<"byhash"_n>();
auto itr = idx.find(hash);
eosio::check(itr != idx.end(), "Заявка доверенного не найдена");
return *itr;
}
@@ -64,6 +64,7 @@ transitions:
- Пайщик зарегистрирован в кооперативе (participants).
- Указанный участок существует (branches).
- Заявление подписано ЭЦП.
- Если участок приватный — пайщик должен быть в белом списке участка.
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
scenario:
@@ -81,6 +82,7 @@ scenario:
- Пайщик присутствует в participants кооператива.
- Участок (branch) с указанным braname существует.
- Заявление подписано ЭЦП.
- Приватный участок доступен к выбору только пайщикам из его белого списка.
post:
- participants.braname = выбранный участок.
- Документ заявления зафиксирован в реестрах документов.
@@ -29,6 +29,7 @@
#include "src/board/updateboard.cpp"
#include "src/branch/deletebranch.cpp"
#include "src/branch/setbranch.cpp"
#include "src/decision/authorize.cpp"
#include "src/decision/freedecision.cpp"
@@ -238,6 +238,7 @@ public:
//branch.cpp
[[eosio::action]] void deletebranch(eosio::name coopname, eosio::name braname);
[[eosio::action]] void setbranch(eosio::name coopname, eosio::name username, eosio::name braname);
};
@@ -0,0 +1,33 @@
/**
* @brief Системная привязка пайщика к кооперативному участку
* Устанавливает или сбрасывает кооперативный участок пайщика без заявления —
* вызывается контрактом участков при назначении или смене председателя участка:
* председатель всегда привязан к собственному участку и не выбирает его заявлением.
* Пустое наименование участка сбрасывает привязку (пайщик выберет участок заново).
* @param coopname Наименование кооператива
* @param username Наименование пайщика
* @param braname Наименование кооперативного участка (пустое — сброс привязки)
* @ingroup public_actions
* @ingroup public_soviet_actions
* @note Авторизация требуется от аккаунта: @p _branch
*/
void soviet::setbranch(eosio::name coopname, eosio::name username, eosio::name braname) {
require_auth(_branch);
participants_index participants(_soviet, coopname.value);
auto participant = participants.find(username.value);
// председатель участка может не быть пайщиком на момент учреждения — не роняем создание участка
if (participant == participants.end()) {
return;
}
participants.modify(participant, get_self(), [&](auto& row) {
if (braname == ""_n) {
row.braname.reset();
} else {
row.braname = braname;
}
});
}
@@ -15,7 +15,22 @@
require_auth(coopname);
verify_document_or_fail(document);
get_branch_or_fail(coopname, braname);
auto target_branch = get_branch_or_fail(coopname, braname);
// приватный кооперативный участок может выбрать только аккаунт из белого списка участка;
// белым списком управляет председатель совета (действия addwhite/delwhite контракта branch)
if (target_branch.is_branch_private()) {
eosio::check(target_branch.is_account_in_whitelist(username),
"Кооперативный участок приватный — выбрать его могут только пайщики из белого списка участка");
}
// председатель кооперативного участка привязан к собственному участку
// и не может сменить его, пока исполняет обязанности
branch_index branches(_branch, coopname.value);
auto branches_by_trustee = branches.get_index<"bytrustee"_n>();
eosio::check(branches_by_trustee.find(username.value) == branches_by_trustee.end(),
"Председатель кооперативного участка не может сменить участок, пока исполняет обязанности председателя");
participants_index participants(_soviet, coopname.value);
auto participant = participants.find(username.value);
eosio::check(participant != participants.end(), "Пайщик не найден");
-1
View File
@@ -1,5 +1,4 @@
{
"cSpell.words": ["subcode"],
"editor.formatOnSave": false,
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": null,
@@ -22,6 +22,7 @@ import { randomUUID } from 'crypto';
import type { Cooperative } from 'cooptypes';
import { ORGANIZATION_REPOSITORY, OrganizationRepository } from '~/domain/common/repositories/organization.repository';
import { INDIVIDUAL_REPOSITORY, IndividualRepository } from '~/domain/common/repositories/individual.repository';
import type { PassportDataDomainInterface } from '~/domain/common/interfaces/passport-data-domain.interface';
import { ENTREPRENEUR_REPOSITORY, EntrepreneurRepository } from '~/domain/common/repositories/entrepreneur.repository';
import {
SEARCH_PRIVATE_ACCOUNTS_REPOSITORY,
@@ -182,6 +183,35 @@ export class AccountInteractor {
return result;
}
/**
* Self-service: пайщик сохраняет СВОИ паспортные данные в реестр.
*
* Принимается только если паспорт ещё не был установлен ранее — существующие
* данные не перезатираются (их изменение — отдельный процесс, не здесь).
* Нужно для ролей, которым паспорт обязателен (председатель участка, доверенное
* лицо) в кооперативах, где паспорт при регистрации не собирается.
*/
async saveOwnPassport(username: string, passport: PassportDataDomainInterface): Promise<AccountDomainEntity> {
const existing = await this.individualRepository.findByUsername(username);
if (!existing) {
throw new HttpApiError(httpStatus.BAD_REQUEST, 'Паспортные данные можно сохранить только для физического лица');
}
if (existing.passport) {
throw new HttpApiError(
httpStatus.BAD_REQUEST,
'Паспортные данные уже установлены ранее — их изменение производится отдельным процессом'
);
}
await this.individualRepository.create({ ...existing, passport, username });
const account = await this.getAccount(username);
this.eventsService.emit('account::updated', { username, account });
return new AccountDomainEntity(account);
}
/**
* Удаление аккаунта из системы учёта провайдера (off-chain).
*
@@ -12,6 +12,7 @@ import type { PaginationResultDomainInterface } from '~/domain/common/interfaces
import { RegisterAccountInputDTO } from '../dto/register-account-input.dto';
import { RegisteredAccountDTO } from '../dto/registered-account.dto';
import { UpdateAccountInputDTO } from '../dto/update-account-input.dto';
import { PassportInputDTO } from '../dto/passport-input.dto';
import { DeleteAccountInputDTO } from '../dto/delete-account-input.dto';
import { SearchPrivateAccountsInputDTO } from '../dto/search-private-accounts-input.dto';
import { PrivateAccountSearchResultDTO } from '../dto/search-private-accounts-result.dto';
@@ -111,4 +112,17 @@ export class AccountResolver {
): Promise<AccountDTO> {
return await this.accountService.updateAccount(data);
}
@Mutation(() => AccountDTO, {
name: 'saveMyPassport',
description:
'Сохранить собственные паспортные данные в реестре пайщиков. Применяется, когда паспорт ранее не был указан (например, при подписании договора материальной ответственности председателем кооперативного участка или доверенным лицом). Если паспортные данные уже установлены — они не перезаписываются.',
})
@UseGuards(GqlJwtAuthGuard)
async saveMyPassport(
@Args('passport', { type: () => PassportInputDTO }) passport: PassportInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<AccountDTO> {
return await this.accountService.saveOwnPassport(currentUser.username, passport);
}
}
@@ -8,6 +8,7 @@ import type { RegisterAccountInputDTO } from '../dto/register-account-input.dto'
import { RegisteredAccountDTO } from '../dto/registered-account.dto';
import type { DeleteAccountInputDTO } from '../dto/delete-account-input.dto';
import type { UpdateAccountInputDTO } from '../dto/update-account-input.dto';
import type { PassportInputDTO } from '../dto/passport-input.dto';
import type { SearchPrivateAccountsInputDTO } from '../dto/search-private-accounts-input.dto';
import { PrivateAccountSearchResultDTO } from '../dto/search-private-accounts-result.dto';
@@ -20,6 +21,11 @@ export class AccountService {
return new AccountDTO(result);
}
public async saveOwnPassport(username: string, passport: PassportInputDTO): Promise<AccountDTO> {
const result = await this.accountInteractor.saveOwnPassport(username, passport);
return new AccountDTO(result);
}
public async deleteAccount(data: DeleteAccountInputDTO): Promise<void> {
await this.accountInteractor.deleteAccount(data.username_for_delete);
}
@@ -0,0 +1,14 @@
import { Field, InputType } from '@nestjs/graphql';
import type { AddBranchWhitelistDomainInterface } from '~/domain/branch/interfaces/add-branch-whitelist-domain-input.interface';
@InputType('AddBranchWhitelistInput')
export class AddBranchWhitelistGraphQLInput implements AddBranchWhitelistDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
coopname!: string;
@Field(() => String, { description: 'Имя аккаунта кооперативного участка' })
braname!: string;
@Field(() => String, { description: 'Имя аккаунта пайщика, добавляемого в белый список участка' })
account!: string;
}
@@ -1,9 +1,11 @@
import { ObjectType, Field } from '@nestjs/graphql';
import { ObjectType, Field, Int } from '@nestjs/graphql';
import { RepresentedByDTO } from '~/application/common/dto/represented-by.dto';
import type { BranchDomainInterface } from '~/domain/branch/interfaces/branch-domain.interface';
import type { BranchDomainEntity } from '~/domain/branch/entities/branch-domain.entity';
import { OrganizationDetailsDTO } from '~/application/common/dto/organization-details.dto';
import { IndividualDTO } from '~/application/common/dto/individual.dto';
import { IndividualCertificateDTO } from '~/application/common/dto/individual-certificate.dto';
import { AccountType } from '~/application/account/enum/account-type.enum';
import { IsArray, IsJSON, IsString } from 'class-validator';
import { BankPaymentMethodDTO } from '~/application/payment-method/dto/bank-payment-method.dto';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
@@ -31,6 +33,14 @@ export class BranchDTO implements BranchDomainInterface {
@IsArray()
public readonly trusted: IndividualDTO[];
// публичная часть (сертификаты — только ФИО и имя аккаунта) доступна любому
// пайщику; полные персональные данные выше остаются под ограничением ролей
@Field(() => IndividualCertificateDTO, { description: 'Сертификат председателя кооперативного участка (ФИО)' })
public readonly trustee_certificate: IndividualCertificateDTO;
@Field(() => [IndividualCertificateDTO], { description: 'Сертификаты доверенных лиц участка (ФИО)' })
public readonly trusted_certificates: IndividualCertificateDTO[];
@Field(() => String, { description: 'Тип организации' })
@IsString()
public readonly type: string;
@@ -79,11 +89,33 @@ export class BranchDTO implements BranchDomainInterface {
@IsJSON()
public readonly details: OrganizationDetailsDTO;
@Field(() => Int, { description: 'Количество пайщиков, состоящих в кооперативном участке' })
public readonly participants_count: number;
@Field(() => Boolean, {
description: 'Приватный кооперативный участок: выбрать его при вступлении или смене могут только пайщики из белого списка',
})
public readonly is_private: boolean;
@Field(() => Boolean, {
description: 'Доступен ли участок текущему пайщику для выбора (публичный участок либо пайщик в белом списке)',
})
public readonly is_available: boolean;
// список ФИО пайщиков белого списка нужен председателю для управления приватным участком
@Field(() => [IndividualCertificateDTO], { description: 'Пайщики в белом списке приватного участка (ФИО)' })
@AuthRoles(['chairman', 'member'])
public readonly whitelist_certificates: IndividualCertificateDTO[];
constructor(entity: BranchDomainEntity) {
this.coopname = entity.coopname;
this.braname = entity.braname;
this.trustee = new IndividualDTO(entity.trustee);
this.trusted = entity.trusted.map((trustedEntity) => new IndividualDTO(trustedEntity));
this.trustee_certificate = new IndividualCertificateDTO({ ...entity.trustee, type: AccountType.individual });
this.trusted_certificates = entity.trusted.map(
(trustedEntity) => new IndividualCertificateDTO({ ...trustedEntity, type: AccountType.individual }),
);
this.type = entity.type;
this.short_name = entity.short_name;
this.full_name = entity.full_name;
@@ -96,5 +128,11 @@ export class BranchDTO implements BranchDomainInterface {
this.email = entity.email;
this.details = new OrganizationDetailsDTO(entity.details);
this.bank_account = new BankPaymentMethodDTO(entity.bank_account);
this.participants_count = entity.participants_count;
this.is_private = entity.is_private;
this.is_available = entity.is_available;
this.whitelist_certificates = entity.whitelist_members.map(
(member) => new IndividualCertificateDTO({ ...member, type: AccountType.individual }),
);
}
}
@@ -0,0 +1,14 @@
import { Field, InputType } from '@nestjs/graphql';
import type { DeleteBranchWhitelistDomainInterface } from '~/domain/branch/interfaces/delete-branch-whitelist-domain-input.interface';
@InputType('DeleteBranchWhitelistInput')
export class DeleteBranchWhitelistGraphQLInput implements DeleteBranchWhitelistDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
coopname!: string;
@Field(() => String, { description: 'Имя аккаунта кооперативного участка' })
braname!: string;
@Field(() => String, { description: 'Имя аккаунта пайщика, удаляемого из белого списка участка' })
account!: string;
}
@@ -0,0 +1,16 @@
import { Field, InputType } from '@nestjs/graphql';
import type { SetBranchPrivateDomainInterface } from '~/domain/branch/interfaces/set-branch-private-domain-input.interface';
@InputType('SetBranchPrivateInput')
export class SetBranchPrivateGraphQLInput implements SetBranchPrivateDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
coopname!: string;
@Field(() => String, { description: 'Имя аккаунта кооперативного участка' })
braname!: string;
@Field(() => Boolean, {
description: 'Признак приватности участка: при включении выбрать участок смогут только пайщики из белого списка',
})
is_private!: boolean;
}
@@ -11,7 +11,12 @@ import { EditBranchGraphQLInput } from '../dto/edit-branch-input.dto';
import { DeleteBranchGraphQLInput } from '../dto/delete-branch-input.dto';
import { AddTrustedAccountGraphQLInput } from '../dto/add-trusted-account-input.dto';
import { DeleteTrustedAccountGraphQLInput } from '../dto/delete-trusted-account-input.dto';
import { SetBranchPrivateGraphQLInput } from '../dto/set-branch-private-input.dto';
import { AddBranchWhitelistGraphQLInput } from '../dto/add-branch-whitelist-input.dto';
import { DeleteBranchWhitelistGraphQLInput } from '../dto/delete-branch-whitelist-input.dto';
import { SelectBranchInputDTO } from '../dto/select-branch-input.dto';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { SelectBranchGenerateDocumentInputDTO } from '../../document/documents-dto/select-branch-document.dto';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
@@ -26,9 +31,11 @@ export class BranchResolver {
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
async getBranches(
@Args('data', { type: () => GetBranchesGraphQLInput }) data: GetBranchesGraphQLInput
@Args('data', { type: () => GetBranchesGraphQLInput }) data: GetBranchesGraphQLInput,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<BranchDTO[]> {
return this.branchService.getBranches(data);
// имя текущего пайщика нужно, чтобы вычислить доступность приватных участков (is_available)
return this.branchService.getBranches(data, currentUser?.username);
}
@Mutation(() => BranchDTO, { name: 'createBranch', description: 'Создать кооперативный участок' })
@@ -80,6 +87,42 @@ export class BranchResolver {
return this.branchService.deleteTrustedAccount(data);
}
@Mutation(() => BranchDTO, {
name: 'setBranchPrivate',
description: 'Установить приватность кооперативного участка (выбор только из белого списка)',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async setBranchPrivate(
@Args('data', { type: () => SetBranchPrivateGraphQLInput }) data: SetBranchPrivateGraphQLInput
): Promise<BranchDTO> {
return this.branchService.setBranchPrivate(data);
}
@Mutation(() => BranchDTO, {
name: 'addBranchWhitelist',
description: 'Добавить пайщика в белый список приватного кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async addBranchWhitelist(
@Args('data', { type: () => AddBranchWhitelistGraphQLInput }) data: AddBranchWhitelistGraphQLInput
): Promise<BranchDTO> {
return this.branchService.addBranchWhitelist(data);
}
@Mutation(() => BranchDTO, {
name: 'deleteBranchWhitelist',
description: 'Удалить пайщика из белого списка приватного кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async deleteBranchWhitelist(
@Args('data', { type: () => DeleteBranchWhitelistGraphQLInput }) data: DeleteBranchWhitelistGraphQLInput
): Promise<BranchDTO> {
return this.branchService.deleteBranchWhitelist(data);
}
@Mutation(() => Boolean, { name: 'selectBranch', description: 'Выбрать кооперативный участок' })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
@@ -7,6 +7,9 @@ import type { CreateBranchGraphQLInput } from '../dto/create-branch-input.dto';
import type { DeleteBranchGraphQLInput } from '../dto/delete-branch-input.dto';
import type { AddTrustedAccountGraphQLInput } from '../dto/add-trusted-account-input.dto';
import type { DeleteTrustedAccountGraphQLInput } from '../dto/delete-trusted-account-input.dto';
import type { SetBranchPrivateGraphQLInput } from '../dto/set-branch-private-input.dto';
import type { AddBranchWhitelistGraphQLInput } from '../dto/add-branch-whitelist-input.dto';
import type { DeleteBranchWhitelistGraphQLInput } from '../dto/delete-branch-whitelist-input.dto';
import type { SelectBranchInputDTO } from '../dto/select-branch-input.dto';
import type { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import type { SelectBranchGenerateDocumentInputDTO } from '../../document/documents-dto/select-branch-document.dto';
@@ -16,8 +19,8 @@ import type { GeneratedDocumentDTO } from '~/application/document/dto/generated-
export class BranchService {
constructor(private readonly branchInteractor: BranchInteractor) {}
public async getBranches(data: GetBranchesGraphQLInput): Promise<BranchDTO[]> {
const branches = await this.branchInteractor.getBranches(data);
public async getBranches(data: GetBranchesGraphQLInput, currentUsername?: string): Promise<BranchDTO[]> {
const branches = await this.branchInteractor.getBranches(data, currentUsername);
const result: BranchDTO[] = [];
@@ -56,6 +59,21 @@ export class BranchService {
return new BranchDTO(branch);
}
public async setBranchPrivate(data: SetBranchPrivateGraphQLInput): Promise<BranchDTO> {
const branch = await this.branchInteractor.setBranchPrivate(data);
return new BranchDTO(branch);
}
public async addBranchWhitelist(data: AddBranchWhitelistGraphQLInput): Promise<BranchDTO> {
const branch = await this.branchInteractor.addBranchWhitelist(data);
return new BranchDTO(branch);
}
public async deleteBranchWhitelist(data: DeleteBranchWhitelistGraphQLInput): Promise<BranchDTO> {
const branch = await this.branchInteractor.deleteBranchWhitelist(data);
return new BranchDTO(branch);
}
public async selectBranch(data: SelectBranchInputDTO): Promise<boolean> {
const selected = await this.branchInteractor.selectBranch(data);
return selected;
@@ -2,7 +2,7 @@ import { BRANCH_BLOCKCHAIN_PORT, BranchBlockchainPort } from '~/domain/branch/in
import type { GetBranchesDomainInput } from '~/domain/branch/interfaces/get-branches-domain-input.interface';
import { BranchDomainEntity } from '~/domain/branch/entities/branch-domain.entity';
import { ORGANIZATION_REPOSITORY, OrganizationRepository } from '~/domain/common/repositories/organization.repository';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import type { CreateBranchDomainInput } from '~/domain/branch/interfaces/create-branch-domain-input.interface';
import { HttpApiError } from '~/utils/httpApiError';
import httpStatus from 'http-status';
@@ -10,6 +10,9 @@ import type { EditBranchDomainInput } from '~/domain/branch/interfaces/edit-bran
import type { DeleteBranchDomainInput } from '~/domain/branch/interfaces/delete-branch-domain-input';
import type { AddTrustedAccountDomainInterface } from '~/domain/branch/interfaces/add-trusted-account-domain-input.interface';
import type { DeleteTrustedAccountDomainInterface } from '~/domain/branch/interfaces/delete-trusted-account-domain-input.interface';
import type { SetBranchPrivateDomainInterface } from '~/domain/branch/interfaces/set-branch-private-domain-input.interface';
import type { AddBranchWhitelistDomainInterface } from '~/domain/branch/interfaces/add-branch-whitelist-domain-input.interface';
import type { DeleteBranchWhitelistDomainInterface } from '~/domain/branch/interfaces/delete-branch-whitelist-domain-input.interface';
import { INDIVIDUAL_REPOSITORY, IndividualRepository } from '~/domain/common/repositories/individual.repository';
import { IndividualDomainEntity } from '~/domain/branch/entities/individual-domain.entity';
import { OrganizationDomainEntity } from '~/domain/branch/entities/organization-domain.entity';
@@ -26,6 +29,8 @@ import { DocumentDomainEntity } from '~/domain/document/entity/document-domain.e
@Injectable()
export class BranchInteractor {
private readonly logger = new Logger(BranchInteractor.name);
constructor(
@Inject(PAYMENT_METHOD_REPOSITORY) private readonly paymentMethodRepository: PaymentMethodRepository,
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
@@ -35,7 +40,26 @@ export class BranchInteractor {
private readonly documentInteractor: DocumentInteractor
) {}
async getBranch(coopname: string, braname: string): Promise<BranchDomainEntity> {
// Количество пайщиков на каждом участке: участники совета (status accepted),
// у которых проставлен braname. Председатель и доверенные участка — тоже пайщики
// и входят в это число. Возвращаем карту braname → количество за одно чтение таблицы.
private async countParticipantsByBranch(coopname: string): Promise<Map<string, number>> {
const participants = await this.branchBlockchainPort.getParticipants(coopname);
const counts = new Map<string, number>();
for (const participant of participants) {
if (participant.status === 'accepted' && participant.braname) {
counts.set(participant.braname, (counts.get(participant.braname) ?? 0) + 1);
}
}
return counts;
}
async getBranch(
coopname: string,
braname: string,
participantsCount?: number,
currentUsername?: string
): Promise<BranchDomainEntity> {
const branch = await this.branchBlockchainPort.getBranch(coopname, braname);
if (!branch) {
@@ -51,6 +75,19 @@ export class BranchInteractor {
trustedData.push(new IndividualDomainEntity(tr));
}
// белый список приватного участка — резолвим ФИО для управления председателем
// (поля is_private/whitelist приходят из binary_extension контракта и могут отсутствовать у старых записей)
const whitelist = branch.whitelist ?? [];
const whitelistMembers: IndividualDomainEntity[] = [];
for (const account of whitelist) {
const member = await this.individualRepository.findByUsername(account);
if (member) whitelistMembers.push(new IndividualDomainEntity(member));
}
// доступность участка для выбора текущим пайщиком: публичный либо пайщик в белом списке
const isPrivate = branch.is_private ?? false;
const isAvailable = !isPrivate || (!!currentUsername && whitelist.includes(currentUsername));
const bankAccount = new BankPaymentMethodDTO(
await this.paymentMethodRepository.get({
username: braname,
@@ -59,19 +96,50 @@ export class BranchInteractor {
})
);
return new BranchDomainEntity(coopname, branch, databaseData, trusteeData, trustedData, bankAccount);
// при одиночном запросе считаем число пайщиков сами; в списке оно приходит готовым
const count = participantsCount ?? (await this.countParticipantsByBranch(coopname)).get(braname) ?? 0;
return new BranchDomainEntity(
coopname,
branch,
databaseData,
trusteeData,
trustedData,
bankAccount,
count,
whitelistMembers,
isAvailable
);
}
async getBranches(data: GetBranchesDomainInput): Promise<BranchDomainEntity[]> {
async getBranches(data: GetBranchesDomainInput, currentUsername?: string): Promise<BranchDomainEntity[]> {
const branches = await this.branchBlockchainPort.getBranches(data.coopname);
// Фильтрация до сборки
const filteredBranches = data.braname ? branches.filter((branch) => branch.braname === data.braname) : branches;
// одно чтение таблицы участников на весь список, далее раздаём по braname
const participantCounts = await this.countParticipantsByBranch(data.coopname);
const result: BranchDomainEntity[] = [];
for (const branch of filteredBranches) {
const branchEntity = await this.getBranch(data.coopname, branch.braname);
result.push(branchEntity);
try {
const branchEntity = await this.getBranch(
data.coopname,
branch.braname,
participantCounts.get(branch.braname) ?? 0,
currentUsername
);
result.push(branchEntity);
} catch (error) {
// Карточка участка (организация/председатель/реквизиты) после одобрения советом
// создаётся обработчиком события confirmdec асинхронно. Пока проекция не догнала
// блокчейн, getBranch бросает «Организация не найдена». Пропускаем не-готовый
// участок, чтобы один такой не ронял весь список — он появится на следующей загрузке.
this.logger.warn(
`getBranches: участок ${branch.braname} ещё не материализован — пропущен (${(error as Error).message})`
);
}
}
return result;
@@ -240,6 +308,54 @@ export class BranchInteractor {
return await this.getBranch(data.coopname, data.braname);
}
async setBranchPrivate(data: SetBranchPrivateDomainInterface): Promise<BranchDomainEntity> {
const existingBranch = await this.branchBlockchainPort.getBranch(data.coopname, data.braname);
if (!existingBranch) {
throw new HttpApiError(httpStatus.BAD_REQUEST, 'Кооперативный участок не найден');
}
await this.branchBlockchainPort.setBranchPrivate({
coopname: data.coopname,
braname: data.braname,
is_private: data.is_private,
});
return await this.getBranch(data.coopname, data.braname);
}
async addBranchWhitelist(data: AddBranchWhitelistDomainInterface): Promise<BranchDomainEntity> {
const existingBranch = await this.branchBlockchainPort.getBranch(data.coopname, data.braname);
if (!existingBranch) {
throw new HttpApiError(httpStatus.BAD_REQUEST, 'Кооперативный участок не найден');
}
await this.branchBlockchainPort.addBranchWhitelist({
coopname: data.coopname,
braname: data.braname,
account: data.account,
});
return await this.getBranch(data.coopname, data.braname);
}
async deleteBranchWhitelist(data: DeleteBranchWhitelistDomainInterface): Promise<BranchDomainEntity> {
const existingBranch = await this.branchBlockchainPort.getBranch(data.coopname, data.braname);
if (!existingBranch) {
throw new HttpApiError(httpStatus.BAD_REQUEST, 'Кооперативный участок не найден');
}
await this.branchBlockchainPort.deleteBranchWhitelist({
coopname: data.coopname,
braname: data.braname,
account: data.account,
});
return await this.getBranch(data.coopname, data.braname);
}
async selectBranch(data: SelectBranchInputDomainInterface): Promise<boolean> {
// TODO move it to separate document domain service for validate
const document = await this.documentRepository.findByHash(data.document.doc_hash);
@@ -39,7 +39,10 @@ export class SettingsDTO implements SettingsDomainInterface {
@Field(() => Date, { description: 'Дата последнего обновления' })
updated_at!: Date;
constructor(data: SettingsDomainInterface) {
@Field(() => Boolean, { description: 'Открыта ли регистрация новых пайщиков' })
is_registration_open!: boolean;
constructor(data: SettingsDomainInterface, isRegistrationOpen = false) {
this.coopname = data.coopname;
this.authorized_default_workspace = data.authorized_default_workspace;
this.authorized_default_route = data.authorized_default_route;
@@ -48,6 +51,7 @@ export class SettingsDTO implements SettingsDomainInterface {
this.provider_name = data.provider_name;
this.created_at = data.created_at;
this.updated_at = data.updated_at;
this.is_registration_open = isRegistrationOpen;
}
}
@@ -13,6 +13,7 @@ import { SymbolsDTO } from './symbols.dto';
import { SettingsDTO } from './settings.dto';
import { BoardMemberDTO } from './board-member.dto';
import { SystemFeaturesDTO } from './system-features.dto';
import { isRegistrationOpen } from '~/domain/system/utils/is-registration-open.util';
@ObjectType('SystemInfo')
export class SystemInfoDTO {
@@ -79,7 +80,7 @@ export class SystemInfoDTO {
this.blockchain_info = new BlockchainInfoDTO(entity.blockchain_info);
this.system_status = entity.system_status || 'install';
this.symbols = entity.symbols;
this.settings = new SettingsDTO(entity.settings);
this.settings = new SettingsDTO(entity.settings, isRegistrationOpen(entity.vars));
this.blockchain_account = entity.blockchain_account;
this.cooperator_account = new CooperativeOperatorAccountDTO({
...entity.cooperator_account,
@@ -36,6 +36,7 @@ import {
PaymentMethodDomainPort,
} from '~/domain/payment-method/ports/payment-method-domain.port';
import { LoadContactsInteractor } from './load-contacts.interactor';
import { isRegistrationOpen } from '~/domain/system/utils/is-registration-open.util';
@Injectable()
export class SystemInteractor {
@@ -307,4 +308,9 @@ export class SystemInteractor {
async updateSettings(updates: UpdateSettingsInputDomainInterface): Promise<SettingsDomainEntity> {
return this.settingsDomainPort.updateSettings(updates);
}
async getRegistrationOpenStatus(): Promise<boolean> {
const vars = await this.varsRepository.get();
return isRegistrationOpen(vars);
}
}
@@ -99,8 +99,11 @@ export class SystemService {
* Обновляет настройки системы
*/
public async updateSettings(data: UpdateSettingsInputDTO): Promise<SettingsDTO> {
const settings = await this.systemInteractor.updateSettings(data);
return new SettingsDTO(settings);
const [settings, isRegistrationOpenStatus] = await Promise.all([
this.systemInteractor.updateSettings(data),
this.systemInteractor.getRegistrationOpenStatus(),
]);
return new SettingsDTO(settings, isRegistrationOpenStatus);
}
/**
@@ -33,6 +33,10 @@ export class BranchDomainEntity implements BranchDomainInterface {
ogrn: string;
kpp: string;
};
public readonly participants_count: number;
public readonly is_private: boolean;
public readonly is_available: boolean;
public readonly whitelist_members: IndividualDomainInterface[];
constructor(
coopname: string,
@@ -40,7 +44,10 @@ export class BranchDomainEntity implements BranchDomainInterface {
organizationDatabaseData: OrganizationDomainInterface,
trusteeData: IndividualDomainInterface,
trustedData: IndividualDomainInterface[],
bankAccount: BankPaymentMethodDomainInterface
bankAccount: BankPaymentMethodDomainInterface,
participantsCount = 0,
whitelistMembers: IndividualDomainInterface[] = [],
isAvailable = true
) {
if (branchBlockchainData.braname != organizationDatabaseData.username)
throw new Error(`Неверные данные для агрегата: username и braname кооперативного участка должны совпадать`);
@@ -63,5 +70,11 @@ export class BranchDomainEntity implements BranchDomainInterface {
this.phone = organizationDatabaseData.phone;
this.email = organizationDatabaseData.email;
this.details = organizationDatabaseData.details;
this.participants_count = participantsCount;
// признак приватности приходит из binary_extension контракта: у старых записей отсутствует → публичный
this.is_private = branchBlockchainData.is_private ?? false;
this.whitelist_members = whitelistMembers;
this.is_available = isAvailable;
}
}
@@ -0,0 +1,3 @@
import { BranchContract } from 'cooptypes';
export type AddBranchWhitelistDomainInterface = BranchContract.Actions.AddWhite.IAddWhite;
@@ -6,9 +6,13 @@ export interface BranchBlockchainPort {
editBranch(data: BranchContract.Actions.EditBranch.IEditBranch): Promise<TransactResult>;
getBranches(coopname: string): Promise<BranchContract.Tables.Branches.IBranch[]>;
getBranch(coopname: string, braname: string): Promise<BranchContract.Tables.Branches.IBranch | null>;
getParticipants(coopname: string): Promise<SovietContract.Tables.Participants.IParticipants[]>;
deleteBranch(data: BranchContract.Actions.DeleteBranch.IDeleteBranch): Promise<TransactResult>;
addTrustedAccount(data: BranchContract.Actions.AddTrusted.IAddTrusted): Promise<TransactResult>;
deleteTrustedAccount(data: BranchContract.Actions.DeleteTrusted.IDeleteTrusted): Promise<TransactResult>;
setBranchPrivate(data: BranchContract.Actions.SetPrivate.ISetPrivate): Promise<TransactResult>;
addBranchWhitelist(data: BranchContract.Actions.AddWhite.IAddWhite): Promise<TransactResult>;
deleteBranchWhitelist(data: BranchContract.Actions.DelWhite.IDelWhite): Promise<TransactResult>;
selectBranch(data: SovietContract.Actions.Branches.SelectBranch.ISelectBranch): Promise<TransactResult>;
}
@@ -5,4 +5,7 @@ export type BranchDomainInterface = Omit<OrganizationDomainInterface, 'username'
braname: string; ///< имя аккаунта
trustee: IndividualDomainInterface;
trusted: IndividualDomainInterface[];
participants_count: number; ///< количество пайщиков, состоящих в участке
is_private: boolean; ///< приватный участок: выбрать его можно только из белого списка
is_available: boolean; ///< доступен ли участок текущему пайщику для выбора (публичный или он в белом списке)
};
@@ -0,0 +1,3 @@
import { BranchContract } from 'cooptypes';
export type DeleteBranchWhitelistDomainInterface = BranchContract.Actions.DelWhite.IDelWhite;
@@ -0,0 +1,3 @@
import { BranchContract } from 'cooptypes';
export type SetBranchPrivateDomainInterface = BranchContract.Actions.SetPrivate.ISetPrivate;
@@ -0,0 +1,35 @@
import type { AgreementNumberDomainInterface } from '~/domain/agreement/interfaces/agreement-number.interface';
import type { VarsDomainInterface } from '~/domain/system/interfaces/vars-domain.interface';
const REGISTRATION_REQUIRED_AGREEMENT_VARS = [
'wallet_agreement',
'signature_agreement',
'privacy_agreement',
'user_agreement',
'participant_application',
] as const satisfies ReadonlyArray<keyof VarsDomainInterface>;
function isAgreementVarFilled(agreement?: AgreementNumberDomainInterface | null): boolean {
if (!agreement) {
return false;
}
const protocolNumber = agreement.protocol_number?.trim();
const protocolDate = agreement.protocol_day_month_year?.trim();
return Boolean(protocolNumber) && Boolean(protocolDate);
}
/**
* Регистрация пайщиков доступна, когда заполнены реквизиты протоколов
* базовых соглашений кооператива без них factory не сгенерирует пакет документов.
*/
export function isRegistrationOpen(vars: VarsDomainInterface | null | undefined): boolean {
if (!vars) {
return false;
}
return REGISTRATION_REQUIRED_AGREEMENT_VARS.every((field) =>
isAgreementVarFilled(vars[field] as AgreementNumberDomainInterface | null | undefined)
);
}
@@ -873,7 +873,7 @@ export class CapitalBlockchainAdapter implements CapitalBlockchainPort {
): Promise<CapitalContract.Tables.Segments.ISegment | null> {
// Создаем составной ключ для поиска по индексу by_project_user (позиция 3)
const compositeKey = this.domainToBlockchainUtils.combineChecksumAndUsername(projectHash, username);
const keyUInt128 = UInt128.from(compositeKey);
const keyUInt128 = UInt128.from(compositeKey.toString());
// Получаем сегмент из таблицы segments контракта capital
const segment = await this.blockchainService.getSingleRow<CapitalContract.Tables.Segments.ISegment>(
@@ -11,6 +11,7 @@ import { ParticipantPluginModule } from './participant/participant-extension.mod
import { ChatCoopPluginModule } from './chatcoop/chatcoop-extension.module';
import { OneCoopPluginModule } from './1ccoop/oneccoop-extension.module';
import { ReportsExtensionModule } from './reports/reports-extension.module';
import { KuPluginModule } from './ku/ku-extension.module';
import { ExpensesExtensionModule } from './expenses/expenses-extension.module';
import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module';
import { GatewayDomainModule } from '~/domain/gateway/gateway-domain.module';
@@ -37,6 +38,7 @@ export class ExtensionsModule {
InterCommunicationBridgeModule,
OneCoopPluginModule,
ReportsExtensionModule,
KuPluginModule,
ExpensesExtensionModule,
],
providers: [],
@@ -55,6 +57,7 @@ export class ExtensionsModule {
InterCommunicationBridgeModule,
OneCoopPluginModule,
ReportsExtensionModule,
KuPluginModule,
ExpensesExtensionModule,
],
};
@@ -14,6 +14,7 @@ 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';
import { KuPluginModule, KuPlugin, Schema as KuSchema } from './ku/ku-extension.module';
/**
* Конфигурация рабочего стола (workspace), который предоставляет расширение
@@ -141,23 +142,23 @@ export const AppRegistry: INamedExtension = {
trustee: {
is_builtin: true,
is_internal: true,
is_available: false,
is_available: true,
desktops: [
{
name: 'trustee',
title: 'Стол Уполномоченного',
title: 'Кооперативный участок',
icon: 'fa-solid fa-users-cog',
},
],
title: 'Стол Уполномоченного',
description: 'Приложение для председателя кооперативного участка.',
title: 'Кооперативный участок',
description: 'Собрания пайщиков кооперативных участков: учреждение участков решением собрания с утверждением советом, свободные решения и приём доверенных лиц по заявлению.',
image: 'https://i.ibb.co/MxbHCqqf/Chat-GPT-Image-11-2025-18-26-44.png',
class: BuiltinPluginModule,
pluginClass: BuiltinPlugin,
schema: BuiltinSchema,
class: KuPluginModule,
pluginClass: KuPlugin,
schema: KuSchema,
tags: ['стол', 'управление'],
readme: getReadmeContent('./yookassa'),
instructions: getInstructionsContent('./yookassa'),
readme: getReadmeContent('./ku'),
instructions: getInstructionsContent('./ku'),
get is_desktop() {
return !!this.desktops && this.desktops.length > 0;
},
@@ -0,0 +1,4 @@
# Установка
Расширение встроенное и не требует конфигурации. После установки рабочий стол
«Кооперативный участок» становится доступен пайщикам кооператива.
@@ -0,0 +1,9 @@
# Кооперативный участок
Расширение-стол для самоорганизации кооперативных участков:
- **Собрания пайщиков** — любой пайщик объявляет собрание со своей повесткой; участники присоединяются по заявлению, голосуют бюллетенями (за / против / воздержался по каждому вопросу), председатель собрания утверждает протокол одной подписью.
- **Учреждение участка** — решение собрания типа «учреждение кооперативного участка» председатель направляет заявлением в совет; по утверждению советом участок создаётся с избранным председателем.
- **Доверенные лица** — пайщик подаёт заявление и договор о полной материальной ответственности; председатель участка одобряет встречной подписью (не более трёх доверенных).
Данные читаются из PG-проекций (`ku_decisions`, `ku_decision_questions`, `ku_trust_requests`), история сохраняется после завершения процессов в блокчейне. Действия отправляются в контракт `branch` с подписью кооператива.
@@ -0,0 +1,359 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsArray, IsNotEmpty, IsNumber, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { KuDecisionType } from '../../domain/enums/ku-decision-type.enum';
import type {
ApproveKuTrustedInputDomainInterface,
CancelKuDecisionInputDomainInterface,
CloseKuDecisionInputDomainInterface,
CreateKuDecisionInputDomainInterface,
DeclineKuTrustedInputDomainInterface,
ExecKuDecisionInputDomainInterface,
JoinKuDecisionInputDomainInterface,
KuAgendaPointInputDomainInterface,
KuVoteItemInputDomainInterface,
RequestKuTrustedInputDomainInterface,
StartKuDecisionInputDomainInterface,
VoteOnKuDecisionInputDomainInterface,
} from '../../domain/interfaces/ku-action-inputs.interface';
import {
BranchTrusteeLiabilityAgreementSignedDocumentInputDTO,
BranchEstablishmentPetitionSignedDocumentInputDTO,
BranchTrustedLiabilityAgreementSignedDocumentInputDTO,
BranchTrusteePowerOfAttorneySignedDocumentInputDTO,
BranchTrustedPowerOfAttorneySignedDocumentInputDTO,
BranchMeetingBallotSignedDocumentInputDTO,
BranchMeetingDecisionSignedDocumentInputDTO,
BranchMeetingProposalSignedDocumentInputDTO,
} from './ku-documents.dto';
@InputType('KuAgendaPointInput', { description: 'Пункт повестки собрания пайщиков участка' })
export class KuAgendaPointInputDTO implements KuAgendaPointInputDomainInterface {
@Field(() => String, { description: 'Заголовок вопроса' })
@IsString()
@IsNotEmpty()
title!: string;
@Field(() => String, { description: 'Проект решения по вопросу' })
@IsString()
@IsNotEmpty()
decision!: string;
@Field(() => String, { description: 'Дополнительная информация по вопросу', defaultValue: '' })
@IsString()
context!: string;
}
@InputType('CreateKuDecisionInput', { description: 'Объявление собрания пайщиков кооперативного участка' })
export class CreateKuDecisionInputDTO implements CreateKuDecisionInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Хэш решения собрания (якорь процесса)' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => KuDecisionType, { description: 'Тип решения собрания' })
type!: KuDecisionType;
@Field(() => String, { description: 'Инициатор собрания' })
@IsString()
@IsNotEmpty()
initiator!: string;
@Field(() => String, {
description: 'Имя аккаунта будущего кооперативного участка (для учреждения)',
defaultValue: '',
})
@IsString()
braname!: string;
@Field(() => [KuAgendaPointInputDTO], { description: 'Повестка собрания' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => KuAgendaPointInputDTO)
agenda!: KuAgendaPointInputDTO[];
@Field(() => BranchMeetingProposalSignedDocumentInputDTO, { description: 'Подписанное предложение повестки' })
@ValidateNested()
@Type(() => BranchMeetingProposalSignedDocumentInputDTO)
proposal!: BranchMeetingProposalSignedDocumentInputDTO;
@Field(() => String, { description: 'Место проведения собрания (видно только пайщикам кооператива)' })
@IsString()
@IsNotEmpty()
meet_place!: string;
@Field(() => String, { description: 'Дата и время проведения собрания (ISO)' })
@IsString()
@IsNotEmpty()
meet_at!: string;
}
@InputType('JoinKuDecisionInput', { description: 'Присоединение пайщика к собранию участка' })
export class JoinKuDecisionInputDTO implements JoinKuDecisionInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Пайщик, присоединяющийся к собранию' })
@IsString()
@IsNotEmpty()
username!: string;
}
@InputType('StartKuDecisionInput', { description: 'Открытие голосования на собрании участка' })
export class StartKuDecisionInputDTO implements StartKuDecisionInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Избираемый председатель кооперативного участка из числа присоединившихся участников' })
@IsString()
@IsNotEmpty()
chairman!: string;
@Field(() => String, { description: 'Адрес привязки кооперативного участка (для учреждения)', defaultValue: '' })
@IsString()
address!: string;
@Field(() => String, {
description: 'Наименование кооперативного участка (видно только пайщикам кооператива)',
defaultValue: '',
})
@IsString()
branch_name!: string;
@Field(() => String, {
description: 'Email кооперативного участка (видно только пайщикам кооператива)',
defaultValue: '',
})
@IsString()
branch_email!: string;
@Field(() => String, {
description: 'Телефон кооперативного участка (видно только пайщикам кооператива)',
defaultValue: '',
})
@IsString()
branch_phone!: string;
@Field(() => [KuAgendaPointInputDTO], {
description: 'Дополнительные вопросы повестки, внесённые на собрании',
defaultValue: [],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => KuAgendaPointInputDTO)
agenda!: KuAgendaPointInputDTO[];
}
@InputType('KuVoteItemInput', { description: 'Волеизъявление по вопросу повестки собрания участка' })
export class KuVoteItemInputDTO implements KuVoteItemInputDomainInterface {
@Field(() => Int, { description: 'Идентификатор вопроса повестки' })
@IsNumber()
question_id!: number;
@Field(() => String, { description: 'Голос по вопросу (for | against | abstained)' })
@IsString()
@IsNotEmpty()
vote!: string;
}
@InputType('VoteOnKuDecisionInput', { description: 'Подача бюллетеня на собрании участка' })
export class VoteOnKuDecisionInputDTO implements VoteOnKuDecisionInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Голосующий участник собрания' })
@IsString()
@IsNotEmpty()
username!: string;
@Field(() => BranchMeetingBallotSignedDocumentInputDTO, { description: 'Подписанный бюллетень' })
@ValidateNested()
@Type(() => BranchMeetingBallotSignedDocumentInputDTO)
ballot!: BranchMeetingBallotSignedDocumentInputDTO;
@Field(() => [KuVoteItemInputDTO], { description: 'Волеизъявления по вопросам повестки' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => KuVoteItemInputDTO)
votes!: KuVoteItemInputDTO[];
}
@InputType('CloseKuDecisionInput', { description: 'Закрытие голосования и утверждение протокола собрания участка' })
export class CloseKuDecisionInputDTO implements CloseKuDecisionInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => BranchMeetingDecisionSignedDocumentInputDTO, {
description: 'Протокол собрания, утверждённый подписью председателя',
})
@ValidateNested()
@Type(() => BranchMeetingDecisionSignedDocumentInputDTO)
protocol!: BranchMeetingDecisionSignedDocumentInputDTO;
}
@InputType('ExecKuDecisionInput', { description: 'Направление заявления председателя собрания в совет' })
export class ExecKuDecisionInputDTO implements ExecKuDecisionInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => BranchEstablishmentPetitionSignedDocumentInputDTO, {
description: 'Подписанное заявление председателя в совет',
})
@ValidateNested()
@Type(() => BranchEstablishmentPetitionSignedDocumentInputDTO)
petition!: BranchEstablishmentPetitionSignedDocumentInputDTO;
@Field(() => BranchTrusteeLiabilityAgreementSignedDocumentInputDTO, {
description: 'Подписанный председателем участка договор о полной материальной ответственности (идёт в пакете в совет)',
})
@ValidateNested()
@Type(() => BranchTrusteeLiabilityAgreementSignedDocumentInputDTO)
liability!: BranchTrusteeLiabilityAgreementSignedDocumentInputDTO;
@Field(() => BranchTrusteePowerOfAttorneySignedDocumentInputDTO, {
description: 'Подписанная председателем участка доверенность председателю участка (идёт в пакете в совет)',
})
@ValidateNested()
@Type(() => BranchTrusteePowerOfAttorneySignedDocumentInputDTO)
authority!: BranchTrusteePowerOfAttorneySignedDocumentInputDTO;
}
@InputType('CancelKuDecisionInput', { description: 'Отмена собрания пайщиков участка' })
export class CancelKuDecisionInputDTO implements CancelKuDecisionInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Причина отмены' })
@IsString()
@IsNotEmpty()
reason!: string;
}
@InputType('RequestKuTrustedInput', { description: 'Заявка на приём доверенным лицом кооперативного участка' })
export class RequestKuTrustedInputDTO implements RequestKuTrustedInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Имя аккаунта кооперативного участка' })
@IsString()
@IsNotEmpty()
braname!: string;
@Field(() => String, { description: 'Пайщик-заявитель' })
@IsString()
@IsNotEmpty()
username!: string;
@Field(() => String, { description: 'Хэш заявки' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => BranchTrustedLiabilityAgreementSignedDocumentInputDTO, {
description: 'Подписанный договор о полной материальной ответственности доверенного лица',
})
@ValidateNested()
@Type(() => BranchTrustedLiabilityAgreementSignedDocumentInputDTO)
application!: BranchTrustedLiabilityAgreementSignedDocumentInputDTO;
@Field(() => BranchTrustedPowerOfAttorneySignedDocumentInputDTO, {
description: 'Подписанная доверенным лицом доверенность доверенному лицу/оператору участка',
})
@ValidateNested()
@Type(() => BranchTrustedPowerOfAttorneySignedDocumentInputDTO)
authority!: BranchTrustedPowerOfAttorneySignedDocumentInputDTO;
}
@InputType('ApproveKuTrustedInput', { description: 'Одобрение заявки доверенного встречной подписью председателя участка' })
export class ApproveKuTrustedInputDTO implements ApproveKuTrustedInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Хэш заявки' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => BranchTrustedLiabilityAgreementSignedDocumentInputDTO, {
description: 'Договор материальной ответственности со встречной подписью председателя участка',
})
@ValidateNested()
@Type(() => BranchTrustedLiabilityAgreementSignedDocumentInputDTO)
countersigned!: BranchTrustedLiabilityAgreementSignedDocumentInputDTO;
@Field(() => BranchTrustedPowerOfAttorneySignedDocumentInputDTO, {
description: 'Доверенность доверенному лицу со встречной подписью председателя участка',
})
@ValidateNested()
@Type(() => BranchTrustedPowerOfAttorneySignedDocumentInputDTO)
countersigned_authority!: BranchTrustedPowerOfAttorneySignedDocumentInputDTO;
}
@InputType('DeclineKuTrustedInput', { description: 'Отклонение заявки доверенного лица' })
export class DeclineKuTrustedInputDTO implements DeclineKuTrustedInputDomainInterface {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => String, { description: 'Хэш заявки' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Причина отклонения' })
@IsString()
@IsNotEmpty()
reason!: string;
}
@@ -0,0 +1,188 @@
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-type-json';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { KuDecisionType } from '../../domain/enums/ku-decision-type.enum';
import { KuDecisionStatus } from '../../domain/enums/ku-decision-status.enum';
import { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto';
import { AccountType } from '~/application/account/enum/account-type.enum';
@ObjectType('KuDecisionQuestion', { description: 'Вопрос повестки собрания пайщиков кооперативного участка' })
export class KuDecisionQuestionDTO {
@Field(() => Int, { nullable: true, description: 'Идентификатор вопроса в блокчейне' })
id?: number;
@Field(() => Int, { nullable: true, description: 'Идентификатор решения собрания' })
decision_id?: number;
@Field(() => Int, { nullable: true, description: 'Порядковый номер вопроса в повестке' })
number?: number;
@Field(() => String, { nullable: true, description: 'Заголовок вопроса' })
title?: string;
@Field(() => String, { nullable: true, description: 'Проект решения по вопросу' })
decision?: string;
@Field(() => String, { nullable: true, description: 'Дополнительная информация по вопросу' })
context?: string;
@Field(() => Int, { nullable: true, description: 'Количество голосов «за»' })
counter_votes_for?: number;
@Field(() => Int, { nullable: true, description: 'Количество голосов «против»' })
counter_votes_against?: number;
@Field(() => Int, { nullable: true, description: 'Количество голосов «воздержался»' })
counter_votes_abstained?: number;
@Field(() => [String], { nullable: true, description: 'Проголосовавшие «за»' })
voters_for?: string[];
@Field(() => [String], { nullable: true, description: 'Проголосовавшие «против»' })
voters_against?: string[];
@Field(() => [String], { nullable: true, description: 'Проголосовавшие «воздержался»' })
voters_abstained?: string[];
}
@ObjectType('KuMeetingParticipant', { description: 'Участник собрания пайщиков кооперативного участка' })
export class KuMeetingParticipantDTO {
@Field(() => String, { description: 'Имя аккаунта участника' })
username!: string;
@Field(() => String, { description: 'Отображаемое имя участника (ФИО)' })
display_name!: string;
@Field(() => AccountType, { description: 'Тип аккаунта участника' })
account_type!: AccountType;
}
@ObjectType('KuDecision', { description: 'Решение собрания пайщиков кооперативного участка' })
export class KuDecisionDTO {
@Field(() => String, { description: 'Хэш решения (якорь процесса)' })
hash!: string;
@Field(() => Int, { nullable: true, description: 'Идентификатор решения в блокчейне' })
id?: number;
@Field(() => String, { nullable: true, description: 'Имя аккаунта кооператива' })
coopname?: string;
@Field(() => KuDecisionType, { nullable: true, description: 'Тип решения' })
type?: KuDecisionType;
@Field(() => String, { nullable: true, description: 'Инициатор собрания' })
initiator?: string;
@Field(() => String, { nullable: true, description: 'Председатель собрания' })
chairman?: string;
@Field(() => KuDecisionStatus, { nullable: true, description: 'Статус решения' })
status?: KuDecisionStatus;
@Field(() => Boolean, { description: 'Существует ли запись в блокчейне (false — завершено и стёрто)' })
present!: boolean;
@Field(() => GraphQLJSON, { nullable: true, description: 'Подписанное предложение повестки' })
proposal?: object;
@Field(() => GraphQLJSON, { nullable: true, description: 'Утверждённый протокол собрания' })
protocol?: object;
@Field(() => GraphQLJSON, { nullable: true, description: 'Заявление председателя в совет' })
petition?: object;
@Field(() => GraphQLJSON, { nullable: true, description: 'Решение совета' })
authorization?: object;
@Field(() => DocumentAggregateDTO, {
nullable: true,
description: 'Протокол собрания пайщиков с подписью и бюллетенями — для отображения на странице собрания',
})
protocol_document?: DocumentAggregateDTO;
@Field(() => DocumentAggregateDTO, {
nullable: true,
description: 'Решение совета об организации кооперативного участка — для отображения на странице собрания',
})
authorization_document?: DocumentAggregateDTO;
@Field(() => String, { nullable: true, description: 'Дата и время открытия голосования' })
open_at?: string;
@Field(() => String, { nullable: true, description: 'Дата и время закрытия голосования' })
close_at?: string;
@Field(() => Int, { nullable: true, description: 'Количество поданных бюллетеней' })
signed_ballots?: number;
@Field(() => String, { nullable: true, description: 'Имя аккаунта кооперативного участка (служебное)' })
braname?: string;
@Field(() => String, { nullable: true, description: 'Адрес привязки кооперативного участка' })
address?: string;
@Field(() => [String], { nullable: true, description: 'Участники собрания' })
participants?: string[];
@Field(() => [KuMeetingParticipantDTO], {
nullable: true,
description: 'Участники собрания с отображаемыми именами',
})
participants_info?: KuMeetingParticipantDTO[];
@Field(() => String, { nullable: true, description: 'Дата и время объявления собрания' })
created_at?: string;
@Field(() => String, { nullable: true, description: 'Место проведения собрания (видно только пайщикам)' })
meet_place?: string;
@Field(() => String, { nullable: true, description: 'Дата и время проведения собрания (видно только пайщикам)' })
meet_at?: string;
@Field(() => String, { nullable: true, description: 'Наименование кооперативного участка (видно только пайщикам)' })
branch_name?: string;
@Field(() => String, { nullable: true, description: 'Email кооперативного участка (видно только пайщикам)' })
branch_email?: string;
@Field(() => String, { nullable: true, description: 'Телефон кооперативного участка (видно только пайщикам)' })
branch_phone?: string;
@Field(() => [KuDecisionQuestionDTO], { nullable: true, description: 'Вопросы повестки собрания' })
questions?: KuDecisionQuestionDTO[];
@Field(() => Int, { nullable: true, description: 'Номер блока последнего обновления' })
block_num?: number;
}
@InputType('KuDecisionFilterInput', { description: 'Фильтр решений собраний кооперативных участков' })
export class KuDecisionFilterInputDTO {
@Field(() => String, { nullable: true, description: 'Имя аккаунта кооператива' })
@IsOptional()
@IsString()
coopname?: string;
@Field(() => KuDecisionType, { nullable: true, description: 'Тип решения' })
@IsOptional()
type?: KuDecisionType;
@Field(() => KuDecisionStatus, { nullable: true, description: 'Статус решения' })
@IsOptional()
status?: KuDecisionStatus;
@Field(() => String, { nullable: true, description: 'Наименование кооперативного участка' })
@IsOptional()
@IsString()
braname?: string;
@Field(() => String, { nullable: true, description: 'Инициатор собрания' })
@IsOptional()
@IsString()
initiator?: string;
@Field(() => Boolean, { nullable: true, description: 'Только записи, существующие в блокчейне' })
@IsOptional()
@IsBoolean()
present?: boolean;
}
@@ -0,0 +1,644 @@
import { Field, InputType, Int, IntersectionType, OmitType } from '@nestjs/graphql';
import { Cooperative } from 'cooptypes';
import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
import type { ExcludeCommonProps } from '~/application/document/types';
// ─────────────────────────────────────────────────────────────────────────────
// Общие вложенные DTO
// ─────────────────────────────────────────────────────────────────────────────
@InputType('KuAgendaQuestionInput', { description: 'Вопрос повестки собрания пайщиков участка' })
export class KuAgendaQuestionInputDTO {
@Field(() => String, { description: 'Номер вопроса' })
@IsString()
@IsNotEmpty()
number!: string;
@Field(() => String, { description: 'Заголовок вопроса' })
@IsString()
@IsNotEmpty()
title!: string;
@Field(() => String, { nullable: true, description: 'Дополнительная информация' })
@IsOptional()
@IsString()
context?: string;
@Field(() => String, { description: 'Проект решения по вопросу' })
@IsString()
@IsNotEmpty()
decision!: string;
}
@InputType('KuBallotAnswerInput', { description: 'Волеизъявление по вопросу повестки' })
export class KuBallotAnswerInputDTO {
@Field(() => String, { description: 'ID вопроса' })
@IsString()
@IsNotEmpty()
id!: string;
@Field(() => String, { description: 'Номер вопроса' })
@IsString()
@IsNotEmpty()
number!: string;
@Field(() => String, { description: 'Голос (за/против/воздержался)' })
@IsString()
@IsNotEmpty()
vote!: 'for' | 'against' | 'abstained';
}
@InputType('KuBallotQuestionInput', { description: 'Вопрос собрания участка для бюллетеня' })
export class KuBallotQuestionInputDTO {
@Field(() => String, { description: 'ID вопроса' })
@IsString()
@IsNotEmpty()
id!: string;
@Field(() => String, { description: 'Номер вопроса' })
@IsString()
@IsNotEmpty()
number!: string;
@Field(() => String, { description: 'Заголовок вопроса' })
@IsString()
@IsNotEmpty()
title!: string;
@Field(() => String, { nullable: true, description: 'Дополнительная информация' })
@IsOptional()
@IsString()
context?: string;
@Field(() => String, { description: 'Проект решения по вопросу' })
@IsString()
@IsNotEmpty()
decision!: string;
}
@InputType('KuProtocolQuestionInput', { description: 'Вопрос протокола с результатами голосования' })
export class KuProtocolQuestionInputDTO {
@Field(() => String, { description: 'Номер вопроса' })
@IsString()
@IsNotEmpty()
number!: string;
@Field(() => String, { description: 'Заголовок вопроса' })
@IsString()
@IsNotEmpty()
title!: string;
@Field(() => String, { nullable: true, description: 'Дополнительная информация' })
@IsOptional()
@IsString()
context?: string;
@Field(() => String, { description: 'Текст решения по вопросу' })
@IsString()
@IsNotEmpty()
decision!: string;
@Field(() => String, { description: 'Количество голосов «за»' })
@IsString()
counter_votes_for!: string;
@Field(() => String, { description: 'Количество голосов «против»' })
@IsString()
counter_votes_against!: string;
@Field(() => String, { description: 'Количество голосов «воздержался»' })
@IsString()
counter_votes_abstained!: string;
@Field(() => Number, { description: 'Процент голосов «за»' })
@IsNumber()
votes_for_percent!: number;
@Field(() => Number, { description: 'Процент голосов «против»' })
@IsNumber()
votes_against_percent!: number;
@Field(() => Number, { description: 'Процент голосов «воздержался»' })
@IsNumber()
votes_abstained_percent!: number;
@Field(() => Boolean, { description: 'Принято ли решение по вопросу' })
is_accepted!: boolean;
}
// ─────────────────────────────────────────────────────────────────────────────
// 320 — Предложение повестки собрания пайщиков участка
// ─────────────────────────────────────────────────────────────────────────────
type proposalAction = Cooperative.Registry.BranchMeetingProposal.Action;
@InputType('BaseBranchMeetingProposalMetaDocumentInput')
class BaseBranchMeetingProposalMetaDocumentInputDTO implements ExcludeCommonProps<proposalAction> {
@Field(() => String, { description: 'Тип решения собрания (createbranch | free)' })
@IsString()
@IsNotEmpty()
type!: 'createbranch' | 'free';
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { nullable: true, description: 'Наименование кооперативного участка' })
@IsOptional()
@IsString()
braname?: string;
@Field(() => String, { nullable: true, description: 'Адрес привязки кооперативного участка' })
@IsOptional()
@IsString()
address?: string;
@Field(() => String, { nullable: true, description: 'Кандидат в председатели кооперативного участка' })
@IsOptional()
@IsString()
chairman_candidate?: string;
@Field(() => [KuAgendaQuestionInputDTO], { description: 'Вопросы повестки' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => KuAgendaQuestionInputDTO)
questions!: KuAgendaQuestionInputDTO[];
}
@InputType('BranchMeetingProposalGenerateDocumentInput')
export class BranchMeetingProposalGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchMeetingProposalMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements proposalAction
{
registry_id!: number;
}
@InputType('BranchMeetingProposalSignedMetaDocumentInput')
export class BranchMeetingProposalSignedMetaDocumentInputDTO extends IntersectionType(
BaseBranchMeetingProposalMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
@InputType('BranchMeetingProposalSignedDocumentInput')
export class BranchMeetingProposalSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BranchMeetingProposalSignedMetaDocumentInputDTO, {
description: 'Метаинформация предложения повестки собрания',
})
public readonly meta!: BranchMeetingProposalSignedMetaDocumentInputDTO;
}
// ─────────────────────────────────────────────────────────────────────────────
// 322 — Бюллетень голосования на собрании участка
// ─────────────────────────────────────────────────────────────────────────────
type ballotAction = Cooperative.Registry.BranchMeetingBallot.Action;
@InputType('BaseBranchMeetingBallotMetaDocumentInput')
class BaseBranchMeetingBallotMetaDocumentInputDTO implements ExcludeCommonProps<ballotAction> {
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => [KuBallotAnswerInputDTO], { description: 'Волеизъявления по вопросам повестки' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => KuBallotAnswerInputDTO)
answers!: KuBallotAnswerInputDTO[];
@Field(() => [KuBallotQuestionInputDTO], { description: 'Вопросы повестки собрания' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => KuBallotQuestionInputDTO)
questions!: KuBallotQuestionInputDTO[];
}
@InputType('BranchMeetingBallotGenerateDocumentInput')
export class BranchMeetingBallotGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchMeetingBallotMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements ballotAction
{
registry_id!: number;
}
@InputType('BranchMeetingBallotSignedMetaDocumentInput')
export class BranchMeetingBallotSignedMetaDocumentInputDTO extends IntersectionType(
BaseBranchMeetingBallotMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
@InputType('BranchMeetingBallotSignedDocumentInput')
export class BranchMeetingBallotSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BranchMeetingBallotSignedMetaDocumentInputDTO, {
description: 'Метаинформация бюллетеня голосования',
})
public readonly meta!: BranchMeetingBallotSignedMetaDocumentInputDTO;
}
// ─────────────────────────────────────────────────────────────────────────────
// 323 — Протокол решения собрания пайщиков участка
// ─────────────────────────────────────────────────────────────────────────────
type protocolAction = Cooperative.Registry.BranchMeetingDecision.Action;
@InputType('BaseBranchMeetingDecisionMetaDocumentInput')
class BaseBranchMeetingDecisionMetaDocumentInputDTO implements ExcludeCommonProps<protocolAction> {
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Номер протокола' })
@IsString()
@IsNotEmpty()
protocol_number!: string;
@Field(() => String, { description: 'Имя аккаунта (username) председателя собрания' })
@IsString()
@IsNotEmpty()
chairman!: string;
@Field(() => String, { description: 'Дата и время открытия собрания' })
@IsString()
@IsNotEmpty()
open_at_datetime!: string;
@Field(() => String, { description: 'Дата и время закрытия собрания' })
@IsString()
@IsNotEmpty()
close_at_datetime!: string;
@Field(() => Number, { description: 'Кворум собрания, %' })
@IsNumber()
current_quorum_percent!: number;
@Field(() => [KuProtocolQuestionInputDTO], { description: 'Вопросы повестки с результатами голосования' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => KuProtocolQuestionInputDTO)
questions!: KuProtocolQuestionInputDTO[];
}
@InputType('BranchMeetingDecisionGenerateDocumentInput')
export class BranchMeetingDecisionGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchMeetingDecisionMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements protocolAction
{
registry_id!: number;
}
@InputType('BranchMeetingDecisionSignedMetaDocumentInput')
export class BranchMeetingDecisionSignedMetaDocumentInputDTO extends IntersectionType(
BaseBranchMeetingDecisionMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
@InputType('BranchMeetingDecisionSignedDocumentInput')
export class BranchMeetingDecisionSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BranchMeetingDecisionSignedMetaDocumentInputDTO, {
description: 'Метаинформация протокола решения собрания',
})
public readonly meta!: BranchMeetingDecisionSignedMetaDocumentInputDTO;
}
// ─────────────────────────────────────────────────────────────────────────────
// 324 — Заявление председателя собрания в совет об учреждении участка
// ─────────────────────────────────────────────────────────────────────────────
type petitionAction = Cooperative.Registry.BranchEstablishmentPetition.Action;
@InputType('BaseBranchEstablishmentPetitionMetaDocumentInput')
class BaseBranchEstablishmentPetitionMetaDocumentInputDTO implements ExcludeCommonProps<petitionAction> {
@Field(() => String, { description: 'Хэш решения собрания' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Наименование кооперативного участка' })
@IsString()
@IsNotEmpty()
branch_name!: string;
@Field(() => String, { description: 'Имя аккаунта (username) избранного председателя кооперативного участка' })
@IsString()
@IsNotEmpty()
chairman!: string;
@Field(() => String, { description: 'Адрес привязки кооперативного участка' })
@IsString()
@IsNotEmpty()
address!: string;
}
@InputType('BranchEstablishmentPetitionGenerateDocumentInput')
export class BranchEstablishmentPetitionGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchEstablishmentPetitionMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements petitionAction
{
registry_id!: number;
}
@InputType('BranchEstablishmentPetitionSignedMetaDocumentInput')
export class BranchEstablishmentPetitionSignedMetaDocumentInputDTO extends IntersectionType(
BaseBranchEstablishmentPetitionMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
@InputType('BranchEstablishmentPetitionSignedDocumentInput')
export class BranchEstablishmentPetitionSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BranchEstablishmentPetitionSignedMetaDocumentInputDTO, {
description: 'Метаинформация заявления в совет об учреждении участка',
})
public readonly meta!: BranchEstablishmentPetitionSignedMetaDocumentInputDTO;
}
// ─────────────────────────────────────────────────────────────────────────────
// 325 — Решение совета об учреждении кооперативного участка
// ─────────────────────────────────────────────────────────────────────────────
type establishmentDecisionAction = Cooperative.Registry.BranchEstablishmentSovietDecision.Action;
@InputType('BaseBranchEstablishmentDecisionMetaDocumentInput')
class BaseBranchEstablishmentDecisionMetaDocumentInputDTO implements ExcludeCommonProps<establishmentDecisionAction> {
@Field(() => Int, { description: 'Идентификатор решения совета' })
@IsNumber()
decision_id!: number;
@Field(() => String, { description: 'Наименование кооперативного участка' })
@IsString()
@IsNotEmpty()
branch_name!: string;
@Field(() => String, { description: 'Адрес привязки кооперативного участка' })
@IsString()
@IsNotEmpty()
address!: string;
@Field(() => String, { description: 'Имя аккаунта (username) избранного председателя кооперативного участка' })
@IsString()
@IsNotEmpty()
chairman!: string;
}
@InputType('BranchEstablishmentDecisionGenerateDocumentInput')
export class BranchEstablishmentDecisionGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchEstablishmentDecisionMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements establishmentDecisionAction
{
registry_id!: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// 326 — Заявление о приёме доверенным лицом участка
// ─────────────────────────────────────────────────────────────────────────────
type trustedStatementAction = Cooperative.Registry.BranchTrustedStatement.Action;
@InputType('BaseBranchTrustedStatementMetaDocumentInput')
class BaseBranchTrustedStatementMetaDocumentInputDTO implements ExcludeCommonProps<trustedStatementAction> {
@Field(() => String, { description: 'Хэш заявки доверенного' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Наименование кооперативного участка' })
@IsString()
@IsNotEmpty()
braname!: string;
}
@InputType('BranchTrustedStatementGenerateDocumentInput')
export class BranchTrustedStatementGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchTrustedStatementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements trustedStatementAction
{
registry_id!: number;
}
@InputType('BranchTrustedStatementSignedMetaDocumentInput')
export class BranchTrustedStatementSignedMetaDocumentInputDTO extends IntersectionType(
BaseBranchTrustedStatementMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
@InputType('BranchTrustedStatementSignedDocumentInput')
export class BranchTrustedStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BranchTrustedStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация заявления доверенного лица',
})
public readonly meta!: BranchTrustedStatementSignedMetaDocumentInputDTO;
}
// ─────────────────────────────────────────────────────────────────────────────
// 327 — Договор о полной индивидуальной материальной ответственности доверенного лица
// ─────────────────────────────────────────────────────────────────────────────
type trustedLiabilityAction = Cooperative.Registry.BranchTrustedLiabilityAgreement.Action;
@InputType('BaseBranchTrustedLiabilityAgreementMetaDocumentInput')
class BaseBranchTrustedLiabilityAgreementMetaDocumentInputDTO implements ExcludeCommonProps<trustedLiabilityAction> {
@Field(() => String, { description: 'Хэш заявки доверенного' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Наименование кооперативного участка' })
@IsString()
@IsNotEmpty()
branch_name!: string;
@Field(() => String, { description: 'Имя аккаунта (username) председателя кооперативного участка' })
@IsString()
@IsNotEmpty()
trustee!: string;
}
@InputType('BranchTrustedLiabilityAgreementGenerateDocumentInput')
export class BranchTrustedLiabilityAgreementGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchTrustedLiabilityAgreementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements trustedLiabilityAction
{
registry_id!: number;
}
@InputType('BranchTrustedLiabilityAgreementSignedMetaDocumentInput')
export class BranchTrustedLiabilityAgreementSignedMetaDocumentInputDTO extends IntersectionType(
BaseBranchTrustedLiabilityAgreementMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
@InputType('BranchTrustedLiabilityAgreementSignedDocumentInput')
export class BranchTrustedLiabilityAgreementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BranchTrustedLiabilityAgreementSignedMetaDocumentInputDTO, {
description: 'Метаинформация договора материальной ответственности доверенного лица',
})
public readonly meta!: BranchTrustedLiabilityAgreementSignedMetaDocumentInputDTO;
}
// ─────────────────────────────────────────────────────────────────────────────
// 328 — Договор о полной индивидуальной материальной ответственности председателя участка
// ─────────────────────────────────────────────────────────────────────────────
type trusteeLiabilityAction = Cooperative.Registry.BranchTrusteeLiabilityAgreement.Action;
@InputType('BaseBranchTrusteeLiabilityAgreementMetaDocumentInput')
class BaseBranchTrusteeLiabilityAgreementMetaDocumentInputDTO implements ExcludeCommonProps<trusteeLiabilityAction> {
@Field(() => String, { description: 'Якорь процесса учреждения участка' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Наименование кооперативного участка' })
@IsString()
@IsNotEmpty()
branch_name!: string;
}
@InputType('BranchTrusteeLiabilityAgreementGenerateDocumentInput')
export class BranchTrusteeLiabilityAgreementGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchTrusteeLiabilityAgreementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements trusteeLiabilityAction
{
registry_id!: number;
}
@InputType('BranchTrusteeLiabilityAgreementSignedMetaDocumentInput')
export class BranchTrusteeLiabilityAgreementSignedMetaDocumentInputDTO extends IntersectionType(
BaseBranchTrusteeLiabilityAgreementMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
@InputType('BranchTrusteeLiabilityAgreementSignedDocumentInput')
export class BranchTrusteeLiabilityAgreementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BranchTrusteeLiabilityAgreementSignedMetaDocumentInputDTO, {
description: 'Метаинформация договора материальной ответственности председателя участка',
})
public readonly meta!: BranchTrusteeLiabilityAgreementSignedMetaDocumentInputDTO;
}
// ─────────────────────────────────────────────────────────────────────────────
// 329 — Доверенность председателю кооперативного участка
// ─────────────────────────────────────────────────────────────────────────────
type trusteePowerOfAttorneyAction = Cooperative.Registry.BranchTrusteePowerOfAttorney.Action;
@InputType('BaseBranchTrusteePowerOfAttorneyMetaDocumentInput')
class BaseBranchTrusteePowerOfAttorneyMetaDocumentInputDTO implements ExcludeCommonProps<trusteePowerOfAttorneyAction> {
@Field(() => String, { description: 'Якорь процесса учреждения участка' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Наименование кооперативного участка' })
@IsString()
@IsNotEmpty()
branch_name!: string;
@Field(() => String, { description: 'Адрес привязки кооперативного участка' })
@IsString()
@IsNotEmpty()
branch_address!: string;
}
@InputType('BranchTrusteePowerOfAttorneyGenerateDocumentInput')
export class BranchTrusteePowerOfAttorneyGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchTrusteePowerOfAttorneyMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements trusteePowerOfAttorneyAction
{
registry_id!: number;
}
@InputType('BranchTrusteePowerOfAttorneySignedMetaDocumentInput')
export class BranchTrusteePowerOfAttorneySignedMetaDocumentInputDTO extends IntersectionType(
BaseBranchTrusteePowerOfAttorneyMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
@InputType('BranchTrusteePowerOfAttorneySignedDocumentInput')
export class BranchTrusteePowerOfAttorneySignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BranchTrusteePowerOfAttorneySignedMetaDocumentInputDTO, {
description: 'Метаинформация доверенности председателю участка',
})
public readonly meta!: BranchTrusteePowerOfAttorneySignedMetaDocumentInputDTO;
}
// ─────────────────────────────────────────────────────────────────────────────
// 330 — Доверенность доверенному лицу кооперативного участка
// ─────────────────────────────────────────────────────────────────────────────
type trustedPowerOfAttorneyAction = Cooperative.Registry.BranchTrustedPowerOfAttorney.Action;
@InputType('BaseBranchTrustedPowerOfAttorneyMetaDocumentInput')
class BaseBranchTrustedPowerOfAttorneyMetaDocumentInputDTO implements ExcludeCommonProps<trustedPowerOfAttorneyAction> {
@Field(() => String, { description: 'Хэш заявки доверенного' })
@IsString()
@IsNotEmpty()
hash!: string;
@Field(() => String, { description: 'Наименование кооперативного участка' })
@IsString()
@IsNotEmpty()
branch_name!: string;
@Field(() => String, { description: 'Имя аккаунта (username) председателя кооперативного участка' })
@IsString()
@IsNotEmpty()
trustee!: string;
}
@InputType('BranchTrustedPowerOfAttorneyGenerateDocumentInput')
export class BranchTrustedPowerOfAttorneyGenerateDocumentInputDTO
extends IntersectionType(
BaseBranchTrustedPowerOfAttorneyMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements trustedPowerOfAttorneyAction
{
registry_id!: number;
}
@InputType('BranchTrustedPowerOfAttorneySignedMetaDocumentInput')
export class BranchTrustedPowerOfAttorneySignedMetaDocumentInputDTO extends IntersectionType(
BaseBranchTrustedPowerOfAttorneyMetaDocumentInputDTO,
MetaDocumentInputDTO
) {}
@InputType('BranchTrustedPowerOfAttorneySignedDocumentInput')
export class BranchTrustedPowerOfAttorneySignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => BranchTrustedPowerOfAttorneySignedMetaDocumentInputDTO, {
description: 'Метаинформация доверенности доверенному лицу участка',
})
public readonly meta!: BranchTrustedPowerOfAttorneySignedMetaDocumentInputDTO;
}
@@ -0,0 +1,78 @@
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-type-json';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto';
@ObjectType('KuTrustRequest', { description: 'Заявка на приём доверенным лицом кооперативного участка' })
export class KuTrustRequestDTO {
@Field(() => String, { description: 'Хэш заявки' })
hash!: string;
@Field(() => Int, { nullable: true, description: 'Идентификатор заявки в блокчейне' })
id?: number;
@Field(() => String, { nullable: true, description: 'Имя аккаунта кооператива' })
coopname?: string;
@Field(() => String, { nullable: true, description: 'Наименование кооперативного участка' })
braname?: string;
@Field(() => String, { nullable: true, description: 'Пайщик-заявитель' })
username?: string;
@Field(() => String, { nullable: true, description: 'ФИО пайщика-заявителя' })
display_name?: string;
@Field(() => Boolean, { description: 'Существует ли запись в блокчейне (false — рассмотрена и стёрта)' })
present!: boolean;
@Field(() => GraphQLJSON, {
nullable: true,
description: 'Заявление и договор о полной материальной ответственности',
})
application?: object;
@Field(() => GraphQLJSON, {
nullable: true,
description: 'Доверенность доверенному лицу/оператору участка с подписью заявителя',
})
authority?: object;
@Field(() => DocumentAggregateDTO, {
nullable: true,
description: 'Договор о полной материальной ответственности с подписью заявителя — для просмотра и встречной подписи председателя',
})
document?: DocumentAggregateDTO;
@Field(() => DocumentAggregateDTO, {
nullable: true,
description: 'Доверенность доверенному лицу с подписью заявителя — для просмотра и встречной подписи председателя',
})
authority_document?: DocumentAggregateDTO;
@Field(() => Int, { nullable: true, description: 'Номер блока последнего обновления' })
block_num?: number;
}
@InputType('KuTrustRequestFilterInput', { description: 'Фильтр заявок доверенных лиц кооперативных участков' })
export class KuTrustRequestFilterInputDTO {
@Field(() => String, { nullable: true, description: 'Имя аккаунта кооператива' })
@IsOptional()
@IsString()
coopname?: string;
@Field(() => String, { nullable: true, description: 'Наименование кооперативного участка' })
@IsOptional()
@IsString()
braname?: string;
@Field(() => String, { nullable: true, description: 'Пайщик-заявитель' })
@IsOptional()
@IsString()
username?: string;
@Field(() => Boolean, { nullable: true, description: 'Только записи, существующие в блокчейне' })
@IsOptional()
@IsBoolean()
present?: boolean;
}
@@ -0,0 +1,372 @@
import { Args, Mutation, Query, Resolver } 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 { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { createPaginationResult, PaginationInputDTO, PaginationResult } from '~/application/common/dto/pagination.dto';
import { KuService } from '../services/ku.service';
import { KuDecisionDTO, KuDecisionFilterInputDTO } from '../dto/ku-decision.dto';
import { KuTrustRequestDTO, KuTrustRequestFilterInputDTO } from '../dto/ku-trust-request.dto';
import {
ApproveKuTrustedInputDTO,
CancelKuDecisionInputDTO,
CloseKuDecisionInputDTO,
CreateKuDecisionInputDTO,
DeclineKuTrustedInputDTO,
ExecKuDecisionInputDTO,
JoinKuDecisionInputDTO,
RequestKuTrustedInputDTO,
StartKuDecisionInputDTO,
VoteOnKuDecisionInputDTO,
} from '../dto/ku-action-inputs.dto';
import {
BranchEstablishmentPetitionGenerateDocumentInputDTO,
BranchEstablishmentDecisionGenerateDocumentInputDTO,
BranchTrustedLiabilityAgreementGenerateDocumentInputDTO,
BranchTrusteeLiabilityAgreementGenerateDocumentInputDTO,
BranchTrusteePowerOfAttorneyGenerateDocumentInputDTO,
BranchTrustedPowerOfAttorneyGenerateDocumentInputDTO,
BranchMeetingBallotGenerateDocumentInputDTO,
BranchMeetingDecisionGenerateDocumentInputDTO,
BranchMeetingProposalGenerateDocumentInputDTO,
BranchTrustedStatementGenerateDocumentInputDTO,
} from '../dto/ku-documents.dto';
// Пагинированные результаты
const paginatedKuDecisionsResult = createPaginationResult(KuDecisionDTO, 'PaginatedKuDecisions');
const paginatedKuTrustRequestsResult = createPaginationResult(KuTrustRequestDTO, 'PaginatedKuTrustRequests');
/**
* GraphQL-резолвер собраний и решений кооперативных участков (контракт branch)
*/
@Resolver()
export class KuResolver {
constructor(private readonly kuService: KuService) {}
// ───────────────────────────────────────────────────────────────────────────
// Мутации собрания
// ───────────────────────────────────────────────────────────────────────────
@Mutation(() => TransactionDTO, {
name: 'kuCreateDecision',
description: 'Объявить собрание пайщиков кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuCreateDecision(
@Args('data', { type: () => CreateKuDecisionInputDTO }) data: CreateKuDecisionInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.createDecision(data, currentUser);
}
@Mutation(() => TransactionDTO, {
name: 'kuJoinDecision',
description: 'Присоединиться к собранию пайщиков кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuJoinDecision(
@Args('data', { type: () => JoinKuDecisionInputDTO }) data: JoinKuDecisionInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.joinDecision(data, currentUser);
}
@Mutation(() => TransactionDTO, {
name: 'kuStartDecision',
description: 'Открыть голосование на собрании пайщиков участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuStartDecision(
@Args('data', { type: () => StartKuDecisionInputDTO }) data: StartKuDecisionInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.startDecision(data, currentUser);
}
@Mutation(() => TransactionDTO, {
name: 'kuVoteOnDecision',
description: 'Подать бюллетень на собрании пайщиков участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuVoteOnDecision(
@Args('data', { type: () => VoteOnKuDecisionInputDTO }) data: VoteOnKuDecisionInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.voteOnDecision(data, currentUser);
}
@Mutation(() => TransactionDTO, {
name: 'kuCloseDecision',
description: 'Закрыть голосование и утвердить протокол собрания',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuCloseDecision(
@Args('data', { type: () => CloseKuDecisionInputDTO }) data: CloseKuDecisionInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.closeDecision(data, currentUser);
}
@Mutation(() => TransactionDTO, {
name: 'kuExecDecision',
description: 'Направить заявление председателя собрания в совет об учреждении участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuExecDecision(
@Args('data', { type: () => ExecKuDecisionInputDTO }) data: ExecKuDecisionInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.execDecision(data, currentUser);
}
@Mutation(() => TransactionDTO, {
name: 'kuCancelDecision',
description: 'Отменить собрание пайщиков участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuCancelDecision(
@Args('data', { type: () => CancelKuDecisionInputDTO }) data: CancelKuDecisionInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.cancelDecision(data, currentUser);
}
// ───────────────────────────────────────────────────────────────────────────
// Мутации доверенных лиц
// ───────────────────────────────────────────────────────────────────────────
@Mutation(() => TransactionDTO, {
name: 'kuRequestTrusted',
description: 'Подать заявку на приём доверенным лицом кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuRequestTrusted(
@Args('data', { type: () => RequestKuTrustedInputDTO }) data: RequestKuTrustedInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.requestTrusted(data, currentUser);
}
@Mutation(() => TransactionDTO, {
name: 'kuApproveTrusted',
description: 'Одобрить заявку доверенного встречной подписью председателя участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuApproveTrusted(
@Args('data', { type: () => ApproveKuTrustedInputDTO }) data: ApproveKuTrustedInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.approveTrusted(data, currentUser);
}
@Mutation(() => TransactionDTO, {
name: 'kuDeclineTrusted',
description: 'Отклонить заявку доверенного лица',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuDeclineTrusted(
@Args('data', { type: () => DeclineKuTrustedInputDTO }) data: DeclineKuTrustedInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
return this.kuService.declineTrusted(data, currentUser);
}
// ───────────────────────────────────────────────────────────────────────────
// Генерация документов
// ───────────────────────────────────────────────────────────────────────────
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateMeetingProposal',
description: 'Сгенерировать предложение повестки собрания пайщиков участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuGenerateMeetingProposal(
@Args('data', { type: () => BranchMeetingProposalGenerateDocumentInputDTO })
data: BranchMeetingProposalGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchMeetingProposal(data, options)) as GeneratedDocumentDTO;
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateMeetingBallot',
description: 'Сгенерировать бюллетень голосования на собрании участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuGenerateMeetingBallot(
@Args('data', { type: () => BranchMeetingBallotGenerateDocumentInputDTO })
data: BranchMeetingBallotGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchMeetingBallot(data, options)) as GeneratedDocumentDTO;
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateMeetingDecision',
description: 'Сгенерировать протокол решения собрания пайщиков участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuGenerateMeetingDecision(
@Args('data', { type: () => BranchMeetingDecisionGenerateDocumentInputDTO })
data: BranchMeetingDecisionGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchMeetingDecision(data, options)) as GeneratedDocumentDTO;
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateEstablishmentPetition',
description: 'Сгенерировать заявление председателя собрания в совет об учреждении участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuGenerateEstablishmentPetition(
@Args('data', { type: () => BranchEstablishmentPetitionGenerateDocumentInputDTO })
data: BranchEstablishmentPetitionGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchEstablishmentPetition(data, options)) as GeneratedDocumentDTO;
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateEstablishmentDecision',
description: 'Сгенерировать решение совета об учреждении кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['member', 'chairman'])
async kuGenerateEstablishmentDecision(
@Args('data', { type: () => BranchEstablishmentDecisionGenerateDocumentInputDTO })
data: BranchEstablishmentDecisionGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchEstablishmentDecision(data, options)) as GeneratedDocumentDTO;
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateTrustedStatement',
description: 'Сгенерировать заявление о приёме доверенным лицом участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuGenerateTrustedStatement(
@Args('data', { type: () => BranchTrustedStatementGenerateDocumentInputDTO })
data: BranchTrustedStatementGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchTrustedStatement(data, options)) as GeneratedDocumentDTO;
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateTrustedLiabilityAgreement',
description: 'Сгенерировать договор о полной индивидуальной материальной ответственности доверенного лица кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuGenerateTrustedLiabilityAgreement(
@Args('data', { type: () => BranchTrustedLiabilityAgreementGenerateDocumentInputDTO })
data: BranchTrustedLiabilityAgreementGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchTrustedLiabilityAgreement(data, options)) as GeneratedDocumentDTO;
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateTrusteeLiabilityAgreement',
description: 'Сгенерировать договор о полной индивидуальной материальной ответственности председателя кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuGenerateTrusteeLiabilityAgreement(
@Args('data', { type: () => BranchTrusteeLiabilityAgreementGenerateDocumentInputDTO })
data: BranchTrusteeLiabilityAgreementGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchTrusteeLiabilityAgreement(data, options)) as GeneratedDocumentDTO;
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateTrusteePowerOfAttorney',
description: 'Сгенерировать доверенность председателю кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuGenerateTrusteePowerOfAttorney(
@Args('data', { type: () => BranchTrusteePowerOfAttorneyGenerateDocumentInputDTO })
data: BranchTrusteePowerOfAttorneyGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchTrusteePowerOfAttorney(data, options)) as GeneratedDocumentDTO;
}
@Mutation(() => GeneratedDocumentDTO, {
name: 'kuGenerateTrustedPowerOfAttorney',
description: 'Сгенерировать доверенность доверенному лицу кооперативного участка',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuGenerateTrustedPowerOfAttorney(
@Args('data', { type: () => BranchTrustedPowerOfAttorneyGenerateDocumentInputDTO })
data: BranchTrustedPowerOfAttorneyGenerateDocumentInputDTO,
@Args('options', { nullable: true }) options?: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return (await this.kuService.generateBranchTrustedPowerOfAttorney(data, options)) as GeneratedDocumentDTO;
}
// ───────────────────────────────────────────────────────────────────────────
// Запросы
// ───────────────────────────────────────────────────────────────────────────
@Query(() => paginatedKuDecisionsResult, {
name: 'kuDecisions',
description: 'Получить список решений собраний кооперативных участков',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuDecisions(
@Args('filter', { nullable: true }) filter?: KuDecisionFilterInputDTO,
@Args('options', { nullable: true }) options?: PaginationInputDTO
): Promise<PaginationResult<KuDecisionDTO>> {
return this.kuService.getDecisions(filter, options);
}
@Query(() => KuDecisionDTO, {
name: 'kuDecision',
description: 'Получить решение собрания участка по хэшу (с вопросами повестки)',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuDecision(@Args('hash', { type: () => String }) hash: string): Promise<KuDecisionDTO> {
return this.kuService.getDecision(hash);
}
@Query(() => paginatedKuTrustRequestsResult, {
name: 'kuTrustRequests',
description: 'Получить список заявок доверенных лиц кооперативных участков',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['user', 'member', 'chairman'])
async kuTrustRequests(
@Args('filter', { nullable: true }) filter?: KuTrustRequestFilterInputDTO,
@Args('options', { nullable: true }) options?: PaginationInputDTO
): Promise<PaginationResult<KuTrustRequestDTO>> {
return this.kuService.getTrustRequests(filter, options);
}
}
@@ -0,0 +1,279 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import { BranchContract } from 'cooptypes';
import { randomUUID } from 'crypto';
import { Workflows } from '@coopenomics/notifications';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { NOTIFICATION_PORT, type NotificationPort } from '~/domain/notification/interfaces/notify.port';
import { ACCOUNT_DATA_PORT, type AccountDataPort } from '~/domain/account/ports/account-data.port';
import { ORGANIZATION_REPOSITORY, type OrganizationRepository } from '~/domain/common/repositories/organization.repository';
import { INDIVIDUAL_REPOSITORY, type IndividualRepository } from '~/domain/common/repositories/individual.repository';
import {
PAYMENT_METHOD_REPOSITORY,
type PaymentMethodRepository,
} from '~/domain/common/repositories/payment-method.repository';
import { IndividualDomainEntity } from '~/domain/branch/entities/individual-domain.entity';
import { OrganizationDomainEntity } from '~/domain/branch/entities/organization-domain.entity';
import { PaymentMethodDomainEntity } from '~/domain/payment-method/entities/method-domain.entity';
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
import config from '~/config/config';
import { BRANCH_BLOCKCHAIN_PORT, type BranchBlockchainPort } from '~/domain/branch/interfaces/branch-blockchain.port';
import { KU_DECISION_REPOSITORY, type KuDecisionRepository } from '../../domain/repositories/ku-decision.repository';
import {
KU_TRUST_REQUEST_REPOSITORY,
type KuTrustRequestRepository,
} from '../../domain/repositories/ku-trust-request.repository';
/**
* Событийные реакции собраний кооперативных участков:
* старт голосования (branch::startdec) уведомления всем участникам собрания;
* утверждение советом (branch::confirmdec) создание карточки организации
* участка в БД (человекочитаемое наименование и адрес приватные данные,
* которых нет в блокчейне), по образцу core-создания КУ со стола председателя.
*/
@Injectable()
export class KuEventsService {
constructor(
@Inject(NOTIFICATION_PORT) private readonly notificationPort: NotificationPort,
@Inject(ACCOUNT_DATA_PORT) private readonly accountPort: AccountDataPort,
@Inject(KU_DECISION_REPOSITORY) private readonly decisionRepository: KuDecisionRepository,
@Inject(KU_TRUST_REQUEST_REPOSITORY) private readonly trustRequestRepository: KuTrustRequestRepository,
@Inject(BRANCH_BLOCKCHAIN_PORT) private readonly branchBlockchainPort: BranchBlockchainPort,
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
@Inject(INDIVIDUAL_REPOSITORY) private readonly individualRepository: IndividualRepository,
@Inject(PAYMENT_METHOD_REPOSITORY) private readonly paymentMethodRepository: PaymentMethodRepository,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(KuEventsService.name);
}
@OnEvent(`action::${BranchContract.contractName.production}::startdec`)
async handleVotingStarted(actionData: ActionDomainInterface): Promise<void> {
try {
const action = actionData.data as { coopname: string; hash: string };
if (action.coopname !== config.coopname) return;
const decision = await this.decisionRepository.findByHash(action.hash);
if (!decision) {
this.logger.warn(`startdec: решение ${action.hash} не найдено в проекции — уведомления не отправлены`);
return;
}
const coopShortName = await this.accountPort.getDisplayName(action.coopname).catch(() => action.coopname);
const closeAtTime = decision.close_at
? new Date(decision.close_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' })
: '';
const payload: Workflows.BranchVotingStarted.IPayload = {
coopShortName,
meetPlace: decision.meet_place || '',
closeAtTime,
meetingUrl: `${config.frontend_url}/${action.coopname}/ku/meetings/${decision.hash}`,
};
let sent = 0;
for (const username of decision.participants ?? []) {
try {
const account = await this.accountPort.getAccount(username);
const subscriberId = account.provider_account?.subscriber_id?.trim();
const email = account.provider_account?.email;
if (!subscriberId || !email) continue;
await this.notificationPort.notify({
coopname: action.coopname,
workflowId: Workflows.BranchVotingStarted.id,
to: { subscriberId, email, username },
payload,
});
sent++;
} catch (error: any) {
this.logger.warn(`startdec: не удалось уведомить участника ${username}: ${error.message}`);
}
}
this.logger.log(`startdec ${action.hash}: уведомлено ${sent}/${decision.participants?.length ?? 0} участников`);
} catch (error: any) {
this.logger.error(`Ошибка обработки startdec: ${error.message}`, error.stack);
}
}
/** Уведомление одному пайщику; молча пропускает аккаунты без подписки на уведомления */
private async notifyUser(username: string, workflowId: string, payload: Record<string, unknown>): Promise<boolean> {
const account = await this.accountPort.getAccount(username);
const subscriberId = account.provider_account?.subscriber_id?.trim();
const email = account.provider_account?.email;
if (!subscriberId || !email) return false;
await this.notificationPort.notify({
coopname: config.coopname,
workflowId,
to: { subscriberId, email, username },
payload,
});
return true;
}
/** Напоминание участникам за час до начала собрания (если время назначено) */
@Cron(CronExpression.EVERY_5_MINUTES)
async remindUpcomingMeetings(): Promise<void> {
try {
const now = Date.now();
const from = new Date(now);
const to = new Date(now + 65 * 60 * 1000);
const meetings = await this.decisionRepository.findMeetingsForReminder(from, to);
for (const meeting of meetings) {
// окно [now, now+65m): шлём, когда до начала остался час или меньше
const coopShortName = await this.accountPort.getDisplayName(config.coopname).catch(() => config.coopname);
const payload: Workflows.BranchMeetingReminder.IPayload = {
coopShortName,
meetPlace: meeting.meet_place || '',
meetAtTime: meeting.meet_at
? new Date(meeting.meet_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' })
: '',
meetingUrl: `${config.frontend_url}/${config.coopname}/ku/meetings/${meeting.hash}`,
};
let sent = 0;
for (const username of meeting.participants ?? []) {
try {
if (await this.notifyUser(username, Workflows.BranchMeetingReminder.id, payload)) sent++;
} catch (error: any) {
this.logger.warn(`напоминание о собрании ${meeting.hash}: не удалось уведомить ${username}: ${error.message}`);
}
}
await this.decisionRepository.markReminderSent(meeting.hash as string);
this.logger.log(`напоминание о собрании ${meeting.hash}: уведомлено ${sent}/${meeting.participants?.length ?? 0}`);
}
} catch (error: any) {
this.logger.error(`Ошибка напоминаний о собраниях участков: ${error.message}`, error.stack);
}
}
/** Новая заявка доверенного → уведомление председателю участка */
@OnEvent(`action::${BranchContract.contractName.production}::reqtrusted`)
async handleTrustedRequested(actionData: ActionDomainInterface): Promise<void> {
try {
const action = actionData.data as { coopname: string; braname: string; username: string; hash: string };
if (action.coopname !== config.coopname) return;
const branch = await this.branchBlockchainPort.getBranch(action.coopname, action.braname);
if (!branch?.trustee) {
this.logger.warn(`reqtrusted: участок ${action.braname} не найден — председатель не уведомлён`);
return;
}
const coopShortName = await this.accountPort.getDisplayName(action.coopname).catch(() => action.coopname);
const applicantName = await this.accountPort.getDisplayName(action.username).catch(() => action.username);
const payload: Workflows.BranchTrustedRequested.IPayload = {
coopShortName,
applicantName,
branchUrl: `${config.frontend_url}/${action.coopname}/ku/branches/${action.braname}`,
};
await this.notifyUser(branch.trustee, Workflows.BranchTrustedRequested.id, payload);
this.logger.log(`reqtrusted ${action.hash}: председатель ${branch.trustee} уведомлён о заявке ${action.username}`);
} catch (error: any) {
this.logger.error(`Ошибка обработки reqtrusted: ${error.message}`, error.stack);
}
}
@OnEvent(`action::${BranchContract.contractName.production}::apprtrusted`)
async handleTrustedApproved(actionData: ActionDomainInterface): Promise<void> {
await this.notifyTrustedResolved(actionData, 'одобрена');
}
@OnEvent(`action::${BranchContract.contractName.production}::decltrusted`)
async handleTrustedDeclined(actionData: ActionDomainInterface): Promise<void> {
await this.notifyTrustedResolved(actionData, 'отклонена');
}
/** Решение председателя по заявке доверенного → уведомление заявителю */
private async notifyTrustedResolved(actionData: ActionDomainInterface, resolution: string): Promise<void> {
try {
const action = actionData.data as { coopname: string; hash: string };
if (action.coopname !== config.coopname) return;
const request = await this.trustRequestRepository.findByHash(action.hash);
if (!request?.username) {
this.logger.warn(`заявка доверенного ${action.hash} не найдена в проекции — заявитель не уведомлён`);
return;
}
const coopShortName = await this.accountPort.getDisplayName(action.coopname).catch(() => action.coopname);
const payload: Workflows.BranchTrustedResolved.IPayload = {
coopShortName,
resolution,
branchUrl: `${config.frontend_url}/${action.coopname}/ku/branches/${request.braname}`,
};
await this.notifyUser(request.username, Workflows.BranchTrustedResolved.id, payload);
this.logger.log(`заявка доверенного ${action.hash} ${resolution}: заявитель ${request.username} уведомлён`);
} catch (error: any) {
this.logger.error(`Ошибка уведомления о решении по заявке доверенного: ${error.message}`, error.stack);
}
}
@OnEvent(`action::${BranchContract.contractName.production}::confirmdec`)
async handleBranchEstablished(actionData: ActionDomainInterface): Promise<void> {
try {
const action = actionData.data as { coopname: string; hash: string };
if (action.coopname !== config.coopname) return;
const decision = await this.decisionRepository.findByHash(action.hash);
if (!decision?.braname || !decision.chairman) {
this.logger.warn(`confirmdec: решение ${action.hash} не найдено в проекции — карточка участка не создана`);
return;
}
const existing = await this.organizationRepository.findByUsername(decision.braname).catch(() => null);
if (existing) return; // идемпотентность при повторной доставке события
const cooperative = await this.organizationRepository.findByUsername(action.coopname);
if (!cooperative) {
this.logger.error(`confirmdec: организация кооператива ${action.coopname} не найдена`);
return;
}
const trustee = new IndividualDomainEntity(await this.individualRepository.findByUsername(decision.chairman));
const branchName = decision.branch_name || decision.braname;
const combinedData = new OrganizationDomainEntity({
...cooperative,
short_name: `КУ «${branchName}»`,
full_name: `Кооперативный Участок «${branchName}»`,
fact_address: decision.address || '',
represented_by: {
first_name: trustee.first_name,
last_name: trustee.last_name,
middle_name: trustee.middle_name,
based_on: `Протокол собрания пайщиков кооперативного участка от ${new Date().toLocaleDateString('ru-RU')}`,
position: 'председатель кооперативного участка',
},
username: decision.braname,
});
await this.organizationRepository.create(combinedData);
const cooperativeBank = await this.paymentMethodRepository.get({
username: action.coopname,
method_type: 'bank_transfer',
is_default: true,
});
await this.paymentMethodRepository.save(
new PaymentMethodDomainEntity({
username: decision.braname,
method_id: randomUUID().toString(),
method_type: 'bank_transfer',
data: cooperativeBank.data,
is_default: true,
})
);
this.logger.log(`confirmdec ${action.hash}: создана карточка участка ${decision.braname}${branchName}»)`);
} catch (error: any) {
this.logger.error(`Ошибка обработки confirmdec: ${error.message}`, error.stack);
}
}
}
@@ -0,0 +1,515 @@
import { Inject, Injectable } from '@nestjs/common';
import { Cooperative } from 'cooptypes';
import httpStatus from 'http-status';
import { HttpApiError } from '~/utils/httpApiError';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import { DocumentAggregator } from '~/domain/document/aggregators/document.aggregator';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
import type { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import type { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { BRANCH_BLOCKCHAIN_PORT, type BranchBlockchainPort } from '~/domain/branch/interfaces/branch-blockchain.port';
import { ACCOUNT_DATA_PORT, type AccountDataPort } from '~/domain/account/ports/account-data.port';
import { AccountType } from '~/application/account/enum/account-type.enum';
import type { PaginationInputDomainInterface } from '~/domain/common/interfaces/pagination.interface';
import type { PaginationResult } from '~/application/common/dto/pagination.dto';
import { KU_BLOCKCHAIN_PORT, type KuBlockchainPort } from '../../domain/interfaces/ku-blockchain.port';
import { KU_DECISION_REPOSITORY, type KuDecisionRepository } from '../../domain/repositories/ku-decision.repository';
import {
KU_DECISION_QUESTION_REPOSITORY,
type KuDecisionQuestionRepository,
} from '../../domain/repositories/ku-decision-question.repository';
import {
KU_TRUST_REQUEST_REPOSITORY,
type KuTrustRequestRepository,
} from '../../domain/repositories/ku-trust-request.repository';
import type { KuDecisionDomainEntity } from '../../domain/entities/ku-decision.entity';
import type { KuDecisionQuestionDomainEntity } from '../../domain/entities/ku-decision-question.entity';
import type { KuTrustRequestDomainEntity } from '../../domain/entities/ku-trust-request.entity';
import type { KuDecisionType } from '../../domain/enums/ku-decision-type.enum';
import type { KuDecisionStatus } from '../../domain/enums/ku-decision-status.enum';
import type {
ApproveKuTrustedInputDomainInterface,
CancelKuDecisionInputDomainInterface,
CloseKuDecisionInputDomainInterface,
CreateKuDecisionInputDomainInterface,
DeclineKuTrustedInputDomainInterface,
ExecKuDecisionInputDomainInterface,
JoinKuDecisionInputDomainInterface,
RequestKuTrustedInputDomainInterface,
StartKuDecisionInputDomainInterface,
VoteOnKuDecisionInputDomainInterface,
} from '../../domain/interfaces/ku-action-inputs.interface';
import { KuDecisionDTO, KuDecisionFilterInputDTO, KuDecisionQuestionDTO } from '../dto/ku-decision.dto';
import { KuTrustRequestDTO, KuTrustRequestFilterInputDTO } from '../dto/ku-trust-request.dto';
import { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto';
/**
* Сервис собраний и решений кооперативных участков.
* Действия отправляются в контракт branch с подписью кооператива;
* чтение из PG-проекций (история сохраняется после erase в блокчейне).
*/
@Injectable()
export class KuService {
constructor(
@Inject(KU_BLOCKCHAIN_PORT) private readonly kuBlockchainPort: KuBlockchainPort,
@Inject(BRANCH_BLOCKCHAIN_PORT) private readonly branchBlockchainPort: BranchBlockchainPort,
@Inject(KU_DECISION_REPOSITORY) private readonly decisionRepository: KuDecisionRepository,
@Inject(KU_DECISION_QUESTION_REPOSITORY) private readonly questionRepository: KuDecisionQuestionRepository,
@Inject(KU_TRUST_REQUEST_REPOSITORY) private readonly trustRequestRepository: KuTrustRequestRepository,
@Inject(ACCOUNT_DATA_PORT) private readonly accountPort: AccountDataPort,
private readonly documentDomainService: DocumentDomainService,
private readonly documentAggregator: DocumentAggregator
) {}
// ───────────────────────────────────────────────────────────────────────────
// Проверки прав
// ───────────────────────────────────────────────────────────────────────────
private assertSameUser(currentUser: MonoAccountDomainInterface, username: string): void {
if (currentUser.username !== username) {
throw new HttpApiError(httpStatus.FORBIDDEN, 'Действие доступно только от своего имени');
}
}
private async getDecisionOrFail(hash: string): Promise<KuDecisionDomainEntity> {
const decision = await this.decisionRepository.findByHash(hash);
if (!decision) {
throw new HttpApiError(httpStatus.NOT_FOUND, 'Решение собрания участка не найдено');
}
return decision;
}
private async assertIsDecisionChairman(currentUser: MonoAccountDomainInterface, hash: string): Promise<void> {
const decision = await this.getDecisionOrFail(hash);
if (decision.chairman !== currentUser.username) {
throw new HttpApiError(httpStatus.FORBIDDEN, 'Действие доступно только председателю собрания');
}
}
private async assertIsDecisionInitiator(currentUser: MonoAccountDomainInterface, hash: string): Promise<void> {
const decision = await this.getDecisionOrFail(hash);
if (decision.initiator !== currentUser.username) {
throw new HttpApiError(httpStatus.FORBIDDEN, 'Действие доступно только инициатору собрания');
}
}
private async assertIsBranchTrustee(currentUser: MonoAccountDomainInterface, requestHash: string): Promise<void> {
const request = await this.trustRequestRepository.findByHash(requestHash);
if (!request?.braname) {
throw new HttpApiError(httpStatus.NOT_FOUND, 'Заявка доверенного не найдена');
}
const branch = await this.branchBlockchainPort.getBranch(request.coopname as string, request.braname);
if (!branch) {
throw new HttpApiError(httpStatus.NOT_FOUND, 'Кооперативный участок не найден');
}
if (branch.trustee !== currentUser.username) {
throw new HttpApiError(httpStatus.FORBIDDEN, 'Действие доступно только председателю кооперативного участка');
}
}
// ───────────────────────────────────────────────────────────────────────────
// Действия собрания (контракт branch)
// ───────────────────────────────────────────────────────────────────────────
async createDecision(
data: CreateKuDecisionInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
this.assertSameUser(currentUser, data.initiator);
const result = await this.kuBlockchainPort.createDecision(data);
// Место и время проведения собрания — приватные данные пайщиков,
// в блокчейн не публикуются и сохраняются только в БД платформы
await this.decisionRepository.upsertPrivateData({
hash: data.hash,
coopname: data.coopname,
type: data.type,
initiator: data.initiator,
meet_place: data.meet_place,
meet_at: new Date(data.meet_at),
});
return result as unknown as TransactionDTO;
}
async joinDecision(
data: JoinKuDecisionInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
this.assertSameUser(currentUser, data.username);
const result = await this.kuBlockchainPort.joinDecision(data);
return result as unknown as TransactionDTO;
}
async startDecision(
data: StartKuDecisionInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
// Голосование открывает организатор собрания, назначая председателя
// из числа присоединившихся участников
await this.assertIsDecisionInitiator(currentUser, data.hash);
const decision = await this.getDecisionOrFail(data.hash);
if (!(decision.participants ?? []).includes(data.chairman)) {
throw new HttpApiError(httpStatus.BAD_REQUEST, 'Председатель должен быть участником собрания');
}
if (decision.type === 'createbranch' && !data.branch_name) {
throw new HttpApiError(httpStatus.BAD_REQUEST, 'Укажите наименование кооперативного участка');
}
// контакты нужны для добавления участка как подразделения после решения совета
if (decision.type === 'createbranch' && (!data.branch_email || !data.branch_phone)) {
throw new HttpApiError(httpStatus.BAD_REQUEST, 'Укажите email и телефон кооперативного участка');
}
if (decision.type === 'createbranch') {
const chairmanAccount = await this.accountPort.getAccount(data.chairman);
if (chairmanAccount.private_account?.type !== AccountType.individual) {
throw new HttpApiError(
httpStatus.BAD_REQUEST,
'Председателем кооперативного участка может быть только физическое лицо'
);
}
}
// Наименование и контакты участка — приватные данные, в блокчейн не публикуются
await this.decisionRepository.upsertPrivateData({
hash: data.hash,
branch_name: data.branch_name,
branch_email: data.branch_email,
branch_phone: data.branch_phone,
});
const result = await this.kuBlockchainPort.startDecision(data);
return result as unknown as TransactionDTO;
}
async voteOnDecision(
data: VoteOnKuDecisionInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
this.assertSameUser(currentUser, data.username);
const result = await this.kuBlockchainPort.voteOnDecision(data);
return result as unknown as TransactionDTO;
}
async closeDecision(
data: CloseKuDecisionInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
// протокол утверждает председатель собрания — им автоматически является организатор
await this.assertIsDecisionInitiator(currentUser, data.hash);
const result = await this.kuBlockchainPort.closeDecision(data);
return result as unknown as TransactionDTO;
}
async execDecision(
data: ExecKuDecisionInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
// Заявление в совет и договор о материальной ответственности подписывает
// избранный собранием председатель участка (он же сторона договора)
await this.assertIsDecisionChairman(currentUser, data.hash);
const result = await this.kuBlockchainPort.execDecision(data);
return result as unknown as TransactionDTO;
}
async cancelDecision(
data: CancelKuDecisionInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
await this.assertIsDecisionInitiator(currentUser, data.hash);
const result = await this.kuBlockchainPort.cancelDecision(data);
// контракт стирает запись одинаково при любом исходе — факт отмены
// фиксируем в БД, чтобы отличать «Отменено» от «Завершено»
await this.decisionRepository.upsertPrivateData({
hash: data.hash,
cancelled: true,
});
return result as unknown as TransactionDTO;
}
// ───────────────────────────────────────────────────────────────────────────
// Доверенные лица
// ───────────────────────────────────────────────────────────────────────────
async requestTrusted(
data: RequestKuTrustedInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
this.assertSameUser(currentUser, data.username);
const result = await this.kuBlockchainPort.requestTrusted(data);
return result as unknown as TransactionDTO;
}
async approveTrusted(
data: ApproveKuTrustedInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
await this.assertIsBranchTrustee(currentUser, data.hash);
const result = await this.kuBlockchainPort.approveTrusted(data);
return result as unknown as TransactionDTO;
}
async declineTrusted(
data: DeclineKuTrustedInputDomainInterface,
currentUser: MonoAccountDomainInterface
): Promise<TransactionDTO> {
await this.assertIsBranchTrustee(currentUser, data.hash);
const result = await this.kuBlockchainPort.declineTrusted(data);
return result as unknown as TransactionDTO;
}
// ───────────────────────────────────────────────────────────────────────────
// Генерация документов 320–327
// ───────────────────────────────────────────────────────────────────────────
private async generate(
data: Cooperative.Document.IGenerate,
registry_id: number,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
data.registry_id = registry_id;
return await this.documentDomainService.generateDocument({ data, options: options || {} });
}
async generateBranchMeetingProposal(
data: Cooperative.Registry.BranchMeetingProposal.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchMeetingProposal.registry_id, options);
}
async generateBranchMeetingBallot(
data: Cooperative.Registry.BranchMeetingBallot.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchMeetingBallot.registry_id, options);
}
async generateBranchMeetingDecision(
data: Cooperative.Registry.BranchMeetingDecision.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchMeetingDecision.registry_id, options);
}
async generateBranchEstablishmentPetition(
data: Cooperative.Registry.BranchEstablishmentPetition.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchEstablishmentPetition.registry_id, options);
}
async generateBranchEstablishmentDecision(
data: Cooperative.Registry.BranchEstablishmentSovietDecision.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchEstablishmentSovietDecision.registry_id, options);
}
async generateBranchTrustedStatement(
data: Cooperative.Registry.BranchTrustedStatement.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchTrustedStatement.registry_id, options);
}
async generateBranchTrustedLiabilityAgreement(
data: Cooperative.Registry.BranchTrustedLiabilityAgreement.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchTrustedLiabilityAgreement.registry_id, options);
}
async generateBranchTrusteeLiabilityAgreement(
data: Cooperative.Registry.BranchTrusteeLiabilityAgreement.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchTrusteeLiabilityAgreement.registry_id, options);
}
async generateBranchTrusteePowerOfAttorney(
data: Cooperative.Registry.BranchTrusteePowerOfAttorney.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchTrusteePowerOfAttorney.registry_id, options);
}
async generateBranchTrustedPowerOfAttorney(
data: Cooperative.Registry.BranchTrustedPowerOfAttorney.Action,
options?: GenerateDocumentOptionsInputDTO
): Promise<DocumentDomainEntity> {
return this.generate(data, Cooperative.Registry.BranchTrustedPowerOfAttorney.registry_id, options);
}
// ───────────────────────────────────────────────────────────────────────────
// Запросы
// ───────────────────────────────────────────────────────────────────────────
private toDecisionDTO(entity: KuDecisionDomainEntity, questions?: KuDecisionQuestionDomainEntity[]): KuDecisionDTO {
return {
hash: entity.hash as string,
id: entity.id,
coopname: entity.coopname,
type: entity.type as KuDecisionType,
initiator: entity.initiator,
chairman: entity.chairman,
status: (entity.present ? entity.status : entity.cancelled ? 'cancelled' : 'completed') as KuDecisionStatus,
present: entity.present,
proposal: entity.proposal,
protocol: entity.protocol,
petition: entity.petition,
authorization: entity.authorization,
open_at: entity.open_at,
close_at: entity.close_at,
signed_ballots: entity.signed_ballots,
braname: entity.braname,
address: entity.address,
participants: entity.participants,
created_at: entity.created_at,
meet_place: entity.meet_place,
meet_at: entity.meet_at?.toISOString(),
branch_name: entity.branch_name,
branch_email: entity.branch_email,
branch_phone: entity.branch_phone,
questions: questions?.map((question) => this.toQuestionDTO(question)),
block_num: entity.block_num,
};
}
/** Отображаемые имена и типы аккаунтов участников собрания (для выбора председателя по ФИО) */
private async resolveParticipantsInfo(
participants: string[]
): Promise<{ username: string; display_name: string; account_type: AccountType }[]> {
return Promise.all(
participants.map(async (username) => {
try {
const account = await this.accountPort.getAccount(username);
const display_name = await this.accountPort.getDisplayName(username);
const account_type = (account.private_account?.type as AccountType) ?? AccountType.individual;
return { username, display_name: display_name || username, account_type };
} catch {
return { username, display_name: username, account_type: AccountType.individual };
}
})
);
}
private toQuestionDTO(entity: KuDecisionQuestionDomainEntity): KuDecisionQuestionDTO {
return {
id: entity.id,
decision_id: entity.decision_id,
number: entity.number,
title: entity.title,
decision: entity.decision,
context: entity.context,
counter_votes_for: entity.counter_votes_for,
counter_votes_against: entity.counter_votes_against,
counter_votes_abstained: entity.counter_votes_abstained,
voters_for: entity.voters_for,
voters_against: entity.voters_against,
voters_abstained: entity.voters_abstained,
};
}
private toTrustRequestDTO(entity: KuTrustRequestDomainEntity): KuTrustRequestDTO {
return {
hash: entity.hash as string,
id: entity.id,
coopname: entity.coopname,
braname: entity.braname,
username: entity.username,
present: entity.present,
application: entity.application,
authority: entity.authority,
block_num: entity.block_num,
};
}
async getDecisions(
filter?: KuDecisionFilterInputDTO,
options?: PaginationInputDomainInterface
): Promise<PaginationResult<KuDecisionDTO>> {
const result = await this.decisionRepository.findAllPaginated(filter, options);
return {
...result,
items: result.items.map((item) => this.toDecisionDTO(item)),
};
}
async getDecision(hash: string): Promise<KuDecisionDTO> {
const decision = await this.getDecisionOrFail(hash);
let questions: KuDecisionQuestionDomainEntity[] = [];
if (decision.coopname && decision.id !== undefined) {
questions = await this.questionRepository.findByDecisionId(decision.coopname, decision.id);
// контракт заменяет черновую повестку при открытии голосования (erase + emplace) —
// стёртые вопросы остаются в БД с present=false и в повестку не попадают
questions = questions.filter((question) => question.present);
}
const dto = this.toDecisionDTO(decision, questions);
dto.participants_info = await this.resolveParticipantsInfo(decision.participants ?? []);
// Протокол собрания пайщиков (323) и решение совета (325) — публикуемые документы
// для страницы собрания. Договор матответственности (328) и доверенность (329)
// содержат паспортные данные и сюда НЕ выносятся.
if (decision.protocol) {
const aggregate = await this.documentAggregator
.buildDocumentAggregate(decision.protocol as unknown as ISignedDocumentDomainInterface)
.catch(() => null);
dto.protocol_document = aggregate ? new DocumentAggregateDTO(aggregate) : undefined;
}
if (decision.authorization) {
const aggregate = await this.documentAggregator
.buildDocumentAggregate(decision.authorization as unknown as ISignedDocumentDomainInterface)
.catch(() => null);
dto.authorization_document = aggregate ? new DocumentAggregateDTO(aggregate) : undefined;
}
return dto;
}
async getTrustRequests(
filter?: KuTrustRequestFilterInputDTO,
options?: PaginationInputDomainInterface
): Promise<PaginationResult<KuTrustRequestDTO>> {
const result = await this.trustRequestRepository.findAllPaginated(filter, options);
const items = await Promise.all(
result.items.map(async (item) => {
const dto = this.toTrustRequestDTO(item);
if (dto.username) {
dto.display_name = await this.accountPort.getDisplayName(dto.username).catch(() => dto.username);
}
// договор заявителя с сертификатами подписантов — председатель смотрит документ
// и накладывает встречную подпись на него же, без регенерации
if (item.application) {
const aggregate = await this.documentAggregator
.buildDocumentAggregate(item.application as unknown as ISignedDocumentDomainInterface)
.catch(() => null);
dto.document = aggregate ? new DocumentAggregateDTO(aggregate) : undefined;
}
// доверенность доверенному лицу — председатель так же накладывает встречную подпись
if (item.authority) {
const authorityAggregate = await this.documentAggregator
.buildDocumentAggregate(item.authority as unknown as ISignedDocumentDomainInterface)
.catch(() => null);
dto.authority_document = authorityAggregate ? new DocumentAggregateDTO(authorityAggregate) : undefined;
}
return dto;
})
);
return {
...result,
items,
};
}
}
@@ -0,0 +1,41 @@
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { AbstractEntitySyncService } from '~/shared/services/abstract-entity-sync.service';
import { KuDecisionQuestionDomainEntity } from '../../domain/entities/ku-decision-question.entity';
import { KuDecisionQuestionRepository, KU_DECISION_QUESTION_REPOSITORY } from '../../domain/repositories/ku-decision-question.repository';
import { KuDecisionQuestionDeltaMapper } from '../../infrastructure/blockchain/mappers/ku-decision-question-delta.mapper';
import type { IKuDecisionQuestionBlockchainData } from '../../domain/interfaces/ku-blockchain-data.interface';
/**
* Сервис синхронизации вопросов повесток собраний участков с блокчейном (контракт branch).
* История сохраняется в PG после erase записи в блокчейне (present=false).
*/
@Injectable()
export class KuDecisionQuestionSyncService extends AbstractEntitySyncService<KuDecisionQuestionDomainEntity, IKuDecisionQuestionBlockchainData> implements OnModuleInit {
protected readonly entityName = 'KuDecisionQuestionDomainEntity';
constructor(
@Inject(KU_DECISION_QUESTION_REPOSITORY)
repository: KuDecisionQuestionRepository,
mapper: KuDecisionQuestionDeltaMapper,
logger: WinstonLoggerService,
private readonly eventEmitter: EventEmitter2
) {
super(repository, mapper, logger);
}
async onModuleInit() {
const allPatterns = this.getAllEventPatterns();
this.logger.debug(`Подписка на ${allPatterns.length} паттернов событий: ${allPatterns.join(', ')}`);
allPatterns.forEach((pattern) => {
this.eventEmitter.on(pattern, this.processDelta.bind(this));
});
}
@OnEvent('fork::*')
async handleEntityFork(forkData: { block_num: number }): Promise<void> {
await this.handleFork(forkData.block_num);
}
}
@@ -0,0 +1,41 @@
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { AbstractEntitySyncService } from '~/shared/services/abstract-entity-sync.service';
import { KuDecisionDomainEntity } from '../../domain/entities/ku-decision.entity';
import { KuDecisionRepository, KU_DECISION_REPOSITORY } from '../../domain/repositories/ku-decision.repository';
import { KuDecisionDeltaMapper } from '../../infrastructure/blockchain/mappers/ku-decision-delta.mapper';
import type { IKuDecisionBlockchainData } from '../../domain/interfaces/ku-blockchain-data.interface';
/**
* Сервис синхронизации решений собраний участков с блокчейном (контракт branch).
* История сохраняется в PG после erase записи в блокчейне (present=false).
*/
@Injectable()
export class KuDecisionSyncService extends AbstractEntitySyncService<KuDecisionDomainEntity, IKuDecisionBlockchainData> implements OnModuleInit {
protected readonly entityName = 'KuDecisionDomainEntity';
constructor(
@Inject(KU_DECISION_REPOSITORY)
repository: KuDecisionRepository,
mapper: KuDecisionDeltaMapper,
logger: WinstonLoggerService,
private readonly eventEmitter: EventEmitter2
) {
super(repository, mapper, logger);
}
async onModuleInit() {
const allPatterns = this.getAllEventPatterns();
this.logger.debug(`Подписка на ${allPatterns.length} паттернов событий: ${allPatterns.join(', ')}`);
allPatterns.forEach((pattern) => {
this.eventEmitter.on(pattern, this.processDelta.bind(this));
});
}
@OnEvent('fork::*')
async handleEntityFork(forkData: { block_num: number }): Promise<void> {
await this.handleFork(forkData.block_num);
}
}
@@ -0,0 +1,41 @@
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { AbstractEntitySyncService } from '~/shared/services/abstract-entity-sync.service';
import { KuTrustRequestDomainEntity } from '../../domain/entities/ku-trust-request.entity';
import { KuTrustRequestRepository, KU_TRUST_REQUEST_REPOSITORY } from '../../domain/repositories/ku-trust-request.repository';
import { KuTrustRequestDeltaMapper } from '../../infrastructure/blockchain/mappers/ku-trust-request-delta.mapper';
import type { IKuTrustRequestBlockchainData } from '../../domain/interfaces/ku-blockchain-data.interface';
/**
* Сервис синхронизации заявок доверенных участков с блокчейном (контракт branch).
* История сохраняется в PG после erase записи в блокчейне (present=false).
*/
@Injectable()
export class KuTrustRequestSyncService extends AbstractEntitySyncService<KuTrustRequestDomainEntity, IKuTrustRequestBlockchainData> implements OnModuleInit {
protected readonly entityName = 'KuTrustRequestDomainEntity';
constructor(
@Inject(KU_TRUST_REQUEST_REPOSITORY)
repository: KuTrustRequestRepository,
mapper: KuTrustRequestDeltaMapper,
logger: WinstonLoggerService,
private readonly eventEmitter: EventEmitter2
) {
super(repository, mapper, logger);
}
async onModuleInit() {
const allPatterns = this.getAllEventPatterns();
this.logger.debug(`Подписка на ${allPatterns.length} паттернов событий: ${allPatterns.join(', ')}`);
allPatterns.forEach((pattern) => {
this.eventEmitter.on(pattern, this.processDelta.bind(this));
});
}
@OnEvent('fork::*')
async handleEntityFork(forkData: { block_num: number }): Promise<void> {
await this.handleFork(forkData.block_num);
}
}
@@ -0,0 +1,79 @@
import type { IBlockchainSynchronizable } from '~/shared/interfaces/blockchain-sync.interface';
import { BaseDomainEntity } from '~/shared/sync/entities/base-domain.entity';
import type {
IKuDecisionQuestionBlockchainData,
IKuDecisionQuestionDatabaseData,
} from '../interfaces/ku-blockchain-data.interface';
/**
* Доменная сущность вопроса повестки собрания пайщиков кооперативного участка
* (таблица decisionq контракта branch).
*/
export class KuDecisionQuestionDomainEntity
extends BaseDomainEntity<IKuDecisionQuestionDatabaseData>
implements IBlockchainSynchronizable, Partial<IKuDecisionQuestionBlockchainData>
{
private static primary_key = 'id';
private static sync_key = 'id';
public id?: number;
public decision_id?: number;
public number?: number;
public coopname?: string;
public title?: string;
public decision?: string;
public context?: string;
public counter_votes_for?: number;
public counter_votes_against?: number;
public counter_votes_abstained?: number;
public voters_for?: string[];
public voters_against?: string[];
public voters_abstained?: string[];
constructor(databaseData: IKuDecisionQuestionDatabaseData, blockchainData?: IKuDecisionQuestionBlockchainData) {
super(databaseData, 'active');
if (blockchainData) {
this.updateFromBlockchain(blockchainData, databaseData.block_num ?? 0, databaseData.present);
}
}
getBlockNum(): number | undefined {
return this.block_num;
}
public static getPrimaryKey(): string {
return KuDecisionQuestionDomainEntity.primary_key;
}
public static getSyncKey(): string {
return KuDecisionQuestionDomainEntity.sync_key;
}
getPrimaryKey(): string {
return KuDecisionQuestionDomainEntity.primary_key;
}
getSyncKey(): string {
return KuDecisionQuestionDomainEntity.sync_key;
}
updateFromBlockchain(blockchainData: IKuDecisionQuestionBlockchainData, blockNum: number, present = true): void {
this.block_num = blockNum;
this.present = present;
this.id = Number(blockchainData.id);
this.decision_id = Number(blockchainData.decision_id);
this.number = Number(blockchainData.number);
this.coopname = blockchainData.coopname;
this.title = blockchainData.title;
this.decision = blockchainData.decision;
this.context = blockchainData.context;
this.counter_votes_for = Number(blockchainData.counter_votes_for);
this.counter_votes_against = Number(blockchainData.counter_votes_against);
this.counter_votes_abstained = Number(blockchainData.counter_votes_abstained);
this.voters_for = blockchainData.voters_for;
this.voters_against = blockchainData.voters_against;
this.voters_abstained = blockchainData.voters_abstained;
}
}
@@ -0,0 +1,109 @@
import type { IBlockchainSynchronizable } from '~/shared/interfaces/blockchain-sync.interface';
import { BaseDomainEntity } from '~/shared/sync/entities/base-domain.entity';
import type { IKuDecisionBlockchainData, IKuDecisionDatabaseData } from '../interfaces/ku-blockchain-data.interface';
/**
* Доменная сущность решения собрания пайщиков кооперативного участка.
* Агрегирует данные базы данных (история сохраняется после erase в блокчейне)
* и данные таблицы decisions контракта branch.
*/
export class KuDecisionDomainEntity
extends BaseDomainEntity<IKuDecisionDatabaseData>
implements IBlockchainSynchronizable, Partial<IKuDecisionBlockchainData>
{
// Статические поля ключей для поиска и синхронизации
private static primary_key = 'hash';
private static sync_key = 'hash';
public id?: number;
public hash?: string;
public coopname?: string;
public type?: string;
public initiator?: string;
public chairman?: string;
public proposal?: IKuDecisionBlockchainData['proposal'];
public protocol?: IKuDecisionBlockchainData['protocol'];
public petition?: IKuDecisionBlockchainData['petition'];
public liability?: IKuDecisionBlockchainData['liability'];
public authority?: IKuDecisionBlockchainData['authority'];
public authorization?: IKuDecisionBlockchainData['authorization'];
public open_at?: string;
public close_at?: string;
public signed_ballots?: number;
public braname?: string;
public address?: string;
public participants?: string[];
public created_at?: string;
// Приватные данные собрания — только БД, в блокчейн не публикуются
public meet_place?: string;
public meet_at?: Date;
public branch_name?: string;
public branch_email?: string;
public branch_phone?: string;
public cancelled?: boolean;
public meet_reminder_sent?: boolean;
constructor(databaseData: IKuDecisionDatabaseData, blockchainData?: IKuDecisionBlockchainData) {
super(databaseData);
this.meet_place = databaseData.meet_place;
this.meet_at = databaseData.meet_at;
this.branch_name = databaseData.branch_name;
this.branch_email = databaseData.branch_email;
this.branch_phone = databaseData.branch_phone;
this.cancelled = databaseData.cancelled;
this.meet_reminder_sent = databaseData.meet_reminder_sent;
if (blockchainData) {
this.updateFromBlockchain(blockchainData, databaseData.block_num ?? 0, databaseData.present);
}
}
getBlockNum(): number | undefined {
return this.block_num;
}
public static getPrimaryKey(): string {
return KuDecisionDomainEntity.primary_key;
}
public static getSyncKey(): string {
return KuDecisionDomainEntity.sync_key;
}
getPrimaryKey(): string {
return KuDecisionDomainEntity.primary_key;
}
getSyncKey(): string {
return KuDecisionDomainEntity.sync_key;
}
updateFromBlockchain(blockchainData: IKuDecisionBlockchainData, blockNum: number, present = true): void {
this.block_num = blockNum;
this.present = present;
this.id = Number(blockchainData.id);
this.hash = blockchainData.hash?.toLowerCase();
this.coopname = blockchainData.coopname;
this.type = blockchainData.type;
this.initiator = blockchainData.initiator;
this.chairman = blockchainData.chairman;
// Статус контракта сохраняем в базовое поле status
this.status = blockchainData.status;
this.proposal = blockchainData.proposal;
this.protocol = blockchainData.protocol;
this.petition = blockchainData.petition;
this.liability = blockchainData.liability;
this.authority = blockchainData.authority;
this.authorization = blockchainData.authorization;
this.open_at = blockchainData.open_at;
this.close_at = blockchainData.close_at;
this.signed_ballots = Number(blockchainData.signed_ballots);
this.braname = blockchainData.braname;
this.address = blockchainData.address;
this.participants = blockchainData.participants;
this.created_at = blockchainData.created_at;
}
}
@@ -0,0 +1,67 @@
import type { IBlockchainSynchronizable } from '~/shared/interfaces/blockchain-sync.interface';
import { BaseDomainEntity } from '~/shared/sync/entities/base-domain.entity';
import type {
IKuTrustRequestBlockchainData,
IKuTrustRequestDatabaseData,
} from '../interfaces/ku-blockchain-data.interface';
/**
* Доменная сущность заявки на приём доверенным лицом кооперативного участка
* (таблица trustreqs контракта branch).
*/
export class KuTrustRequestDomainEntity
extends BaseDomainEntity<IKuTrustRequestDatabaseData>
implements IBlockchainSynchronizable, Partial<IKuTrustRequestBlockchainData>
{
private static primary_key = 'hash';
private static sync_key = 'hash';
public id?: number;
public hash?: string;
public coopname?: string;
public braname?: string;
public username?: string;
public application?: IKuTrustRequestBlockchainData['application'];
public authority?: IKuTrustRequestBlockchainData['authority'];
constructor(databaseData: IKuTrustRequestDatabaseData, blockchainData?: IKuTrustRequestBlockchainData) {
super(databaseData, 'pending');
if (blockchainData) {
this.updateFromBlockchain(blockchainData, databaseData.block_num ?? 0, databaseData.present);
}
}
getBlockNum(): number | undefined {
return this.block_num;
}
public static getPrimaryKey(): string {
return KuTrustRequestDomainEntity.primary_key;
}
public static getSyncKey(): string {
return KuTrustRequestDomainEntity.sync_key;
}
getPrimaryKey(): string {
return KuTrustRequestDomainEntity.primary_key;
}
getSyncKey(): string {
return KuTrustRequestDomainEntity.sync_key;
}
updateFromBlockchain(blockchainData: IKuTrustRequestBlockchainData, blockNum: number, present = true): void {
this.block_num = blockNum;
this.present = present;
this.id = Number(blockchainData.id);
this.hash = blockchainData.hash?.toLowerCase();
this.coopname = blockchainData.coopname;
this.braname = blockchainData.braname;
this.username = blockchainData.username;
this.application = blockchainData.application;
this.authority = blockchainData.authority;
}
}
@@ -0,0 +1,25 @@
import { registerEnumType } from '@nestjs/graphql';
/**
* Статус решения собрания пайщиков кооперативного участка.
* Значения совпадают со статусами контракта branch.
*/
export enum KuDecisionStatus {
/** Собрание объявлено, открыто присоединение участников */
OPENED = 'opened',
/** Голосование открыто */
VOTING = 'voting',
/** Протокол утверждён председателем собрания */
APPROVED = 'approved',
/** Заявление направлено на рассмотрение совета */
ONAPPROVAL = 'onapproval',
/** Завершено (запись стёрта в блокчейне: исполнено или отклонено) */
COMPLETED = 'completed',
/** Отменено организатором собрания */
CANCELLED = 'cancelled',
}
registerEnumType(KuDecisionStatus, {
name: 'KuDecisionStatus',
description: 'Статус решения собрания пайщиков кооперативного участка',
});
@@ -0,0 +1,16 @@
import { registerEnumType } from '@nestjs/graphql';
/**
* Тип решения собрания пайщиков кооперативного участка
*/
export enum KuDecisionType {
/** Учреждение кооперативного участка */
CREATEBRANCH = 'createbranch',
/** Свободное решение */
FREE = 'free',
}
registerEnumType(KuDecisionType, {
name: 'KuDecisionType',
description: 'Тип решения собрания пайщиков кооперативного участка',
});
@@ -0,0 +1,104 @@
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Доменные input-интерфейсы действий собрания пайщиков кооперативного участка
* и приёма доверенных лиц (контракт branch).
*/
export interface KuAgendaPointInputDomainInterface {
title: string;
decision: string;
context: string;
}
export interface CreateKuDecisionInputDomainInterface {
coopname: string;
hash: string;
type: string;
initiator: string;
braname: string;
agenda: KuAgendaPointInputDomainInterface[];
proposal: ISignedDocumentDomainInterface;
/** Место проведения собрания — приватные данные пайщиков, хранятся только в БД */
meet_place: string;
/** Время проведения собрания — приватные данные пайщиков, хранятся только в БД */
meet_at: string;
}
export interface JoinKuDecisionInputDomainInterface {
coopname: string;
hash: string;
username: string;
}
export interface StartKuDecisionInputDomainInterface {
coopname: string;
hash: string;
/** Избираемый председатель кооперативного участка — из присоединившихся участников */
chairman: string;
/** Адрес привязки кооперативного участка, определённый собранием */
address: string;
/** Человекочитаемое наименование участка («РОМАШКА») — в блокчейн не публикуется */
branch_name: string;
/** Email участка — в блокчейн не публикуется */
branch_email: string;
/** Телефон участка — в блокчейн не публикуется */
branch_phone: string;
/** Дополнительные вопросы повестки, внесённые на собрании */
agenda: KuAgendaPointInputDomainInterface[];
}
export interface KuVoteItemInputDomainInterface {
question_id: number;
vote: string;
}
export interface VoteOnKuDecisionInputDomainInterface {
coopname: string;
hash: string;
username: string;
ballot: ISignedDocumentDomainInterface;
votes: KuVoteItemInputDomainInterface[];
}
export interface CloseKuDecisionInputDomainInterface {
coopname: string;
hash: string;
protocol: ISignedDocumentDomainInterface;
}
export interface ExecKuDecisionInputDomainInterface {
coopname: string;
hash: string;
petition: ISignedDocumentDomainInterface;
liability: ISignedDocumentDomainInterface;
authority: ISignedDocumentDomainInterface;
}
export interface CancelKuDecisionInputDomainInterface {
coopname: string;
hash: string;
reason: string;
}
export interface RequestKuTrustedInputDomainInterface {
coopname: string;
braname: string;
username: string;
hash: string;
application: ISignedDocumentDomainInterface;
authority: ISignedDocumentDomainInterface;
}
export interface ApproveKuTrustedInputDomainInterface {
coopname: string;
hash: string;
countersigned: ISignedDocumentDomainInterface;
countersigned_authority: ISignedDocumentDomainInterface;
}
export interface DeclineKuTrustedInputDomainInterface {
coopname: string;
hash: string;
reason: string;
}
@@ -0,0 +1,34 @@
import type { BranchContract } from 'cooptypes';
import type { IBaseDatabaseData } from '~/shared/sync/interfaces/base-database.interface';
/**
* Интерфейс данных решения собрания участка из блокчейна (таблица decisions контракта branch)
*/
export type IKuDecisionBlockchainData = BranchContract.Tables.Decisions.IDecision;
/**
* Интерфейс данных вопроса повестки из блокчейна (таблица decisionq контракта branch)
*/
export type IKuDecisionQuestionBlockchainData = BranchContract.Tables.DecisionQuestions.IDecisionQuestion;
/**
* Интерфейс данных заявки доверенного из блокчейна (таблица trustreqs контракта branch)
*/
export type IKuTrustRequestBlockchainData = BranchContract.Tables.TrustReqs.ITrustRequest;
/**
* Интерфейсы данных из базы данных.
* Приватные данные собрания (место/время проведения, наименование участка)
* в блокчейн не публикуются доступны только пайщикам кооператива через БД.
*/
export type IKuDecisionDatabaseData = IBaseDatabaseData & {
meet_place?: string;
meet_at?: Date;
branch_name?: string;
branch_email?: string;
branch_phone?: string;
cancelled?: boolean;
meet_reminder_sent?: boolean;
};
export type IKuDecisionQuestionDatabaseData = IBaseDatabaseData;
export type IKuTrustRequestDatabaseData = IBaseDatabaseData;
@@ -0,0 +1,53 @@
import type { TransactResult } from '@wharfkit/session';
import type {
ApproveKuTrustedInputDomainInterface,
CancelKuDecisionInputDomainInterface,
CloseKuDecisionInputDomainInterface,
CreateKuDecisionInputDomainInterface,
DeclineKuTrustedInputDomainInterface,
ExecKuDecisionInputDomainInterface,
JoinKuDecisionInputDomainInterface,
RequestKuTrustedInputDomainInterface,
StartKuDecisionInputDomainInterface,
VoteOnKuDecisionInputDomainInterface,
} from './ku-action-inputs.interface';
/**
* Блокчейн-порт собраний и решений кооперативных участков (контракт branch).
* Все действия подписываются ключом кооператива.
*/
export interface KuBlockchainPort {
/** Объявление собрания пайщиков участка */
createDecision(data: CreateKuDecisionInputDomainInterface): Promise<TransactResult>;
/** Присоединение пайщика к собранию */
joinDecision(data: JoinKuDecisionInputDomainInterface): Promise<TransactResult>;
/** Назначение председателя собрания */
/** Открытие голосования */
startDecision(data: StartKuDecisionInputDomainInterface): Promise<TransactResult>;
/** Подача бюллетеня участником */
voteOnDecision(data: VoteOnKuDecisionInputDomainInterface): Promise<TransactResult>;
/** Закрытие голосования и утверждение протокола председателем */
closeDecision(data: CloseKuDecisionInputDomainInterface): Promise<TransactResult>;
/** Направление заявления председателя в совет (учреждение участка) */
execDecision(data: ExecKuDecisionInputDomainInterface): Promise<TransactResult>;
/** Отмена собрания инициатором */
cancelDecision(data: CancelKuDecisionInputDomainInterface): Promise<TransactResult>;
/** Подача заявки доверенного лица участка */
requestTrusted(data: RequestKuTrustedInputDomainInterface): Promise<TransactResult>;
/** Одобрение заявки доверенного встречной подписью председателя участка */
approveTrusted(data: ApproveKuTrustedInputDomainInterface): Promise<TransactResult>;
/** Отклонение заявки доверенного */
declineTrusted(data: DeclineKuTrustedInputDomainInterface): Promise<TransactResult>;
}
export const KU_BLOCKCHAIN_PORT = Symbol('KuBlockchainPort');
@@ -0,0 +1,8 @@
import type { IBlockchainSyncRepository } from '~/shared/interfaces/blockchain-sync.interface';
import type { KuDecisionQuestionDomainEntity } from '../entities/ku-decision-question.entity';
export interface KuDecisionQuestionRepository extends IBlockchainSyncRepository<KuDecisionQuestionDomainEntity> {
findByDecisionId(coopname: string, decisionId: number): Promise<KuDecisionQuestionDomainEntity[]>;
}
export const KU_DECISION_QUESTION_REPOSITORY = Symbol('KuDecisionQuestionRepository');
@@ -0,0 +1,51 @@
import type { IBlockchainSyncRepository } from '~/shared/interfaces/blockchain-sync.interface';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import type { KuDecisionDomainEntity } from '../entities/ku-decision.entity';
export interface KuDecisionFilterDomainInterface {
coopname?: string;
type?: string;
status?: string;
braname?: string;
initiator?: string;
present?: boolean;
}
/**
* Приватные данные собрания хранятся только в БД платформы,
* в блокчейн не публикуются (видны только пайщикам кооператива).
*/
export interface KuDecisionPrivateDataDomainInterface {
hash: string;
coopname?: string;
type?: string;
initiator?: string;
meet_place?: string;
meet_at?: Date;
branch_name?: string;
branch_email?: string;
branch_phone?: string;
/** Контракт стирает запись одинаково при любом исходе — факт отмены фиксируем в БД */
cancelled?: boolean;
}
export interface KuDecisionRepository extends IBlockchainSyncRepository<KuDecisionDomainEntity> {
findByHash(hash: string): Promise<KuDecisionDomainEntity | null>;
findAllPaginated(
filter?: KuDecisionFilterDomainInterface,
options?: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<KuDecisionDomainEntity>>;
/**
* Записать приватные данные собрания (upsert по hash): запись могла ещё
* не появиться из синка тогда создаётся placeholder, который синк дополнит.
*/
upsertPrivateData(data: KuDecisionPrivateDataDomainInterface): Promise<void>;
/** Живые собрания с назначенным временем в окне [from, to), по которым напоминание ещё не отправлено */
findMeetingsForReminder(from: Date, to: Date): Promise<KuDecisionDomainEntity[]>;
markReminderSent(hash: string): Promise<void>;
}
export const KU_DECISION_REPOSITORY = Symbol('KuDecisionRepository');
@@ -0,0 +1,23 @@
import type { IBlockchainSyncRepository } from '~/shared/interfaces/blockchain-sync.interface';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import type { KuTrustRequestDomainEntity } from '../entities/ku-trust-request.entity';
export interface KuTrustRequestFilterDomainInterface {
coopname?: string;
braname?: string;
username?: string;
present?: boolean;
}
export interface KuTrustRequestRepository extends IBlockchainSyncRepository<KuTrustRequestDomainEntity> {
findByHash(hash: string): Promise<KuTrustRequestDomainEntity | null>;
findAllPaginated(
filter?: KuTrustRequestFilterDomainInterface,
options?: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<KuTrustRequestDomainEntity>>;
}
export const KU_TRUST_REQUEST_REPOSITORY = Symbol('KuTrustRequestRepository');
@@ -0,0 +1,159 @@
import { Inject, Injectable } from '@nestjs/common';
import { BranchContract } from 'cooptypes';
import type { TransactResult } from '@wharfkit/session';
import httpStatus from 'http-status';
import { BlockchainService } from '~/infrastructure/blockchain/blockchain.service';
import { VaultDomainService, VAULT_DOMAIN_SERVICE } from '~/domain/vault/services/vault-domain.service';
import { HttpApiError } from '~/utils/httpApiError';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
import type { KuBlockchainPort } from '../../../domain/interfaces/ku-blockchain.port';
import type {
ApproveKuTrustedInputDomainInterface,
CancelKuDecisionInputDomainInterface,
CloseKuDecisionInputDomainInterface,
CreateKuDecisionInputDomainInterface,
DeclineKuTrustedInputDomainInterface,
ExecKuDecisionInputDomainInterface,
JoinKuDecisionInputDomainInterface,
RequestKuTrustedInputDomainInterface,
StartKuDecisionInputDomainInterface,
VoteOnKuDecisionInputDomainInterface,
} from '../../../domain/interfaces/ku-action-inputs.interface';
/**
* Адаптер блокчейн-порта собраний и решений кооперативных участков.
* Все действия контракта branch подписываются ключом кооператива из vault.
*/
@Injectable()
export class KuBlockchainAdapter implements KuBlockchainPort {
constructor(
private readonly blockchainService: BlockchainService,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils,
@Inject(VAULT_DOMAIN_SERVICE) private readonly vaultDomainService: VaultDomainService
) {}
private async transactAs(coopname: string, name: string, data: Record<string, unknown>): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(coopname);
if (!wif) throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ для совершения операции');
this.blockchainService.initialize(coopname, wif);
return (await this.blockchainService.transact({
account: BranchContract.contractName.production,
name,
authorization: [{ actor: coopname, permission: 'active' }],
data,
})) as TransactResult;
}
async createDecision(data: CreateKuDecisionInputDomainInterface): Promise<TransactResult> {
const blockchainData: BranchContract.Actions.CreateDec.ICreateDec = {
coopname: data.coopname,
hash: data.hash,
type: data.type,
initiator: data.initiator,
proposal: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.proposal),
braname: data.braname,
agenda: data.agenda,
};
return this.transactAs(data.coopname, BranchContract.Actions.CreateDec.actionName, blockchainData as any);
}
async joinDecision(data: JoinKuDecisionInputDomainInterface): Promise<TransactResult> {
const blockchainData: BranchContract.Actions.JoinDec.IJoinDec = {
coopname: data.coopname,
hash: data.hash,
username: data.username,
};
return this.transactAs(data.coopname, BranchContract.Actions.JoinDec.actionName, blockchainData as any);
}
async startDecision(data: StartKuDecisionInputDomainInterface): Promise<TransactResult> {
// branch_name в блокчейн не уходит — приватное наименование хранится в БД
const blockchainData: BranchContract.Actions.StartDec.IStartDec = {
coopname: data.coopname,
hash: data.hash,
chairman: data.chairman,
address: data.address,
agenda: (data.agenda ?? []).map((point) => ({
title: point.title,
decision: point.decision,
context: point.context ?? '',
})),
};
return this.transactAs(data.coopname, BranchContract.Actions.StartDec.actionName, blockchainData as any);
}
async voteOnDecision(data: VoteOnKuDecisionInputDomainInterface): Promise<TransactResult> {
const blockchainData: BranchContract.Actions.VoteDec.IVoteDec = {
coopname: data.coopname,
hash: data.hash,
username: data.username,
ballot: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.ballot),
votes: data.votes,
};
return this.transactAs(data.coopname, BranchContract.Actions.VoteDec.actionName, blockchainData as any);
}
async closeDecision(data: CloseKuDecisionInputDomainInterface): Promise<TransactResult> {
const blockchainData: BranchContract.Actions.CloseDec.ICloseDec = {
coopname: data.coopname,
hash: data.hash,
protocol: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.protocol),
};
return this.transactAs(data.coopname, BranchContract.Actions.CloseDec.actionName, blockchainData as any);
}
async execDecision(data: ExecKuDecisionInputDomainInterface): Promise<TransactResult> {
const blockchainData: BranchContract.Actions.Exec.IExec = {
coopname: data.coopname,
hash: data.hash,
petition: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.petition),
liability: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.liability),
authority: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.authority),
};
return this.transactAs(data.coopname, BranchContract.Actions.Exec.actionName, blockchainData as any);
}
async cancelDecision(data: CancelKuDecisionInputDomainInterface): Promise<TransactResult> {
const blockchainData: BranchContract.Actions.CancelDec.ICancelDec = {
coopname: data.coopname,
hash: data.hash,
reason: data.reason,
};
return this.transactAs(data.coopname, BranchContract.Actions.CancelDec.actionName, blockchainData as any);
}
async requestTrusted(data: RequestKuTrustedInputDomainInterface): Promise<TransactResult> {
const blockchainData: BranchContract.Actions.ReqTrusted.IReqTrusted = {
coopname: data.coopname,
braname: data.braname,
username: data.username,
hash: data.hash,
application: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.application),
authority: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.authority),
};
return this.transactAs(data.coopname, BranchContract.Actions.ReqTrusted.actionName, blockchainData as any);
}
async approveTrusted(data: ApproveKuTrustedInputDomainInterface): Promise<TransactResult> {
const blockchainData: BranchContract.Actions.ApprTrusted.IApprTrusted = {
coopname: data.coopname,
hash: data.hash,
countersigned: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.countersigned),
countersigned_authority: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(
data.countersigned_authority
),
};
return this.transactAs(data.coopname, BranchContract.Actions.ApprTrusted.actionName, blockchainData as any);
}
async declineTrusted(data: DeclineKuTrustedInputDomainInterface): Promise<TransactResult> {
const blockchainData: BranchContract.Actions.DeclTrusted.IDeclTrusted = {
coopname: data.coopname,
hash: data.hash,
reason: data.reason,
};
return this.transactAs(data.coopname, BranchContract.Actions.DeclTrusted.actionName, blockchainData as any);
}
}
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import type { IDelta } from '~/types/common';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { AbstractBlockchainDeltaMapper } from '~/shared/abstract-blockchain-delta.mapper';
import { KuDecisionDomainEntity } from '../../../domain/entities/ku-decision.entity';
import type { IKuDecisionBlockchainData } from '../../../domain/interfaces/ku-blockchain-data.interface';
import { KuContractInfoService } from '../../services/ku-contract-info.service';
/**
* Маппер дельт блокчейна для таблицы decisions контракта branch
*/
@Injectable()
export class KuDecisionDeltaMapper extends AbstractBlockchainDeltaMapper<IKuDecisionBlockchainData, KuDecisionDomainEntity> {
constructor(private readonly logger: WinstonLoggerService, private readonly contractInfo: KuContractInfoService) {
super();
this.logger.setContext(KuDecisionDeltaMapper.name);
}
mapDeltaToBlockchainData(delta: IDelta): IKuDecisionBlockchainData | null {
const value = delta.value;
if (!value) {
this.logger.warn(`Delta has no value: table=${delta.table}, key=${delta.primary_key}`);
return null;
}
return value as IKuDecisionBlockchainData;
}
extractSyncValue(delta: IDelta): string {
const key = this.extractSyncKey();
if (!delta.value || delta.value[key] === undefined || delta.value[key] === null) {
throw new Error(`Delta has no value: table=${delta.table}, key=${key}`);
}
return String(delta.value[key]);
}
extractSyncKey(): string {
return KuDecisionDomainEntity.getSyncKey();
}
getSupportedContractNames(): string[] {
return this.contractInfo.getSupportedContractNames();
}
getSupportedTableNames(): string[] {
return this.contractInfo.getTablePatterns('decisions');
}
}
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import type { IDelta } from '~/types/common';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { AbstractBlockchainDeltaMapper } from '~/shared/abstract-blockchain-delta.mapper';
import { KuDecisionQuestionDomainEntity } from '../../../domain/entities/ku-decision-question.entity';
import type { IKuDecisionQuestionBlockchainData } from '../../../domain/interfaces/ku-blockchain-data.interface';
import { KuContractInfoService } from '../../services/ku-contract-info.service';
/**
* Маппер дельт блокчейна для таблицы decisionq контракта branch
*/
@Injectable()
export class KuDecisionQuestionDeltaMapper extends AbstractBlockchainDeltaMapper<IKuDecisionQuestionBlockchainData, KuDecisionQuestionDomainEntity> {
constructor(private readonly logger: WinstonLoggerService, private readonly contractInfo: KuContractInfoService) {
super();
this.logger.setContext(KuDecisionQuestionDeltaMapper.name);
}
mapDeltaToBlockchainData(delta: IDelta): IKuDecisionQuestionBlockchainData | null {
const value = delta.value;
if (!value) {
this.logger.warn(`Delta has no value: table=${delta.table}, key=${delta.primary_key}`);
return null;
}
return value as IKuDecisionQuestionBlockchainData;
}
extractSyncValue(delta: IDelta): string {
const key = this.extractSyncKey();
if (!delta.value || delta.value[key] === undefined || delta.value[key] === null) {
throw new Error(`Delta has no value: table=${delta.table}, key=${key}`);
}
return String(delta.value[key]);
}
extractSyncKey(): string {
return KuDecisionQuestionDomainEntity.getSyncKey();
}
getSupportedContractNames(): string[] {
return this.contractInfo.getSupportedContractNames();
}
getSupportedTableNames(): string[] {
return this.contractInfo.getTablePatterns('decisionq');
}
}
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import type { IDelta } from '~/types/common';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { AbstractBlockchainDeltaMapper } from '~/shared/abstract-blockchain-delta.mapper';
import { KuTrustRequestDomainEntity } from '../../../domain/entities/ku-trust-request.entity';
import type { IKuTrustRequestBlockchainData } from '../../../domain/interfaces/ku-blockchain-data.interface';
import { KuContractInfoService } from '../../services/ku-contract-info.service';
/**
* Маппер дельт блокчейна для таблицы trustreqs контракта branch
*/
@Injectable()
export class KuTrustRequestDeltaMapper extends AbstractBlockchainDeltaMapper<IKuTrustRequestBlockchainData, KuTrustRequestDomainEntity> {
constructor(private readonly logger: WinstonLoggerService, private readonly contractInfo: KuContractInfoService) {
super();
this.logger.setContext(KuTrustRequestDeltaMapper.name);
}
mapDeltaToBlockchainData(delta: IDelta): IKuTrustRequestBlockchainData | null {
const value = delta.value;
if (!value) {
this.logger.warn(`Delta has no value: table=${delta.table}, key=${delta.primary_key}`);
return null;
}
return value as IKuTrustRequestBlockchainData;
}
extractSyncValue(delta: IDelta): string {
const key = this.extractSyncKey();
if (!delta.value || delta.value[key] === undefined || delta.value[key] === null) {
throw new Error(`Delta has no value: table=${delta.table}, key=${key}`);
}
return String(delta.value[key]);
}
extractSyncKey(): string {
return KuTrustRequestDomainEntity.getSyncKey();
}
getSupportedContractNames(): string[] {
return this.contractInfo.getSupportedContractNames();
}
getSupportedTableNames(): string[] {
return this.contractInfo.getTablePatterns('trustreqs');
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EntityVersionTypeormEntity } from '~/shared/sync/entities/entity-version.typeorm-entity';
import { KuDecisionTypeormEntity } from '../entities/ku-decision.typeorm-entity';
import { KuDecisionQuestionTypeormEntity } from '../entities/ku-decision-question.typeorm-entity';
import { KuTrustRequestTypeormEntity } from '../entities/ku-trust-request.typeorm-entity';
@Module({
imports: [
TypeOrmModule.forFeature([
KuDecisionTypeormEntity,
KuDecisionQuestionTypeormEntity,
KuTrustRequestTypeormEntity,
EntityVersionTypeormEntity,
]),
],
exports: [TypeOrmModule],
})
export class KuDatabaseModule {}
@@ -0,0 +1,54 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseTypeormEntity } from '~/shared/sync/entities/base-typeorm.entity';
export const EntityName = 'ku_decision_questions';
@Entity(EntityName)
@Index(`idx_${EntityName}_blockchain_id`, ['id'])
@Index(`idx_${EntityName}_decision_id`, ['decision_id'])
@Index(`idx_${EntityName}_coopname`, ['coopname'])
export class KuDecisionQuestionTypeormEntity extends BaseTypeormEntity {
static getTableName(): string {
return EntityName;
}
@Column({ type: 'integer', nullable: true })
id!: number;
// Поля из блокчейна (table_branch_decisions.hpp, таблица decisionq)
@Column({ type: 'integer' })
decision_id!: number;
@Column({ type: 'integer' })
number!: number;
@Column({ type: 'varchar', length: 12 })
coopname!: string;
@Column({ type: 'text' })
title!: string;
@Column({ type: 'text' })
decision!: string;
@Column({ type: 'text', default: '' })
context!: string;
@Column({ type: 'integer', default: 0 })
counter_votes_for!: number;
@Column({ type: 'integer', default: 0 })
counter_votes_against!: number;
@Column({ type: 'integer', default: 0 })
counter_votes_abstained!: number;
@Column({ type: 'jsonb', default: () => `'[]'` })
voters_for!: string[];
@Column({ type: 'jsonb', default: () => `'[]'` })
voters_against!: string[];
@Column({ type: 'jsonb', default: () => `'[]'` })
voters_abstained!: string[];
}
@@ -0,0 +1,99 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseTypeormEntity } from '~/shared/sync/entities/base-typeorm.entity';
export const EntityName = 'ku_decisions';
@Entity(EntityName)
@Index(`idx_${EntityName}_blockchain_id`, ['id'])
@Index(`idx_${EntityName}_hash`, ['hash'], { unique: true })
@Index(`idx_${EntityName}_coopname`, ['coopname'])
@Index(`idx_${EntityName}_type`, ['type'])
@Index(`idx_${EntityName}_braname`, ['braname'])
@Index(`idx_${EntityName}_initiator`, ['initiator'])
@Index(`idx_${EntityName}_created_at`, ['_created_at'])
export class KuDecisionTypeormEntity extends BaseTypeormEntity {
static getTableName(): string {
return EntityName;
}
@Column({ type: 'integer', nullable: true })
id!: number;
// Поля из блокчейна (table_branch_decisions.hpp)
@Column({ type: 'varchar' })
hash!: string;
@Column({ type: 'varchar', length: 12 })
coopname!: string;
@Column({ type: 'varchar', length: 12 })
type!: string;
@Column({ type: 'varchar', length: 12 })
initiator!: string;
@Column({ type: 'varchar', length: 12, default: '' })
chairman!: string;
@Column({ type: 'jsonb', nullable: true })
proposal!: object;
@Column({ type: 'jsonb', nullable: true })
protocol!: object;
@Column({ type: 'jsonb', nullable: true })
petition!: object;
@Column({ type: 'jsonb', nullable: true })
liability!: object;
@Column({ type: 'jsonb', nullable: true })
authority!: object;
@Column({ type: 'jsonb', nullable: true })
authorization!: object;
@Column({ type: 'timestamp', nullable: true })
open_at!: Date;
@Column({ type: 'timestamp', nullable: true })
close_at!: Date;
@Column({ type: 'integer', default: 0 })
signed_ballots!: number;
@Column({ type: 'varchar', length: 12, default: '' })
braname!: string;
@Column({ type: 'varchar', default: '' })
address!: string;
@Column({ type: 'jsonb', default: () => `'[]'` })
participants!: string[];
@Column({ type: 'timestamp', nullable: true })
created_at!: Date;
// Приватные данные собрания — только БД, в блокчейн не публикуются
@Column({ type: 'varchar', nullable: true })
meet_place!: string | null;
@Column({ type: 'timestamp', nullable: true })
meet_at!: Date | null;
@Column({ type: 'varchar', nullable: true })
branch_name!: string | null;
@Column({ type: 'varchar', nullable: true })
branch_email!: string | null;
@Column({ type: 'varchar', nullable: true })
branch_phone!: string | null;
@Column({ type: 'boolean', default: false })
cancelled!: boolean;
// напоминание участникам за час до собрания уже отправлено
@Column({ type: 'boolean', default: false })
meet_reminder_sent!: boolean;
}
@@ -0,0 +1,38 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseTypeormEntity } from '~/shared/sync/entities/base-typeorm.entity';
export const EntityName = 'ku_trust_requests';
@Entity(EntityName)
@Index(`idx_${EntityName}_blockchain_id`, ['id'])
@Index(`idx_${EntityName}_hash`, ['hash'], { unique: true })
@Index(`idx_${EntityName}_coopname`, ['coopname'])
@Index(`idx_${EntityName}_braname`, ['braname'])
@Index(`idx_${EntityName}_username`, ['username'])
export class KuTrustRequestTypeormEntity extends BaseTypeormEntity {
static getTableName(): string {
return EntityName;
}
@Column({ type: 'integer', nullable: true })
id!: number;
// Поля из блокчейна (table_branch_trustreqs.hpp)
@Column({ type: 'varchar' })
hash!: string;
@Column({ type: 'varchar', length: 12 })
coopname!: string;
@Column({ type: 'varchar', length: 12 })
braname!: string;
@Column({ type: 'varchar', length: 12 })
username!: string;
@Column({ type: 'jsonb', nullable: true })
application!: object;
@Column({ type: 'jsonb', nullable: true })
authority!: object;
}
@@ -0,0 +1,83 @@
import { KuDecisionQuestionDomainEntity } from '../../domain/entities/ku-decision-question.entity';
import type { KuDecisionQuestionTypeormEntity } from '../entities/ku-decision-question.typeorm-entity';
import type {
IKuDecisionQuestionBlockchainData,
IKuDecisionQuestionDatabaseData,
} from '../../domain/interfaces/ku-blockchain-data.interface';
/**
* Маппер между доменной сущностью вопроса повестки и TypeORM-сущностью
*/
export class KuDecisionQuestionMapper {
static toDomain(entity: KuDecisionQuestionTypeormEntity): KuDecisionQuestionDomainEntity {
const databaseData: IKuDecisionQuestionDatabaseData = {
_id: entity._id,
block_num: entity.block_num,
present: entity.present,
status: entity.status,
_created_at: entity._created_at,
_updated_at: entity._updated_at,
};
let blockchainData: IKuDecisionQuestionBlockchainData | undefined;
if (entity.id !== null && entity.id !== undefined) {
blockchainData = {
id: entity.id,
decision_id: entity.decision_id,
number: entity.number,
coopname: entity.coopname,
title: entity.title,
decision: entity.decision,
context: entity.context,
counter_votes_for: entity.counter_votes_for,
counter_votes_against: entity.counter_votes_against,
counter_votes_abstained: entity.counter_votes_abstained,
voters_for: entity.voters_for ?? [],
voters_against: entity.voters_against ?? [],
voters_abstained: entity.voters_abstained ?? [],
};
}
return new KuDecisionQuestionDomainEntity(databaseData, blockchainData);
}
static toEntity(domain: KuDecisionQuestionDomainEntity): Partial<KuDecisionQuestionTypeormEntity> {
return {
_id: domain._id,
block_num: domain.block_num ?? 0,
present: domain.present,
status: domain.status as string,
_created_at: domain._created_at,
_updated_at: domain._updated_at,
id: domain.id as number,
decision_id: domain.decision_id as number,
number: domain.number as number,
coopname: domain.coopname as string,
title: domain.title as string,
decision: domain.decision as string,
context: domain.context ?? '',
counter_votes_for: domain.counter_votes_for ?? 0,
counter_votes_against: domain.counter_votes_against ?? 0,
counter_votes_abstained: domain.counter_votes_abstained ?? 0,
voters_for: domain.voters_for ?? [],
voters_against: domain.voters_against ?? [],
voters_abstained: domain.voters_abstained ?? [],
};
}
static toUpdateEntity(domain: Partial<KuDecisionQuestionDomainEntity>): Partial<KuDecisionQuestionTypeormEntity> {
const updateData: Partial<KuDecisionQuestionTypeormEntity> = {};
if (domain.block_num !== undefined) updateData.block_num = domain.block_num;
if (domain.present !== undefined) updateData.present = domain.present;
if (domain.counter_votes_for !== undefined) updateData.counter_votes_for = domain.counter_votes_for;
if (domain.counter_votes_against !== undefined) updateData.counter_votes_against = domain.counter_votes_against;
if (domain.counter_votes_abstained !== undefined) updateData.counter_votes_abstained = domain.counter_votes_abstained;
if (domain.voters_for !== undefined) updateData.voters_for = domain.voters_for;
if (domain.voters_against !== undefined) updateData.voters_against = domain.voters_against;
if (domain.voters_abstained !== undefined) updateData.voters_abstained = domain.voters_abstained;
return updateData;
}
}
@@ -0,0 +1,131 @@
import { KuDecisionDomainEntity } from '../../domain/entities/ku-decision.entity';
import type { KuDecisionTypeormEntity } from '../entities/ku-decision.typeorm-entity';
import type {
IKuDecisionBlockchainData,
IKuDecisionDatabaseData,
} from '../../domain/interfaces/ku-blockchain-data.interface';
/**
* Маппер между доменной сущностью решения собрания участка и TypeORM-сущностью
*/
export class KuDecisionMapper {
static toDomain(entity: KuDecisionTypeormEntity): KuDecisionDomainEntity {
const databaseData: IKuDecisionDatabaseData = {
_id: entity._id,
block_num: entity.block_num,
present: entity.present,
status: entity.status,
_created_at: entity._created_at,
_updated_at: entity._updated_at,
meet_place: entity.meet_place ?? undefined,
meet_at: entity.meet_at ?? undefined,
branch_name: entity.branch_name ?? undefined,
branch_email: entity.branch_email ?? undefined,
branch_phone: entity.branch_phone ?? undefined,
cancelled: entity.cancelled ?? undefined,
meet_reminder_sent: entity.meet_reminder_sent ?? undefined,
};
let blockchainData: IKuDecisionBlockchainData | undefined;
if (entity.hash) {
blockchainData = {
id: entity.id,
hash: entity.hash,
coopname: entity.coopname,
type: entity.type,
initiator: entity.initiator,
chairman: entity.chairman,
status: entity.status,
proposal: entity.proposal as IKuDecisionBlockchainData['proposal'],
protocol: entity.protocol as IKuDecisionBlockchainData['protocol'],
petition: entity.petition as IKuDecisionBlockchainData['petition'],
liability: entity.liability as IKuDecisionBlockchainData['liability'],
authority: entity.authority as IKuDecisionBlockchainData['authority'],
authorization: entity.authorization as IKuDecisionBlockchainData['authorization'],
open_at: entity.open_at?.toISOString() ?? '',
close_at: entity.close_at?.toISOString() ?? '',
signed_ballots: entity.signed_ballots,
braname: entity.braname,
address: entity.address,
participants: entity.participants ?? [],
created_at: entity.created_at?.toISOString() ?? '',
};
}
return new KuDecisionDomainEntity(databaseData, blockchainData);
}
static toEntity(domain: KuDecisionDomainEntity): Partial<KuDecisionTypeormEntity> {
return {
_id: domain._id,
block_num: domain.block_num ?? 0,
present: domain.present,
status: domain.status as string,
_created_at: domain._created_at,
_updated_at: domain._updated_at,
id: domain.id as number,
hash: domain.hash as string,
coopname: domain.coopname as string,
type: domain.type as string,
initiator: domain.initiator as string,
chairman: domain.chairman as string,
proposal: domain.proposal as object,
protocol: domain.protocol as object,
petition: domain.petition as object,
liability: domain.liability as object,
authority: domain.authority as object,
authorization: domain.authorization as object,
open_at: domain.open_at ? new Date(domain.open_at) : undefined,
close_at: domain.close_at ? new Date(domain.close_at) : undefined,
signed_ballots: domain.signed_ballots ?? 0,
braname: domain.braname as string,
address: domain.address as string,
participants: domain.participants ?? [],
created_at: domain.created_at ? new Date(domain.created_at) : undefined,
meet_place: domain.meet_place ?? null,
meet_at: domain.meet_at ?? null,
branch_name: domain.branch_name ?? null,
branch_email: domain.branch_email ?? null,
branch_phone: domain.branch_phone ?? null,
cancelled: domain.cancelled ?? false,
meet_reminder_sent: domain.meet_reminder_sent ?? false,
};
}
static toUpdateEntity(domain: Partial<KuDecisionDomainEntity>): Partial<KuDecisionTypeormEntity> {
const updateData: Partial<KuDecisionTypeormEntity> = {};
// запись могла быть создана placeholder'ом (upsertPrivateData) до прихода синка —
// bc-поля первой дельты обязаны материализоваться при обновлении
if (domain.id !== undefined) updateData.id = domain.id as number;
if (domain.type !== undefined) updateData.type = domain.type as string;
if (domain.initiator !== undefined) updateData.initiator = domain.initiator as string;
if (domain.proposal !== undefined) updateData.proposal = domain.proposal as object;
if (domain.braname !== undefined) updateData.braname = domain.braname as string;
if (domain.created_at !== undefined && domain.created_at) updateData.created_at = new Date(domain.created_at);
if (domain.block_num !== undefined) updateData.block_num = domain.block_num;
if (domain.present !== undefined) updateData.present = domain.present;
if (domain.status !== undefined) updateData.status = domain.status;
if (domain.chairman !== undefined) updateData.chairman = domain.chairman;
if (domain.protocol !== undefined) updateData.protocol = domain.protocol as object;
if (domain.petition !== undefined) updateData.petition = domain.petition as object;
if (domain.liability !== undefined) updateData.liability = domain.liability as object;
if (domain.authority !== undefined) updateData.authority = domain.authority as object;
if (domain.authorization !== undefined) updateData.authorization = domain.authorization as object;
if (domain.open_at !== undefined) updateData.open_at = new Date(domain.open_at);
if (domain.close_at !== undefined) updateData.close_at = new Date(domain.close_at);
if (domain.signed_ballots !== undefined) updateData.signed_ballots = domain.signed_ballots;
if (domain.address !== undefined) updateData.address = domain.address;
if (domain.participants !== undefined) updateData.participants = domain.participants;
if (domain.meet_place !== undefined) updateData.meet_place = domain.meet_place;
if (domain.meet_at !== undefined) updateData.meet_at = domain.meet_at;
if (domain.branch_name !== undefined) updateData.branch_name = domain.branch_name;
if (domain.branch_email !== undefined) updateData.branch_email = domain.branch_email;
if (domain.branch_phone !== undefined) updateData.branch_phone = domain.branch_phone;
if (domain.cancelled !== undefined) updateData.cancelled = domain.cancelled;
if (domain.meet_reminder_sent !== undefined) updateData.meet_reminder_sent = domain.meet_reminder_sent;
return updateData;
}
}
@@ -0,0 +1,67 @@
import { KuTrustRequestDomainEntity } from '../../domain/entities/ku-trust-request.entity';
import type { KuTrustRequestTypeormEntity } from '../entities/ku-trust-request.typeorm-entity';
import type {
IKuTrustRequestBlockchainData,
IKuTrustRequestDatabaseData,
} from '../../domain/interfaces/ku-blockchain-data.interface';
/**
* Маппер между доменной сущностью заявки доверенного и TypeORM-сущностью
*/
export class KuTrustRequestMapper {
static toDomain(entity: KuTrustRequestTypeormEntity): KuTrustRequestDomainEntity {
const databaseData: IKuTrustRequestDatabaseData = {
_id: entity._id,
block_num: entity.block_num,
present: entity.present,
status: entity.status,
_created_at: entity._created_at,
_updated_at: entity._updated_at,
};
let blockchainData: IKuTrustRequestBlockchainData | undefined;
if (entity.hash) {
blockchainData = {
id: entity.id,
hash: entity.hash,
coopname: entity.coopname,
braname: entity.braname,
username: entity.username,
application: entity.application as IKuTrustRequestBlockchainData['application'],
authority: entity.authority as IKuTrustRequestBlockchainData['authority'],
};
}
return new KuTrustRequestDomainEntity(databaseData, blockchainData);
}
static toEntity(domain: KuTrustRequestDomainEntity): Partial<KuTrustRequestTypeormEntity> {
return {
_id: domain._id,
block_num: domain.block_num ?? 0,
present: domain.present,
status: domain.status as string,
_created_at: domain._created_at,
_updated_at: domain._updated_at,
id: domain.id as number,
hash: domain.hash as string,
coopname: domain.coopname as string,
braname: domain.braname as string,
username: domain.username as string,
application: domain.application as object,
authority: domain.authority as object,
};
}
static toUpdateEntity(domain: Partial<KuTrustRequestDomainEntity>): Partial<KuTrustRequestTypeormEntity> {
const updateData: Partial<KuTrustRequestTypeormEntity> = {};
if (domain.block_num !== undefined) updateData.block_num = domain.block_num;
if (domain.present !== undefined) updateData.present = domain.present;
if (domain.application !== undefined) updateData.application = domain.application as object;
if (domain.authority !== undefined) updateData.authority = domain.authority as object;
return updateData;
}
}
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { IBlockchainSyncRepository } from '~/shared/interfaces/blockchain-sync.interface';
import { BaseBlockchainRepository } from '~/shared/sync/repositories/base-blockchain.repository';
import { EntityVersioningService } from '~/shared/sync/services/entity-versioning.service';
import type { KuDecisionQuestionRepository } from '../../domain/repositories/ku-decision-question.repository';
import { KuDecisionQuestionDomainEntity } from '../../domain/entities/ku-decision-question.entity';
import { KuDecisionQuestionTypeormEntity } from '../entities/ku-decision-question.typeorm-entity';
import { KuDecisionQuestionMapper } from '../mappers/ku-decision-question.mapper';
import type {
IKuDecisionQuestionBlockchainData,
IKuDecisionQuestionDatabaseData,
} from '../../domain/interfaces/ku-blockchain-data.interface';
@Injectable()
export class KuDecisionQuestionTypeormRepository
extends BaseBlockchainRepository<KuDecisionQuestionDomainEntity, KuDecisionQuestionTypeormEntity>
implements KuDecisionQuestionRepository, IBlockchainSyncRepository<KuDecisionQuestionDomainEntity>
{
constructor(
@InjectRepository(KuDecisionQuestionTypeormEntity)
repository: Repository<KuDecisionQuestionTypeormEntity>,
entityVersioningService: EntityVersioningService
) {
super(repository, entityVersioningService);
}
protected getMapper() {
return {
toDomain: KuDecisionQuestionMapper.toDomain,
toEntity: KuDecisionQuestionMapper.toEntity,
};
}
protected createDomainEntity(
databaseData: IKuDecisionQuestionDatabaseData,
blockchainData: IKuDecisionQuestionBlockchainData
): KuDecisionQuestionDomainEntity {
return new KuDecisionQuestionDomainEntity(databaseData, blockchainData);
}
protected getSyncKey(): string {
return KuDecisionQuestionDomainEntity.getSyncKey();
}
async findByDecisionId(coopname: string, decisionId: number): Promise<KuDecisionQuestionDomainEntity[]> {
const entities = await this.repository.find({
where: { coopname, decision_id: decisionId },
order: { number: 'ASC' },
});
return entities.map((entity) => KuDecisionQuestionMapper.toDomain(entity));
}
async update(entity: KuDecisionQuestionDomainEntity): Promise<KuDecisionQuestionDomainEntity> {
const updateData = KuDecisionQuestionMapper.toUpdateEntity(entity);
await this.repository.update(entity._id, updateData);
const updatedEntity = await this.repository.findOne({ where: { _id: entity._id } });
if (!updatedEntity) {
throw new Error(`Вопрос повестки ${entity.id} не найден после обновления`);
}
return KuDecisionQuestionMapper.toDomain(updatedEntity);
}
}
@@ -0,0 +1,156 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { IBlockchainSyncRepository } from '~/shared/interfaces/blockchain-sync.interface';
import { BaseBlockchainRepository } from '~/shared/sync/repositories/base-blockchain.repository';
import { EntityVersioningService } from '~/shared/sync/services/entity-versioning.service';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import { PaginationUtils } from '~/shared/utils/pagination.utils';
import type {
KuDecisionFilterDomainInterface,
KuDecisionPrivateDataDomainInterface,
KuDecisionRepository,
} from '../../domain/repositories/ku-decision.repository';
import { KuDecisionDomainEntity } from '../../domain/entities/ku-decision.entity';
import { KuDecisionTypeormEntity } from '../entities/ku-decision.typeorm-entity';
import { KuDecisionMapper } from '../mappers/ku-decision.mapper';
import type {
IKuDecisionBlockchainData,
IKuDecisionDatabaseData,
} from '../../domain/interfaces/ku-blockchain-data.interface';
@Injectable()
export class KuDecisionTypeormRepository
extends BaseBlockchainRepository<KuDecisionDomainEntity, KuDecisionTypeormEntity>
implements KuDecisionRepository, IBlockchainSyncRepository<KuDecisionDomainEntity>
{
constructor(
@InjectRepository(KuDecisionTypeormEntity)
repository: Repository<KuDecisionTypeormEntity>,
entityVersioningService: EntityVersioningService
) {
super(repository, entityVersioningService);
}
protected getMapper() {
return {
toDomain: KuDecisionMapper.toDomain,
toEntity: KuDecisionMapper.toEntity,
};
}
protected createDomainEntity(
databaseData: IKuDecisionDatabaseData,
blockchainData: IKuDecisionBlockchainData
): KuDecisionDomainEntity {
return new KuDecisionDomainEntity(databaseData, blockchainData);
}
protected getSyncKey(): string {
return KuDecisionDomainEntity.getSyncKey();
}
async findByHash(hash: string): Promise<KuDecisionDomainEntity | null> {
const entity = await this.repository.findOne({ where: { hash: hash.toLowerCase() } });
return entity ? KuDecisionMapper.toDomain(entity) : null;
}
async upsertPrivateData(data: KuDecisionPrivateDataDomainInterface): Promise<void> {
const hash = data.hash.toLowerCase();
const existing = await this.repository.findOne({ where: { hash } });
const privateFields: Partial<KuDecisionTypeormEntity> = {};
if (data.meet_place !== undefined) privateFields.meet_place = data.meet_place;
if (data.meet_at !== undefined) privateFields.meet_at = data.meet_at;
if (data.branch_name !== undefined) privateFields.branch_name = data.branch_name;
if (data.branch_email !== undefined) privateFields.branch_email = data.branch_email;
if (data.branch_phone !== undefined) privateFields.branch_phone = data.branch_phone;
if (data.cancelled !== undefined) privateFields.cancelled = data.cancelled;
if (existing) {
await this.repository.update(existing._id, privateFields);
return;
}
// Запись из синка ещё не пришла — создаём placeholder, который синк дополнит
await this.repository.save(
this.repository.create({
hash,
coopname: data.coopname ?? '',
type: data.type ?? '',
initiator: data.initiator ?? '',
present: false,
...privateFields,
})
);
}
async findMeetingsForReminder(from: Date, to: Date): Promise<KuDecisionDomainEntity[]> {
const entities = await this.repository
.createQueryBuilder('decision')
.where('decision.present = true')
.andWhere('decision.cancelled = false')
.andWhere('decision.meet_reminder_sent = false')
.andWhere('decision.meet_at >= :from AND decision.meet_at < :to', { from, to })
.getMany();
return entities.map((entity) => KuDecisionMapper.toDomain(entity));
}
async markReminderSent(hash: string): Promise<void> {
await this.repository.update({ hash: hash.toLowerCase() }, { meet_reminder_sent: true });
}
async update(entity: KuDecisionDomainEntity): Promise<KuDecisionDomainEntity> {
const updateData = KuDecisionMapper.toUpdateEntity(entity);
await this.repository.update(entity._id, updateData);
const updatedEntity = await this.repository.findOne({ where: { _id: entity._id } });
if (!updatedEntity) {
throw new Error(`Решение собрания участка ${entity.hash} не найдено после обновления`);
}
return KuDecisionMapper.toDomain(updatedEntity);
}
async findAllPaginated(
filter?: KuDecisionFilterDomainInterface,
options?: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<KuDecisionDomainEntity>> {
const validatedOptions: PaginationInputDomainInterface = options
? PaginationUtils.validatePaginationOptions(options)
: { page: 1, limit: 10, sortBy: undefined, sortOrder: 'ASC' as const };
const { limit, offset } = PaginationUtils.getSqlPaginationParams(validatedOptions);
const where: any = {};
if (filter?.coopname) where.coopname = filter.coopname;
if (filter?.type) where.type = filter.type;
if (filter?.status) where.status = filter.status;
if (filter?.braname) where.braname = filter.braname;
if (filter?.initiator) where.initiator = filter.initiator;
if (filter?.present !== undefined) where.present = filter.present;
const totalCount = await this.repository.count({ where });
const orderBy: any = {};
if (validatedOptions.sortBy) {
orderBy[validatedOptions.sortBy] = validatedOptions.sortOrder;
} else {
orderBy._created_at = 'DESC';
}
const entities = await this.repository.find({
where,
skip: offset,
take: limit,
order: orderBy,
});
const items = entities.map((entity) => KuDecisionMapper.toDomain(entity));
return PaginationUtils.createPaginationResult(items, totalCount, validatedOptions);
}
}
@@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { IBlockchainSyncRepository } from '~/shared/interfaces/blockchain-sync.interface';
import { BaseBlockchainRepository } from '~/shared/sync/repositories/base-blockchain.repository';
import { EntityVersioningService } from '~/shared/sync/services/entity-versioning.service';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import { PaginationUtils } from '~/shared/utils/pagination.utils';
import type {
KuTrustRequestFilterDomainInterface,
KuTrustRequestRepository,
} from '../../domain/repositories/ku-trust-request.repository';
import { KuTrustRequestDomainEntity } from '../../domain/entities/ku-trust-request.entity';
import { KuTrustRequestTypeormEntity } from '../entities/ku-trust-request.typeorm-entity';
import { KuTrustRequestMapper } from '../mappers/ku-trust-request.mapper';
import type {
IKuTrustRequestBlockchainData,
IKuTrustRequestDatabaseData,
} from '../../domain/interfaces/ku-blockchain-data.interface';
@Injectable()
export class KuTrustRequestTypeormRepository
extends BaseBlockchainRepository<KuTrustRequestDomainEntity, KuTrustRequestTypeormEntity>
implements KuTrustRequestRepository, IBlockchainSyncRepository<KuTrustRequestDomainEntity>
{
constructor(
@InjectRepository(KuTrustRequestTypeormEntity)
repository: Repository<KuTrustRequestTypeormEntity>,
entityVersioningService: EntityVersioningService
) {
super(repository, entityVersioningService);
}
protected getMapper() {
return {
toDomain: KuTrustRequestMapper.toDomain,
toEntity: KuTrustRequestMapper.toEntity,
};
}
protected createDomainEntity(
databaseData: IKuTrustRequestDatabaseData,
blockchainData: IKuTrustRequestBlockchainData
): KuTrustRequestDomainEntity {
return new KuTrustRequestDomainEntity(databaseData, blockchainData);
}
protected getSyncKey(): string {
return KuTrustRequestDomainEntity.getSyncKey();
}
async findByHash(hash: string): Promise<KuTrustRequestDomainEntity | null> {
const entity = await this.repository.findOne({ where: { hash: hash.toLowerCase() } });
return entity ? KuTrustRequestMapper.toDomain(entity) : null;
}
async update(entity: KuTrustRequestDomainEntity): Promise<KuTrustRequestDomainEntity> {
const updateData = KuTrustRequestMapper.toUpdateEntity(entity);
await this.repository.update(entity._id, updateData);
const updatedEntity = await this.repository.findOne({ where: { _id: entity._id } });
if (!updatedEntity) {
throw new Error(`Заявка доверенного ${entity.hash} не найдена после обновления`);
}
return KuTrustRequestMapper.toDomain(updatedEntity);
}
async findAllPaginated(
filter?: KuTrustRequestFilterDomainInterface,
options?: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<KuTrustRequestDomainEntity>> {
const validatedOptions: PaginationInputDomainInterface = options
? PaginationUtils.validatePaginationOptions(options)
: { page: 1, limit: 10, sortBy: undefined, sortOrder: 'ASC' as const };
const { limit, offset } = PaginationUtils.getSqlPaginationParams(validatedOptions);
const where: any = {};
if (filter?.coopname) where.coopname = filter.coopname;
if (filter?.braname) where.braname = filter.braname;
if (filter?.username) where.username = filter.username;
if (filter?.present !== undefined) where.present = filter.present;
const totalCount = await this.repository.count({ where });
const orderBy: any = {};
if (validatedOptions.sortBy) {
orderBy[validatedOptions.sortBy] = validatedOptions.sortOrder;
} else {
orderBy._created_at = 'DESC';
}
const entities = await this.repository.find({
where,
skip: offset,
take: limit,
order: orderBy,
});
const items = entities.map((entity) => KuTrustRequestMapper.toDomain(entity));
return PaginationUtils.createPaginationResult(items, totalCount, validatedOptions);
}
}
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
/**
* Сервис информации о поддерживаемых контрактах и таблицах для модуля КУ.
* Источник данных контракт branch (собрания/решения участков и заявки доверенных).
*/
@Injectable()
export class KuContractInfoService {
private readonly supportedContractNames: string[] = ['branch'];
/**
* Паттерны таблиц для каждой сущности (базовое имя + маска)
*/
private readonly tablePatterns: Record<string, string[]> = {
decisions: ['decisions', 'decisions*'],
decisionq: ['decisionq', 'decisionq*'],
trustreqs: ['trustreqs', 'trustreqs*'],
};
getSupportedContractNames(): string[] {
return [...this.supportedContractNames];
}
getTablePatterns(entityName: string): string[] {
const patterns = this.tablePatterns[entityName];
if (!patterns) {
throw new Error(
`Unknown entity name: ${entityName}. Supported entities: ${Object.keys(this.tablePatterns).join(', ')}`
);
}
return [...patterns];
}
}
@@ -0,0 +1,131 @@
import { Inject, Module } from '@nestjs/common';
import { z } from 'zod';
import {
EXTENSION_REPOSITORY,
type ExtensionDomainRepository,
} from '~/domain/extension/repositories/extension-domain.repository';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import type { ExtensionDomainEntity } from '~/domain/extension/entities/extension-domain.entity';
import { BaseExtModule } from '../base.extension.module';
import { DocumentDomainModule } from '~/domain/document/document.module';
import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
// База данных
import { KuDatabaseModule } from './infrastructure/database/ku-database.module';
// Репозитории
import { KU_DECISION_REPOSITORY } from './domain/repositories/ku-decision.repository';
import { KU_DECISION_QUESTION_REPOSITORY } from './domain/repositories/ku-decision-question.repository';
import { KU_TRUST_REQUEST_REPOSITORY } from './domain/repositories/ku-trust-request.repository';
import { KuDecisionTypeormRepository } from './infrastructure/repositories/ku-decision.typeorm-repository';
import { KuDecisionQuestionTypeormRepository } from './infrastructure/repositories/ku-decision-question.typeorm-repository';
import { KuTrustRequestTypeormRepository } from './infrastructure/repositories/ku-trust-request.typeorm-repository';
// Blockchain
import { KU_BLOCKCHAIN_PORT } from './domain/interfaces/ku-blockchain.port';
import { KuBlockchainAdapter } from './infrastructure/blockchain/adapters/ku-blockchain.adapter';
import { KuContractInfoService } from './infrastructure/services/ku-contract-info.service';
import { KuDecisionDeltaMapper } from './infrastructure/blockchain/mappers/ku-decision-delta.mapper';
import { KuDecisionQuestionDeltaMapper } from './infrastructure/blockchain/mappers/ku-decision-question-delta.mapper';
import { KuTrustRequestDeltaMapper } from './infrastructure/blockchain/mappers/ku-trust-request-delta.mapper';
// Синхронизация
import { KuDecisionSyncService } from './application/syncers/ku-decision-sync.service';
import { KuDecisionQuestionSyncService } from './application/syncers/ku-decision-question-sync.service';
import { KuTrustRequestSyncService } from './application/syncers/ku-trust-request-sync.service';
// Application
import { AccountInfrastructureModule } from '~/infrastructure/account/account-infrastructure.module';
import { KuService } from './application/services/ku.service';
import { KuEventsService } from './application/services/ku-events.service';
import { KuResolver } from './application/resolvers/ku.resolver';
// Дефолтные параметры конфигурации
export const defaultConfig = {};
export const Schema = z.object({});
// Интерфейс для параметров конфигурации плагина
export type IConfig = z.infer<typeof Schema>;
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ILog {}
export class KuPlugin extends BaseExtModule {
constructor(
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository,
private readonly logger: WinstonLoggerService
) {
super();
this.logger.setContext(KuPlugin.name);
}
name = 'trustee';
plugin!: ExtensionDomainEntity<IConfig>;
public configSchemas = Schema;
public defaultConfig = defaultConfig;
async initialize(): Promise<void> {
this.logger.info(`Инициализация расширения «Кооперативный участок» (${this.name})`);
}
}
@Module({
imports: [KuDatabaseModule, DocumentDomainModule, VaultDomainModule, AccountInfrastructureModule],
providers: [
// Plugin
KuPlugin,
// Репозитории
{
provide: KU_DECISION_REPOSITORY,
useClass: KuDecisionTypeormRepository,
},
{
provide: KU_DECISION_QUESTION_REPOSITORY,
useClass: KuDecisionQuestionTypeormRepository,
},
{
provide: KU_TRUST_REQUEST_REPOSITORY,
useClass: KuTrustRequestTypeormRepository,
},
KuDecisionTypeormRepository,
KuDecisionQuestionTypeormRepository,
KuTrustRequestTypeormRepository,
// Blockchain
{
provide: KU_BLOCKCHAIN_PORT,
useClass: KuBlockchainAdapter,
},
KuBlockchainAdapter,
KuContractInfoService,
KuDecisionDeltaMapper,
KuDecisionQuestionDeltaMapper,
KuTrustRequestDeltaMapper,
// Синхронизация
KuDecisionSyncService,
KuDecisionQuestionSyncService,
KuTrustRequestSyncService,
// Utils
DomainToBlockchainUtils,
// Application
KuService,
KuEventsService,
KuResolver,
],
exports: [KuPlugin, KuDecisionSyncService, KuDecisionQuestionSyncService, KuTrustRequestSyncService],
})
export class KuPluginModule {
constructor(private readonly kuPlugin: KuPlugin) {}
async initialize(config?: IConfig) {
await this.kuPlugin.initialize();
void config;
}
}

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