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
458 changed files with 26802 additions and 5786 deletions
+10 -31
View File
@@ -390,34 +390,21 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lerna run build
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
- name: Generate controller schema.gql (required by sdk prepublishOnly)
working-directory: components/controller
run: pnpm run generate-schema
- run: pnpm lerna publish from-package --yes
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# ============================================================================
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
# Гейт: ветка main + задан secret DOCS_DISPATCH_TOKEN.
# Гейт: ветка main.
#
# Dual-host: Actions могут бежать на GitHub или Gitea. Сайт доков живёт на
# Gitea (C9S/coopenomics), поэтому API всегда бьёт в GITEA_SERVER_URL
# (var, дефолт https://git.coopenomics.world) — НЕ в github.server_url
# (на GitHub Actions тот = github.com → 410).
#
# Без DOCS_DISPATCH_TOKEN job пропускается (GitHub-only режим: токен не
# задаём → релиз не валится). На Gitea задаём PAT (префикс GITEA_ у секретов
# зарезервирован, поэтому имя DOCS_DISPATCH_TOKEN; write на C9S/coopenomics).
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
# Целевой workflow слушает workflow_dispatch с входами
# mono_sha/mono_ref.
# ============================================================================
trigger-coopenomics-docs:
needs: release
@@ -425,20 +412,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
env:
DOCS_DISPATCH_TOKEN: ${{ secrets.DOCS_DISPATCH_TOKEN }}
GITEA_SERVER_URL: ${{ vars.GITEA_SERVER_URL }}
run: |
if [ -z "$DOCS_DISPATCH_TOKEN" ]; then
echo "DOCS_DISPATCH_TOKEN не задан — skip (GitHub-only / без Gitea docs dispatch)"
exit 0
fi
GITEA_SERVER_URL="${GITEA_SERVER_URL:-https://git.coopenomics.world}"
curl -fsSL -X POST \
-H "Authorization: token ${DOCS_DISPATCH_TOKEN}" \
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
"${GITEA_SERVER_URL}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
# ============================================================================
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
+1
View File
@@ -1,4 +1,5 @@
{
"cSpell.enabled": false,
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"editor.tabCompletion": "onlySnippets",
-8
View File
@@ -45,14 +45,6 @@ RUN corepack enable && npm install -g lerna --no-fund --no-audit
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
COPY . .
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
# Отключаем сами загрузки, пакеты всё равно ставятся.
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
PUPPETEER_SKIP_DOWNLOAD=true
# Используем существующий lockfile. Если он не совпадает с workspace —
# падать сразу, не дрейфовать незаметно.
RUN pnpm install --frozen-lockfile
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.7.23-2",
"version": "2026.6.16-1",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+1 -8
View File
@@ -35,14 +35,7 @@ RUN npm install -g pnpm@10.33.0
COPY . .
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
&& pnpm rebuild --filter "@coopenomics/boot..."
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..."
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
# свой bundle с inlined workspace-deps.
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.7.23-2",
"version": "2026.6.16-1",
"private": true,
"packageManager": "pnpm@10.33.0",
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "",
+1 -1
View File
@@ -76,7 +76,7 @@ export class CooperativeClass {
await this.blockchain.createProgram({
coopname,
username: coopname,
type: 'capital',
type: 'blagorost',
title: 'Целевая потребительская программа "Благорост"',
announce: '',
description: '',
@@ -1,6 +1,6 @@
/**
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
* (id=4, type='capital') + capital::setconfig.
* (id=4, type='blagorost') + capital::setconfig.
*
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
* setconfig — только если `state` пуст.
@@ -88,7 +88,7 @@ export async function phase01(): Promise<void> {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
await blockchain.api.transact({
actions: [{
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
}
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
log(`signAgreement blagorost для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
})
}
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
+1 -1
View File
@@ -213,7 +213,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
+1 -1
View File
@@ -12,6 +12,6 @@ export const capitalDraftId = 1000
export const ratePerHour = '1000.0000 RUB'
export const sourceProgramName = 'generator'
export const capitalProgramName = 'capital'
export const capitalProgramName = 'blagorost'
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
export const circulationAccountId = 80_000
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.7.23-2",
"version": "2026.6.16-1",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
@@ -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
);
}
+5 -1
View File
@@ -80,7 +80,7 @@ static constexpr eosio::name _wallet_program = "wallet"_n; ///< Главный
static constexpr eosio::name _marketplace_program = "marketplace"_n; ///< Кошелёк программы "Маркетплейс"
static constexpr eosio::name _source_program = "generator"_n; ///< Кошелёк для генерации по договору УХД
static constexpr eosio::name _capital_program = "capital"_n; ///< Кошелёк программы "Благорост"
static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелёк программы "Благорост"
static const std::set<eosio::name> soviet_actions = {
@@ -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(), "Пайщик не найден");
@@ -12,22 +12,14 @@
void soviet::migrate() {
require_auth(_soviet); // Проверяем авторизацию
// Откат program_type для кооператива voskhod, программа id=4: blagorost -> capital.
// Предыдущая версия этой миграции переименовывала programs.program_type в
// 'blagorost', но coagreements.type для той же программы (program_id=4) всегда
// оставался 'capital' и никогда не переименовывался — таблицы разъехались.
// 'capital' — каноничное значение (используется coagreements.type, contract-
// константой _capital_program и всем controller-кодом); эта миграция приводит
// programs.program_type к нему же. Идемпотентно.
{
eosio::name coopname = "voskhod"_n;
programs_index programs(_soviet, coopname.value);
auto program_it = programs.find(4);
if (program_it != programs.end() && program_it->program_type != "capital"_n) {
programs.modify(program_it, _soviet, [&](auto &pr) {
pr.program_type = "capital"_n;
});
}
// Миграция program_type для кооператива voskhod, программа id=4 -> blagorost
eosio::name coopname = "voskhod"_n;
programs_index programs(_soviet, coopname.value);
auto program_it = programs.find(4);
if (program_it != programs.end() && program_it->program_type != "blagorost"_n) {
programs.modify(program_it, _soviet, [&](auto &pr) {
pr.program_type = "blagorost"_n;
});
}
// ────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.7.23-2",
"version": "2026.6.16-1",
"private": true,
"type": "module",
"scripts": {
-1
View File
@@ -1,5 +1,4 @@
{
"cSpell.words": ["subcode"],
"editor.formatOnSave": false,
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": null,
@@ -1,126 +0,0 @@
import { createHash } from 'node:crypto';
import type { DataSource } from 'typeorm';
import mongoose from 'mongoose';
import config from '~/config/config';
type MigrationLogger = {
info: (message: string) => void;
error: (message: string) => void;
warn: (message: string) => void;
};
const CAPITAL_DOC_REGISTRY_ID = 994;
const TARGET_COOPNAME = 'voskhod';
const COLLECTION_NAME = 'doc_private_data';
const capitalProgramPrivateData = {
generator_program_purpose:
"развитию информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации., на основе международных кооперативных принципов и законодательства Российской Федерации",
eoap_definition:
"информационная экосистема, интегрируемая в социально-экономическую среду Российской Федерации, состоящая из комплекса программных продуктов на базе технологии распределенного реестра, обеспечивающих широкое экономическое и социальное взаимодействие физических и юридических лиц, включая нерезидентов различных юрисдикций и организационно-правовых форм, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских кооперативов (обществ) под названием “Кооперативная Экономика”",
generator_task_goal:
"центра привлечения и интеграции передовых инновационных цифровых разработок, а также экономических и социальных методов и решений",
idea_unit_cost:
"50",
idea_unit_cost_words:
"пятьдесят",
blagorost_goal_expansion:
"развитии информационной экосистемы взаимодействия физических и юридических лиц, на основе международных кооперативных принципов и законодательства Российской Федерации в отношении потребительских обществ (кооперативов) – под названием “Кооперативная Экономика”, с целью самоорганизации и интеграции в социально-экономическую среду Российской Федерации",
blagorost_goal_reason:
"вследствие увеличения количества Участников информационной кооперативной экосистемы - ЕОАП - для расширения и повышения социальной эффективности их экономического взаимодействия в некоммерческом формате",
blagorost_task_expansion:
"расширение участников ЕОАП - информационной кооперативной экосистемы как центра экономического взаимодействия в некоммерческом формате",
blagorost_task_development:
"развитие ЕОАП как центра привлечения и интеграции инновационных цифровых разработок, а также экономических и социальных методов и решений",
return_source_description:
"аппаратно-программная сеть узлов распределенного реестра в формате «СМЭВ+SWIFT», построенная на принципах самоорганизации и самофинансирования деятельности технологической инфраструктуры ЕОАП, обеспечивающей консенсус ее распределенных узлов по формированию базового продукта ЕОАП - полного цикла документооборота по синхронному взаимодействию пайщиков и кооперативов - участников экосистемы ЕАОП - через использование цифровых контрактов, с одновременным выполнением функций нотариата и учета финансовых и юридических событий (подробнее на Сайте)",
return_additional_source:
"взносы пользователей ЕОАП, его отдельных программных продуктов и приложений, переданных Обществу или создаваемых в рамках Общества, которые интегрируются в ЕОАП"
};
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
const entries = Object.keys(value as Record<string, unknown>)
.sort()
.map((key) => {
const v = (value as Record<string, unknown>)[key];
return `${JSON.stringify(key)}:${stableStringify(v)}`;
});
return `{${entries.join(',')}}`;
}
function calculateSha256(value: unknown): string {
return createHash('sha256').update(Buffer.from(stableStringify(value), 'utf8')).digest('hex').toUpperCase();
}
export default {
name: 'Backfill capital_program_doc_data_hash for voskhod',
validUntil: new Date('2027-05-01T00:00:00Z'),
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
if (config.coopname !== TARGET_COOPNAME) {
logger.info(`COOPNAME=${config.coopname}: миграция предназначена только для ${TARGET_COOPNAME}, пропускаю.`);
return true;
}
const hash = calculateSha256(capitalProgramPrivateData);
let mongo: mongoose.Connection | null = null;
try {
mongo = await mongoose.createConnection(config.mongoose.url).asPromise();
const db = mongo.db;
if (!db) {
throw new Error('Подключение к MongoDB установлено, но db не инициализирована');
}
const collection = db.collection(COLLECTION_NAME);
await collection.createIndex({ hash: 1 }, { unique: true });
await collection.updateOne(
{ hash },
{
$setOnInsert: {
hash,
registry_id: CAPITAL_DOC_REGISTRY_ID,
payload: capitalProgramPrivateData,
_created_at: new Date(),
},
},
{ upsert: true }
);
const result = await dataSource.query(
`UPDATE extensions
SET config = jsonb_set(COALESCE(config, '{}'::jsonb), '{capital_program_doc_data_hash}', to_jsonb($1::text), true),
updated_at = NOW()
WHERE name = 'capital'`,
[hash]
);
logger.info(`capital_program_doc_data_hash=${hash} сохранён для ${TARGET_COOPNAME}; extensions updated=${result?.[1] ?? 'unknown'}.`);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Ошибка миграции capital_program_doc_data_hash для ${TARGET_COOPNAME}: ${message}`);
return false;
} finally {
await mongo?.close();
}
},
async down({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
if (config.coopname !== TARGET_COOPNAME) {
return true;
}
await dataSource.query(
`UPDATE extensions
SET config = COALESCE(config, '{}'::jsonb) - 'capital_program_doc_data_hash',
updated_at = NOW()
WHERE name = 'capital'`
);
logger.warn('capital_program_doc_data_hash удалён из config capital; запись doc_private_data оставлена как идемпотентный audit trail.');
return true;
},
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.7.23-2",
"version": "2026.6.16-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.7.23-2",
"version": "2026.6.16-1",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.7.23-2",
"version": "2026.6.16-1",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
@@ -14,7 +14,6 @@ const { spawnSync } = require('child_process');
const controllerRoot = path.resolve(__dirname, '..');
const schemaPath = path.join(controllerRoot, 'schema.gql');
const zeusConfigPath = path.join(controllerRoot, 'graphql-zeus.json');
const sdkSrc = path.resolve(controllerRoot, '..', 'sdk', 'src');
const localZeusDir = path.join(controllerRoot, 'zeus');
const sdkZeusDir = path.join(sdkSrc, 'zeus');
@@ -41,12 +40,6 @@ if (!fs.existsSync(sdkSrc)) {
process.exit(1);
}
// graphql-zeus 7.x: yargs кладёт path в argv.path, а CLIClass читает args._[0].
// Без graphql-zeus.json config-maker уходит в inquirer и «зависает» без TTY.
if (!fs.existsSync(zeusConfigPath)) {
fs.writeFileSync(zeusConfigPath, `${JSON.stringify({ urlOrPath: './schema.gql' }, null, 2)}\n`, 'utf8');
}
let zeusBin;
let zeusIsWin;
try {
@@ -58,7 +51,6 @@ try {
const zeusArgs = ['./schema.gql', '../sdk/src', '--subscriptions', 'graphql-ws'];
const startedAt = Date.now();
console.log('generate-client: graphql-zeus… (530 с без вывода — норма, не прерывайте)');
const result = spawnSync(zeusBin, zeusArgs, {
stdio: 'inherit',
cwd: controllerRoot,
@@ -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);
@@ -4,11 +4,15 @@ import type { GetDocumentsInputDTO } from '../dto/get-documents-input.dto';
import type { PaginationResultDomainInterface } from '~/domain/common/interfaces/pagination.interface';
import type { DocumentPackageAggregateDomainInterface } from '~/domain/document/interfaces/document-package-aggregate-domain.interface';
import type { GenerateAnyDocumentInputDTO } from '../dto/generate-any-document-input.dto';
import { GeneratorInfrastructureService } from '~/infrastructure/generator/generator.service';
import { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
@Injectable()
export class DocumentService {
constructor(private readonly documentInteractor: DocumentInteractor) {}
constructor(
private readonly documentInteractor: DocumentInteractor,
private readonly generatorService: GeneratorInfrastructureService
) {}
async getDocumentsAggregate(
data: GetDocumentsInputDTO
@@ -30,7 +34,7 @@ export class DocumentService {
}
async generateAnyDocument(input: GenerateAnyDocumentInputDTO): Promise<DocumentDomainEntity> {
return this.documentInteractor.generateDocument({
return this.generatorService.generateDocument({
data: input.data as any,
options: input.options,
});
@@ -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,
@@ -25,9 +25,6 @@ import { Workflows } from '@coopenomics/notifications';
import { TokenApplicationService } from '~/application/token/services/token-application.service';
import { normalizeUserEmail } from '~/utils/normalize-user-email';
/** Минимум членов совета — как MIN_SOVIET_MEMBERS_COUNT в контракте soviet (3 prod / 1 dev). */
const MIN_SOVIET_MEMBERS_COUNT = config.min_soviet_members_count;
@Injectable()
export class InstallInteractor {
constructor(
@@ -121,30 +118,14 @@ export class InstallInteractor {
async install(data: InstallInputDomainInterface): Promise<void> {
const status = await this.monoStatusRepository.getStatus();
const savedVars = await this.varsRepository.get();
const isIncompleteInstallMaintenance =
status === SystemStatus.maintenance && !savedVars?.name;
// Разрешаем установку если:
// - initialized (нормальный прогон)
// - maintenance без сохранённых vars (прерванная установка, можно повторить)
if (status !== SystemStatus.initialized && !isIncompleteInstallMaintenance) {
// Разрешаем установку ТОЛЬКО если система в статусе 'initialized' (после initSystem)
if (status !== SystemStatus.initialized) {
throw new BadRequestException(
`Установка невозможна. Текущий статус: ${status}. Требуется статус: ${SystemStatus.initialized}`
);
}
if (data.soviet.length < MIN_SOVIET_MEMBERS_COUNT) {
throw new BadRequestException(
`Количество членов совета должно быть не менее ${MIN_SOVIET_MEMBERS_COUNT}`
);
}
// Повтор после прерванной установки: снимаем maintenance, дальше — обычный прогон.
if (isIncompleteInstallMaintenance) {
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
}
const info = await this.blockchainPort.getInfo();
const coop = await this.blockchainPort.getCooperative(config.coopname);
@@ -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);
}
/**
@@ -189,10 +189,6 @@ const envVarsSchema = z.object({
.string()
.optional()
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
MIN_SOVIET_MEMBERS_COUNT: z
.string()
.optional()
.describe('Минимум членов совета при install; пусто — 3 на production, 1 на development/test'),
});
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
@@ -264,11 +260,6 @@ export default {
},
},
coopname: envVars.data.COOPNAME,
min_soviet_members_count: envVars.data.MIN_SOVIET_MEMBERS_COUNT
? parseInt(envVars.data.MIN_SOVIET_MEMBERS_COUNT, 10)
: envVars.data.NODE_ENV === 'production'
? 3
: 1,
graphql_service: envVars.data.GRAPHQL_SERVICE,
provider_base_url: envVars.data.PROVIDER_BASE_URL,
union: {
@@ -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;
@@ -6,7 +6,7 @@ import {
UserCertificateDomainService,
USER_CERTIFICATE_DOMAIN_SERVICE,
} from '~/domain/user/services/user-certificate-domain.service';
import { Cooperative, SovietContract } from 'cooptypes';
import { Cooperative, SovietContract, WalletContract } from 'cooptypes';
import { getActions } from '~/utils/getFetch';
import type { DocumentPackageAggregateDomainInterface } from '../interfaces/document-package-aggregate-domain.interface';
import type { StatementDetailAggregateDomainInterface } from '../interfaces/statement-detail-aggregate-domain.interface';
@@ -298,17 +298,9 @@ export class DocumentPackageV1Aggregator {
/**
* Находит on-chain действие-носитель подписанного связанного документа по doc_hash.
* Сначала ищет в реестре соглашений совета (soviet::newagreement второе,
* явное уведомление, которое дополнительно шлёт soviet::sndagreement), затем
* в общем уведомлении о завершённом документе (soviet::newresolved), которое
* централизованно шлёт make_complete_document (lib/core/soviet/soviet.hpp) на
* КАЖДЫЙ вызов и из soviet::sndagreement, и из wallet::signagree с
* require_recipient(coopname), заданным один раз внутри newresolved.cpp.
* Программные оферты (capital: Благорост/Генератор, program_id>0) идут через
* wallet::signagree и не порождают newagreement, но newresolved у них есть
* всегда расширять notification-поверхность самого wallet::signagree не
* нужно, единственное место для этого уже newresolved.cpp.
* Возвращает поле document найденного действия либо null.
* Сначала ищет в реестре соглашений совета (soviet::newagreement), затем
* в программных подписях кошелька (wallet::signagree). Возвращает поле document
* найденного действия либо null.
*/
private async findLinkedAgreementDocument(
linkHash: string
@@ -329,10 +321,10 @@ export class DocumentPackageV1Aggregator {
const sovietDocument = sovietAgreement?.results?.[0]?.data?.document;
if (sovietDocument) return sovietDocument;
const resolvedAgreement = await getActions(`${process.env.SIMPLE_EXPLORER_API}/get-actions`, {
const walletAgreement = await getActions(`${process.env.SIMPLE_EXPLORER_API}/get-actions`, {
filter: JSON.stringify({
account: SovietContract.contractName.production,
name: SovietContract.Actions.Registry.NewResolved.actionName,
account: WalletContract.contractName.production,
name: WalletContract.Actions.SignAgreement.actionName,
receiver: process.env.COOPNAME,
'data.document.doc_hash': docHashFilter,
}),
@@ -340,8 +332,8 @@ export class DocumentPackageV1Aggregator {
limit: 1,
});
const resolvedDocument = resolvedAgreement?.results?.[0]?.data?.document;
if (resolvedDocument) return resolvedDocument;
const walletDocument = walletAgreement?.results?.[0]?.data?.document;
if (walletDocument) return walletDocument;
return null;
}
@@ -1,13 +0,0 @@
import type { Cooperative } from 'cooptypes';
/**
* Узкая задача пред-генерации: документ, у которого часть полей приватна и
* хранится off-chain (`doc_data`) `DocumentDomainService.generateDocument`
* сохраняет `doc_data` через `saveDocData` и публикует дальше только
* `doc_data_hash`. См. `Cooperative.Document.IDocDataRef` в cooptypes
* on-chain-сторона того же контракта.
*/
export interface GenerateDocumentWithPrivateDataDomainInterface extends Cooperative.Document.IGenerate {
doc_data?: Record<string, unknown>;
doc_data_hash?: string;
}
@@ -10,7 +10,6 @@ import { DocumentPackageAggregator } from '../aggregators/document-package.aggre
import { getActions } from '~/utils/getFetch';
import { toDotNotation } from '~/utils/toDotNotation';
import type { ISignedDocumentDomainInterface } from '../interfaces/signed-document-domain.interface';
import type { GenerateDocumentWithPrivateDataDomainInterface } from '../interfaces/generate-document-with-private-data.interface';
@Injectable()
export class DocumentDomainService {
@@ -23,23 +22,6 @@ export class DocumentDomainService {
) {}
public async generateDocument(data: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
const documentData: GenerateDocumentWithPrivateDataDomainInterface = data.data;
if (documentData.doc_data) {
const { doc_data, ...publicDocumentData } = documentData;
const { hash } = documentData.doc_data_hash
? { hash: documentData.doc_data_hash }
: await this.saveDocData(doc_data, documentData.registry_id);
return await this.generatorInfrastructureService.generateDocument({
...data,
data: {
...publicDocumentData,
doc_data_hash: hash,
},
});
}
return await this.generatorInfrastructureService.generateDocument(data);
}
@@ -8,13 +8,6 @@ export interface ExtensionDomainRepository<TConfig = any> {
create(data: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>>;
find(filter?: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>[]>;
update(data: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>>;
/**
* Атомарно мёржит `patch` в jsonb-колонку `config` одним UPDATE (`config || patch`),
* без чтения-изменения-записи всего объекта в памяти приложения. Конкурентные вызовы
* с разными ключами patch не теряют изменения друг друга (в отличие от `update()`,
* который перезаписывает весь config целиком и подвержен lost update при гонке).
*/
patchConfig(name: string, patch: Partial<TConfig>): Promise<ExtensionDomainEntity<TConfig>>;
}
export const EXTENSION_REPOSITORY = Symbol('ExtensionDomainRepository'); // Создаем уникальный токен
@@ -60,14 +60,6 @@ export interface IAgreementConfigItem {
* Порядок отображения на фронтенде (сортировка)
*/
order: number;
/**
* Резолвер hash'а приватных данных документа (doc_data_hash), если шаблон
* оферты на фабрике требует PrivateData. Заполняется только для
* extension-оферт из AgreementRegistrationSpec; платформенные соглашения
* его не имеют. В GraphQL DTO не попадает (поля мапятся декораторами).
*/
resolve_doc_data_hash?: () => Promise<string | undefined>;
}
/**
@@ -55,18 +55,4 @@ export interface AgreementRegistrationSpec {
* реестр удаляет все записи с этим extension_name.
*/
extension_name: string;
/**
* Опциональный резолвер hash'а приватных данных документа (doc_data_hash).
* Если шаблон оферты на фабрике требует PrivateData (например, параметры
* ЦПП, утверждённые советом), расширение предоставляет резолвер ядро
* вызывает его перед каждой генерацией документа этой оферты и передаёт
* результат в фабрику как doc_data_hash.
*
* Резолвер вызывается на каждую генерацию (не кэшируется): источник
* значения конфигурация расширения, которая может обновляться без
* перезапуска (совет пересохранил параметры программ). Ядро не знает,
* откуда берётся hash это контракт расширения с его фабричным шаблоном.
*/
resolve_doc_data_hash?: () => Promise<string | undefined>;
}
@@ -50,7 +50,6 @@ export class AgreementConfigurationService {
link_to_statement: true,
applicable_account_types: spec.applicable_account_types,
order: spec.order,
resolve_doc_data_hash: spec.resolve_doc_data_hash,
};
}
@@ -132,17 +132,11 @@ export class RegistrationDocumentsService {
): Promise<IGeneratedRegistrationDocument> {
this.logger.debug(`Генерация документа: ${config.id} (registry_id=${config.registry_id})`);
// Если шаблон оферты требует PrivateData, расширение-владелец предоставило
// резолвер hash'а в спеке регистрации — ядро не знает ни источника значения,
// ни того, какие registry_id этого требуют.
const doc_data_hash = config.resolve_doc_data_hash ? await config.resolve_doc_data_hash() : undefined;
const document = await this.documentInteractor.generateDocument({
data: {
coopname,
username,
registry_id: config.registry_id,
...(doc_data_hash ? { doc_data_hash } : {}),
},
options: {
skip_save: false, // Сохраняем документ в базу для последующей сверки
@@ -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)
);
}
@@ -36,14 +36,6 @@ export class CapitalOnboardingStepInputDTO {
decision!: string;
}
@InputType('SaveCapitalProgramDocDataInput')
export class SaveCapitalProgramDocDataInputDTO {
@Field(() => String)
@IsString()
@IsNotEmpty()
doc_data_hash!: string;
}
@ObjectType('CapitalOnboardingState')
export class CapitalOnboardingStateDTO {
@Field(() => Boolean)
@@ -76,9 +68,6 @@ export class CapitalOnboardingStateDTO {
@Field(() => String, { nullable: true })
onboarding_blagorost_offer_template_hash?: string | null;
@Field(() => String, { nullable: true })
capital_program_doc_data_hash?: string | null;
@Field(() => String)
onboarding_init_at!: string;
@@ -26,17 +26,10 @@ import type { IConfig } from '../../capital-extension.module';
* при завершённом L1 две оферты (generator/blagorost) и две
* программы (generation/capitalization);
* идемпотентность гарантируется AgreementRegistryService.
*
* `resolveDocDataHash` резолвер hash'а PrivateData параметров ЦПП,
* прикрепляется к обеим офертам: их фабричные шаблоны (#996/#1000) требуют
* doc_data. Сейчас все документы ЦПП читают единый набор параметров
* (capital_program_doc_data_hash); если параметры разделятся по программам,
* каждая спека получит собственный резолвер ядро менять не потребуется.
*/
export function registerCapitalInAgreementRegistry(
port: AgreementRegistrationPort,
extensionConfig: IConfig,
resolveDocDataHash?: () => Promise<string | undefined>
extensionConfig: IConfig
): boolean {
const onboardingDone =
extensionConfig.onboarding_generator_program_template_done &&
@@ -59,7 +52,6 @@ export function registerCapitalInAgreementRegistry(
applicable_account_types: [],
order: 6,
extension_name: CAPITAL_EXTENSION_NAME,
resolve_doc_data_hash: resolveDocDataHash,
});
port.registerAgreement({
@@ -78,7 +70,6 @@ export function registerCapitalInAgreementRegistry(
applicable_account_types: [],
order: 5,
extension_name: CAPITAL_EXTENSION_NAME,
resolve_doc_data_hash: resolveDocDataHash,
});
port.registerProgram({
@@ -3,11 +3,7 @@ import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import {
CapitalOnboardingStepInputDTO,
CapitalOnboardingStateDTO,
SaveCapitalProgramDocDataInputDTO,
} from '../dto/onboarding.dto';
import { CapitalOnboardingStepInputDTO, CapitalOnboardingStateDTO } from '../dto/onboarding.dto';
import { CapitalOnboardingService } from '../services/onboarding.service';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
@@ -38,16 +34,4 @@ export class CapitalOnboardingResolver {
): Promise<CapitalOnboardingStateDTO> {
return this.onboardingService.completeStep(data, currentUser?.username);
}
@Mutation(() => CapitalOnboardingStateDTO, {
name: 'saveCapitalProgramDocDataHash',
description: 'Сохранить hash PrivateData параметров документов ЦПП',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async saveProgramDocDataHash(
@Args('data', { type: () => SaveCapitalProgramDocDataInputDTO }) data: SaveCapitalProgramDocDataInputDTO,
): Promise<CapitalOnboardingStateDTO> {
return this.onboardingService.saveProgramDocDataHash(data.doc_data_hash);
}
}
@@ -73,12 +73,13 @@ export class CapitalOnboardingEventsService {
const wasAlreadyDone = !!(plugin.config as any)[flagKey];
// Атомарный частичный UPDATE (config || patch) вместо read-modify-write всего
// config целиком: два DecisionTrackedEvent для разных шагов, обработанные
// конкурентно, иначе теряют флаг друг друга (lost update на общем jsonb-блобе).
const updated = await this.extensionRepository.patchConfig('capital', {
// Обновляем флаг завершения шага
const updatedConfig = {
...plugin.config,
[flagKey]: true,
} as Partial<IConfig>);
};
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
this.logger.info(`Флаг ${flagKey} установлен в true`);
@@ -88,7 +89,7 @@ export class CapitalOnboardingEventsService {
// restartApp('capital'), чтобы initialize(config) увидел свежий config
// и зарегистрировал оферты/программы в платформенном реестре.
// Совет ничего не нажимает (раздел 7.1 плана C28-10, вариант (в)).
if (!wasAlreadyDone && this.isL1Complete(updated.config as IConfig)) {
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
this.logger.info(
'[ONBOARDING_COMPLETED] capital L1 завершён последним шагом, эмиттим событие для auto-restart'
);
@@ -29,10 +29,6 @@ type OnboardingHashKey =
| 'onboarding_blagorost_provision_hash'
| 'onboarding_blagorost_offer_template_hash';
type CapitalOnboardingConfig = IConfig &
Partial<Record<OnboardingFlagKey, boolean>> &
Partial<Record<OnboardingHashKey | 'onboarding_init_at' | 'onboarding_expire_at' | 'capital_program_doc_data_hash', string>>;
@Injectable()
export class CapitalOnboardingService {
constructor(
@@ -92,65 +88,32 @@ export class CapitalOnboardingService {
}
}
private isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
private getMetaString(meta: unknown, key: string): string | undefined {
if (!this.isRecord(meta)) return undefined;
const value = meta[key];
return typeof value === 'string' ? value : undefined;
}
private isSignatureInfo(value: unknown): value is ISignedDocumentDomainInterface['signatures'][number] {
if (!this.isRecord(value)) return false;
return (
typeof value.id === 'number' &&
typeof value.signed_hash === 'string' &&
typeof value.signer === 'string' &&
typeof value.public_key === 'string' &&
typeof value.signature === 'string' &&
typeof value.signed_at === 'string' &&
typeof value.meta === 'string'
);
}
private getMetaSignatures(meta: unknown): ISignedDocumentDomainInterface['signatures'] {
if (!this.isRecord(meta)) return [];
const value = meta.signatures;
return Array.isArray(value) ? value.filter((item) => this.isSignatureInfo(item)) : [];
}
private async loadPlugin(): Promise<ExtensionDomainEntity<CapitalOnboardingConfig>> {
private async loadPlugin(): Promise<ExtensionDomainEntity<IConfig & Record<string, any>>> {
const plugin = await this.extensionRepository.findByName('capital');
if (!plugin) throw new Error('Конфигурация расширения capital не найдена');
const pluginConfig: CapitalOnboardingConfig = { ...plugin.config };
const pluginConfig = { ...plugin.config } as IConfig & Record<string, any>;
const patch: Partial<CapitalOnboardingConfig> = {};
let needUpdate = false;
if (!pluginConfig.onboarding_init_at) {
pluginConfig.onboarding_init_at = new Date().toISOString();
patch.onboarding_init_at = pluginConfig.onboarding_init_at;
needUpdate = true;
}
if (!pluginConfig.onboarding_expire_at) {
const start = new Date(pluginConfig.onboarding_init_at);
pluginConfig.onboarding_expire_at = computeOnboardingExpiresAt(start);
patch.onboarding_expire_at = pluginConfig.onboarding_expire_at;
needUpdate = true;
}
if (Object.keys(patch).length > 0) {
// Точечный merge только изменившихся полей — полная перезапись config
// целиком (как раньше) конкурирует с параллельными записями других шагов
// онбординга (флаги, хэши) и теряет их изменения (lost update).
const updated = await this.extensionRepository.patchConfig('capital', patch);
return { ...plugin, config: updated.config };
if (needUpdate) {
await this.extensionRepository.update({ ...plugin, config: pluginConfig });
return { ...plugin, config: pluginConfig };
}
return { ...plugin, config: pluginConfig };
}
private buildState(pluginConfig: CapitalOnboardingConfig): CapitalOnboardingStateDTO {
private buildState(pluginConfig: IConfig & Record<string, any>): CapitalOnboardingStateDTO {
return {
generator_program_template_done: !!pluginConfig.onboarding_generator_program_template_done,
onboarding_generator_program_template_hash: pluginConfig.onboarding_generator_program_template_hash || null,
@@ -162,7 +125,6 @@ export class CapitalOnboardingService {
onboarding_blagorost_provision_hash: pluginConfig.onboarding_blagorost_provision_hash || null,
blagorost_offer_template_done: !!pluginConfig.onboarding_blagorost_offer_template_done,
onboarding_blagorost_offer_template_hash: pluginConfig.onboarding_blagorost_offer_template_hash || null,
capital_program_doc_data_hash: pluginConfig.capital_program_doc_data_hash || null,
onboarding_init_at: pluginConfig.onboarding_init_at || '',
onboarding_expire_at: pluginConfig.onboarding_expire_at || '',
};
@@ -173,28 +135,13 @@ export class CapitalOnboardingService {
return this.buildState(plugin.config);
}
public async saveProgramDocDataHash(docDataHash: string): Promise<CapitalOnboardingStateDTO> {
await this.loadPlugin();
const normalizedHash = docDataHash.trim();
if (!normalizedHash) {
throw new Error('Hash PrivateData документов ЦПП не может быть пустым');
}
const updated = await this.extensionRepository.patchConfig('capital', {
capital_program_doc_data_hash: normalizedHash,
} as Partial<CapitalOnboardingConfig>);
return this.buildState(updated.config as CapitalOnboardingConfig);
}
public async completeStep(data: CapitalOnboardingStepInputDTO, username: string): Promise<CapitalOnboardingStateDTO> {
const plugin = await this.loadPlugin();
const flagKey = this.mapStepToFlag(data.step);
const hashKey = this.mapStepToHash(data.step);
const normalizedTitle = data.title?.trim().substring(0, 200) || undefined;
if (plugin.config[flagKey]) {
if ((plugin.config as any)[flagKey]) {
return this.buildState(plugin.config);
}
const project_id = uuid();
@@ -221,12 +168,12 @@ export class CapitalOnboardingService {
// Публикуем проект решения в блокчейн сразу после генерации
const documentForPublish: ISignedDocumentDomainInterface = {
version: this.getMetaString(generatedDoc.meta, 'version') || '1.0',
version: (generatedDoc.meta as any)?.version || '1.0',
hash: generatedDoc.hash,
doc_hash: this.getMetaString(generatedDoc.meta, 'doc_hash') || generatedDoc.hash,
meta_hash: this.getMetaString(generatedDoc.meta, 'meta_hash') || generatedDoc.hash,
doc_hash: (generatedDoc.meta as any)?.doc_hash || generatedDoc.hash,
meta_hash: (generatedDoc.meta as any)?.meta_hash || generatedDoc.hash,
meta: generatedDoc.meta,
signatures: this.getMetaSignatures(generatedDoc.meta),
signatures: (generatedDoc.meta as any)?.signatures || [],
};
await this.freeDecisionPort.publishProjectOfFreeDecision({
@@ -236,12 +183,12 @@ export class CapitalOnboardingService {
document: documentForPublish,
});
// Сохраняем hash в конфиге для отображения на фронтенде — точечный merge
// (не полная перезапись config), чтобы не потерять конкурентно записанные
// флаги/хэши других шагов онбординга (lost update на общем jsonb-блобе).
const updated = await this.extensionRepository.patchConfig('capital', {
// Сохраняем hash в конфиге для отображения на фронтенде
const updatedConfig = {
...plugin.config,
[hashKey]: generatedDoc.hash,
} as Partial<CapitalOnboardingConfig>);
};
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
// Регистрируем правило отслеживания в фабрике
const varsField = this.mapStepToVarsField(data.step);
@@ -257,6 +204,6 @@ export class CapitalOnboardingService {
},
});
return this.buildState(updated.config as CapitalOnboardingConfig);
return this.buildState(updatedConfig);
}
}
@@ -306,11 +306,10 @@ export class PermissionsService {
? await this.isProjectContributor(username, project.coopname, project.project_hash)
: false;
// Запрос на рассмотрении — только status=created и только если допуска ещё нет
const pending_clearance =
!has_clearance && project.coopname
? (await this.appendixRepository.findCreatedByUsernameAndProjectHash(username, project.project_hash)) !== null
: false;
// Проверяем наличие pending clearance
const pending_clearance = project.coopname
? (await this.appendixRepository.findCreatedByUsernameAndProjectHash(username, project.project_hash)) !== null
: false;
// Проверяем допуск к родителю — каскадно вниз по иерархии проектов:
// если есть допуск к корневому проекту, пользователь видит артефакты всех его компонентов.
@@ -51,11 +51,6 @@ import { ProgramKey } from '~/domain/registration/enum';
import type { GenerateCapitalRegistrationDocumentsDomainInput } from '../../domain/actions/generate-capital-registration-documents-domain-input.interface';
import type { GenerateCapitalRegistrationDocumentsDomainOutput } from '../../domain/actions/generate-capital-registration-documents-domain-output.interface';
import type { CompleteCapitalRegistrationDomainInput } from '../../domain/actions/complete-capital-registration-domain-input.interface';
import {
EXTENSION_REPOSITORY,
type ExtensionDomainRepository,
} from '~/domain/extension/repositories/extension-domain.repository';
import type { IConfig } from '../../capital-extension.module';
/**
* Интерактор домена для управления участием в CAPITAL контракте
@@ -81,24 +76,8 @@ export class ParticipationManagementInteractor {
private readonly projectManagementInteractor: ProjectManagementInteractor,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils,
private readonly documentInteractor: DocumentInteractor,
@Inject(EXTENSION_REPOSITORY)
private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
) { }
private async getCapitalProgramDocDataHash(): Promise<string> {
const extension = await this.extensionRepository.findByName('capital');
const hash = (extension?.config as IConfig | undefined)?.capital_program_doc_data_hash?.trim();
if (!hash) {
throw new HttpApiError(
httpStatus.BAD_REQUEST,
'Параметры документов ЦПП не заполнены: отсутствует capital_program_doc_data_hash в конфигурации capital'
);
}
return hash;
}
/**
* Получение отображаемого имени из аккаунта через порт расширения
*/
@@ -334,8 +313,7 @@ export class ParticipationManagementInteractor {
}
// Извлекаем appendix_hash из метаданных документа
const documentMeta: Record<string, unknown> = document.meta;
const appendix_hash = typeof documentMeta.appendix_hash === 'string' ? documentMeta.appendix_hash : undefined;
const appendix_hash = (document.meta as any).appendix_hash;
//TODO: адаптировать или документ или код ниже к parent_appendix_hash
if (!appendix_hash) {
@@ -345,20 +323,7 @@ export class ParticipationManagementInteractor {
);
}
// Уже подтверждённый допуск — повторный отклик не нужен
const confirmedAppendix =
await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
data.username,
data.project_hash
);
if (confirmedAppendix) {
throw new HttpApiError(
httpStatus.CONFLICT,
'Вы уже являетесь участником этого проекта'
);
}
// Есть незакрытый запрос на рассмотрении
// Проверяем, есть ли уже запрос на рассмотрении для данного пользователя и проекта
const existingAppendix =
await this.appendixRepository.findCreatedByUsernameAndProjectHash(
data.username,
@@ -625,17 +590,11 @@ export class ParticipationManagementInteractor {
);
}
// 6. Допуск к родительскому проекту: confirmed (уже участник)
// или created (только что отправили отклик на родителя в том же диалоге)
const parentAppendix =
(await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
data.username,
parentProject.project_hash
)) ??
(await this.appendixRepository.findCreatedByUsernameAndProjectHash(
data.username,
parentProject.project_hash
));
// 6. Находим родительское приложение (appendix) к родительскому проекту
const parentAppendix = await this.appendixRepository.findCreatedByUsernameAndProjectHash(
data.username,
parentProject.project_hash
);
if (!parentAppendix) {
throw new HttpApiError(
@@ -819,7 +778,6 @@ export class ParticipationManagementInteractor {
// Генерируем параметры для соглашения Благорост
await this.udataDocumentParametersService.generateBlagorostAgreementParametersIfNotExist(data.coopname, data.username);
const capitalProgramDocDataHash = await this.getCapitalProgramDocDataHash();
blagorostAgreement = await this.documentInteractor.generateDocument({
data: {
@@ -827,7 +785,6 @@ export class ParticipationManagementInteractor {
username: data.username,
lang,
registry_id: Cooperative.Registry.BlagorostAgreement.registry_id,
doc_data_hash: capitalProgramDocDataHash,
},
options: { skip_save: false },
});
@@ -852,7 +809,6 @@ export class ParticipationManagementInteractor {
// Генерируем параметры для оферты Генератор
await this.udataDocumentParametersService.generateGeneratorOfferParametersIfNotExist(data.coopname, data.username);
const capitalProgramDocDataHash = await this.getCapitalProgramDocDataHash();
generatorOffer = await this.documentInteractor.generateDocument({
data: {
@@ -860,7 +816,6 @@ export class ParticipationManagementInteractor {
username: data.username,
lang,
registry_id: Cooperative.Registry.GeneratorOffer.registry_id,
doc_data_hash: capitalProgramDocDataHash,
},
options: { skip_save: false },
});
@@ -40,7 +40,6 @@ export const defaultConfig = {
onboarding_generator_offer_template_done: false,
onboarding_blagorost_provision_done: false,
onboarding_blagorost_offer_template_done: false,
capital_program_doc_data_hash: '',
/** Ветка для выборки коммитов (FR3 / PRD); URL репозитория — на проекте/компоненте (PRD §6.2.1). */
github_sync_branch: 'dev',
/** Интервал polling GitHub API в минутах (FR2); 0 — периодический опрос отключён. */
@@ -254,10 +253,6 @@ export const Schema = z.object({
.boolean()
.default(defaultConfig.onboarding_blagorost_offer_template_done)
.describe(describeField({ label: 'Шаг предложения Благорост выполнен', visible: false })),
capital_program_doc_data_hash: z
.string()
.default(defaultConfig.capital_program_doc_data_hash)
.describe(describeField({ label: 'PrivateData документов ЦПП', visible: false })),
});
@@ -582,10 +577,8 @@ export class CapitalPlugin extends BaseExtModule {
} else {
this.logger.log('Конфигурация контракта CAPITAL уже актуальна');
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Не удалось синхронизировать конфигурацию с контрактом CAPITAL: ${message}`, stack);
} catch (error: any) {
this.logger.error(`Не удалось синхронизировать конфигурацию с контрактом CAPITAL: ${error.message}`, error.stack);
// Не бросаем ошибку, чтобы не блокировать запуск модуля
}
@@ -593,12 +586,10 @@ export class CapitalPlugin extends BaseExtModule {
try {
await this.syncInteractor.initializeSync();
this.logger.log('Синхронизация благороста с блокчейном инициализирована');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
} catch (error: any) {
this.logger.error(
`Не удалось инициализировать синхронизацию благороста с блокчейном: ${message}`,
stack
`Не удалось инициализировать синхронизацию благороста с блокчейном: ${error.message}`,
error.stack
);
// Не бросаем ошибку, чтобы не блокировать запуск модуля
}
@@ -654,25 +645,7 @@ export class CapitalPlugin extends BaseExtModule {
// Если L1 ещё не завершён — реестр остаётся пустым, SignUp не предлагает
// программы capital (раздел 4.1 req 44 проекта 13).
try {
// Резолвер читает конфиг заново на каждую генерацию: совет может
// пересохранить параметры ЦПП (новый hash) без перезапуска расширения,
// а спека в реестре регистрируется один раз на initialize.
const resolveCapitalProgramDocDataHash = async (): Promise<string | undefined> => {
const ext = await this.extensionRepository.findByName(this.name);
const hash = ext?.config?.capital_program_doc_data_hash?.trim();
if (!hash) {
throw new Error(
'Параметры документов ЦПП не заполнены: отсутствует capital_program_doc_data_hash в конфигурации capital'
);
}
return hash;
};
const registered = registerCapitalInAgreementRegistry(
this.agreementRegistrationPort,
extensionConfig as IConfig,
resolveCapitalProgramDocDataHash
);
const registered = registerCapitalInAgreementRegistry(this.agreementRegistrationPort, extensionConfig as IConfig);
if (registered) {
this.logger.log('[CAPITAL.REGISTRY] зарегистрировано 2 оферты + 2 программы capital');
} else {
@@ -23,13 +23,9 @@ export const GENERATOR_OFFER_AGREEMENT_ID = 'generator_offer';
export const BLAGOROST_OFFER_AGREEMENT_ID = 'blagorost_offer';
export const GENERATOR_AGREEMENT_TYPE = 'generator';
// On-chain имя оферты Благорост в `soviet::coagreements` (_capital_program в
// lib/consts.hpp контракта) — 'capital' (program_id=4). Подтверждено прямым
// запросом к продовой цепочке voskhod (185.70.105.87). Значение здесь ОБЯЗАНО
// совпадать буквально с контрактной константой — иначе account.adapter.ts не
// находит coagreement по type, программный signagree подменяется на
// sndagreement, и `get_coagreement_or_fail` падает с «Соглашение указанного
// типа не найдено».
// On-chain имя оферты Благорост в `soviet::coagreements` — 'capital' (program_id=4).
// Контроллер обязан слать sndagreement/signagree с этим значением, иначе
// `get_coagreement_or_fail` падает с «Соглашение указанного типа не найдено».
export const BLAGOROST_AGREEMENT_TYPE = 'capital';
export const GENERATION_PROGRAM_KEY = 'GENERATION';
@@ -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>(
@@ -72,15 +72,13 @@ export class AppendixTypeormRepository
}
/**
* Найти приложение со статусом created (запрос на рассмотрении)
* по имени пользователя и хэшу проекта
* Найти созданное приложение по имени пользователя и хэшу проекта
*/
async findCreatedByUsernameAndProjectHash(username: string, projectHash: string): Promise<AppendixDomainEntity | null> {
const entity = await this.repository.findOne({
where: {
username: username.toLowerCase(),
username,
project_hash: projectHash.toLowerCase(),
status: AppendixStatus.CREATED,
},
});
@@ -74,6 +74,8 @@ export class ChairmanOnboardingEventsService {
return;
}
const cfg = { ...plugin.config };
// Определяем ключ флага для обновления на основе metadata
const flagKey = this.mapStepToFlag(step);
@@ -82,15 +84,16 @@ export class ChairmanOnboardingEventsService {
return;
}
const wasAlreadyDone = !!(plugin.config as any)[flagKey];
const wasAlreadyDone = !!(cfg as any)[flagKey];
// Атомарный частичный UPDATE (config || patch) вместо read-modify-write всего
// config целиком: несколько DecisionTrackedEvent (согласия/подписи), обработанные
// конкурентно, иначе теряют флаг друг друга (lost update на общем jsonb-блобе) —
// тот же класс бага, что чинили в capital-онбординге.
const updated = await this.extensionRepository.patchConfig('chairman', {
// Обновляем флаг выполнения шага
const patch: Partial<IConfig> = {
[flagKey]: true,
} as Partial<IConfig>);
};
const updatedConfig = { ...cfg, ...patch };
const updated: ExtensionDomainEntity<IConfig> = { ...plugin, config: updatedConfig };
await this.extensionRepository.update(updated);
this.logger.info(`Онбординг обновлён: ${flagKey} = true (решение ${result.decision_id})`);
@@ -98,7 +101,7 @@ export class ChairmanOnboardingEventsService {
// завершённым, эмиттим ONBOARDING_COMPLETED_EVENT.
// У chairman нет L2-оферт в реестре сейчас, но событие важно как
// сигнал «L1 кооператива закрыт» — раздел 7.1 C28-10.
if (!wasAlreadyDone && this.isL1Complete(updated.config as IConfig)) {
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
this.logger.info(
'[ONBOARDING_COMPLETED] chairman L1 завершён последним шагом, эмиттим событие'
);
@@ -99,25 +99,22 @@ export class ChairmanOnboardingService {
if (!plugin) throw new Error('Конфигурация расширения chairman не найдена');
const config = { ...plugin.config };
const patch: Partial<IConfig> = {};
let needUpdate = false;
if (!config.onboarding_init_at) {
config.onboarding_init_at = new Date().toISOString();
patch.onboarding_init_at = config.onboarding_init_at;
needUpdate = true;
}
if (!config.onboarding_expire_at) {
const start = new Date(config.onboarding_init_at);
const expire = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000);
config.onboarding_expire_at = expire.toISOString();
patch.onboarding_expire_at = config.onboarding_expire_at;
needUpdate = true;
}
if (Object.keys(patch).length > 0) {
// Точечный merge только изменившихся полей — полная перезапись config
// целиком конкурирует с параллельными записями флагов/хэшей других шагов
// онбординга и теряет их изменения (lost update на общем jsonb-блобе).
const updated = await this.extensionRepository.patchConfig('chairman', patch);
return { ...plugin, config: updated.config };
if (needUpdate) {
await this.extensionRepository.update({ ...plugin, config });
return { ...plugin, config };
}
return plugin;
@@ -201,12 +198,12 @@ export class ChairmanOnboardingService {
document: documentForPublish,
});
// Сохраняем hash в конфиге для отображения на фронтенде — точечный merge
// (не полная перезапись config), чтобы не потерять конкурентно записанные
// флаги/хэши других шагов онбординга (lost update на общем jsonb-блобе).
const updated = await this.extensionRepository.patchConfig('chairman', {
// Сохраняем hash в конфиге для отображения на фронтенде
const updatedConfig: IConfig = {
...plugin.config,
[hashKey]: generatedDoc.hash,
} as Partial<IConfig>);
};
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
// Регистрируем правило отслеживания в фабрике
const varsField = this.mapStepToVarsField(data.step);
@@ -222,7 +219,7 @@ export class ChairmanOnboardingService {
},
});
return this.buildState(updated.config);
return this.buildState(updatedConfig);
}
// Сохраняем hash общего собрания, флаг закроется после newresolved
@@ -236,9 +233,8 @@ export class ChairmanOnboardingService {
// Если hash не пришёл (не должно быть), не затираем существующее значение
const meetHash = proposal_hash || plugin.config.onboarding_general_meet_hash || '';
const updated = await this.extensionRepository.patchConfig('chairman', {
onboarding_general_meet_hash: meetHash,
} as Partial<IConfig>);
const updatedConfig: IConfig = { ...plugin.config, onboarding_general_meet_hash: meetHash };
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
// Регистрируем правило отслеживания общего собрания
await this.decisionTrackingPort.registerTrackingRule({
@@ -251,6 +247,6 @@ export class ChairmanOnboardingService {
},
});
return this.buildState(updated.config);
return this.buildState(updatedConfig);
}
}

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