Compare commits

...

30 Commits

Author SHA1 Message Date
ant de55c3aa3e Merge pull request 'Эпик 1 (Благорост): контракт capital — займы, расходы программы, L2-роли' (#3) from feat/blagorost-E1-contract into blagorost
Reviewed-on: #3
2026-06-02 06:17:24 +00:00
coopops 32fc211006 fix(capital): стандарт debt — «подписанный акт приёма-передачи»
Поправка терминологии в p.cap.debt.standard.yaml: «вторая подпись на
акте» → «подписанный акт приёма-передачи» — корректное юридическое
название документа, который завершает приём результата работ по проекту.

Затронуты: operations.o.cap.repay.description, related.p.cap.rid.note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 06:27:59 +00:00
coopops 24663bdaac fix(capital,loan): gitea PR #3 review — 3 коммента
1. loan.debts.source_contract — убрана binary_extension-обёртка, простой
   name. Таблица в продакшене пустая, расширение не нужно.

2. p.cap.debt.standard.yaml переписан бизнес-языком для председателей /
   бухгалтеров / пайщиков. Из прозы убраны идентификаторы кошельков
   (w.cap.*), коды операций (o.cap.*), бухгалтерские проводки в шапке и
   action.purpose / state.description / scenario / alternatives. Технические
   детали остались только в structured-секциях 5 (documents) и 6 (operations).

3. «акт-2» убран как ошибочная терминология — у проекта один акт, на
   нём ставится вторая подпись. Поправлено везде в стандарте.

Compile: loan + capital собраны через cdt-cpp v4.1.0 без ошибок.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 06:25:00 +00:00
coopops c3a433b591 refactor(capital): финальный канон имён debt-операций невозврата
Утверждено автором 2026-05-19. Семантика операции через имя, группировка по
сущности (dbt для debt-операций), без аббревиатур типа seize/wroff/seizecollat.

Переименования (относительно предыдущего refactor-коммита):
  o.cap.seize       → o.cap.crtnma   (CREATE_NMA — создать НМА кооператива)
  o.cap.wroff       → o.cap.dbtwrf   (DEBT_WRITEOFF — списать долг)
  capital::seizecollat → capital::closedebt (закрыть долг)
  Status::SEIZED    → Status::WRITEOFF
  mark_seized       → mark_writeoff
  seizecollat.cpp   → closedebt.cpp

Кошелёк w.cap.nma и проводки (Дт 04 / Кт 08 + Дт 80 / Кт 58) — без изменений.

Канон утверждён ДО реализации (правило в auto-memory:
feedback-kanon-naming-soglasovyvat); предыдущий заход с собственными
именами отвергнут автором.

Compile: cdt-cpp v4.1.0 — exit 0, capital.abi содержит action closedebt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:55:05 +00:00
coopops ca877ab294 refactor(capital): канон имён ledger2 + реализован action seizecollat
Канон имён в ledger2-онтологии — самодокументирующиеся английские термины
без аббревиатур. Префикс контракта (cap) не дублировать в имени.

Переименования (старые имена — отвергнутый рабочий драфт; нигде больше
не используются):
  o.cap.dflt    → o.cap.seize    (SEIZE_COLLATERAL — изъятие обеспечения)
  o.cap.lnwoff  → o.cap.wroff    (WRITE_OFF_LOAN — списание займа)
  w.cap.coopn   → w.cap.nma      (NMA — НМА кооператива)
  capital::dfltdebt → capital::seizecollat
  state defaulted (virtual) → seized (Status::SEIZED + mark_seized)

Story 1.13 — action capital::seizecollat:
  - guard: debt.status ∈ {paid, overdue} ∧ project.status == cancelled;
  - apply o.cap.seize  (TRANSFER w.cap.gen → w.cap.nma, Дт 04 / Кт 08);
  - apply o.cap.wroff  (BURN w.cap.loan,                Дт 80 / Кт 58);
  - decrease segment.debt_amount + active_debts_count;
  - subtract project.used_for_compensation;
  - decrease contributors.debt_amount;
  - mark debt SEIZED (mark_seized);
  - inline Loan::settle_debt (loan-контракт);
  - require_recipient(username, coopname);
  - require_auth(coopname) — без подписи пайщика, без сбора совета.

Решения 2026-05-19 (Игорь Смуров / Алексей Муравьёв):
  - срок займа фикс — 1 год;
  - проводки невозврата: Дт 04 / Кт 08 + Дт 80 / Кт 58;
  - автоматизм без подписи пайщика и без сбора совета.

Compile: cdt-cpp v4.1.0 OK (warnings only Ricardian + deprecation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:30:47 +00:00
coopops 4d6a03a142 feat(capital): закрыть невозврат займа — обращение взыскания на коммиты-обеспечение
Утверждённые проводки и срок (обсуждение Matrix 2026-05-19, Игорь Смуров / Алексей Муравьев):

* Срок займа — фиксированный 1 год вместо 3 мес. (`DEFAULT_DEBT_TERM_SECONDS = 365 × 24 × 3600`).
* Новые операции ledger2:
  - `o.cap.dflt` — обращение взыскания на коммиты-обеспечение (Дт 04 / Кт 08, TRANSFER `w.cap.gen` → `w.cap.coopn`).
  - `o.cap.lnwoff` — списание займа в счёт обращения взыскания (Дт 80 / Кт 58, BURN `w.cap.loan`).
* Новый кошелёк `w.cap.coopn` (COOP_NMA, COOPERATIVE) — НМА кооператива, полученные из обеспечения.
* Кооперативный стандарт `p.cap.debt.standard.yaml` обновлён: состояние `defaulted`, шаг `dfltdebt`, две новые операции в секции 6, альтернативная ветка в сценарии.
* Тесты-заготовки в `capital-blagorost.test.ts` (Story 1.13).

Реализация самого action `capital::dfltdebt` остаётся за Story 1.13 — здесь только ontology + стандарт + проводки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:07:58 +00:00
coopops c185b0167f fix(capital+loan): review PR#402 round 2 — изоляция loan, счётчик займов в сегменте, бездокументарные роли
Round 2 ревью PR #402 — 8 замечаний:

LOAN-контракт независим от прикладных (capital/marketplace/...):
- loan.debts: убран binary_extension<checksum256> project_hash и индекс byuserproj
- loan.debts: добавлен binary_extension<name> source_contract (контракт-источник)
- loan::createdebt(): убран параметр project_hash, payer пишется в source_contract
- Loan::create_debt(): убран project_hash из сигнатуры и хелпера
- Loan::count_user_project_debts(): удалена — capital считает у себя
- Локальный контекст (project_hash и т.п.) остаётся в Capital::Debts

CAPITAL — счётчик активных займов на сегменте через binary_extension:
- Segment::active_debts_count (binary_extension<uint32_t>, хвостом)
- increase/decrease/get_active_debts_count хелперы
- debtpaycnfrm: после confirm_paid +1 счётчика сегмента
- settledebt: после mark_settled -1 счётчика
- signact2: лимит «10 займов на проект в одном паевом взносе» проверяется
  по счётчику (не через обход byprojhash); сам перебор ограничен счётчиком,
  ранний выход после нужного числа активных записей

role_requests — бездокументарная схема и строгая типизация:
- namespace Role { CREATOR, AUTHOR, MASTER, NONE } в role_requests.hpp
- удалены поля statement и master_decision из role_request struct
- create()/approve() — без параметров document2
- validate_role_or_fail и apply_role_to_project — через Role:: namespace
- requestrole/inviterole/approverole/acceptinvite — без statement/master_decision
- requestrateu — role = Role::NONE (не маркер "rate"_n), request_type = RATE_UPDATE
- decline сохраняет только reason; решения подписываются coopname-транзакцией

prerequisite fix:
- names.hpp: убран дубль Names::Capital::CREATE_PROGRAM_EXPENSE (был объявлен
  и в callback-блоке line 55, и в акцепт-блоке line 130 — оставлен один)

Замечания закрыты:
- PR comment id=3264976591 (фу-фу byuserproj) — индекс удалён
- id=3264980890 (count_user_project_debts) — функция удалена
- id=3265021918 (loan независим от capital) — изоляция выполнена
- id=3265072380 (rate как name через namespace) — RequestType::RATE_UPDATE + Role::NONE
- id=3265080990 (счётчик в binary_extension вместо перебора) — active_debts_count
- id=3265088096 (namespace типа роли) — Role::{CREATOR,AUTHOR,MASTER}
- id=3265101459 (без statement/master_decision) — поля удалены
- id=3265104196 (через Namespace проверка) — validate_role_or_fail через Role::

Сборка: cdt-cpp v4.1.0 capital.wasm + loan.wasm без ошибок.
Тесты: capital-blagorost.test.ts — формулировки A2/D обновлены под счётчик
       и бездокументарную схему.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:40:09 +00:00
coopops 07f35ea084 feat(loan): централизация учёта займов — capital вызывает loan через inline action
Ревью PR#402 (debtpaycnfrm:23): учёт займов должен быть централизован в одном контракте,
как Gateway для платежей. Существующий контракт loan/ уже задеплоен и в whitelist,
но capital его не вызывал — теперь вызывает.

loan-контракт:
— table_loan_debts: добавлено поле project_hash (binary_extension<checksum256> —
  таблица уже в продакшене, расширение схемы только хвостом) + индекс byuserproj
— Loan::create_debt: новая сигнатура с project_hash; createdebt сохраняет привязку к проекту
— Loan::count_user_project_debts: хелпер для лимита займов на проект в паевом взносе

capital:
— debtpaycnfrm: после confirm_paid дублирует запись в loan.debts через inline
  Loan::create_debt (с due_at из обновлённой capital-записи и project_hash)
— settledebt: дополнительно зовёт inline Loan::settle_debt; принимает также OVERDUE
  (не только PAID) — погашение просроченного через председателя
— signact2 (паевой взнос): собирает все PAID/OVERDUE займы пайщика на проекте
  из capital.debts, проверяет лимит 10 (transaction limit EOSIO), на каждый —
  inline Loan::settle_debt + capital.Debts::mark_settled. Никакого «боевого взноса» —
  только паевой по интеллектуальному результату

Boot/deploy не требует правок: loan/ уже в configs/contracts.ts и configs/index.ts,
аккаунт loan создаётся, контракт деплоится. capital уже в contracts_whitelist в lib/consts.

Тесты: добавлен describe «A2 — Централизованный учёт займов» с 6 it.todo
по новым сценариям (createdebt в loan, settle через capital, лимит 10, whitelist).

Полный перенос таблицы debts из capital в loan (с удалением capital.debts) —
отдельная Story 1.12, оставлена на следующую итерацию: текущий PR сохраняет
параллельный учёт в обеих таблицах для безопасной миграции прода.
2026-05-19 08:16:15 +00:00
coopops 4a13cd26c0 fix(capital/roles): review PR#402 — 3 роли, description, явная ставка, inline add author/master
role_requests:
— role строго {creator, author, master}; contributor — L1, в role_requests не попадает
— validate_role_or_fail() — валидация на уровне requestrole/inviterole
— description (std::string, может быть пустой) — текст заявки/приглашения от пайщика к мастеру и наоборот
— apply_role_to_project(): inline addauthor для role=author, setmaster для role=master,
  creator → no-op (фиксируется при первом createcmmt)

Actions:
— requestrole/inviterole/requestrateu: добавлено description в сигнатуру + проброс в RoleRequests::create
— approverole/acceptinvite: после approve/segment.set_approved_rate вызывают
  apply_role_to_project — заявленная роль немедленно отражается в реестре участников
  (раньше creator становился creator только после первого commit'а)
— inviterole: уточнено в комментарии что master-параметр может быть председателем
  при приглашении кандидата на роль master (когда мастера в проекте ещё нет)

Ставка часа всегда подаётся явно (rate_per_hour, hours_per_day) — это ставка пайщика
именно под этот проект; фронт по дефолту подставляет contributors.rate_per_hour,
но пайщик может изменить под конкретный проект. createcmmt берёт approved_rate
сегмента, не глобальную из contributors.
2026-05-19 08:09:08 +00:00
coopops 59a8b9b6ee fix(capital): review PR#402 — binary_extension + auth + лимиты + 86 счёт
— segments.approved_rate_per_hour/hours_per_day → binary_extension<> (прод-таблица)
— global_state.program_expense_pool/reserved → binary_extension<asset> (прод-таблица)
— settledebt: require_auth(coopname), убран has_auth(username) — backend подписывает
— markdebtoverd: batch_limit 100 → 25 (transaction time limit EOSIO 0.5с)
— approverole/inviterole/requestrole/requestrateu: норма часов 12 → 8 (фронт-ограничение)
— debtpayretry: новый action для повторной отправки outpay из AUTHORIZED + last_pay_error
— PAY_EXPENSE: проводка Dr 86 / Cr 51 (TARGET_RECEIPTS/BANK_ACCOUNT) — целевое финансирование, не общехоз 26
— memo.get_settle_debt_memo: "Погашение займа участником" (без "деньгами") — погашение не только наличными
2026-05-19 08:04:50 +00:00
coopops eb720a88ef test(capital): scaffold capital-blagorost.test.ts под новые ветви Эпика 1
Story 1.11 эпика «Благорост» (blago issue 51F-23).

Существующий capital.test.ts ≈2650 строк — раздувать монолит нерационально.
Новые сценарии вынесены в отдельный файл со структурой describe/it.todo() —
каркас под пошаговое наполнение реальными helper'ами по мере появления
backend-обвязки (Эпик 4: processDebtDeclinePay, processSettleDebt,
processMarkOverdue, processProgramExpense, processRequestRole, и т.д.).

Покрытые сценарии (it.todo):
- A: debtpaydcln (×2), settledebt (×3), markdebtoverd (×3) + due_at (×1)
- B: exppaycnfrm проводка, topupprogexp, createpgexp резервирование,
  полный цикл создания/одобрения/авторизации/оплаты, declpgexp на 3 фазах
- D: requestrole/approverole + approved_rate в createcmmt (1200 ≠ 1500),
  editcontrib не трогает approved, inviterole/acceptinvite/declinvite,
  declinerole, requestrateu
- E: require_recipient на всех новых actions (Story 1.10)

Существующие тесты не сломаны (отдельный файл, отдельный describe).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:07:41 +00:00
coopops 58b8654989 feat(capital): require_recipient во все новые actions эпика «Благорост»
Story 1.10 эпика «Благорост» (blago issue 51F-22).

Notification-services (Эпик 6) подписываются на chain через action traces
и триггерят Novu workflows. На каждой ветке переходов требуется явный
require_recipient, чтобы участники, не подписавшие транзакцию, всё равно
получили её в свою цепочку recipient — это и есть «event ridge» из PRD-E.

Покрыто:
- debtpaydcln  → должник + coopname
- settledebt   → должник + coopname
- createpgexp  → creator + coopname
- apprvpgexp   → expense.username + approver
- authpgexp    → expense.username + coopname
- pgexppay     → expense.username + coopname
- declpgexp    → expense.username + coopname
- requestrole  → username + master
- approverole  → req.username + req.master
- declinerole  → req.username + req.master
- inviterole   → candidate + master
- acceptinvite → req.username + req.master
- declinvite   → req.username + req.master
- requestrateu → username + master

markdebtoverd сознательно пропущен — это batch-action (до 100 записей
за вызов), массовая рассылка require_recipient ухудшит RAM-стоимость
транзакции. Novu по этому переходу подписывается на изменение статуса
debt записи через парсер.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:05:58 +00:00
coopops 138d7ab350 chore(capital): чистка debug print() в startvoting + [[deprecated]] на program_invest
Story 1.9 эпика «Благорост» (blago issue 51F-21).

PRD: «удалить отладочные print() в startvoting.cpp; пометить progrinvests
deprecated до 07.2026». Логирующие print() в returntopool.cpp и lvlnotify.cpp
оставлены — они несут смысл, не отладочные маркеры.

Изменения:
- startvoting.cpp: убраны 5 маркеров print("z"/"a"/"b"/"c"/"d")
- program_invests.hpp: атрибут [[deprecated("remove after 2026-07")]] на struct
  program_invest + расширенный doxygen-комментарий с пояснением жизненного цикла

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:04:30 +00:00
coopops b2adb56535 feat(capital/roles): L2-роли — requestrole/approverole/declinerole/inviterole/acceptinvite/declinvite/requestrateu
Story 1.7 эпика «Благорост» (blago issue 51F-19).

PRD-D1/D3/D4: «двухуровневые допуски, мастер компонента валидирует исполнителей
и ставку часа на сегменте». До этого коммита actions L2-цикла в контракте
не существовало; ставки фиксировались только глобально через editcontrib.

Изменения:
- domain/entities/role_requests.hpp: таблица rolerequests, статусы pending/approved/declined,
  направление request/invite, тип role/rateupdate, helpers create/approve/decline
- 7 новых cpp actions в app/participation_management/roles/:
  - requestrole — пайщик просит L2-роль (creator/author/contributor) + ставку
  - approverole — мастер одобряет: фиксация approved-ставки в сегменте через
    Capital::Segments::set_approved_rate (Story 1.8)
  - declinerole — мастер отклоняет с причиной (запись для аудита остаётся)
  - inviterole — мастер инициирует обратный поток, приглашает кандидата
  - acceptinvite — кандидат принимает инвайт → applied к сегменту
  - declinvite — кандидат отказывается (имя укорочено с declineinvite — лимит 12 chars)
  - requestrateu — пайщик просит обновить approved-ставку (тот же flow, RequestType::RATE_UPDATE);
    approverole с новой ставкой → новая approved в сегменте
- name declineinvite сокращена до declinvite (13 → 10 chars, eosio лимит 12)

Авторизация на текущем этапе — require_auth(coopname); backend проверяет
master-роль приглашающего/одобряющего перед отправкой action.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:03:35 +00:00
coopops 1f486ce48a feat(capital/segments): approved_rate_per_hour в сегменте + createcmmt через approved-ставку
Story 1.8 эпика «Благорост» (blago issue 51F-20).

PRD-D2: «approved_rate_per_hour хранится в сегменте; createcmmt использует
approved-ставку». До этого коммита createcmmt брал ставку из глобального
contributors.rate_per_hour — мастер компонента не мог дифференцировать.

Изменения:
- segments.hpp: поля approved_rate_per_hour, approved_hours_per_day (по умолчанию 0
  — fallback на глобальную ставку из contributors)
- segments.hpp::set_approved_rate(coopname, project_hash, username, rate, hours)
  — helper будет вызван из approverole (Story 1.7)
- createcmmt.cpp: если segment.approved_rate_per_hour.amount > 0 — используется
  approved; иначе contributor->rate_per_hour. Меняется и creator_base, и
  delta_amounts (через calculate_fact_generation_amounts)
- editcontrib (без изменения) — теперь не влияет на approved-ставки на
  сегментах: контракт берёт сегментные значения вперёд глобальных

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:00:43 +00:00
coopops 7ca2695121 feat(capital/expense): расходы программы — createpgexp/apprvpgexp/authpgexp/pgexppay/declpgexp + таблица progexpenses
Story 1.5 эпика «Благорост» (blago issue 51F-17).

Полный цикл целевого расхода программы «Благорост» (не привязанного к проекту):
от заявки председателя до подтверждения оплаты gateway. Источник средств —
program_expense_pool (Story 1.6), резерв снимается окончательно в pgexppay.

PRD-B1: «createprogexp/approveprogex/authprogexp/progexppay/declprogexp». Имена
действий сокращены под 12-символьный лимит EOSIO — семантика та же.

Изменения:
- expenses.hpp: struct program_expense + таблица progexpenses (scope coopname)
  + helper'ы create/get/set_program_approved/set_program_authorized/delete
- 5 новых cpp actions в expense_managment/program_expenses/:
  - createpgexp — резервирует в State::reserve_program_expense, создаёт запись
  - apprvpgexp — председатель → soviet::create_agenda с CAPITAL_RESOLVE_PROGRAM_EXPENSE
  - authpgexp — совет → ::Gateway::create_outpay (через Action::send), статус AUTHORIZED
  - pgexppay — gateway callback → consume_program_expense + Ledger2::apply PAY_EXPENSE + delete
  - declpgexp — отказ от coopname/_soviet/_gateway → release_program_expense + delete
- names.hpp: CREATE_PROGRAM_EXPENSE, AUTHORIZE_PROGRAM_EXPENSE, DECLINE_PROGRAM_EXPENSE,
  CONFIRM_PROGRAM_EXPENSE_PAYMENT, SovietActions::CAPITAL_RESOLVE_PROGRAM_EXPENSE

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:58:46 +00:00
coopops bbfc592357 feat(capital/state): program_expense_pool + topupprogexp
Story 1.6 эпика «Благорост» (blago issue 51F-18).

Программные расходы (Story 1.5) списываются из дедикейтнутого пула, а не из
аллокаций проектов. До этого коммита поля для такого пула в global_state не было.

Изменения:
- global_state.program_expense_pool — доступная сумма программы под расходы
- global_state.program_expense_reserved — зарезервированная под approved/authorized
  программные расходы (Story 1.5: createprogexp резервирует, declprogexp возвращает,
  exppaycnfrm списывает окончательно)
- State::topup_program_expense_pool / reserve_program_expense /
  release_program_expense / consume_program_expense — utility-функции для Story 1.5
- topupprogexp(coopname, amount) action — председатель переводит amount из
  global_available_invest_pool в program_expense_pool; деньги остаются на
  ledger2-кошельке BLAGOROST_FUND, меняется только разбивка по назначению

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:55:44 +00:00
coopops 18646f8353 feat(capital/expense): exppaycnfrm — проводка по фонду программы
Story 1.4 эпика «Благорост» (blago issue 51F-16).

Закрывает TODO в exppaycnfrm.cpp:35 — после подтверждения оплаты проектного
расхода списываем сумму с программного фонда «Благорост» в пул хозрасходов
из целевого финансирования.

Изменения:
- processes::capital::EXPENSE (p.cap.expns) — новый process_type для цикла
  целевого расхода (создание → одобрение → авторизация → outpay → подтверждение)
- operations::capital::PAY_EXPENSE (o.cap.expns) — TRANSFER BLAGOROST_FUND
  → SOV_EXPENSES, без Dr/Cr; параллельная бухпроводка Dr 26 / Cr 51 на
  расчётном делается gateway и здесь не дублируется
- запись 20 в OPERATION_REGISTRY (lib/core/ledger2/operations.hpp)
- get_expense_pay_memo() в Capital::Memo
- exppaycnfrm.cpp теперь после complete_expense вызывает Ledger2::apply
  до delete_expense (нужны expense.amount/username для проводки)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:54:00 +00:00
coopops e932260847 feat(capital/debt): markdebtoverd + due_at в debtpaycnfrm — статус просрочки
Story 1.3 эпика «Благорост» (blago issue 51F-15).

PRD-A3, FR-A11: «срок погашения 3 месяца, дальше — overdue, без блокировки операций».
Поле due_at и статус OVERDUE добавлены, helper sweep_overdue вычитан в action.

Изменения:
- статус OVERDUE — выставляется markdebtoverd для долгов с now > due_at
- поле debt.due_at (time_point_sec) — на 3 месяца (DEFAULT_DEBT_TERM_SECONDS = 90 дней)
  от даты подтверждения выплаты gateway
- helper debts.hpp::confirm_paid — атомарный переход pay_pending → paid с
  проставлением due_at и сбросом last_pay_error
- helper debts.hpp::sweep_overdue(coopname, batch_limit) — итерация по debts
  в scope, перевод paid→overdue для просроченных, не более batch_limit записей
- markdebtoverd(coopname) action — backend-cron вызовет раз в сутки (Эпик 4),
  batch=100 чтобы уложиться в 30s транзакции; повтор до 0 изменений
- mark_settled расширен: SETTLED доступен также из OVERDUE
- debtpaycnfrm теперь использует confirm_paid (раньше update_debt_status +
  только PAID, без due_at)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:51:13 +00:00
coopops b42394bfb4 feat(capital/debt): settledebt — прямое погашение займа деньгами
Story 1.2 эпика «Благорост» (blago issue 51F-14).

settledebt был stub'ом. Реализация по PRD-A3: должник или председатель
вносит сумму, ровно равную остатку долга — заём закрывается.

Изменения:
- сигнатура: settledebt(coopname, debt_hash, amount, statement) — раньше
  принимался username, но идентифицировать долг надо по hash (у одного
  пайщика может быть несколько активных)
- статус SETTLED — финальное состояние погашенного долга (как через
  деньги settledebt, так и через результат signact2)
- helper debts.hpp::mark_settled — атомарный переход paid → settled с
  пометкой даты погашения и memo
- get_settle_debt_memo() — описание проводки в ledger2
- авторизация: has_auth(debt.username) || has_auth(coopname) — раньше
  было только coopname, что мешало пайщику самому погасить долг
- проводка REPAY (Dr 80 / Cr 58) на полную сумму долга
- contributors.decrease_debt_amount на ту же сумму

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:48:42 +00:00
coopops ff8f60e4b2 feat(capital/debt): debtpaydcln + статус PAY_PENDING — откат outpay без удаления долга
Story 1.1 эпика «Благорост» (blago issue 51F-13).

PRD-A3 говорит «отказ оплаты по реквизитам не приводит к удалению долга».
До правки debtpaydcln был stub'ом, статусы не различали «в полёте до gateway»
и «авторизован, ждёт повторной отправки».

Изменения в lifecycle долга:
- статус PAY_PENDING — долг отдан в gateway на outpay
- поле last_pay_error — текст ошибки от gateway (пусто, пока нет отказов)
- утилиты debts.hpp: start_pay() (approved/authorized → PAY_PENDING + сохранение
  решения совета + сброс last_pay_error) и mark_pay_declined() (PAY_PENDING →
  AUTHORIZED + last_pay_error)

Касается actions:
- debtauthcnfr теперь через start_pay() переводит долг в PAY_PENDING и
  параллельно фиксирует document решения совета как authorization
- debtpaycnfrm требует PAY_PENDING (а не AUTHORIZED) перед PAID
- debtpaydcln вызывает mark_pay_declined() — повторная outpay из AUTHORIZED
  доступна без новой авторизации совета

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 16:46:11 +00:00
Alex Ant abcd34f23d fix(capital): revert ошибочной замены GenerationConvertStatement → MoneyInvestStatement (#394) (#396)
PR #394 «починил» TS-ошибки coopback заменой типа `Cooperative.Registry.GenerationConvertStatement`
на `GenerationMoneyInvestStatement` в 3 файлах controller'а. Это семантически неверно:

- 1080 GenerationConvertStatement — заявление о трансляции паевого взноса
  (поля: project_hash, main_wallet_amount, blagorost_wallet_amount, to_wallet, to_blagorost, appendix_hash)
- 1020 GenerationMoneyInvestStatement — заявление о денежном паевом взносе по программе Генерация
  (совсем другой набор полей)

DTO BaseGenerationConvertStatementMetaDocumentInputDTO декларирует поля 1080, но `implements ExcludeCommonProps<action>`
где action = тип 1020 → TS2352 на as-cast в interactor, потому что Action'ы не пересекаются по полям.

Реальная причина исходных ошибок coopback после #392 — несвежий dist `@coopenomics/cooptypes`
на dev-узле (старое имя символа). Лечится пересборкой пакета, не переименованием ссылок.

Изменено (откат #394):
- generation-convert-statement-document.dto.ts:12 — `action = ...GenerationConvertStatement.Action`
- distribution-management.service.ts:56 — `registry_id: ...GenerationConvertStatement.registry_id`
- distribution-management.interactor.ts:48,66 — `Promise<...GenerationConvertStatement.Action>`

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:00:45 +05:00
Alex Ant 83cac58fca fix(controller): IS_UNIONED zod-парсер принимает string из .env (#395)
z.boolean().default(true) валится для переменной из .env, потому что
process.env всегда отдаёт строку: zod не приводит "true"/"false" к
boolean, в результате validateEnv падает с «IS_UNIONED: параметр не
установлен» и coopback не стартует, если в .env стоит IS_UNIONED=false
(стандартный dev-обход messenger-гейта, см. flow подключения партнёра).

Заменено на string().default('true').transform(v => v === 'true') —
сохранение прежнего default=true и поддержка string-форм из env.

Co-authored-by: coopops <coopos@coopenomics.world>
2026-05-18 12:40:57 +05:00
Alex Ant dbf46d88d0 fix(capital): подтянуть 3 ссылки на GenerationConvertStatement → MoneyInvestStatement (#394)
PR #392 переименовал Cooperative.Registry.GenerationConvertStatement в
GenerationMoneyInvestStatement в @coopenomics/document, но в controller
осталось 3 несинхронизированные ссылки:

- distribution-management.service.ts:56 — registry_id метода generation
- distribution-management.interactor.ts:48,66 — тип Action в return и as-cast
- generation-convert-statement-document.dto.ts:12 — type action в DTO

Coopback падал на ts-node compile (TS2551/2724), что блокировало старт
всего dev-stack после reboot dev-chain 2026-05-18.

Co-authored-by: coopops <coopos@coopenomics.world>
2026-05-18 12:29:32 +05:00
Alex Ant a0cb930b77 refactor(capital/convert): унифицировать шаблон 1080, убрать 1081/1082 (#392)
* refactor(capital/convert): унифицировать 1080 как универсальное заявление о конвертации, убрать 1081/1082

Шаблон 1080 (GenerationConvertStatement) уже технически универсален — содержит
обе суммы (main_wallet_amount/blagorost_wallet_amount) и условные блоки в
context. Шаблоны 1081 (GenerationToProjectConvertStatement) и 1082
(GenerationToCapitalizationConvertStatement) были недоделанными заглушками
без полей и нигде не подключены в UI.

Изменения:
- cooptypes: 1080 переименован GenerationToMainWalletConvertStatement →
  GenerationConvertStatement; title/description нейтральные. 1081/1082 удалены.
- factory: Template + Action 1080 переименованы; в Action добавлено
  super.formatAsset(...) для обеих сумм (как в Action 1020). 1081/1082 удалены.
- controller: DTO переименован; appendix_hash убран из generate-input и
  перенесён в signed-meta-input; добавлен enrich appendix_hash через
  AppendixRepository.findConfirmedByUsernameAndProjectHash в
  DistributionManagementInteractor.prepareGenerationConvertStatementData
  (по образцу InvestsManagementInteractor для 1020). Резолвер мутации
  переименован в capitalGenerateGenerationConvertStatement; убраны два
  резолвера 1081/1082. mutation-log-mapper обновлён.
- sdk: мутация переименована, две удалены, zeus regenerated.
- desktop: Distribution-фичи 1081/1082 удалены, 1080-фича переименована.
  ConvertSegment теперь шлёт project_hash + обе суммы (formatToEosioAsset) +
  to_wallet/to_blagorost; appendix_hash подтягивается на бекенде.
- controller schema.gql regenerated.

registry_id=1080 не меняется — on-chain контракт convertsegm не сверяет
registry_id, миграций БД/контракта не требуется.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(capital/convert): применить ревью — title «трансляция паевого взноса из программы Генерация»

По комментарию ревью в PR #392 (строка 37): принять доменный термин
«трансляция паевого взноса» (перенос между программами) вместо
«конвертация»; description согласован в том же стиле.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:29:12 +05:00
Alex Ant 4a4145c4ed feat(controller): is_server_init флаг в initSystem для provider-overwrite (#391)
* feat(docs-harness): onboarding 01..06 — визуальная цепочка регистрация → активация

Шесть сценариев visual docs-harness, покрывающих полный путь подключения
нового кооператива через провайдера Восход:

  01-register-coop                 — регистрация кооператива-клиента
  02-sign-and-submit               — подпись заявления о вступлении + PayInitial
  03-operator-approve              — chairman принимает заявку в реестре одобрений
  04-sign-connection-agreement     — Партнёр-1 видит ConnectionAgreementStepper
                                     (на текущем стенде получаем заглушку
                                     /signup, пока пайщик не принят)
  05-activate-from-registry        — оператор открывает карточку инстанса
                                     в provider-frontend, выбирает preset
  06-wait-instance-active          — pending → ACTIVE (overrideInstance
                                     для имитации финального статуса в шоте)

Обвязка:
  • lib/registrator-signup.mjs — переиспользуемый helper подписания.
  • lib/harness.mjs — расширенный dismissOnboardingDialogs (Положение ЦПП
    и связанные модалки chairman'а).
  • state/cooperatives/{,.gitkeep} — папка для фикстур; partner1.json
    (с приватным wif) игнорируется (.gitignore обновлён).

ОГРАНИЧЕНИЕ: в 06 финальный ACTIVE — это playwright-override JSON, а не
реальный POST /instances/activate с боевым Ansible до testnet300.coopenomics.world.
Реальный E2E (аренда VM на Hostkey + поднятие кооператива на домене)
будет следующим шагом Эпика 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(controller): is_server_init flag в initSystem для разблокировки provider-overwrite

Поле data.is_server_init: boolean (optional) в InitDTO + домейн-интерфейсе.
Если true (вызов от провайдера через server-secret) — coopback ставит
init_by_server=true безусловно, даже если до этого пользователь успел
заполнить визард первым (user-init). Это разблокирует ситуацию, когда
провайдер не успел вызвать initSystem до того как chairman открыл
/install — следующий callInitSystemMutation от провайдера перезапишет
organization_data и пометит её readonly для визарда.

Без флага сохраняется прежняя логика: первая инициализация — серверная,
повторная наследует флаг.

Инцидент 2026-05-18 на partner1: при первой установке provider вообще
не успел/не сходил в callInitSystemMutation, визард пользователя
проинициализировал систему как user-init (init_by_server=false), визард
2-го захода не предзаполнил форму. С этим фиксом следующий вызов
provider'а поднимет флаг и фикстура встанет на место.

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:40:49 +05:00
Alex Ant 00860b3b18 fix commit process
Build bootstrap container / build (push) Failing after 2m52s
2026-05-15 22:21:35 +05:00
Alex Ant 8fe9509e97 fix(capital/time-tracking): личные доли estimate + revert при decline (#387)
* chore(release): publish

* chore(release): publish

* ci: атомарный release.yaml, убрать workflow_run-связку (#366)

build-contracts + build-containers через workflow_run упёрлись в:
(а) default-branch caveat (новая логика не активна, пока не в main),
(б) `${{ github.event.workflow_run.head_sha }}` иногда пуст в YAML-
выражениях — описание см. в шаге Resolve tag, инцидент v2026.5.14
не дёрнул PRODUCTION_WEBHOOK_URL.

Замена — один `release.yaml` на push тэга `v*`: резолвит ветку через
`git branch --contains`, собирает контракты → пушит
`dicoop/contracts:<branch>`, собирает базу + сервисные образы →
пушит `dicoop/<svc>:<tag>`, шлёт webhook. Гонок нет by construction.

`build-contracts.yaml` остаётся только на push веток для CI-
обновления `dicoop/contracts:dev|testnet|main` без релизного тэга.
Триггер `tags: ['*']` и логика резолва ветки через --contains
оттуда удалены — это теперь забота release.yaml.

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* chore(release): publish

* chore(release): publish

* ci: tag-only триггеры для build-contracts и docs (#367)

build-contracts.yaml — только workflow_dispatch (ручная пересборка
`dicoop/contracts:<branch>` для отладки на dev-ноде). Тэги обрабатывает
release.yaml атомарно (контракты + контейнеры + webhook), отдельная
сборка по push'у в ветку только давала вторую параллельную сборку.

publish-docs.yaml и build-contracts-docs.yaml — на push:tags v* с
gate-job'ом, пропускающим только продакшн-тэги (без -alpha/-beta/-rc/
-test) на main. Раньше docs пересобирались на каждый push в
main/testnet/dev/reports/marketplace2 — впустую.

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix(capital/time-tracking): личные доли estimate, partial-split, revert при decline

Три бага в распределении билетов времени, вскрытые на прод-инциденте voskhod
(проект CC7-1 «Концепция», estimate=15 ч, 3 creators):

БАГ #1 — recalcDoneEstimatesForContributorProject раздавал «общий остаток пула»
(estimate − committed_total) / N всем creators поровну. Закоммитивший свою долю
получал её ещё раз, остальные — урезанную (15/3=5 → после committed 5 у одного
становилось 10/3=3.33 у каждого, включая того кто уже закоммитил).

Фикс: личная доля = estimate/N − собственный committed estimate. Введён общий
helper redistributeIssueEstimateEntries, который используют и applyExplicit-
EstimateToTimeEntries (force=true), и recalcDoneEstimates (force=false с
no-op оптимизацией если раскладка уже совпадает с планом).

БАГ #2 — commitTime при partial split (entry.hours > requested) создавал новую
committed-запись без entry_type и estimate_snapshot. По default'у БД сохраняла
её как entry_type='hourly', что ломало последующий recalc (он фильтрует только
entry_type='estimate'). Фикс: явно копировать entry_type и estimate_snapshot
из оригинального entry.

БАГ #3 — declineCommit / handleDeclineCommit меняли только commit.status в БД,
но не возвращали time-entries в is_committed=false. После отказа мастера часы
оставались в total_committed_hours и не возвращались в available_hours. Фикс:
новый метод revertEntriesForDeclinedCommit в TimeTrackingInteractor + методы
findCommittedByCommitHash / revertCommittedEntriesByCommitHash в TimeEntry-
Repository. После revert делается force=true redistribute для затронутых DONE
задач, чтобы доли вернулись в норму.

Покрытие тестами: 17 unit-тестов в time-tracking.interactor.spec.ts с
регрессионными сценариями под каждый из трёх багов плюс integration-сценарий
полного lifecycle CC7-1 (estimate → коммит → decline → revert).

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:02:36 +05:00
coopops 848566bd69 fix(file-storage): MinIO стартует только при заданном MINIO_ENDPOINT
Trigger Contracts Docs Deploy / trigger-coopenomics (push) Failing after 11s
Publish Docs / build-and-publish-docs (push) Failing after 18m20s
Раньше MINIO_ENDPOINT имел default http://minio:9000, и на проде без
minio-контейнера контроллер падал на bootstrap в HeadBucket с
getaddrinfo ENOTFOUND minio — Nest application не поднимался вообще.

Теперь MINIO_ENDPOINT optional без default; адаптер хранит enabled-флаг
по наличию endpoint и при отсутствии — onApplicationBootstrap логирует
warning и возвращается без сетевых вызовов. Любая попытка getBucket /
fetchObjectForReadProxy кидает InterFileStorageBackendUnavailableError
с понятным сообщением.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:30:48 +00:00
coopops aeb4efe2b6 fix(capital/commit): nullable description/meta в CommitOutputDTO + не дефолтить satisfaction в 5
Если syncCommit не дождался delta из блокчейна, interactor возвращает DB-only
entity, где description/meta остаются undefined — non-nullable GraphQL field
ломал ответ мутации capitalCreateCommit. Сделал оба поля nullable.

UI CreateCommitButton.vue: satisfaction_stars=0 по умолчанию, label "не указано"
пока пользователь не выбрал; блок contribution_feedback в payload только если
stars >= 1 или review_text непустой.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:43:12 +00:00
138 changed files with 4774 additions and 1685 deletions
-223
View File
@@ -1,223 +0,0 @@
name: Build Docker Images
# Триггерится по успешному завершению `Build contracts container`. Это
# гарантирует, что к моменту webhook'а на тестнет/прод образ
# `dicoop/contracts:<branch>` уже обновлён в DockerHub.
#
# Раньше workflow висел на `push: tags`, и при релизе (`chore(release):
# publish` + git tag) запускался параллельно с build-contracts. Поскольку
# build-containers (~8 мин) короче build-contracts (~11 мин), webhook
# отлетал раньше окончания build-contracts на 2-3 минуты — ансибл на
# тестнете подтягивал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
# старым wasm. Инцидент 2026-05-13 (ledger2 фикс walletop sweep'а
# откатился ансиблом).
#
# Теперь: build-contracts триггерится и по push веток, и по push тэгов
# (см. tags в build-contracts.yaml). После его success этот workflow
# резолвит — есть ли тэг на коммите, и если есть — собирает контейнеры
# и шлёт webhook. Если тэга нет (обычный push в ветку без релиза) —
# делаем no-op.
on:
workflow_run:
workflows: ["Build contracts container"]
types: [completed]
# Ручной fallback: запустить сборку контейнеров и деплой для конкретного
# тэга, если автоматический workflow_run не сработал (типичный кейс —
# миграционное окно: на default-ветке (main) старая версия build-containers
# с `push: tags`, а на dev/testnet уже новая с `workflow_run`. До мержа
# в main новая логика не активна).
workflow_dispatch:
inputs:
tag:
description: 'Git-тэг для сборки и деплоя (например v2026.5.13-alpha-3)'
required: true
jobs:
build:
runs-on: ubuntu-latest
# Job запускается, если: (а) workflow_run от build-contracts успешен,
# либо (б) ручной запуск через workflow_dispatch.
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout repository at triggering SHA
uses: actions/checkout@v3
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Resolve tag for triggering commit
id: resolve_tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG_NAME="${{ github.event.inputs.tag }}"
HEAD_SHA=$(git rev-parse "$TAG_NAME^{commit}")
echo "Ручной запуск: tag=$TAG_NAME sha=$HEAD_SHA"
else
HEAD_SHA="${{ env.HEAD_SHA }}"
# `git describe --exact-match` отдаёт имя тэга, лежащего ровно
# на этом коммите; код 128 если тэга нет — тогда выходим без
# сборки контейнеров (push в ветку без релизного тэга).
TAG_NAME=$(git describe --tags --exact-match "$HEAD_SHA" 2>/dev/null || true)
if [ -z "$TAG_NAME" ]; then
echo "На коммите $HEAD_SHA тэга нет — релиза не было, контейнеры не собираем."
echo "has_tag=false" >> $GITHUB_OUTPUT
exit 0
fi
fi
echo "Найден тэг: $TAG_NAME"
echo "has_tag=true" >> $GITHUB_OUTPUT
echo "DOCKER_TAG=$TAG_NAME" >> $GITHUB_ENV
echo "HEAD_SHA=$HEAD_SHA" >> $GITHUB_ENV
# Проверяем, является ли тег продакшн-тегом (не содержит alpha, beta, rc и т.д.)
if [[ ! $TAG_NAME =~ -(alpha|beta|rc|test) ]]; then
echo "IS_PRODUCTION_TAG=true" >> $GITHUB_ENV
echo "Это продакшн тег, будем добавлять latest"
else
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
echo "Это не продакшн тег, latest не добавляем"
fi
- name: Debug info
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "Триггер: ${{ github.event_name }}"
echo "Триггерящий коммит: ${{ env.HEAD_SHA }}"
echo "Резолвенный тэг: ${{ env.DOCKER_TAG }}"
echo "Последние коммиты:"
git log -n 3 --oneline
echo "Проверяем файлы в директории components/desktop/src-ssr:"
ls -la components/desktop/src-ssr/ || echo "Директория не найдена!"
echo "Проверяем файлы в middlewares:"
ls -la components/desktop/src-ssr/middlewares/ || echo "Директория middlewares не найдена!"
- name: Login to DockerHub
if: steps.resolve_tag.outputs.has_tag == 'true'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Сначала собираем базовый образ с runtime
- name: Build base image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
docker build --target runtime -t dicoop/mono-base:${{ env.DOCKER_TAG }} .
docker push dicoop/mono-base:${{ env.DOCKER_TAG }}
# Если это продакшн тег, добавляем latest
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/mono-base:${{ env.DOCKER_TAG }} dicoop/mono-base:latest
docker push dicoop/mono-base:latest
fi
# Создаем сервисные образы на основе базового
- name: Build desktop image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.desktop
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/desktop\", \"run\", \"start\"]" >> Dockerfile.desktop
docker build -t dicoop/desktop:${{ env.DOCKER_TAG }} -f Dockerfile.desktop .
docker push dicoop/desktop:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/desktop:${{ env.DOCKER_TAG }} dicoop/desktop:latest
docker push dicoop/desktop:latest
fi
- name: Build controller image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.coopback
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/controller\", \"run\", \"start\"]" >> Dockerfile.coopback
docker build -t dicoop/coopback:${{ env.DOCKER_TAG }} -f Dockerfile.coopback .
docker push dicoop/coopback:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/coopback:${{ env.DOCKER_TAG }} dicoop/coopback:latest
docker push dicoop/coopback:latest
fi
# TEMP: парсер используется существующим деплоем как dicoop/cooparser,
# пока не мигрировали на dicoop/parser из отдельного coopenomics/parser repo.
# Оставляем сборку и пуш образа до завершения миграции потребителей.
- name: Build parser image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.cooparser
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/parser\", \"run\", \"start\"]" >> Dockerfile.cooparser
docker build -t dicoop/cooparser:${{ env.DOCKER_TAG }} -f Dockerfile.cooparser .
docker push dicoop/cooparser:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/cooparser:${{ env.DOCKER_TAG }} dicoop/cooparser:latest
docker push dicoop/cooparser:latest
fi
- name: Build notificator image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notificator
echo "CMD [\"pnpm\", \"-F\", \"coop-notificator\", \"run\", \"start\"]" >> Dockerfile.notificator
docker build -t dicoop/notificator:${{ env.DOCKER_TAG }} -f Dockerfile.notificator .
docker push dicoop/notificator:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/notificator:${{ env.DOCKER_TAG }} dicoop/notificator:latest
docker push dicoop/notificator:latest
fi
- name: Build notifications image
if: steps.resolve_tag.outputs.has_tag == 'true'
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notifications
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/notifications\", \"run\", \"sync\"]" >> Dockerfile.notifications
docker build -t dicoop/notifications:${{ env.DOCKER_TAG }} -f Dockerfile.notifications .
docker push dicoop/notifications:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/notifications:${{ env.DOCKER_TAG }} dicoop/notifications:latest
docker push dicoop/notifications:latest
fi
# Отправка хука для деплоя. К этому моменту build-contracts.yaml
# уже завершился успешно (мы и есть его workflow_run-зависимый
# потребитель), значит образ `dicoop/contracts:<branch>` гарантированно
# обновлён в DockerHub — гонки 2026-05-13 больше не воспроизвести.
- name: Trigger deployment webhook
if: success() && steps.resolve_tag.outputs.has_tag == 'true'
run: |
if [[ "${{ env.DOCKER_TAG }}" == *alpha* ]]; then
# Хук для тестнета (alpha теги)
curl -X POST ${{ vars.TESTNET_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '${{ env.DOCKER_TAG }}'
else
# Хук для продакшена (остальные теги)
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '${{ env.DOCKER_TAG }}'
fi
# Уведомление в Telegram об успехе
- name: Telegram notify success
if: success() && steps.resolve_tag.outputs.has_tag == 'true'
run: |
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
ADDITIONAL_INFO=" (с тегом latest)"
else
ADDITIONAL_INFO=""
fi
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="✅ [GITHUB MONO] Успешная сборка контейнеров: $GITHUB_REPOSITORY (sha=${{ env.HEAD_SHA }}) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
# Уведомление в Telegram об ошибке (только если до этого был
# резолвен тэг — иначе no-op запуск без сборки и без алертов).
- name: Telegram notify failure
if: failure() && steps.resolve_tag.outputs.has_tag == 'true'
run: |
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY (sha=${{ env.HEAD_SHA }}) [${{ env.DOCKER_TAG }}]"
+33 -3
View File
@@ -1,18 +1,48 @@
# .github/workflows/trigger-coopenomics.yml
name: Trigger Contracts Docs Deploy
# Триггерит rebuild docs в coopenomics/coopenomics только по продакшн-
# тэгам на main. Раньше дёргался на каждый push в dev/testnet/main/capital,
# теперь — только формальный релиз (без -alpha/-beta/-rc/-test).
on:
push:
branches: [dev, testnet, main, capital] # или когда нужно триггерить
tags: ['v*']
jobs:
gate:
runs-on: ubuntu-latest
outputs:
should_trigger: ${{ steps.check.outputs.should_trigger }}
steps:
- name: Checkout (для git branch --contains)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gate — main + non-alpha
id: check
run: |
TAG="${{ github.ref_name }}"
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
echo "Тэг $TAG — pre-release, docs deploy не дёргаем"
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
echo "Тэг $TAG не на main — docs deploy не дёргаем"
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Тэг $TAG — продакшн релиз на main, дёргаем coopenomics"
echo "should_trigger=true" >> "$GITHUB_OUTPUT"
trigger-coopenomics:
needs: gate
if: needs.gate.outputs.should_trigger == 'true'
runs-on: ubuntu-latest
steps:
- name: Trigger Coopenomics deployment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.COOPENOMICS_PAT }}
repository: coopenomics/coopenomics # укажи правильный owner/repo
repository: coopenomics/coopenomics
event-type: deploy_from_mono
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
+25 -56
View File
@@ -7,12 +7,18 @@ name: Build contracts container
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
# контракты через `cleos set contract`.
#
# Триггер — push в одну из веток-окружений (dev / testnet / main).
# Маппинг:
# Триггер — push в одну из веток-окружений (dev / testnet / main) с
# изменением .cpp/CMakeLists/build-скриптов. Маппинг:
# dev → IS_TESTNET=ON, tag=dev
# testnet → IS_TESTNET=ON, tag=testnet
# main → IS_TESTNET=OFF, tag=main + latest
#
# ВАЖНО: тэги `v*` тут НЕ обрабатываются — они уезжают в `release.yaml`,
# который делает релиз атомарно (контракты + контейнеры + webhook в
# одном job'е). См. release.yaml для контекста — там в комментарии
# описана причина (гонка build-contracts и build-containers по push'у
# тэга, инцидент 2026-05-13 с ledger2 walletop sweep'ом).
#
# Сам compile идёт через CDT-образ `dicoop/blockchain_v5.1.1:dev`
# (тот же, что использует `components/contracts/build.sh|build-all.sh`).
# Артефакты копируются в slim alpine-образ.
@@ -22,24 +28,12 @@ name: Build contracts container
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
on:
push:
branches: [dev, testnet, main]
paths:
- 'components/contracts/cpp/**'
- 'components/contracts/CMakeLists.txt'
- 'components/contracts/build.sh'
- 'components/contracts/build-all.sh'
- 'components/contracts/docker/**'
- '.github/workflows/build-contracts.yaml'
# Дополнительно ловим все push'и тэгов: build-containers.yaml зависит от
# успеха этой сборки через workflow_run, и без unconditional запуска по
# тегу build-containers не получит триггер для коммитов, где .cpp не
# менялся (например `chore(release): publish` правит только версии
# пакетов). См. историю гонки 2026-05-13: webhook деплоя сработал на
# 2:34 быстрее обновления `dicoop/contracts:testnet`, на чейн уехал
# старый wasm.
tags:
- '*'
# Только ручной запуск. Тэги (релизы) обрабатывает release.yaml,
# который собирает контракты атомарно вместе с контейнерами и
# webhook'ом — отдельная сборка по push'у в ветку только мешает
# (две параллельные сборки на каждый release-bump). Здесь оставляем
# workflow_dispatch для ручной пересборки `dicoop/contracts:<branch>`
# (например, чтобы прокатить wasm на dev-ноду без релизного тэга).
workflow_dispatch:
jobs:
@@ -48,55 +42,30 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
# Полная история нужна, чтобы при push'е тэга резолвить ветку
# через `git branch -r --contains $SHA` (см. следующий step).
fetch-depth: 0
- name: Determine build mode and docker tag
# Источник режима — ВЕТКА, не имя тэга. Сначала пытаемся взять
# `github.ref_name` как ветку (push веток); если не подошло — это
# push тэга, и мы ищем какая из dev/testnet/main содержит коммит.
# Тэг такого окружения нести семантику не должен (например,
# `v2026.5.13-alpha-2` → ветка testnet, BUILD_MODE=test).
run: |
REF="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
BRANCH=""
if [ "$REF_TYPE" = "branch" ]; then
BRANCH="$REF"
else
SHA="${{ github.sha }}"
for candidate in main testnet dev; do
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
BRANCH="$candidate"
break
fi
done
fi
case "$BRANCH" in
case "${{ github.ref_name }}" in
main)
echo "BUILD_MODE=prod" >> $GITHUB_ENV
echo "DOCKER_TAG=main" >> $GITHUB_ENV
echo "EXTRA_TAG=latest" >> $GITHUB_ENV
echo "BUILD_MODE=prod" >> "$GITHUB_ENV"
echo "DOCKER_TAG=main" >> "$GITHUB_ENV"
echo "EXTRA_TAG=latest" >> "$GITHUB_ENV"
;;
testnet)
echo "BUILD_MODE=test" >> $GITHUB_ENV
echo "DOCKER_TAG=testnet" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
echo "DOCKER_TAG=testnet" >> "$GITHUB_ENV"
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
;;
dev)
echo "BUILD_MODE=test" >> $GITHUB_ENV
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
echo "DOCKER_TAG=dev" >> "$GITHUB_ENV"
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
;;
*)
echo "Unsupported ref: ref_name=$REF ref_type=$REF_TYPE resolved_branch='$BRANCH'" >&2
echo "Unsupported branch ${{ github.ref_name }}" >&2
exit 1
;;
esac
echo "Resolved BRANCH=$BRANCH (ref_name=$REF ref_type=$REF_TYPE)"
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
+32 -6
View File
@@ -1,16 +1,42 @@
name: Publish Docs
# Публикация только по продакшн-тэгам, лежащим на main (без -alpha/-beta/-rc/-test).
# Раньше триггерилось на каждый push в main/testnet/dev/reports/marketplace2 →
# docs пересобирались по 5+ раз в день впустую. Теперь — только релиз.
on:
push:
branches:
- main
- testnet
- dev
- reports
- marketplace2
tags: ['v*']
jobs:
gate:
runs-on: ubuntu-latest
outputs:
should_publish: ${{ steps.check.outputs.should_publish }}
steps:
- name: Checkout (для git branch --contains)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gate — main + non-alpha
id: check
run: |
TAG="${{ github.ref_name }}"
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
echo "Тэг $TAG — pre-release, docs не публикуем"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
echo "Тэг $TAG не на main — docs не публикуем"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Тэг $TAG — продакшн релиз на main, публикуем docs"
echo "should_publish=true" >> "$GITHUB_OUTPUT"
build-and-publish-docs:
needs: gate
if: needs.gate.outputs.should_publish == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
+276
View File
@@ -0,0 +1,276 @@
name: Release
# Атомарный релизный workflow. Триггерится исключительно push'ем тэга
# `v*`. В одном job'е по порядку:
# 1) собирает контракты под mode (prod/test) и пушит `dicoop/contracts:<branch>`,
# 2) собирает базовый образ + сервисные контейнеры и пушит `dicoop/<svc>:<tag>`,
# 3) шлёт webhook деплоя (testnet/production по типу тэга).
#
# Зачем атомарно: раньше `build-contracts` и `build-containers`
# крутились параллельно по push'у тэга, и webhook от build-containers
# (~8 мин) обгонял окончание build-contracts (~11 мин) → ансибл на
# тестнете подхватывал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
# старым wasm. Попытка связать их через workflow_run упёрлась в
# default-branch caveat и потерю head_sha в YAML-выражениях. Теперь
# нет двух workflow'ов — нет гонки, нет fallback'ов.
#
# Триггер по веткам (dev/testnet/main без тэга) тут НЕ обрабатывается —
# для CI-обновления `dicoop/contracts:<branch>` без релиза остаётся
# `build-contracts.yaml`.
#
# Резолв ветки. Тэг семантики окружения не несёт (например
# `v2026.5.13-alpha-2` может лежать на testnet или dev — определяет
# именно ветка, на которую сделан merge перед тэгом). Поэтому ищем
# первое совпадение SHA с remote-ветками main/testnet/dev.
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve branch, build mode and tags
run: |
TAG_NAME="${{ github.ref_name }}"
SHA="${{ github.sha }}"
BRANCH=""
for candidate in main testnet dev; do
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
BRANCH="$candidate"
break
fi
done
if [ -z "$BRANCH" ]; then
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
exit 1
fi
case "$BRANCH" in
main)
BUILD_MODE=prod
CONTRACTS_TAG=main
CONTRACTS_EXTRA=latest
;;
testnet)
BUILD_MODE=test
CONTRACTS_TAG=testnet
CONTRACTS_EXTRA=
;;
dev)
BUILD_MODE=test
CONTRACTS_TAG=dev
CONTRACTS_EXTRA=
;;
esac
# Прод-тэги (без -alpha/-beta/-rc/-test) уезжают на боевой
# webhook + получают тэг :latest у сервисных образов.
if [[ "$TAG_NAME" =~ -(alpha|beta|rc|test) ]]; then
IS_PROD=false
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
else
IS_PROD=true
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
fi
{
echo "TAG_NAME=$TAG_NAME"
echo "SHA=$SHA"
echo "BRANCH=$BRANCH"
echo "BUILD_MODE=$BUILD_MODE"
echo "CONTRACTS_TAG=$CONTRACTS_TAG"
echo "CONTRACTS_EXTRA=$CONTRACTS_EXTRA"
echo "IS_PROD=$IS_PROD"
echo "WEBHOOK_URL=$WEBHOOK_URL"
} >> "$GITHUB_ENV"
echo "Release $TAG_NAME → branch=$BRANCH, build_mode=$BUILD_MODE, contracts:$CONTRACTS_TAG, prod=$IS_PROD"
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# === Этап 1: контракты ===
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
- name: Compile contracts
working-directory: components/contracts
run: |
./build-all.sh "$BUILD_MODE"
echo "--- build/contracts ---"
ls -la build/contracts/
- name: Stage contracts docker context
working-directory: components/contracts
run: |
ROOT="docker/.context"
rm -rf "$ROOT"
mkdir -p "$ROOT/contracts"
# Имена контрактов = аргументы add_contract_build(...) только в
# незакомменченных строках (CMake-комментарий начинается с `#`).
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
echo "Contracts to package:"
echo "$NAMES"
MANIFEST="$ROOT/contracts/manifest.json"
pack_one() {
local NAME="$1" WASM="$2" ABI="$3"
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
return 1
fi
mkdir -p "$ROOT/contracts/$NAME"
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
"$NAME" "$SHA_WASM" "$SHA_ABI"
}
{
printf '{\n'
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
printf ' "git_sha": "%s",\n' "$SHA"
printf ' "tag": "%s",\n' "$TAG_NAME"
printf ' "branch": "%s",\n' "$BRANCH"
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf ' "contracts": [\n'
FIRST=1
emit() {
entry=$(pack_one "$@") || return 0
[ $FIRST -eq 0 ] && printf ',\n'
FIRST=0
printf '%s' "$entry"
}
for c in $NAMES; do
if [ "$c" = "system" ]; then
# У system многоконтрактная сборка: разносим под-контракты
# eosio.* как полноценные элементы каталога, сам "system"
# как имя пропускаем.
for sub in build/contracts/system/contracts/eosio.*/; do
[ -d "$sub" ] || continue
sub_name=$(basename "$sub")
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
done
else
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
fi
done
printf '\n ]\n'
printf '}\n'
} > "$MANIFEST"
cp docker/Dockerfile "$ROOT/Dockerfile"
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
chmod +x "$ROOT/entrypoint.sh"
echo "--- manifest.json ---"
cat "$MANIFEST"
- name: Build and push contracts image
working-directory: components/contracts
run: |
IMAGE="dicoop/contracts"
SHORT_SHA="${SHA::7}"
docker build \
--label "org.opencontainers.image.revision=$SHA" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "build.mode=$BUILD_MODE" \
-t "$IMAGE:$CONTRACTS_TAG" \
./docker/.context
docker push "$IMAGE:$CONTRACTS_TAG"
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
if [ -n "$CONTRACTS_EXTRA" ]; then
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
docker push "$IMAGE:$CONTRACTS_EXTRA"
fi
- name: Verify pushed contracts image
run: |
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" list
echo "---"
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" sha256
# === Этап 2: контейнеры приложений ===
- name: Build and push base image
run: |
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
docker push "dicoop/mono-base:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
docker push dicoop/mono-base:latest
fi
- name: Build and push service images
run: |
build_service() {
local SVC="$1" PKG="$2" CMD="$3"
local DOCKERFILE="Dockerfile.$SVC"
{
echo "FROM dicoop/mono-base:$TAG_NAME"
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
} > "$DOCKERFILE"
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
docker push "dicoop/$SVC:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
docker push "dicoop/$SVC:latest"
fi
}
build_service desktop '@coopenomics/desktop' start
build_service coopback '@coopenomics/controller' start
# TEMP: cooparser — пока не мигрировали потребителей на dicoop/parser
# из отдельного coopenomics/parser repo.
build_service cooparser '@coopenomics/parser' start
build_service notificator 'coop-notificator' start
build_service notifications '@coopenomics/notifications' sync
# === Этап 3: webhook деплоя ===
- name: Trigger deployment webhook
run: |
curl -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "$TAG_NAME"
- name: Telegram notify success
if: success()
run: |
EXTRA=""
if [ "$IS_PROD" = "true" ]; then
EXTRA=" (с тегом latest)"
fi
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
-d "text=✅ [GITHUB MONO] Релиз $TAG_NAME ($BRANCH, contracts:$CONTRACTS_TAG, sha=${SHA::7})$EXTRA"
- name: Telegram notify failure
if: failure()
run: |
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
-d "text=❌ [GITHUB MONO] Ошибка релиза $TAG_NAME ($BRANCH, sha=${SHA::7})"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
@@ -0,0 +1,174 @@
import { describe, it } from 'vitest'
/**
* Расширение Capital Test под эпик «Благорост» (Story 1.11).
*
* Существующий capital.test.ts (~2650 строк) покрывает базовые happy-paths.
* Новые ветви Story 1.1–1.10 описаны здесь отдельно, чтобы не раздувать
* монолитный файл. it.todo() — каркас под последующее наполнение helpers'ами
* по аналогии с processDebt / processFundProgram / etc.
*
* Helpers, которые потребуются (часть из них появится в Эпиках 2–5):
* - processDebtDeclinePay (gateway → debtpaydcln)
* - processSettleDebt (settledebt деньгами)
* - processMarkOverdue (markdebtoverd batch)
* - processTopupProgramExpense (chairman → topupprogexp)
* - processProgramExpense (createpgexp → apprvpgexp → authpgexp → pgexppay)
* - processDeclineProgramExpense (declpgexp на разных фазах)
* - processRequestRole / processApproveRole / processDeclineRole
* - processInviteRole / processAcceptInvite / processDeclineInvite
* - processRequestRateUpdate
*/
describe('CAPITAL — расширения «Благорост» (Эпик 1)', () => {
describe('A — Займы', () => {
it.todo(
'Story 1.1: debtpaydcln возвращает PAY_PENDING → AUTHORIZED с last_pay_error и не удаляет запись'
)
it.todo(
'Story 1.1: debtpayretry повторно отправляет outpay из AUTHORIZED без новой авторизации совета'
)
it.todo(
'Story 1.1: debtpayretry отвергается если last_pay_error пустой (платёж ещё не пробовали)'
)
it.todo(
'Story 1.2: settledebt на полную сумму → SETTLED, проводка REPAY 30000, contributors.debt_amount уменьшается'
)
it.todo(
'Story 1.2: settledebt доступен и для OVERDUE, не только PAID'
)
it.todo(
'Story 1.2: settledebt отвергается при amount ≠ debt.amount'
)
it.todo(
'Story 1.3: debtpaycnfrm проставляет due_at = now + 1 год (фиксированный срок, решение 2026-05-19)'
)
it.todo(
'Story 1.13: closedebt — компонент cancelled → автоматическое закрытие долга: o.cap.crtnma (Дт 04 / Кт 08, TRANSFER GENERATOR_FUND → NMA, создание НМА из коммитов-обеспечения) + o.cap.dbtwrf (Дт 80 / Кт 58, BURN LOAN_ISSUED, write-off долга); сегмент уменьшен на сумму займа; долг → WRITEOFF; без подписи пайщика и без сбора совета (решение 2026-05-19)'
)
it.todo(
'Story 1.13: closedebt отвергается, если родительский компонент не в статусе cancelled'
)
it.todo(
'Story 1.13: closedebt отвергается, если долг не в статусе paid/overdue'
)
it.todo(
'Story 1.13: closedebt — глобальный contributors.debt_amount пайщика уменьшен; запись в loan.debts удалена; loan.summaries.total опущен'
)
it.todo(
'Story 1.3: markdebtoverd переводит PAID → OVERDUE для долгов с now > due_at; PAID с due_at в будущем не трогает'
)
it.todo(
'Story 1.3: markdebtoverd обрабатывает не более batch_limit (25) записей за вызов'
)
})
describe('A2 — Централизованный учёт займов (loan-контракт + счётчик сегмента)', () => {
it.todo(
'Story 1.12: loan-контракт изолирован — loan.debts хранит только (coopname, username, debt_hash, amount, repaid_at) + source_contract; project_hash отсутствует'
)
it.todo(
'Story 1.12: debtpaycnfrm создаёт запись в loan.debts через inline createdebt и поднимает loan.summaries.total пайщика'
)
it.todo(
'Story 1.12: settledebt в capital удаляет запись из loan.debts через inline settledebt и опускает loan.summaries.total'
)
it.todo(
'Story 1.12: debt в loan.debts помечается source_contract = вызвавший контракт (capital для capital→loan)'
)
it.todo(
'Story 1.12: debtpaycnfrm увеличивает active_debts_count на сегменте (binary_extension), settledebt — уменьшает'
)
it.todo(
'Story 1.12: signact2 с N≤10 займами на проекте — все погашаются inline-actions в loan; active_debts_count = 0'
)
it.todo(
'Story 1.12: signact2 с >10 займами на проекте (active_debts_count > 10) — fail сразу по счётчику, без обхода byprojhash'
)
it.todo(
'Story 1.12: loan.createdebt от аккаунта вне contracts_whitelist — fail (только capital/marketplace/...)'
)
})
describe('B — Расходы программы', () => {
it.todo(
'Story 1.4: exppaycnfrm после complete_expense делает Ledger2 PAY_EXPENSE (BLAGOROST_FUND → SOV_EXPENSES)'
)
it.todo(
'Story 1.5/1.6: topupprogexp пополняет program_expense_pool из global_available_invest_pool'
)
it.todo(
'Story 1.5: createpgexp резервирует amount; вторая createpgexp на превышение — fail'
)
it.todo(
'Story 1.5: cycle createpgexp → apprvpgexp → authpgexp → pgexppay = paid + delete + Ledger2 PAY_EXPENSE'
)
it.todo(
'Story 1.5: declpgexp на любом из {created, approved, authorized} возвращает reserved → pool, удаляет запись'
)
})
describe('D — Двухуровневые допуски и approved-ставка (бездокументарная схема)', () => {
it.todo(
'Story 1.7: requestrole без statement создаёт rolerequests со статусом PENDING; approverole без master_decision фиксирует approved_rate'
)
it.todo(
'Story 1.7: requestrole с role вне Role::{CREATOR, AUTHOR, MASTER} — fail "Допустимые роли заявки..."'
)
it.todo(
'Story 1.7: requestrateu создаёт запись с role = Role::NONE и request_type = RequestType::RATE_UPDATE'
)
it.todo(
'Story 1.8: createcmmt с approved_rate=1200 и creator_hours=4 → generation_amounts на 4800, не на 6000 (глобальная)'
)
it.todo(
'Story 1.7: editcontrib с новой глобальной ставкой не меняет approved_rate_per_hour на сегменте'
)
it.todo(
'Story 1.7: inviterole без statement → acceptinvite применяет инвайт-ставку к сегменту; declinvite оставляет сегмент без approved-rate'
)
it.todo(
'Story 1.7: declinerole оставляет запись со статусом DECLINED + reason, сегмент не трогает'
)
it.todo(
'Story 1.7: requestrateu + approverole с новой ставкой обновляет approved_rate в сегменте; role.apply_role_to_project не вызывается'
)
})
describe('E — Event ridges (Story 1.10)', () => {
it.todo(
'Все новые actions вызывают require_recipient для (автор события, обратная сторона, председатель)'
)
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
@@ -0,0 +1,90 @@
/**
* @brief Закрывает долг созданием НМА кооператива из коммитов-обеспечения.
*
* Срабатывает, когда родительский компонент проекта явно перешёл в `cancelled`:
* проект до акта-2 не дойдёт, возврат через `signact2` невозможен.
* Коммиты-обеспечение, накопленные пайщиком на сегменте проекта-компонента
* (`w.cap.gen`), формируют НМА кооператива на отдельном кошельке `w.cap.nma`,
* а обязательство пайщика по займу списывается (write-off). Никаких санкций
* к пайщику не применяется, совет не собирается, подпись пайщика не требуется.
*
* Двухоперационная свёртка ledger2:
* - o.cap.crtnma — TRANSFER w.cap.gen → w.cap.nma, Дт 04 / Кт 08.
* Зеркалит o.cap.accept, но без участия пайщика: НМА из
* коммитов-обеспечения долга идут в имущество кооператива,
* а не на share программы пайщика.
* - o.cap.dbtwrf — BURN w.cap.loan, Дт 80 / Кт 58.
* Списание долга (write-off). Зеркало o.cap.repay, но share
* пайщика не пополняется — долг закрыт имущественно.
*
* Решение 2026-05-19 (Игорь Смуров / Алексей Муравьёв): срок займа фиксированный
* (1 год от выплаты), но при явной отмене компонента закрытие происходит
* досрочно — без ожидания due_at.
*
* @param coopname Наименование кооператива (scope)
* @param debt_hash Хеш закрываемого займа
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация только от @p coopname. Action идёт из backend кооператива,
* отслеживающего переход компонента в `cancelled`.
*/
void capital::closedebt(name coopname, checksum256 debt_hash) {
require_auth(coopname);
// 1. Получить долг
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
// 2. Долг должен быть активным (выплачен, не погашен)
eosio::check(exist_debt.status == Capital::Debts::Status::PAID
|| exist_debt.status == Capital::Debts::Status::OVERDUE,
"Закрытие долга допустимо для активного (paid) или просроченного (overdue) займа");
// 3. Guard на статус родительского компонента: только `cancelled` (отменён).
// Если компонент завершён нормально (`finalized`/`result`) — возврат идёт
// через signact2 → o.cap.repay, не сюда.
auto project = Capital::Projects::get_project_or_fail(coopname, exist_debt.project_hash);
eosio::check(project.status == Capital::Projects::Status::CANCELLED,
"Закрытие долга через НМА допустимо только при cancelled статусе компонента проекта");
// 4. Сегмент пайщика на компоненте
auto exist_segment = Capital::Segments::get_segment_or_fail(coopname,
exist_debt.project_hash, exist_debt.username, "Сегмент пайщика не найден");
// 5. Контрибьютор для обновления глобального долга
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
eosio::check(contributor.has_value(), "Контрибьютор не найден");
auto memo = Capital::Memo::get_debt_memo(exist_debt.username);
// 6. o.cap.crtnma: TRANSFER GENERATOR_FUND → NMA, Дт 04 / Кт 08.
// Из коммитов-обеспечения долга создаётся НМА кооператива.
Ledger2::apply(_capital, coopname, operations::capital::CREATE_NMA,
exist_debt.amount, exist_debt.username, debt_hash, memo);
// 7. o.cap.dbtwrf: BURN LOAN_ISSUED, Дт 80 / Кт 58.
// Финансовое вложение кооператива в пайщика списано (write-off); share пайщика
// не пополняется — долг закрыт имущественно.
Ledger2::apply(_capital, coopname, operations::capital::DEBT_WRITEOFF,
exist_debt.amount, exist_debt.username, debt_hash, memo);
// 8. Обновить агрегаты сегмента: -debt_amount, -active_debts_count.
Capital::Segments::decrease_debt_amount(coopname, exist_segment.id, exist_debt.amount);
Capital::Segments::decrease_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
// 9. Уменьшить used_for_compensation в проекте (зеркало createdebt).
Capital::Projects::subtract_used_for_compensation(coopname, project.id, exist_debt.amount);
// 10. Глобальный долг контрибьютора уменьшаем (как в settledebt).
Capital::Contributors::decrease_debt_amount(coopname, contributor->id, exist_debt.amount);
// 11. Запись долга → WRITEOFF (для аудита).
Capital::Debts::mark_writeoff(coopname, exist_debt.id);
// 12. Централизованный реестр в loan-контракте: уменьшаем сводную, удаляем запись.
Loan::settle_debt(_capital, coopname, exist_debt.username, debt_hash, exist_debt.amount);
// 13. Уведомить пайщика и кооператив.
require_recipient(exist_debt.username);
require_recipient(coopname);
}
@@ -19,11 +19,11 @@ void capital::debtauthcnfr(eosio::name coopname, checksum256 debt_hash, document
// Получаем долг
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
// Обновляем статус долга
Capital::Debts::update_debt_status(coopname, exist_debt.id, Capital::Debts::Status::AUTHORIZED,
_capital, decision);
// Фиксируем решение совета и переводим долг в pay_pending —
// outpay уходит в gateway, дальше debtpaycnfrm (успех) или debtpaydcln (отказ).
Capital::Debts::start_pay(coopname, exist_debt.id, decision, _capital);
// создаём объект исходящего платежа в gateway с коллбэком после обработки
::Gateway::create_outcome(
_capital,
@@ -18,12 +18,11 @@ void capital::debtpaycnfrm(name coopname, checksum256 debt_hash) {
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
eosio::check(contributor.has_value(), "Контрибьютор не найден");
// Проверяем что долг в статусе 'authorized' (готов к выплате)
eosio::check(exist_debt.status == Capital::Debts::Status::AUTHORIZED,
"Долг должен быть в статусе 'authorized' для подтверждения оплаты");
// Обновляем статус долга на PAID
Capital::Debts::update_debt_status(coopname, exist_debt.id, Capital::Debts::Status::PAID, _gateway);
// Переводим долг в PAID + проставляем due_at = now + 1 год (срок погашения,
// решение 2026-05-19 — срок зафиксирован, не привязан к календарному
// сроку проекта).
// confirm_paid внутри проверяет статус pay_pending и атомарно сбрасывает last_pay_error.
Capital::Debts::confirm_paid(coopname, exist_debt.id, _gateway);
// Выдача пайщику беспроцентного займа: Dr 58 / Cr 51, ISSUE LOAN_ISSUED (4051).
// Семантика момента — деньги ушли пайщику, у кооператива появилось финансовое
@@ -34,5 +33,17 @@ void capital::debtpaycnfrm(name coopname, checksum256 debt_hash) {
// Увеличиваем долг contributor (теперь долг активен и должен быть погашен через внесение результата)
Capital::Contributors::increase_debt_amount(coopname, contributor->id, exist_debt.amount);
};
// Локальный счётчик займов пайщика на проекте этого сегмента (+1).
// Используется в signact2 как лимит «10 займов на проект» без обхода byprojhash.
Capital::Segments::increase_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
// Централизованный учёт займа в контракте loan — loan-контракт независим от capital,
// знает только (coopname, username, debt_hash, amount, repaid_at) + source_contract
// (заполняется самим loan::createdebt из payer-авторизации). Локальная привязка к
// проекту/программе остаётся в Capital::Debts (поле project_hash).
// due_at уже проставлен в confirm_paid, читаем обновлённое значение.
auto fresh_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
Loan::create_debt(_capital, coopname, exist_debt.username,
debt_hash, fresh_debt.due_at, exist_debt.amount);
};
@@ -1,23 +1,27 @@
/**
* @brief Отклоняет оплату долга
* Отклоняет оплату долга и удаляет его из базы данных:
* - Получает долг
* - Удаляет долг из базы данных с указанием причины
* @param coopname Наименование кооператива
* @param debt_hash Хеш долга для отклонения оплаты
* @param reason Причина отклонения оплаты долга
* @brief Отклоняет оплату долга по реквизитам (gateway возвращает ошибку перечисления).
*
* Решение совета уже принято в @ref capital::debtauthcnfr, долг НЕ удаляется и НЕ откатывается
* в approved. Запись остаётся, статус возвращается из @c pay_pending в @c authorized
* с заполненным полем @c last_pay_error = @p reason. Из @c authorized доступна повторная
* отправка в gateway без новой авторизации совета.
*
* @param coopname Наименование кооператива
* @param debt_hash Хеш долга, для которого пришёл отказ от gateway
* @param reason Текстовая причина отказа (заполняется gateway-оператором)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от аккаунта: @p _gateway
*/
void capital::debtpaydcln(name coopname, checksum256 debt_hash, std::string reason) {
require_auth(_gateway);
//TODO: отказ в оплате по реквизитам не должен приводить к удалению и откату т.к. решение о займе уже принято
// // Получаем долг
// auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
// // Удаляем долг
// Capital::Debts::delete_debt(coopname, debt_hash);
};
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
Capital::Debts::mark_pay_declined(coopname, exist_debt.id, reason, _capital);
// event ridge: должник и кооператив получают уведомление об отказе оплаты.
require_recipient(exist_debt.username);
require_recipient(coopname);
};
@@ -0,0 +1,40 @@
/**
* @brief Повторная отправка платежа по займу в gateway после предыдущего отказа.
*
* Допустимо только из AUTHORIZED с непустым last_pay_error (то есть после debtpaydcln).
* Использует уже сохранённое решение совета — новой авторизации не требуется.
* Переводит займ в PAY_PENDING, очищает last_pay_error и создаёт новый outpay.
*
* @param coopname Кооператив
* @param debt_hash Хеш займа для повторной отправки
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname (председатель/backend). Пайщик подпись не ставит.
*/
void capital::debtpayretry(eosio::name coopname, checksum256 debt_hash) {
require_auth(coopname);
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
eosio::check(exist_debt.status == Capital::Debts::Status::AUTHORIZED,
"Повторная отправка платежа возможна только из статуса authorized");
eosio::check(!exist_debt.last_pay_error.empty(),
"Повторная отправка платежа возможна только после предыдущего отказа (debtpaydcln)");
Capital::Debts::start_pay(coopname, exist_debt.id, exist_debt.authorization, _capital);
::Gateway::create_outcome(
_capital,
coopname,
exist_debt.username,
exist_debt.debt_hash,
exist_debt.amount,
_capital,
Names::Capital::CONFIRM_DEBT_PAYMENT,
Names::Capital::DECLINE_DEBT
);
// event ridge: должник и председатель видят повторную отправку.
require_recipient(exist_debt.username);
require_recipient(coopname);
}
@@ -0,0 +1,25 @@
/**
* @brief Помечает просроченные займы статусом OVERDUE.
*
* Долг считается просроченным, когда current_time_point > debt.due_at
* (поле due_at проставляется в debtpaycnfrm на 1 год от даты выплаты;
* срок зафиксирован — решение 2026-05-19).
* Из OVERDUE долг можно так же закрыть settledebt или signact2 как из PAID.
*
* Action рассчитан на запуск из backend через @Cron (см. Эпик 4) — раз в сутки.
* Один вызов помечает не более 25 записей чтобы уложиться в 0.5-секундный
* лимит транзакции EOSIO; backend повторяет до тех пор, пока action не
* помечает 0 — это сигнал, что в текущем scope больше нечего переводить.
*
* @param coopname Кооператив (scope таблицы debts)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от аккаунта: @p coopname (председатель / системный).
*/
void capital::markdebtoverd(name coopname) {
require_auth(coopname);
constexpr uint32_t MAX_PER_BATCH = 25;
Capital::Debts::sweep_overdue(coopname, MAX_PER_BATCH);
}
@@ -1,22 +1,57 @@
/**
* @brief Погашает долг участника
* Погашает долг участника в системе кооператива:
* - Проверяет подлинность заявления о погашении
* - Обрабатывает погашение долга (TODO: реализация)
* @param coopname Наименование кооператива
* @param username Наименование пользователя-должника
* @param amount Сумма погашения долга
* @param statement Заявление о погашении долга
* @brief Погашает активный заём пайщика.
*
* Прямой канал погашения (без сдачи результата): сумма, ровно равная остатку долга,
* закрывает заём. В ledger2 фиксируется операция REPAY (Dr 80 / Cr 58),
* глобальный долг пайщика в contributors.debt_amount уменьшается, запись долга
* переходит в SETTLED.
*
* Альтернативный путь закрытия — через подписание акта-2 по результату (см. signact2.cpp),
* там та же операция REPAY вызывается на сумму result->debt_amount.
*
* @param coopname Наименование кооператива
* @param debt_hash Хеш погашаемого долга
* @param amount Сумма погашения (должна совпасть с debt.amount — полное закрытие)
* @param statement Документ-заявление на погашение (SettleDebtStatement)
* @ingroup public_actions
* @ingroup public_capital_actions
* @note Авторизация требуется от аккаунта: @p coopname
*
* @note Авторизация только от @p coopname. Пайщик транзакции не подписывает — backend кооператива
* подставляет себя.
*/
void capital::settledebt(name coopname, name username, eosio::asset amount, document2 statement){
void capital::settledebt(name coopname, checksum256 debt_hash, eosio::asset amount, document2 statement) {
require_auth(coopname);
//TODO: ?
// // Получаем долг
// auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
}
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
eosio::check(exist_debt.status == Capital::Debts::Status::PAID
|| exist_debt.status == Capital::Debts::Status::OVERDUE,
"Погашение допустимо для активного (paid) или просроченного (overdue) займа");
eosio::check(amount.is_valid(), "Сумма погашения некорректна");
eosio::check(amount.symbol == exist_debt.amount.symbol,
"Символ суммы погашения не совпадает с символом долга");
eosio::check(amount == exist_debt.amount,
"Сумма погашения должна полностью покрывать остаток долга");
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
eosio::check(contributor.has_value(), "Контрибьютор не найден");
// Возврат займа пайщиком: Dr 80 / Cr 58 — закрываем финансовое вложение в участника.
auto memo = Capital::Memo::get_settle_debt_memo(exist_debt.username, debt_hash);
Ledger2::apply(_capital, coopname, operations::capital::REPAY, amount,
exist_debt.username, debt_hash, memo);
Capital::Contributors::decrease_debt_amount(coopname, contributor->id, amount);
Capital::Debts::mark_settled(coopname, exist_debt.id, memo, _capital);
// Локальный счётчик займов пайщика на сегменте этого проекта (-1).
Capital::Segments::decrease_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
// Централизованный учёт: уменьшаем сводную и удаляем запись из loan.debts через inline action.
Loan::settle_debt(_capital, coopname, exist_debt.username, debt_hash, amount);
// event ridge: и должник, и председатель видят факт погашения.
require_recipient(exist_debt.username);
require_recipient(coopname);
}
@@ -28,9 +28,16 @@ void capital::exppaycnfrm(eosio::name coopname, checksum256 expense_hash) {
// Обновляем used_expense_pool в проекте
Capital::Projects::complete_expense(coopname, project.id, expense.amount);
// Списываем сумму расхода с программного фонда в пул хозрасходов:
// TRANSFER BLAGOROST_FUND → SOV_EXPENSES (без Dr/Cr — оба под счётом 80).
// Бухгалтерская проводка Dr 26 / Cr 51 на расчётном фиксируется gateway отдельно.
auto memo = Capital::Memo::get_expense_pay_memo(expense_hash);
Ledger2::apply(_capital, coopname,
operations::capital::PAY_EXPENSE,
expense.amount, expense.username,
expense_hash, memo);
// Удаляем запись расхода
Capital::Expenses::delete_expense(coopname, expense.id);
//TODO: здесь должна быть проводка по фонду
}
@@ -0,0 +1,41 @@
/**
* @brief Одобряет программный расход председателем и отправляет в совет.
*
* @param coopname Кооператив
* @param approver Председатель (или доверенный)
* @param expense_hash Хеш расхода
* @param approved_statement Принятая записка (с подписью председателя)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname.
*/
void capital::apprvpgexp(name coopname, name approver, checksum256 expense_hash,
document2 approved_statement) {
require_auth(coopname);
verify_document_or_fail(approved_statement);
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
eosio::check(expense.status == Capital::Expenses::Status::CREATED,
"Программный расход должен быть в статусе 'created'");
Capital::Expenses::set_program_approved(coopname, expense.id, approved_statement);
::Soviet::create_agenda(
_capital,
coopname,
expense.username,
Names::SovietActions::CAPITAL_RESOLVE_PROGRAM_EXPENSE,
expense_hash,
_capital,
Names::Capital::AUTHORIZE_PROGRAM_EXPENSE,
Names::Capital::DECLINE_PROGRAM_EXPENSE,
expense.expense_statement,
std::string("")
);
// event ridge: автор расхода и approver (председатель/доверенный) уведомляются.
require_recipient(expense.username);
require_recipient(approver);
}
@@ -0,0 +1,37 @@
/**
* @brief Авторизует программный расход советом и создаёт исходящий платёж в gateway.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода
* @param authorization Документ-решение совета
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p _soviet.
*/
void capital::authpgexp(name coopname, checksum256 expense_hash, document2 authorization) {
require_auth(_soviet);
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
eosio::check(expense.status == Capital::Expenses::Status::APPROVED,
"Программный расход должен быть в статусе 'approved'");
Capital::Expenses::set_program_authorized(coopname, expense.id, authorization);
Action::send<createoutpay_interface>(
_gateway,
Names::External::CREATE_OUTPAY,
_capital,
coopname,
expense.username,
expense.expense_hash,
expense.amount,
_capital,
Names::Capital::CONFIRM_PROGRAM_EXPENSE_PAYMENT,
Names::Capital::DECLINE_PROGRAM_EXPENSE
);
// event ridge: автор расхода и председатель уведомляются о решении совета.
require_recipient(expense.username);
require_recipient(coopname);
}
@@ -0,0 +1,39 @@
/**
* @brief Создаёт целевой программный расход и резервирует средства в state.
*
* Программный расход — целевое списание программы «Благорост», не привязанное к проекту.
* Источник средств — пул @c program_expense_pool, наполняемый через @ref capital::topupprogexp.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода (анкер процесса p.cap.expns)
* @param creator Инициатор (председатель / казначей; авторизуется через coopname)
* @param amount Сумма
* @param description Описание (для отчётности)
* @param statement Документ-заявление (ProgramExpenseStatement)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname.
*/
void capital::createpgexp(name coopname, checksum256 expense_hash, name creator,
eosio::asset amount, std::string description, document2 statement) {
require_auth(coopname);
verify_document_or_fail(statement);
Wallet::validate_asset(amount);
eosio::check(amount.amount > 0, "Сумма программного расхода должна быть положительной");
auto exist = Capital::get_program_expense(coopname, expense_hash);
eosio::check(!exist.has_value(), "Программный расход с указанным хэшем уже существует");
// Резервируем средства в пуле программных расходов сразу при создании,
// чтобы две параллельные заявки не превысили доступный пул.
Capital::State::reserve_program_expense(coopname, amount);
Capital::Expenses::create_program_expense(coopname, expense_hash, creator,
amount, description, statement);
// event ridge: создатель расхода и председатель видят появление заявки.
require_recipient(creator);
require_recipient(coopname);
}
@@ -0,0 +1,37 @@
/**
* @brief Отклоняет программный расход на любом этапе до подтверждения оплаты.
*
* Допустимо из created (отказ председателя), approved (отказ совета через callback)
* и authorized (отказ gateway по реквизитам). Возвращает зарезервированную сумму
* обратно в program_expense_pool и удаляет запись.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода
* @param reason Причина отказа
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname (если отказ инициирован председателем),
* @p _soviet (если совет отказал) или @p _gateway (если выпуск платежа провалился).
*/
void capital::declpgexp(name coopname, checksum256 expense_hash, std::string reason) {
eosio::check(
eosio::has_auth(coopname) || eosio::has_auth(_soviet) || eosio::has_auth(_gateway),
"Отклонить программный расход может только председатель, совет или gateway"
);
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
eosio::check(
expense.status == Capital::Expenses::Status::CREATED ||
expense.status == Capital::Expenses::Status::APPROVED ||
expense.status == Capital::Expenses::Status::AUTHORIZED,
"Программный расход уже завершён (paid/declined)"
);
Capital::State::release_program_expense(coopname, expense.amount);
Capital::Expenses::delete_program_expense(coopname, expense.id);
// event ridge: автор расхода и председатель уведомляются об отклонении.
require_recipient(expense.username);
require_recipient(coopname);
}
@@ -0,0 +1,38 @@
/**
* @brief Подтверждает оплату программного расхода (callback от gateway).
*
* Снимает резерв с @c program_expense_reserved, делает ledger2-проводку
* @ref operations::capital::PAY_EXPENSE (TRANSFER BLAGOROST_FUND → SOV_EXPENSES)
* и удаляет запись расхода — финал процесса @c p.cap.expns.
*
* @param coopname Кооператив
* @param expense_hash Хеш расхода
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p _gateway.
*/
void capital::pgexppay(name coopname, checksum256 expense_hash) {
require_auth(_gateway);
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
eosio::check(expense.status == Capital::Expenses::Status::AUTHORIZED,
"Программный расход должен быть в статусе 'authorized' для подтверждения оплаты");
// Окончательное списание из резерва (деньги ушли gateway).
Capital::State::consume_program_expense(coopname, expense.amount);
// Бухгалтерская/кошельковая проводка.
auto memo = Capital::Memo::get_expense_pay_memo(expense_hash);
Ledger2::apply(_capital, coopname,
operations::capital::PAY_EXPENSE,
expense.amount, expense.username,
expense_hash, memo);
// Запись больше не нужна как ожидающая — закрываем процесс.
Capital::Expenses::delete_program_expense(coopname, expense.id);
// event ridge: автор расхода и председатель уведомляются о факте оплаты.
require_recipient(expense.username);
require_recipient(coopname);
}
@@ -0,0 +1,21 @@
/**
* @brief Пополняет пул программных расходов из общего инвестиционного пула программы.
*
* Программные расходы — это целевые списания «Благорост», не аллоцированные ни в один проект
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
* @c program_expense_pool из общего @c global_available_invest_pool — деньги остаются
* на том же ledger2-кошельке @c BLAGOROST_FUND, меняется только разбивка по назначению.
*
* @param coopname Кооператив (scope state)
* @param amount Сумма пополнения (символ строго совпадает с программой)
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от аккаунта: @p coopname (председатель).
*/
void capital::topupprogexp(name coopname, eosio::asset amount) {
require_auth(coopname);
Capital::State::topup_program_expense_pool(coopname, amount);
}
@@ -47,11 +47,22 @@ void capital::createcmmt(eosio::name coopname, eosio::name username, checksum256
eosio::check(creator_hours > 0, "Только положительная сумма часов создателя");
eosio::check(creator_hours <= 1000000, "Превышено максимальное количество часов (1 млн)");
// Если на сегменте уже утверждена мастером approved-ставка (Story 1.7/1.8) — берём её,
// иначе fallback на глобальную rate_per_hour из contributors. Это позволяет мастеру
// компонента дифференцировать ставку по проекту без изменения договора УХД.
auto segment_opt = Capital::Segments::get_segment(coopname, project_hash, username);
eosio::asset effective_rate = contributor->rate_per_hour;
if (segment_opt.has_value()
&& segment_opt->approved_rate_per_hour.has_value()
&& segment_opt->approved_rate_per_hour->amount > 0) {
effective_rate = *segment_opt->approved_rate_per_hour;
}
// считаем сумму фактических затрат создателя на основе ставки в час и потраченного времени
eosio::asset creator_base = contributor -> rate_per_hour * creator_hours;
eosio::asset creator_base = effective_rate * creator_hours;
// Вычисляем фактическое изменение сумм генерации
auto delta_amounts = Capital::Core::Generation::calculate_fact_generation_amounts(contributor -> rate_per_hour, creator_hours);
auto delta_amounts = Capital::Core::Generation::calculate_fact_generation_amounts(effective_rate, creator_hours);
// Создаем коммит без отправки на аппрув председателю
// Одобрение осуществляется мастером проекта через approvecmmt
@@ -0,0 +1,34 @@
/**
* @brief Кандидат принимает инвайт от мастера: ставка/часы из заявки становятся
* approved-параметрами сегмента.
*
* @param coopname Кооператив
* @param request_hash Хеш инвайт-заявки
* @ingroup public_actions
* @ingroup public_capital_actions
*
* @note Авторизация требуется от @p coopname (backend подставляет кандидата).
*/
void capital::acceptinvite(name coopname, checksum256 request_hash) {
require_auth(coopname);
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
eosio::check(req.direction == Capital::RoleRequests::Direction::INVITE,
"Принять можно только инвайт");
Capital::RoleRequests::approve(coopname, req.id, req.rate_per_hour, req.hours_per_day);
Capital::Segments::set_approved_rate(
coopname, req.project_hash, req.username, req.rate_per_hour, req.hours_per_day
);
// Подключаем кандидата к проекту по приглашённой роли.
if (req.request_type == Capital::RoleRequests::RequestType::ROLE) {
Capital::RoleRequests::apply_role_to_project(
coopname, req.project_hash, req.username, req.role
);
}
// event ridge: кандидат и мастер видят принятие инвайта.
require_recipient(req.username);
require_recipient(req.master);
}
@@ -0,0 +1,46 @@
/**
* @brief Мастер компонента одобряет заявку на L2-допуск либо обновление approved-ставки.
*
* Фиксирует approved-ставку и норматив часов в сегменте через
* @ref Capital::Segments::set_approved_rate — далее `createcmmt` берёт их.
* Сегмент должен уже существовать (создаётся при подписании приложения договора УХД
* на этот проект через apprvappndx — L1-допуск).
*
* Бездокументарная схема: решение фиксируется только подписью транзакции
* (require_auth coopname), документ-решение мастера не передаётся.
*
* @param coopname Кооператив
* @param request_hash Хеш заявки (requestrole либо requestrateu)
* @param approved_rate Утверждённая мастером ставка часа (≠ запрошенной — допустимо)
* @param approved_hours Утверждённая норма часов
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::approverole(name coopname, checksum256 request_hash,
eosio::asset approved_rate, uint64_t approved_hours) {
require_auth(coopname);
Wallet::validate_asset(approved_rate);
eosio::check(approved_rate.amount > 0, "Утверждённая ставка должна быть положительной");
eosio::check(approved_hours > 0 && approved_hours <= 8, "Норма часов — от 1 до 8");
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
Capital::RoleRequests::approve(coopname, req.id, approved_rate, approved_hours);
Capital::Segments::set_approved_rate(
coopname, req.project_hash, req.username, approved_rate, approved_hours
);
// Только для ROLE-заявок: подключаем участника к проекту по заявленной роли.
// RATE_UPDATE роль не меняет, только ставку — inline action не нужен.
if (req.request_type == Capital::RoleRequests::RequestType::ROLE) {
Capital::RoleRequests::apply_role_to_project(
coopname, req.project_hash, req.username, req.role
);
}
// event ridge: заявитель и мастер компонента видят решение.
require_recipient(req.username);
require_recipient(req.master);
}
@@ -0,0 +1,20 @@
/**
* @brief Мастер компонента отклоняет заявку на L2-допуск / на обновление approved-ставки.
* Запись остаётся в таблице со статусом DECLINED + причиной — для аудита.
*
* @param coopname Кооператив
* @param request_hash Хеш заявки
* @param reason Причина отказа
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::declinerole(name coopname, checksum256 request_hash, std::string reason) {
require_auth(coopname);
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
Capital::RoleRequests::decline(coopname, req.id, reason);
// event ridge: заявитель и мастер компонента видят отказ.
require_recipient(req.username);
require_recipient(req.master);
}
@@ -0,0 +1,22 @@
/**
* @brief Кандидат отказывается от инвайта мастера.
*
* @param coopname Кооператив
* @param request_hash Хеш инвайт-заявки
* @param reason Причина отказа
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::declinvite(name coopname, checksum256 request_hash, std::string reason) {
require_auth(coopname);
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
eosio::check(req.direction == Capital::RoleRequests::Direction::INVITE,
"Отказаться можно только от инвайта");
Capital::RoleRequests::decline(coopname, req.id, reason);
// event ridge: кандидат и мастер видят отказ от инвайта.
require_recipient(req.username);
require_recipient(req.master);
}
@@ -0,0 +1,46 @@
/**
* @brief Мастер компонента (или председатель — для роли master при пустом слоте мастера)
* приглашает кандидата на роль. Обратный поток к requestrole.
* Кандидат далее acceptinvite (с применением приглашённой ставки) либо declinvite.
*
* Бездокументарная схема: приглашение и принятие/отказ оформляются только
* подписью транзакции (require_auth coopname). Юридический документ-инвайт не нужен.
*
* Ставка часа в приглашении — это ставка, на которую кандидат соглашается через
* acceptinvite. Не путать с contributors.rate_per_hour (личная глобальная ставка).
*
* @param coopname Кооператив
* @param request_hash Хеш заявки (анкер; уникален в scope coopname)
* @param project_hash Проект / компонент
* @param candidate Приглашаемый пайщик (потенциальный участник)
* @param master Мастер-приглашающий (или координатор/председатель для роли master)
* @param role Предлагаемая роль: Role::CREATOR | Role::AUTHOR | Role::MASTER
* @param rate_per_hour Предлагаемая ставка часа
* @param hours_per_day Предлагаемая норма часов в день
* @param description Текст приглашения (может быть пустым)
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::inviterole(name coopname, checksum256 request_hash, checksum256 project_hash,
name candidate, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description) {
require_auth(coopname);
Capital::RoleRequests::validate_role_or_fail(role);
Wallet::validate_asset(rate_per_hour);
eosio::check(rate_per_hour.amount > 0, "Ставка часа должна быть положительной");
eosio::check(hours_per_day > 0 && hours_per_day <= 8, "Норма часов — от 1 до 8");
Capital::RoleRequests::create(
coopname, request_hash, project_hash, candidate, master, role,
rate_per_hour, hours_per_day,
Capital::RoleRequests::Direction::INVITE,
Capital::RoleRequests::RequestType::ROLE,
description
);
// event ridge: кандидат и мастер видят инвайт.
require_recipient(candidate);
require_recipient(master);
}
@@ -0,0 +1,43 @@
/**
* @brief Заявка пайщика на обновление approved-ставки на сегменте.
* Тот же flow что и requestrole, но через RequestType::RATE_UPDATE.
* approverole с новой ставкой → обновление сегмента; declinerole — без изменений.
*
* Бездокументарная схема: заявление-документ не передаётся, утверждение
* фиксируется только транзакцией. role в записи остаётся Role::NONE
* (валидация роли для RATE_UPDATE не применяется).
*
* @param coopname Кооператив
* @param request_hash Хеш заявки
* @param project_hash Проект / компонент
* @param username Заявитель
* @param master Мастер компонента (ожидаемый одобряющий)
* @param new_rate Желаемая новая ставка
* @param new_hours Желаемая новая норма часов
* @param description Текст заявки на обновление ставки (может быть пустым)
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::requestrateu(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master,
eosio::asset new_rate, uint64_t new_hours,
std::string description) {
require_auth(coopname);
Wallet::validate_asset(new_rate);
eosio::check(new_rate.amount > 0, "Новая ставка должна быть положительной");
eosio::check(new_hours > 0 && new_hours <= 8, "Норма часов — от 1 до 8");
Capital::RoleRequests::create(
coopname, request_hash, project_hash, username, master,
Capital::RoleRequests::Role::NONE,
new_rate, new_hours,
Capital::RoleRequests::Direction::REQUEST,
Capital::RoleRequests::RequestType::RATE_UPDATE,
description
);
// event ridge: заявитель и мастер компонента видят запрос на обновление ставки.
require_recipient(username);
require_recipient(master);
}
@@ -0,0 +1,49 @@
/**
* @brief Заявка пайщика на L2-допуск (роль creator/author/master) на компоненте.
*
* Создаёт запись в `rolerequests` со статусом PENDING. Дальше мастер компонента
* (или председатель — для роли master, когда мастера в проекте ещё нет)
* решает: approverole (с фиксацией approved-ставки в сегменте) или declinerole.
*
* Бездокументарная схема: подача и решение оформляются только подписью транзакции
* (require_auth coopname). Юридическое заявление при приёме на роль не требуется —
* допуск даётся подписью действия на стороне кооператива.
*
* Ставка часа подаётся явно — это ставка пайщика именно под этот проект.
* По дефолту фронт подставляет contributors.rate_per_hour, но пайщик может изменить.
*
* @param coopname Кооператив
* @param request_hash Хеш заявки (анкер для approverole/declinerole)
* @param project_hash Проект (или компонент — на уровне хэша одно)
* @param username Заявитель
* @param master Мастер компонента (ожидаемый одобряющий)
* @param role Запрашиваемая роль: Role::CREATOR | Role::AUTHOR | Role::MASTER
* @param rate_per_hour Заявленная ставка часа под этот проект
* @param hours_per_day Заявленная норма часов в день
* @param description Текст заявки (может быть пустым)
* @ingroup public_actions
* @ingroup public_capital_actions
*/
void capital::requestrole(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description) {
require_auth(coopname);
Capital::RoleRequests::validate_role_or_fail(role);
Wallet::validate_asset(rate_per_hour);
eosio::check(rate_per_hour.amount > 0, "Ставка часа должна быть положительной");
eosio::check(hours_per_day > 0 && hours_per_day <= 8, "Норма часов в день — от 1 до 8");
Capital::RoleRequests::create(
coopname, request_hash, project_hash, username, master, role,
rate_per_hour, hours_per_day,
Capital::RoleRequests::Direction::REQUEST,
Capital::RoleRequests::RequestType::ROLE,
description
);
// event ridge: заявитель и мастер компонента видят новую заявку.
require_recipient(username);
require_recipient(master);
}
@@ -14,21 +14,20 @@
*/
void capital::startvoting(name coopname, checksum256 project_hash) {
require_auth(coopname);
print("z");
// Проверяем существование проекта и получаем его
auto project = Capital::Projects::get_project_or_fail(coopname, project_hash);
print("a");
// Проверяем что проект авторизован советом
//eosio::check(project.is_authorized, "Проект не авторизован советом");
// Проверяем, что проект в статусе "active"
eosio::check(project.status == Capital::Projects::Status::ACTIVE, "Проект должен быть в статусе 'active'");
eosio::check(project.counts.total_commits > 0, "Проект без коммитов не может быть поставлен на голосование");
print("b");
// Обновляем статус проекта на "voting"
Capital::Projects::update_status(coopname, project.id, Capital::Projects::Status::VOTING);
print("c");
// Инициализируем голосование по методу Водянова
Capital::Core::Voting::initialize_project_voting(coopname, project.id);
print("d");
}
}
@@ -61,10 +61,41 @@ void capital::signact2(eosio::name coopname, eosio::name chairman, checksum256 r
}
// Возврат беспроцентного займа пайщика: Dr 80 / Cr 58, TRANSFER LOAN_ISSUED → SHARE_FUND_PAY.
// Семантика: пайщик погасил ссуду результатом (интеллектуальным взносом),
// Семантика: пайщик погасил ссуду результатом (паевой взнос интеллектуальным результатом),
// у кооперативa уменьшилось фин. вложение 58, деньги вернулись на расчётный.
if (result -> debt_amount.amount > 0){
Ledger2::apply(_capital, coopname, operations::capital::REPAY, result -> debt_amount, result -> username, result_hash, memo);
// Лимит «10 займов на проект в одном паевом взносе» проверяем по счётчику
// active_debts_count на сегменте (binary_extension) — без обхода byprojhash.
// Сам счётчик инкрементируется в debtpaycnfrm и декрементируется в mark_settled.
uint32_t active_cnt = Capital::Segments::get_active_debts_count(coopname, result->project_hash, result->username);
eosio::check(active_cnt <= 10,
"Превышен лимит займов на проект для одного паевого взноса (10)");
// Перебираем долги пайщика по проекту, но не больше известного счётчика —
// ранний выход сразу после нужного числа активных записей.
Capital::Debts::debts_index debts(_capital, coopname.value);
auto byproj = debts.get_index<"byprojhash"_n>();
std::vector<std::tuple<uint64_t, checksum256, eosio::asset>> to_settle;
to_settle.reserve(active_cnt);
for (auto it = byproj.find(result->project_hash);
it != byproj.end() && it->project_hash == result->project_hash
&& to_settle.size() < active_cnt; ++it) {
if (it->username == result->username
&& (it->status == Capital::Debts::Status::PAID
|| it->status == Capital::Debts::Status::OVERDUE)) {
to_settle.emplace_back(it->id, it->debt_hash, it->amount);
}
}
for (auto& tup : to_settle) {
auto id = std::get<0>(tup);
auto dh = std::get<1>(tup);
auto amt = std::get<2>(tup);
Loan::settle_debt(_capital, coopname, result->username, dh, amt);
Capital::Debts::mark_settled(coopname, id, memo, _capital);
Capital::Segments::decrease_active_debts_count(coopname, result->project_hash, result->username);
}
}
// Обновляем накопительные показатели контрибьютора на основе его ролей в сегменте
@@ -92,9 +92,29 @@
#include "app/debt_managment/create_debt/debtauthcnfr.cpp"
#include "app/debt_managment/create_debt/debtpaycnfrm.cpp"
#include "app/debt_managment/create_debt/debtpaydcln.cpp"
#include "app/debt_managment/create_debt/debtpayretry.cpp"
#include "app/debt_managment/create_debt/declinedebt.cpp"
#include "app/debt_managment/create_debt/markdebtoverd.cpp"
#include "app/debt_managment/create_debt/closedebt.cpp"
#include "app/debt_managment/settle_debt/settledebt.cpp"
// Управление программными расходами
#include "app/expense_managment/program_expenses/topupprogexp.cpp"
#include "app/expense_managment/program_expenses/createpgexp.cpp"
#include "app/expense_managment/program_expenses/apprvpgexp.cpp"
#include "app/expense_managment/program_expenses/authpgexp.cpp"
#include "app/expense_managment/program_expenses/pgexppay.cpp"
#include "app/expense_managment/program_expenses/declpgexp.cpp"
// L2-роли (Story 1.7)
#include "app/participation_management/roles/requestrole.cpp"
#include "app/participation_management/roles/approverole.cpp"
#include "app/participation_management/roles/declinerole.cpp"
#include "app/participation_management/roles/inviterole.cpp"
#include "app/participation_management/roles/acceptinvite.cpp"
#include "app/participation_management/roles/declinvite.cpp"
#include "app/participation_management/roles/requestrateu.cpp"
// Управление расходами
#include "app/expense_managment/create_expense/approveexpns.cpp"
#include "app/expense_managment/create_expense/capauthexpns.cpp"
+64 -2
View File
@@ -315,11 +315,73 @@ public:
[[eosio::action]]
void debtpaydcln(name coopname, checksum256 debt_hash, std::string reason);
[[eosio::action]]
void debtpayretry(eosio::name coopname, checksum256 debt_hash);
[[eosio::action]]
void declinedebt(name coopname, eosio::name username, checksum256 debt_hash, std::string reason);
[[eosio::action]]
void settledebt(name coopname, name username, eosio::asset amount, document2 statement);
void settledebt(name coopname, checksum256 debt_hash, eosio::asset amount, document2 statement);
[[eosio::action]]
void markdebtoverd(name coopname);
[[eosio::action]]
void closedebt(name coopname, checksum256 debt_hash);
[[eosio::action]]
void topupprogexp(name coopname, eosio::asset amount);
[[eosio::action]]
void createpgexp(name coopname, checksum256 expense_hash, name creator,
eosio::asset amount, std::string description, document2 statement);
[[eosio::action]]
void apprvpgexp(name coopname, name approver, checksum256 expense_hash,
document2 approved_statement);
[[eosio::action]]
void authpgexp(name coopname, checksum256 expense_hash, document2 authorization);
[[eosio::action]]
void pgexppay(name coopname, checksum256 expense_hash);
[[eosio::action]]
void declpgexp(name coopname, checksum256 expense_hash, std::string reason);
// L2-роли и approved-ставки сегмента (Story 1.7) — бездокументарная схема,
// решения фиксируются только подписью транзакции (require_auth coopname).
[[eosio::action]]
void requestrole(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description);
[[eosio::action]]
void approverole(name coopname, checksum256 request_hash,
eosio::asset approved_rate, uint64_t approved_hours);
[[eosio::action]]
void declinerole(name coopname, checksum256 request_hash, std::string reason);
[[eosio::action]]
void inviterole(name coopname, checksum256 request_hash, checksum256 project_hash,
name candidate, name master, name role,
eosio::asset rate_per_hour, uint64_t hours_per_day,
std::string description);
[[eosio::action]]
void acceptinvite(name coopname, checksum256 request_hash);
[[eosio::action]]
void declinvite(name coopname, checksum256 request_hash, std::string reason);
[[eosio::action]]
void requestrateu(name coopname, checksum256 request_hash, checksum256 project_hash,
name username, name master,
eosio::asset new_rate, uint64_t new_hours,
std::string description);
};
@@ -15,10 +15,14 @@ namespace Capital::Debts {
*/
namespace Status {
constexpr name CREATED = "created"_n; ///< Долг создан
constexpr name APPROVED = "approved"_n; ///< Долг одобрен
constexpr name AUTHORIZED = "authorized"_n; ///< Долг авторизован советом
constexpr name PAID = "paid"_n; ///< Долг выплачен (готов к использованию)
constexpr name CREATED = "created"_n; ///< Долг создан
constexpr name APPROVED = "approved"_n; ///< Долг одобрен председателем
constexpr name AUTHORIZED = "authorized"_n; ///< Долг авторизован советом, ожидает повторной отправки на оплату
constexpr name PAY_PENDING = "paypending"_n; ///< Долг отправлен в gateway на оплату (исходящий платёж создан, ждём debtpaycnfrm/debtpaydcln)
constexpr name PAID = "paid"_n; ///< Долг выплачен пайщику, активный (ждёт погашения)
constexpr name OVERDUE = "overdue"_n; ///< Долг просрочен (now > due_at) — выставляется action markdebtoverd
constexpr name SETTLED = "settled"_n; ///< Долг полностью погашен (деньгами через settledebt либо результатом через signact2)
constexpr name WRITEOFF = "writeoff"_n; ///< Долг списан (write-off) за счёт созданного НМА из коммитов-обеспечения (через closedebt при отмене родительского компонента; решение 2026-05-19)
}
/**
@@ -42,7 +46,9 @@ struct [[eosio::table, eosio::contract(CAPITAL)]] debt {
document2 approved_statement; ///< Одобренное заявление
document2 authorization; ///< Авторизация совета
std::string memo; ///< Примечание
std::string last_pay_error; ///< Последняя ошибка outpay от gateway (заполняется debtpaydcln; пусто = успех или ещё не отправлен)
time_point_sec due_at; ///< Срок погашения (current_time_point + 1 год при переходе в paid; решение 2026-05-19); now > due_at → overdue
uint64_t primary_key() const { return id; } ///< Первичный ключ (1)
uint64_t by_username() const { return username.value; } ///< Индекс по имени пользователя (2)
@@ -145,6 +151,179 @@ inline void update_debt_status(
});
}
/**
* @brief Переводит долг в PAY_PENDING — фиксирует решение совета и факт отправки в gateway.
* Допустимо из APPROVED (первая отправка после авторизации совета) или из AUTHORIZED
* (повторная отправка после предыдущего отказа gateway). Очищает last_pay_error.
*/
inline void start_pay(
eosio::name coopname,
uint64_t debt_id,
const document2 &authorization,
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::APPROVED || debt->status == Status::AUTHORIZED,
"Старт оплаты допустим только из approved/authorized");
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::PAY_PENDING;
d.authorization = authorization;
d.last_pay_error = "";
});
}
/**
* @brief Возвращает долг из PAY_PENDING в AUTHORIZED с записью причины ошибки оплаты.
* Долг не удаляется — решение совета уже принято, доступна повторная отправка в gateway.
*/
inline void mark_pay_declined(
eosio::name coopname,
uint64_t debt_id,
const std::string &reason,
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAY_PENDING,
"Отказ оплаты допустим только из статуса pay_pending");
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::AUTHORIZED;
d.last_pay_error = reason;
});
}
/**
* @brief Кол-во секунд в стандартном сроке погашения займа (1 год = 365 дней).
*
* Решение 2026-05-19 (Игорь Смуров / Алексей Муравьёв): срок займа
* фиксированный — год, не привязан к календарному сроку проекта. Если
* родительский компонент явно перешёл в `cancelled` раньше — долг закрывается
* досрочно через создание НМА кооператива из коммитов-обеспечения и списание
* долга (см. action `capital::closedebt`, операции o.cap.crtnma + o.cap.dbtwrf).
*/
constexpr uint32_t DEFAULT_DEBT_TERM_SECONDS = 365 * 24 * 60 * 60;
/**
* @brief Переводит долг в PAID, фиксирует дату выплаты и проставляет due_at.
*
* Используется в debtpaycnfrm после успешной outpay из gateway. due_at рассчитывается
* от current_time_point на год вперёд; долги с now > due_at и status == PAID
* далее перейдут в OVERDUE через markdebtoverd.
*/
inline void confirm_paid(
eosio::name coopname,
uint64_t debt_id,
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAY_PENDING,
"Подтверждение оплаты допустимо только из pay_pending");
auto now = eosio::current_time_point();
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::PAID;
d.due_at = eosio::time_point_sec(now.sec_since_epoch() + DEFAULT_DEBT_TERM_SECONDS);
d.last_pay_error = "";
});
}
/**
* @brief Помечает долги PAID с now > due_at как OVERDUE в пределах одного batch.
*
* @param coopname Кооператив (scope)
* @param batch_limit Максимальное число записей за вызов (защита от 30-сек лимита транзакции)
* @return Сколько долгов помечено за этот вызов; backend-cron повторяет до 0.
*/
inline uint32_t sweep_overdue(
eosio::name coopname,
uint32_t batch_limit
) {
debts_index debts(_capital, coopname.value);
auto now = eosio::current_time_point();
auto now_sec = eosio::time_point_sec(now.sec_since_epoch());
uint32_t marked = 0;
for (auto it = debts.begin(); it != debts.end() && marked < batch_limit; ++it) {
if (it->status == Status::PAID && it->due_at != time_point_sec() && now_sec > it->due_at) {
debts.modify(it, coopname, [&](auto &d) {
d.status = Status::OVERDUE;
});
++marked;
}
}
return marked;
}
/**
* @brief Закрывает долг как погашенный — переводит в SETTLED, сохраняет документ-основание
* в поле memo (если передан) и фиксирует дату погашения.
*
* Деньги и проводка REPAY к этому моменту уже должны быть применены в Ledger2,
* а contributors.debt_amount — уменьшен. Функция отвечает только за состояние записи.
*/
inline void mark_settled(
eosio::name coopname,
uint64_t debt_id,
const std::string &settle_memo = "",
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAID || debt->status == Status::OVERDUE,
"Погашение допустимо только из статуса paid или overdue");
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::SETTLED;
d.repaid_at = eosio::current_time_point();
if (!settle_memo.empty()) {
d.memo = settle_memo;
}
});
}
/**
* @brief Закрывает долг как списанный — переводит в WRITEOFF.
*
* Применяется в closedebt после применения o.cap.crtnma + o.cap.dbtwrf и
* декремента сегмента. Семантически зеркалит mark_settled, но не пишет
* memo о погашении: долг закрыт имущественно через созданное НМА из
* коммитов-обеспечения, без зачисления на share пайщика. Решение 2026-05-19.
*/
inline void mark_writeoff(
eosio::name coopname,
uint64_t debt_id,
eosio::name payer = name{}
) {
if (payer == name{}) payer = coopname;
debts_index debts(_capital, coopname.value);
auto debt = debts.find(debt_id);
eosio::check(debt != debts.end(), "Долг не найден");
eosio::check(debt->status == Status::PAID || debt->status == Status::OVERDUE,
"Списание долга допустимо только из статуса paid или overdue");
debts.modify(debt, payer, [&](auto &d) {
d.status = Status::WRITEOFF;
d.repaid_at = eosio::current_time_point();
});
}
/**
* @brief Удаляет долг
*/
@@ -178,8 +178,116 @@ namespace Capital::Expenses {
Capital::expense_index expenses(_capital, coopname.value);
auto expense = expenses.find(expense_id);
eosio::check(expense != expenses.end(), "Расход не найден");
expenses.erase(expense);
}
} // namespace Capital::Expenses
namespace Capital {
/**
* @brief Таблица программных расходов — целевые списания «Благорост»,
* не привязанные к конкретному проекту.
* @par Область памяти (scope): coopname
* @par Имя таблицы (table): progexpenses
*/
struct [[eosio::table, eosio::contract(CAPITAL)]] program_expense {
uint64_t id; ///< Внутренний ключ
name coopname; ///< Имя кооператива
name username; ///< Инициатор расхода (председатель / казначей)
name status = Expenses::Status::CREATED; ///< Статус (CREATED/APPROVED/AUTHORIZED/PAID/DECLINED — общий enum)
checksum256 expense_hash; ///< Хеш расхода — анкер процесса p.cap.expns
uint64_t fund_id; ///< Идентификатор фонда списания
eosio::asset amount; ///< Сумма расхода
std::string description; ///< Описание (для отчётности)
document2 expense_statement; ///< Служебная записка (ProgramExpenseStatement)
document2 approved_statement; ///< Записка, принятая председателем
document2 authorization; ///< Решение совета
time_point_sec spended_at = current_time_point();
uint64_t primary_key() const { return id; }
uint64_t by_username() const { return username.value; }
checksum256 by_expense_hash() const { return expense_hash; }
};
typedef eosio::multi_index<
"progexpenses"_n, program_expense,
indexed_by<"byusername"_n, const_mem_fun<program_expense, uint64_t, &program_expense::by_username>>,
indexed_by<"byhash"_n, const_mem_fun<program_expense, checksum256, &program_expense::by_expense_hash>>
> program_expense_index;
inline std::optional<program_expense> get_program_expense(eosio::name coopname, const checksum256 &hash) {
program_expense_index t(_capital, coopname.value);
auto idx = t.get_index<"byhash"_n>();
auto itr = idx.find(hash);
if (itr == idx.end()) return std::nullopt;
return program_expense(*itr);
}
} // namespace Capital
namespace Capital::Expenses {
inline program_expense get_program_expense_or_fail(eosio::name coopname, const checksum256 &expense_hash) {
auto v = Capital::get_program_expense(coopname, expense_hash);
eosio::check(v.has_value(), "Программный расход с указанным хэшем не найден");
return v.value();
}
inline void create_program_expense(
eosio::name coopname,
const checksum256 &expense_hash,
eosio::name username,
const eosio::asset &amount,
const std::string &description,
const document2 &statement
) {
Capital::program_expense_index t(_capital, coopname.value);
uint64_t id = get_global_id_in_scope(_capital, coopname, "progexpenses"_n);
t.emplace(coopname, [&](auto &e) {
e.id = id;
e.coopname = coopname;
e.username = username;
e.expense_hash = expense_hash;
e.fund_id = Ledger::accounts::GENERAL_EXPENSES;
e.status = Status::CREATED;
e.spended_at = current_time_point();
e.expense_statement = statement;
e.amount = amount;
e.description = description;
});
}
inline void set_program_approved(eosio::name coopname, uint64_t id, const document2 &approved_statement) {
Capital::program_expense_index t(_capital, coopname.value);
auto itr = t.find(id);
eosio::check(itr != t.end(), "Программный расход не найден");
t.modify(itr, coopname, [&](auto &e) {
e.status = Status::APPROVED;
e.approved_statement = approved_statement;
});
}
inline void set_program_authorized(eosio::name coopname, uint64_t id, const document2 &authorization) {
Capital::program_expense_index t(_capital, coopname.value);
auto itr = t.find(id);
eosio::check(itr != t.end(), "Программный расход не найден");
t.modify(itr, coopname, [&](auto &e) {
e.status = Status::AUTHORIZED;
e.authorization = authorization;
});
}
inline void delete_program_expense(eosio::name coopname, uint64_t id) {
Capital::program_expense_index t(_capital, coopname.value);
auto itr = t.find(id);
eosio::check(itr != t.end(), "Программный расход не найден");
t.erase(itr);
}
} // namespace Capital::Expenses
@@ -1,5 +1,7 @@
#pragma once
#include <eosio/binary_extension.hpp>
using namespace eosio;
using std::string;
@@ -40,7 +42,11 @@ namespace Capital {
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
config config; ///< Управляемая конфигурация контракта
// binary_extension: таблица state уже в продакшене, новые поля только хвостом.
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
};
@@ -78,7 +84,7 @@ inline global_state get_global_state(name coopname) {
/**
* @brief Проверяет что контракт инициализирован.
*
*
* @param coopname Имя кооператива.
*/
inline void is_initialized(name coopname) {
@@ -86,6 +92,83 @@ inline void is_initialized(name coopname) {
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
}
/// Lazy-default для binary_extension<asset>: пустое значение = 0 в _root_govern_symbol.
inline asset _read_pool(const eosio::binary_extension<asset> &field) {
return field.has_value() ? *field : asset(0, _root_govern_symbol);
}
/**
* @brief Пополняет пул программных расходов на @p amount из общего пула инвестиций программы.
* Вызывается из action topupprogexp (председатель).
*/
inline void topup_program_expense_pool(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма пополнения должна быть положительной");
eosio::check(itr->global_available_invest_pool >= amount,
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.global_available_invest_pool -= amount;
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) + amount);
});
}
/**
* @brief Резервирует средства под approved/authorized программный расход:
* program_expense_pool -= amount, program_expense_reserved += amount.
*/
inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
eosio::check(_read_pool(itr->program_expense_pool) >= amount,
"Недостаточно средств в пуле программных расходов");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) - amount);
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) + amount);
});
}
/**
* @brief Освобождает зарезервированное при отклонении программного расхода:
* program_expense_reserved -= amount, program_expense_pool += amount.
*/
inline void release_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
eosio::check(_read_pool(itr->program_expense_reserved) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) - amount);
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) + amount);
});
}
/**
* @brief Окончательное списание программного расхода после exppaycnfrm:
* program_expense_reserved -= amount. Деньги уже ушли gateway,
* ledger2-проводка PAY_EXPENSE сделана в action подтверждения.
*/
inline void consume_program_expense(eosio::name coopname, const asset &amount) {
global_state_table global_state_inst(_capital, _capital.value);
auto itr = global_state_inst.find(coopname.value);
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
eosio::check(_read_pool(itr->program_expense_reserved) >= amount,
"Зарезервированных программных расходов меньше указанной суммы");
global_state_inst.modify(itr, _capital, [&](auto &s) {
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) - amount);
});
}
}// namespace State
}// namespace Capital
@@ -17,6 +17,7 @@
#include "projects.hpp"
#include "results.hpp"
#include "debts.hpp"
#include "role_requests.hpp"
#include "wallets.hpp"
#include "plan_pool.hpp"
#include "fact_pool.hpp"
@@ -87,4 +87,12 @@ namespace Capital::Memo {
return "Внесение имущества в программу Благорост: " + checksum256_to_hex(property_hash);
}
inline std::string get_settle_debt_memo(eosio::name username, checksum256 debt_hash) {
return "Погашение займа участником " + username.to_string() + " (заём " + checksum256_to_hex(debt_hash) + ")";
}
inline std::string get_expense_pay_memo(checksum256 expense_hash) {
return "Оплата целевого расхода программы «Благорост»: " + checksum256_to_hex(expense_hash);
}
} // namespace Capital::Memo
@@ -4,17 +4,23 @@ using namespace eosio;
using std::string;
namespace Capital {
//TODO: УДАЛИТЬ после 07.2026 т.к. не используем
/**
* @brief Таблица программных инвестиций (progrinvests): ранее использовалась для заявок на зачёт с этапом одобрения советом; при текущей логике createpinv строки не создаёт, таблица сохранена для совместимости и чтения исторических записей.
* @deprecated description: не используется
* @brief Таблица программных инвестиций (progrinvests): ранее использовалась
* для заявок на зачёт с этапом одобрения советом. При текущей логике
* @ref capital::createpinv строки не создаёт, таблица сохранена для
* совместимости и чтения исторических записей.
*
* @deprecated Запланировано к удалению после 2026-07. Не использовать в новом
* коде. Чтение через scope coopname допустимо до этой даты для
* ретроспективных отчётов; писать через emplace/modify нельзя.
*
* @ingroup public_tables
* @ingroup public_capital_tables
*
* @par Область памяти (scope): coopname
* @par Имя таблицы (table): progrinvests
* @par Имя таблицы (table): progrinvests
*/
struct [[eosio::table, eosio::contract(CAPITAL)]] program_invest {
struct [[deprecated("remove after 2026-07"), eosio::table, eosio::contract(CAPITAL)]] program_invest {
uint64_t id; ///< ID программной инвестиции (внутренний ключ)
eosio::name coopname; ///< Имя кооператива
eosio::name username; ///< Имя инвестора
@@ -0,0 +1,211 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/asset.hpp>
using namespace eosio;
using std::string;
namespace Capital::RoleRequests {
/**
* @brief Статусы заявки на роль / обновление approved-ставки.
*/
namespace Status {
constexpr name PENDING = "pending"_n;
constexpr name APPROVED = "approved"_n;
constexpr name DECLINED = "declined"_n;
}
/**
* @brief Направление: пайщик просит сам (REQUEST) или мастер инвайтит кандидата (INVITE).
*/
namespace Direction {
constexpr name REQUEST = "request"_n;
constexpr name INVITE = "invite"_n;
}
/**
* @brief Тип заявки: «дай мне роль/допуск L2» или «обнови мне approved-ставку».
*/
namespace RequestType {
constexpr name ROLE = "role"_n;
constexpr name RATE_UPDATE = "rateupdate"_n;
}
/**
* @brief Допустимые роли заявки на L2-допуск.
* Контрибьюторы и инвесторы — L1, через role_requests не идут.
*/
namespace Role {
constexpr name CREATOR = "creator"_n;
constexpr name AUTHOR = "author"_n;
constexpr name MASTER = "master"_n;
constexpr name NONE = name{}; ///< Для RATE_UPDATE: поле role не значимо
}
/**
* @brief Очередь заявок на L2-допуск/инвайт и на обновление approved-ставки.
*
* Бездокументарная схема: подача и решение оформляются только транзакцией
* блокчейна (require_auth coopname). Юридическое заявление при приёме на роль
* не требуется — допуск даётся через подпись действия на стороне кооператива.
*
* @par Область памяти (scope): coopname
* @par Имя таблицы (table): rolerequests
*/
struct [[eosio::table, eosio::contract(CAPITAL)]] role_request {
uint64_t id; ///< Внутренний ключ
eosio::name coopname; ///< Кооператив
checksum256 request_hash; ///< Хеш заявки (анкер)
checksum256 project_hash; ///< Проект / компонент
eosio::name username; ///< Заявитель (REQUEST) или кандидат (INVITE)
eosio::name master; ///< Мастер компонента (одобряющий) — при invite на роль master это координатор/председатель
eosio::name role = Role::NONE; ///< Role::CREATOR | Role::AUTHOR | Role::MASTER (для RequestType::RATE_UPDATE — Role::NONE)
eosio::asset rate_per_hour = asset(0, _root_govern_symbol); ///< Заявленная пайщиком/мастером ставка часа (всегда явная, не из contributors)
uint64_t hours_per_day = 0; ///< Заявленная норма часов
eosio::asset approved_rate = asset(0, _root_govern_symbol); ///< Утверждённая ставка (≠ requested при approve)
uint64_t approved_hours = 0; ///< Утверждённая норма часов
eosio::name direction = Direction::REQUEST; ///< request | invite
eosio::name request_type = RequestType::ROLE; ///< role | rateupdate
eosio::name status = Status::PENDING; ///< pending | approved | declined
std::string description; ///< Текст заявки / приглашения (может быть пустым)
std::string decline_reason; ///< Причина отказа (если DECLINED)
time_point_sec created_at = current_time_point();
uint64_t primary_key() const { return id; }
checksum256 by_request_hash() const { return request_hash; }
uint128_t by_project_user() const {
return combine_checksum_ids(project_hash, username);
}
};
typedef eosio::multi_index<
"rolerequests"_n, role_request,
indexed_by<"byhash"_n, const_mem_fun<role_request, checksum256, &role_request::by_request_hash>>,
indexed_by<"byprojuser"_n, const_mem_fun<role_request, uint128_t, &role_request::by_project_user>>
> role_requests_index;
inline std::optional<role_request> get_role_request(eosio::name coopname, const checksum256 &request_hash) {
role_requests_index t(_capital, coopname.value);
auto idx = t.get_index<"byhash"_n>();
auto itr = idx.find(request_hash);
if (itr == idx.end()) return std::nullopt;
return role_request(*itr);
}
inline role_request get_role_request_or_fail(eosio::name coopname, const checksum256 &request_hash) {
auto v = get_role_request(coopname, request_hash);
eosio::check(v.has_value(), "Заявка на роль не найдена");
return v.value();
}
inline void create(
eosio::name coopname,
const checksum256 &request_hash,
const checksum256 &project_hash,
eosio::name username,
eosio::name master,
eosio::name role,
const eosio::asset &rate_per_hour,
uint64_t hours_per_day,
eosio::name direction,
eosio::name request_type,
const std::string &description
) {
role_requests_index t(_capital, coopname.value);
eosio::check(!get_role_request(coopname, request_hash).has_value(),
"Заявка с указанным хэшем уже существует");
uint64_t id = get_global_id_in_scope(_capital, coopname, "rolerequests"_n);
t.emplace(coopname, [&](auto &r) {
r.id = id;
r.coopname = coopname;
r.request_hash = request_hash;
r.project_hash = project_hash;
r.username = username;
r.master = master;
r.role = role;
r.rate_per_hour = rate_per_hour;
r.hours_per_day = hours_per_day;
r.direction = direction;
r.request_type = request_type;
r.status = Status::PENDING;
r.description = description;
r.created_at = current_time_point();
});
}
/**
* @brief Проверяет, что role входит в допустимый набор Role::{CREATOR, AUTHOR, MASTER}.
* Контрибьюторы и инвесторы — это L1-роли и через role_requests не идут.
* Не применяется к заявкам RequestType::RATE_UPDATE (там role = Role::NONE).
*/
inline void validate_role_or_fail(eosio::name role) {
eosio::check(
role == Role::CREATOR || role == Role::AUTHOR || role == Role::MASTER,
"Допустимые роли заявки: creator, author, master"
);
}
/**
* @brief Применяет заявленную роль к проекту через inline action:
* Role::AUTHOR → addauthor, Role::MASTER → setmaster, Role::CREATOR → no-op
* (creator фиксируется фактически при первом createcmmt).
*
* Вызывается из approverole/acceptinvite после фиксации решения, чтобы
* заявленная при подаче роль немедленно отражалась в реестре участников.
* Inline action идёт под permission coopname — председатель/backend подписывает.
*/
inline void apply_role_to_project(
eosio::name coopname,
const checksum256 &project_hash,
eosio::name username,
eosio::name role
) {
if (role == Role::AUTHOR) {
eosio::action(
eosio::permission_level{coopname, "active"_n},
_capital,
"addauthor"_n,
std::make_tuple(coopname, project_hash, username)
).send();
} else if (role == Role::MASTER) {
eosio::action(
eosio::permission_level{coopname, "active"_n},
_capital,
"setmaster"_n,
std::make_tuple(coopname, project_hash, username)
).send();
}
}
inline void approve(
eosio::name coopname,
uint64_t request_id,
const eosio::asset &approved_rate,
uint64_t approved_hours
) {
role_requests_index t(_capital, coopname.value);
auto itr = t.find(request_id);
eosio::check(itr != t.end(), "Заявка не найдена");
eosio::check(itr->status == Status::PENDING, "Заявка уже обработана");
t.modify(itr, coopname, [&](auto &r) {
r.status = Status::APPROVED;
r.approved_rate = approved_rate;
r.approved_hours = approved_hours;
});
}
inline void decline(eosio::name coopname, uint64_t request_id, const std::string &reason) {
role_requests_index t(_capital, coopname.value);
auto itr = t.find(request_id);
eosio::check(itr != t.end(), "Заявка не найдена");
eosio::check(itr->status == Status::PENDING, "Заявка уже обработана");
t.modify(itr, coopname, [&](auto &r) {
r.status = Status::DECLINED;
r.decline_reason = reason;
});
}
} // namespace Capital::RoleRequests
@@ -2,6 +2,7 @@
#include <eosio/eosio.hpp>
#include <eosio/asset.hpp>
#include <eosio/binary_extension.hpp>
using namespace eosio;
@@ -112,7 +113,19 @@ namespace Capital::Segments {
eosio::asset available_for_program = asset(0, _root_govern_symbol); ///< Доступная сумма для конвертации в программу (intellectual_cost - debt_amount)
eosio::asset available_for_wallet = asset(0, _root_govern_symbol); ///< Доступная сумма для конвертации в кошелек (provisional_amount - debt_amount)
double share_percent = 0.0; ///< Доля участника в результате (intellectual_cost / fact.total * 100)
// Допуски L2 — утверждаются мастером компонента/сегмента через approverole (Story 1.7).
// Используются в createcmmt вместо глобального contributors.rate_per_hour, если значение установлено.
// binary_extension: таблица segments уже в продакшене, расширение схемы только хвостом.
eosio::binary_extension<eosio::asset> approved_rate_per_hour; ///< Утверждённая мастером ставка часа на этом сегменте
eosio::binary_extension<uint64_t> approved_hours_per_day; ///< Утверждённое мастером количество часов в день на этом сегменте
// Счётчик активных (PAID/OVERDUE) займов пайщика на проекте этого сегмента.
// Инкрементируется в Capital::Debts::confirm_paid, декрементируется в mark_settled.
// Используется как лимит «10 займов на проект в одном паевом взносе» (signact2),
// чтобы не перебирать byprojhash и не упираться в 30-сек лимит транзакции.
eosio::binary_extension<uint32_t> active_debts_count;
uint64_t primary_key() const { return id; } ///< Первичный ключ (1)
checksum256 by_project_hash() const { return project_hash; } ///< Индекс по хэшу проекта (2)
@@ -156,6 +169,30 @@ namespace Capital::Segments {
return segment(*segment_itr);
}
/**
* @brief Фиксирует утверждённую мастером ставку часа и норматив часов в день на сегменте.
* Вызывается из action approverole (Story 1.7). createcmmt далее берёт эту
* ставку для расчёта generation_amounts вместо глобальной из contributors.
*/
inline void set_approved_rate(
eosio::name coopname,
const checksum256 &project_hash,
eosio::name username,
const eosio::asset &rate_per_hour,
uint64_t hours_per_day
) {
segments_index segments(_capital, coopname.value);
auto idx = segments.get_index<"byprojuser"_n>();
auto rkey = combine_checksum_ids(project_hash, username);
auto it = idx.find(rkey);
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
idx.modify(it, coopname, [&](auto &s) {
s.approved_rate_per_hour.emplace(rate_per_hour);
s.approved_hours_per_day.emplace(hours_per_day);
});
}
/**
* @brief Подсчитывает количество авторов в проекте.
@@ -418,14 +455,57 @@ inline void increase_debt_amount(eosio::name coopname, uint64_t segment_id, eosi
inline void decrease_debt_amount(eosio::name coopname, uint64_t segment_id, eosio::asset amount) {
Capital::Segments::segments_index segments(_capital, coopname.value);
auto segment = segments.find(segment_id);
eosio::check(segment->debt_amount >= amount, "Пайщик не может погасить долг больше, чем должен");
segments.modify(segment, coopname, [&](auto &s) {
s.debt_amount -= amount;
});
}
/**
* @brief Увеличивает счётчик активных займов пайщика на сегменте на +1.
* Вызывается в Capital::Debts::confirm_paid после перехода долга в PAID.
*/
inline void increase_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
segments_index segments(_capital, coopname.value);
auto idx = segments.get_index<"byprojuser"_n>();
auto rkey = combine_checksum_ids(project_hash, username);
auto it = idx.find(rkey);
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
idx.modify(it, coopname, [&](auto &s) {
uint32_t cur = s.active_debts_count.has_value() ? *s.active_debts_count : 0;
s.active_debts_count.emplace(cur + 1);
});
}
/**
* @brief Уменьшает счётчик активных займов пайщика на сегменте на -1.
* Вызывается в Capital::Debts::mark_settled (settledebt и signact2-погашение).
*/
inline void decrease_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
segments_index segments(_capital, coopname.value);
auto idx = segments.get_index<"byprojuser"_n>();
auto rkey = combine_checksum_ids(project_hash, username);
auto it = idx.find(rkey);
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
idx.modify(it, coopname, [&](auto &s) {
uint32_t cur = s.active_debts_count.has_value() ? *s.active_debts_count : 0;
eosio::check(cur > 0, "Счётчик активных займов уже на нуле");
s.active_debts_count.emplace(cur - 1);
});
}
/**
* @brief Текущее значение счётчика активных займов пайщика на сегменте.
* Используется в signact2 как лимит обхода долгов проекта (≤10).
*/
inline uint32_t get_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
auto seg = get_segment(coopname, project_hash, username);
if (!seg.has_value()) return 0;
return seg->active_debts_count.has_value() ? *seg->active_debts_count : 0;
}
/**
* @brief Удаляет сегмент участника
* @param coopname Имя кооператива
@@ -1,16 +1,5 @@
# ─────────────────────────────────────────────────────────────────────────────
# Стандарт «Займ пайщику» — выдача беспроцентного целевого займа из паевого
# фонда кооператива и его возврат через акт приёма-передачи проекта.
#
# Многоактовый процесс с авторизацией советом. Две операции:
# • o.cap.lend — выдача займа на момент авторизации (ISSUE → w.cap.loan, Дт 58 / Кт 51)
# • o.cap.repay — возврат займа из акта-2 (TRANSFER w.cap.loan → w.wal.share, Дт 80 / Кт 58)
#
# Источники правды в коде:
# • cpp/capital/capital.hpp — actions
# • cpp/capital/src/debt/{createdebt,approvedebt,debtauthcnfr,debtpaycnfrm,declinedebt,debtpaydcln}.cpp
# • cpp/lib/core/ledger2/operations.hpp — o.cap.lend, o.cap.repay
# • cpp/lib/core/ledger2/processes.hpp — processes::capital::DEBT
# Стандарт «Займ пайщику»
# ─────────────────────────────────────────────────────────────────────────────
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
@@ -20,103 +9,104 @@ title: Выдача займа пайщику
slug: debt
status: proposed
contract: capital
summary: >
Пайщик получает из паевого фонда кооператива беспроцентный целевой заём
на срок проекта; возврат происходит при сдаче акта-2 проекта.
purpose: >
«Заём пайщику» — кооператив выдаёт пайщику беспроцентный целевой заём
из паевого фонда на срок проекта. Заявку последовательно одобряют
председатель и совет, выплата уходит пайщику через кассира. Возврат
происходит автоматически при сдаче акта приёма-передачи проекта —
отдельная заявка на возврат не нужна.
Пайщик получает из паевого фонда кооператива беспроцентный целевой заём
сроком на один год. Решение о выдаче принимает совет кооператива. Заём
возвращается автоматически при сдаче результата работ по проекту. Если же
проект до сдачи не доведён и был отменён, долг закрывается за счёт
имущества, которое пайщик ранее передал кооперативу в обеспечение по этому
займу. Никаких санкций к пайщику в случае невозврата не применяется.
roles:
- contributor
- chairman
- soviet
- gateway_operator
- Пайщик
- Председатель
- Совет
- Кассир
# ── Секция 2. Действия контракта ────────────────────────────────────────────
actions:
- name: capital::createdebt
human: Подать заявление
actor: contributor
role: opener
actor: Пайщик
purpose: >
Пайщик создаёт заявку на беспроцентный целевой заём. Контракт фиксирует
запись долга в статусе `created`, обновляет сегмент CRPS пайщика на
сумму займа.
Пайщик подаёт заявление на беспроцентный целевой заём.
- name: capital::approvedebt
human: Одобрить решением председателя
actor: chairman
role: progress
human: Одобрить
actor: Председатель
purpose: >
Председатель добавляет к заявке документ-одобрение займа. Статус
заявки → `approved`.
Председатель одобряет заявление пайщика и выносит его на утверждение
советом.
- name: capital::debtauthcnfr
human: Авторизовать выплату
actor: soviet
role: progress
actor: Совет
purpose: >
Совет авторизует выплату займа. На этом этапе применяется ledger2-
операция o.cap.lend и Gateway получает поручение на исходящий платёж.
Статус → `authorized`.
Совет утверждает выдачу займа и поручает кассиру выполнить выплату.
- name: capital::debtpaycnfrm
human: Подтвердить выплату
actor: gateway_operator
role: closer
actor: Кассир
purpose: >
Gateway подтверждает зачисление займа на счёт пайщика. Статус → `paid`.
Это закрывающее действие: запись долга сохраняется до возврата
(операция o.cap.repay сработает уже на акте-2 в процессе РИД).
Кассир подтверждает зачисление денежных средств пайщику. С этого
момента отсчитывается срок возврата — один год.
- name: capital::declinedebt
human: Отклонить
actor: chairman
role: reject
actor: Председатель
purpose: >
Председатель или совет отклоняет заявку на заём. Сегмент CRPS откатывается,
запись долга удаляется, операции в ledger2 не создаются.
Председатель или совет отклоняет заявление о выдаче займа.
- name: capital::debtpaydcln
human: Отклонить выплату
actor: gateway_operator
role: reject
actor: Кассир
purpose: >
Gateway отклонил исходящий платёж по займу (тех. ошибка, возврат).
Запись долга удаляется.
Кассир сообщает, что выплата не прошла (технический сбой, возврат
средств банком). Заявление снимается.
- name: capital::closedebt
human: Закрыть займ
actor: Председатель совета
purpose: >
Если проект, под который был выдан заём, отменён до сдачи результата —
долг закрывается за счёт имущества, ранее переданного пайщиком
кооперативу в обеспечение по этому займу. Делается автоматически:
без сбора совета и без подписи пайщика.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
entity: capital::debt
entity_human: Долг пайщика
entity_source: cpp/capital/src/debt/
states:
- name: created
human: Заявление подано
description: >
Заявка на заём создана. Сегмент CRPS пайщика обновлён на запрошенную сумму.
Пайщик подал заявление на заём. Документ ждёт решения председателя.
kind: normal
- name: approved
human: Председатель одобрил
human: Одобрено председателем
description: >
К заявке добавлен документ-одобрение председателя. Заявка ждёт авторизации
советом.
Председатель одобрил заявление. Документ ушёл на утверждение совету.
kind: normal
- name: authorized
human: Совет авторизовал
human: Утверждено советом
description: >
Совет авторизовал заём, в ledger2 применена операция o.cap.lend
(Дт 58 / Кт 51), Gateway получил поручение на исходящий платёж.
Совет утвердил выдачу займа. Кассиру поручено выполнить выплату.
kind: normal
- name: paid
human: Заём выплачен
description: >
Gateway подтвердил зачисление займа пайщику. Запись долга остаётся
в реестре до момента возврата через акт приёма-передачи проекта.
kind: final
- name: removed
human: Отклонено
Кассир подтвердил зачисление средств пайщику. С этого дня
отсчитывается срок возврата — один год. Возврат произойдёт
автоматически вместе со сдачей результата работ по проекту.
kind: normal
- name: writeoff
human: Списано через имущество-обеспечение
description: >
Заявка отклонена на одном из этапов; запись удалена, операции
o.cap.lend / o.cap.repay не создавались.
Проект, под который был выдан заём, отменён до сдачи результата.
Долг закрыт автоматически за счёт имущества, переданного пайщиком
кооперативу в обеспечение по этому займу. Запись о займе остаётся
в реестре с этой пометкой для последующего аудита; никаких претензий
к пайщику не предъявляется.
kind: normal
- name: removed
human: Заявление отклонено
description: >
Заявление снято на одном из этапов согласования — председателем,
советом или кассиром.
kind: virtual
virtual: true
@@ -124,129 +114,141 @@ transitions:
- from: "∅"
to: created
action: capital::createdebt
actor: contributor
actor: Пайщик
guards:
- Пайщик имеет статус active и участвует в проекте.
- Сегмент CRPS пайщика актуален.
- provisional_amount ≥ запрошенная сумма займа.
- Заявление подписано ЭЦП.
- Пайщик — активный участник кооператива.
- Пайщик участвует в проекте, под который запрашивается заём.
- На сегменте пайщика хватает доступной суммы для запроса.
- Заявление подписано пайщиком.
- from: created
to: approved
action: capital::approvedebt
actor: chairman
actor: Председатель
guards:
- Документ одобрения председателя подписан.
- Председатель подписал решение об одобрении.
- from: approved
to: authorized
action: capital::debtauthcnfr
actor: soviet
ledger_code: o.cap.lend
operations:
- o.cap.lend
actor: Совет
guards:
- Документ авторизации совета подписан.
- Совет принял решение о выдаче займа.
- from: authorized
to: paid
action: capital::debtpaycnfrm
actor: gateway_operator
actor: Кассир
guards:
- Gateway подтвердил зачисление займа пайщику.
- Кассир подтвердил зачисление средств пайщику.
- from: created
to: removed
action: capital::declinedebt
actor: chairman
actor: Председатель
guards:
- Председатель или совет отклонили заявку.
- Председатель или совет отклонили заявление.
- from: approved
to: removed
action: capital::declinedebt
actor: soviet
actor: Совет
guards:
- Совет отклонил заявку.
- Совет отклонил заявление.
- from: authorized
to: removed
action: capital::debtpaydcln
actor: gateway_operator
actor: Кассир
guards:
- Платёж не прошёл / отклонён Gateway.
- Платёж не прошёл — банк отклонил или вернул перевод.
- from: paid
to: writeoff
action: capital::closedebt
actor: Председатель совета
guards:
- Проект, под который был выдан заём, отменён.
- У пайщика на сегменте есть имущество, переданное в обеспечение.
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
scenario:
steps:
- step: 1
title: Подача заявления на заём
actor: contributor
title: Подача заявления
actor: Пайщик
action: capital::createdebt
description: >
Пайщик подаёт заявление на беспроцентный целевой заём.
Контракт создаёт запись долга со статусом `created` и обновляет
сегмент CRPS на запрошенную сумму.
- step: 2
title: Одобрение председателем
actor: chairman
actor: Председатель
action: capital::approvedebt
description: >
Председатель добавляет к заявке документ-одобрение. Статус
заявки → `approved`.
Председатель рассматривает заявление и при согласии одобряет его,
вынося на утверждение совету.
- step: 3
title: Авторизация советом и выдача займа
actor: soviet
title: Утверждение советом и поручение на выплату
actor: Совет
action: capital::debtauthcnfr
description: >
Совет авторизует выплату. В ledger2 применяется операция o.cap.lend
(ISSUE → w.cap.loan, Дт 58 / Кт 51), Gateway получает поручение на
исходящий платёж. Статус → `authorized`.
Совет утверждает выдачу займа. Кассиру передаётся поручение на
перечисление средств пайщику.
- step: 4
title: Подтверждение выплаты
actor: gateway_operator
actor: Кассир
action: capital::debtpaycnfrm
description: >
Gateway подтверждает зачисление средств пайщику. Статус → `paid`.
Запись долга остаётся в реестре до момента возврата через акт-2
проекта (операция o.cap.repay сработает в процессе p.cap.rid).
Кассир выполняет перевод и подтверждает зачисление средств пайщику.
С этого дня отсчитывается срок возврата — один год.
alternatives:
- branch: Отказ председателя или совета
at_step: 2
action: capital::declinedebt
actor: chairman
actor: Председатель
description: >
Заявка отклонена. Сегмент CRPS откатывается, запись долга удаляется,
операции в ledger2 не создаются.
- branch: Отказ Gateway
Заявление отклонено на этапе согласования. Деньги пайщику не
перечисляются.
- branch: Не прошёл платёж
at_step: 4
action: capital::debtpaydcln
actor: gateway_operator
actor: Кассир
description: >
Платёж не прошёл / отклонён. Запись долга удаляется.
Банк отклонил или вернул перевод. Заявление снимается; пайщик может
подать его заново.
- branch: Невозврат — закрытие через имущество-обеспечение
at_step: 4
action: capital::closedebt
actor: Председатель совета
description: >
Если проект, под который был выдан заём, отменён до сдачи
результата — долг закрывается автоматически за счёт имущества,
переданного пайщиком кооперативу в обеспечение по этому займу.
Сбора совета не требуется, подпись пайщика не требуется,
претензий к пайщику не предъявляется.
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
documents:
- step: 1
- action: capital::createdebt
title: Заявление на получение займа
registry_id: 1050
signed_by: [contributor]
signed_by: [Пайщик]
stored_in: debts.statement
- step: 2
- action: capital::approvedebt
title: Решение совета о предоставлении займа (одобрение председателем)
registry_id: 1051
signed_by: [chairman]
signed_by: [Председатель]
stored_in: debts.approved_statement
- step: 3
title: Решение совета о предоставлении займа (авторизация совета)
- action: capital::debtauthcnfr
title: Решение совета о предоставлении займа (утверждение советом)
registry_id: 1051
signed_by: [soviet]
signed_by: [Совет]
stored_in: debts.authorization
# ── Секция 6. Операции ──────────────────────────────────────────────────────
@@ -254,32 +256,65 @@ operations:
- ledger_code: o.cap.lend
human_name: Выдача пайщику беспроцентного займа
wallet_op: ISSUE
wallet_from: ''
wallet_to: w.cap.loan # Выданные пайщикам беспроцентные займы
debit: 58 # Финансовые вложения
credit: 51 # Расчётный счёт
debit: 58
credit: 51
wallet_from: null
wallet_to: w.cap.loan
amount_ref: debt.amount
triggered_by: capital::debtauthcnfr
description: >
Выдача беспроцентного займа пайщику. Деньги уходят с расчётного счёта
кооператива (Кт 51) и появляются как финансовое вложение Дт 58.
Кошелёк «Выданные пайщикам беспроцентные займы» (w.cap.loan) фиксирует
обязательство пайщика перед кооперативом.
Кооператив перечисляет пайщику беспроцентный целевой заём. На стороне
кооператива деньги уходят с расчётного счёта и фиксируются как
финансовое вложение в пайщика-должника.
- ledger_code: o.cap.repay
human_name: Возврат беспроцентного займа пайщика по акту-2
human_name: Возврат беспроцентного займа пайщика при сдаче результата
wallet_op: TRANSFER
wallet_from: w.cap.loan # Выданные пайщикам беспроцентные займы
wallet_to: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
debit: 80 # Паевой фонд (складочный капитал)
credit: 58 # Финансовые вложения
debit: 80
credit: 58
wallet_from: w.cap.loan
wallet_to: w.wal.share
user_wallet: w.wal.share
user_ref: debt.username
available_delta: +debt.amount
blocked_delta: null
amount_ref: debt.amount
triggered_by: capital::debtpaycnfrm
description: >
Возврат займа в момент сдачи акта-2 проекта (происходит как часть
процесса «Приём РИД» — p.cap.rid). Финансовое вложение закрывается
(Кт 58), сумма зачитывается в паевой фонд (Дт 80) и появляется как
доступный остаток на SHARE_FUND_PAY пайщика.
Происходит в момент сдачи результата работ по проекту
(подписанный акт приёма-передачи). Долг пайщика гасится, а сумма
займа зачитывается ему как паевой взнос — появляется как свободный
остаток на личном паевом кошельке пайщика.
- ledger_code: o.cap.crtnma
human_name: Создание имущества кооператива из обеспечения по займу
wallet_op: TRANSFER
debit: 4
credit: 8
wallet_from: w.cap.gen
wallet_to: w.cap.nma
amount_ref: debt.amount
triggered_by: capital::closedebt
description: >
Срабатывает при невозврате: проект, под который был выдан заём,
отменён до сдачи результата. Имущество, ранее переданное пайщиком
кооперативу в обеспечение, переходит в собственность кооператива
как нематериальный актив.
- ledger_code: o.cap.dbtwrf
human_name: Списание долга за счёт имущества-обеспечения
wallet_op: BURN
debit: 80
credit: 58
wallet_from: w.cap.loan
wallet_to: null
amount_ref: debt.amount
triggered_by: capital::closedebt
description: >
Парная операция к созданию имущества из обеспечения: обязательство
пайщика по займу закрывается. На паевой кошелёк пайщика ничего не
зачисляется — долг закрыт имущественно, через переданное в
обеспечение имущество.
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
related:
@@ -287,11 +322,20 @@ related:
id: public_capital_rid_process
relation: triggers
note: >
Возврат займа (o.cap.repay) технически происходит в процессе
«Приём РИД» при подписании акта-2 проекта.
Возврат займа физически происходит при сдаче результата работ по
проекту (подписанный акт приёма-передачи) — в процессе «Приём
результата интеллектуальной деятельности».
- process_type: p.reg.accept
id: public_registrator_accept_process
relation: provides
note: >
Заём может получить только активный пайщик кооператива.
- process_type: p.cap.rid
id: public_capital_rid_process
relation: affects
note: >
Закрытие долга через имущество-обеспечение уменьшает на сегменте
пайщика ту часть, которая раньше была передана в обеспечение и
теперь стала собственностью кооператива.
@@ -77,9 +77,12 @@ namespace operations {
inline constexpr eosio::name DROP_PREIMP = "o.cap.drppre"_n; ///< Закрытие пред-импорт-учёта при переходе на электронный учёт (Dr 80 / Cr 04, BURN PREIMP_FUND). Вызывается из capital::importcontr перед o.cap.import.
inline constexpr eosio::name LEND = "o.cap.lend"_n; ///< Выдача беспроцентного займа пайщику (Dr 58 / Cr 51, ISSUE LOAN_ISSUED).
inline constexpr eosio::name REPAY = "o.cap.repay"_n; ///< Возврат займа пайщика по акту-2 (Dr 80 / Cr 58, TRANSFER LOAN_ISSUED → SHARE_FUND_PAY).
inline constexpr eosio::name CREATE_NMA = "o.cap.crtnma"_n; ///< Создать НМА кооператива из коммитов-обеспечения долга при невозврате займа (Dr 04 / Cr 08, TRANSFER GENERATOR_FUND → NMA). Решение 2026-05-19.
inline constexpr eosio::name DEBT_WRITEOFF = "o.cap.dbtwrf"_n; ///< Списание долга (write-off) за счёт созданного НМА (Dr 80 / Cr 58, BURN LOAN_ISSUED). Зеркало REPAY без зачисления на share пайщика — долг закрыт имущественно. Решение 2026-05-19.
inline constexpr eosio::name WITHDRAW_FROM_CAPITAL = "o.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY, без Dr/Cr).
inline constexpr eosio::name CONVERT_TO_SHARE = "o.cap.cnvshr"_n; ///< Конвертация сегмента: РИД → главный кошелёк (TRANSFER GENERATOR_FUND → SHARE_FUND_PAY, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
inline constexpr eosio::name CONVERT_TO_BLAGO = "o.cap.cnvbl"_n; ///< Конвертация сегмента: РИД → ЦПП «Благорост» (TRANSFER GENERATOR_FUND → BLAGOROST_FUND, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
inline constexpr eosio::name PAY_EXPENSE = "o.cap.expns"_n; ///< Оплата целевого расхода программы (Dr 86 / Cr 51, TRANSFER BLAGOROST_FUND → SOV_EXPENSES). Списание целевого финансирования на расчётный счёт.
}
// marketplace
@@ -252,6 +255,28 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
ledger2_accounts::SHARE_FUND, ledger2_accounts::FINANCIAL_INVESTMENTS,
"Возврат беспроцентного займа пайщика по акту-2" },
// 11a. Создать НМА кооператива из коммитов-обеспечения долга при невозврате:
// Dr 04 / Cr 08, TRANSFER GENERATOR_FUND → NMA.
// Срабатывает автоматически из `capital::closedebt`, когда родительский
// компонент перешёл в `cancelled` (решение 2026-05-19 — без сбора совета,
// без подписи пайщика). РИД-коммиты, накопленные на GENERATOR_FUND пайщика
// как обеспечение долга, формируют НМА кооператива на отдельном кошельке
// `w.cap.nma`. Пара по 04 зеркалит `o.cap.accept`, но без участия пайщика —
// сегмент пайщика уменьшается на сумму операции.
{ operations::capital::CREATE_NMA, processes::capital::DEBT, WalletOp::TRANSFER,
ledger2_wallets::GENERATOR_FUND, ledger2_wallets::NMA,
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::NON_CURRENT_INVESTMENTS,
"Создание НМА кооператива из коммитов-обеспечения долга" },
// 11b. Списание долга (write-off) за счёт созданного НМА: Dr 80 / Cr 58, BURN LOAN_ISSUED.
// Парная к `o.cap.crtnma` операция, закрывает обязательство пайщика по займу.
// Зеркало `o.cap.repay`, но без зачисления на share пайщика (долг закрыт
// имущественно — через НМА из коммитов-обеспечения). Решение 2026-05-19.
{ operations::capital::DEBT_WRITEOFF, processes::capital::DEBT, WalletOp::BURN,
ledger2_wallets::LOAN_ISSUED, eosio::name{},
ledger2_accounts::SHARE_FUND, ledger2_accounts::FINANCIAL_INVESTMENTS,
"Списание долга (write-off) за счёт созданного НМА" },
// 12. Подтверждение поставки: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
{ operations::marketplace::CONFIRM_SUPPLY, processes::marketplace::REQUEST, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
@@ -305,6 +330,16 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
0, 0,
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
// 20. Оплата целевого расхода программы: Dr 86 / Cr 51, TRANSFER BLAGOROST_FUND → SOV_EXPENSES.
// Закрывает TODO в exppaycnfrm.cpp: списывает сумму расхода из программного фонда
// «Благорост» в пул «Хозяйственные расходы из числа целевого финансирования»;
// одновременно фиксируется бухпроводка по уменьшению целевого финансирования (86)
// и списанию с расчётного счёта (51).
{ operations::capital::PAY_EXPENSE, processes::capital::EXPENSE, WalletOp::TRANSFER,
ledger2_wallets::BLAGOROST_FUND, ledger2_wallets::SOV_EXPENSES,
ledger2_accounts::TARGET_RECEIPTS, ledger2_accounts::BANK_ACCOUNT,
"Оплата целевого расхода по программе «Благорост»" },
// ----- Миграционные (o.mig.*) — вызываются только из migrate.cpp -----
// 15. Миграция: минимальный паевой: Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND
@@ -48,11 +48,12 @@ namespace processes {
namespace capital {
inline constexpr eosio::name IMPORT = "p.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (o.cap.drppre (опц.) + o.cap.import).
inline constexpr eosio::name INVEST = "p.cap.invest"_n; ///< Инвестиция в ЦПП Благорост (wallet-only 2001→9001).
inline constexpr eosio::name DEBT = "p.cap.debt"_n; ///< Займ пайщику (o.cap.lend + o.cap.repay).
inline constexpr eosio::name DEBT = "p.cap.debt"_n; ///< Займ пайщику (o.cap.lend + o.cap.repay; альтернативная ветка невозврата — o.cap.crtnma + o.cap.dbtwrf, решение 2026-05-19).
inline constexpr eosio::name RID = "p.cap.rid"_n; ///< Приём РИД в паевой фонд: o.cap.commit (коммиты) + o.cap.accept (акт-2) + опц. o.cap.repay + o.cap.cnvshr/o.cap.cnvbl (финальная конвертация сегмента). Анкер процесса — result_hash.
inline constexpr eosio::name PROPERTY = "p.cap.prop"_n; ///< Приём имущественного паевого взноса (одноактовый).
inline constexpr eosio::name PREIMP = "p.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (одноактовый, anchor = preimp register hash).
inline constexpr eosio::name WTHCAP = "p.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (одноактовый).
inline constexpr eosio::name EXPENSE = "p.cap.expns"_n; ///< Целевой расход программы (создание → одобрение председателя → авторизация совета → outpay → подтверждение оплаты). Anchor — expense_hash.
}
// marketplace
@@ -63,6 +63,7 @@ struct ledger2_wallets {
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
static constexpr eosio::name NMA = "w.cap.nma"_n; ///< НМА кооператива — собственное имущество, перешедшее из коммитов-обеспечения при невозврате займа (COOPERATIVE; решение 2026-05-19)
// marketplace — выплаты
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
@@ -94,7 +95,7 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (6) — L3-разрез по пайщику
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
@@ -103,7 +104,7 @@ inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
// COOPERATIVE (8) — единый кооперативный баланс, без L3
// COOPERATIVE (9) — единый кооперативный баланс, без L3
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам", WalletKind::COOPERATIVE },
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
@@ -112,6 +113,7 @@ inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
{ ledger2_wallets::NMA, "НМА кооператива (из коммитов-обеспечения по займам)", WalletKind::COOPERATIVE },
}};
static constexpr size_t LEDGER2_WALLET_REGISTRY_SIZE = LEDGER2_WALLET_REGISTRY.size();
@@ -50,6 +50,12 @@ namespace Names {
// Коллбэки для расходов
constexpr eosio::name AUTHORIZE_EXPENSE = "capauthexpns"_n;
constexpr eosio::name DECLINE_EXPENSE = "capdeclexpns"_n;
// Коллбэки для программных расходов
// (CREATE_PROGRAM_EXPENSE объявлен ниже в блоке "акцептов" — общий для обоих контекстов)
constexpr eosio::name AUTHORIZE_PROGRAM_EXPENSE = "authpgexp"_n;
constexpr eosio::name DECLINE_PROGRAM_EXPENSE = "declpgexp"_n;
constexpr eosio::name CONFIRM_PROGRAM_EXPENSE_PAYMENT = "pgexppay"_n;
// Коллбэки для долгов
constexpr eosio::name AUTHORIZE_DEBT = "debtauthcnfr"_n;
@@ -91,6 +97,7 @@ namespace Names {
constexpr eosio::name CAPITAL_WITHDRAW_FROM_PROGRAM = "capwthdrprog"_n;
constexpr eosio::name CAPITAL_WITHDRAW_FROM_PROJECT = "capwthdrproj"_n;
constexpr eosio::name CAPITAL_RESOLVE_EXPENSE = "capresexpns"_n;
constexpr eosio::name CAPITAL_RESOLVE_PROGRAM_EXPENSE = "caprespgexp"_n;
constexpr eosio::name CREATE_RESULT = "createresult"_n;
constexpr eosio::name CREATE_DEBT = "createdebt"_n;
constexpr eosio::name CREATE_PROJECT = "createprj"_n;
@@ -120,6 +127,7 @@ namespace Names {
constexpr eosio::name CREATE_INVESTMENT = "createinvest"_n; // акцепт инвестиции
constexpr eosio::name CREATE_PROGRAM_INVESTMENT = "createpinv"_n; // акцепт программной инвестиции
constexpr eosio::name CREATE_EXPENSE = "createexpnse"_n; // акцепт расхода
constexpr eosio::name CREATE_PROGRAM_EXPENSE = "createpgexp"_n; // акцепт программного расхода
constexpr eosio::name CREATE_WITHDRAW_1 = "createwthd1"_n; // акцепт возврата из задания
constexpr eosio::name CREATE_WITHDRAW_2 = "createwthd2"_n; // акцепт возврата из проекта
constexpr eosio::name CREATE_WITHDRAW_3 = "createwthd3"_n; // акцепт возврата из программы
@@ -19,6 +19,11 @@ struct [[eosio::table, eosio::contract(LOAN)]] debt {
time_point_sec created_at;
time_point_sec repaid_at;
// Контракт-источник, инициировавший выдачу займа (capital / marketplace / ...).
// loan не знает доменного контекста (проекта, программы) — это ответственность
// вызывающего контракта; ему же принадлежит и локальная привязка debt_hash.
name source_contract;
uint64_t primary_key() const { return id; }
uint64_t by_username() const { return username.value; }
checksum256 by_debt_hash() const { return debt_hash; }
@@ -2,10 +2,13 @@ using namespace Loan;
/**
* @brief Создание долгового обязательства.
* Создает новое долговое обязательство для пользователя с указанным сроком погашения
* Создает новое долговое обязательство пайщика. loan-контракт независим от
* прикладных контрактов — здесь нет привязки к проекту/программе/компоненту.
* Локальный контекст (project_hash и т.п.) хранится на стороне вызывающего
* контракта в его собственной таблице долгов.
* @param coopname Наименование кооператива
* @param username Имя пользователя-должника
* @param debt_hash Хэш долгового обязательства
* @param debt_hash Хэш долгового обязательства (идентификатор)
* @param repaid_at Срок погашения долга
* @param quantity Сумма долга
* @ingroup public_actions
@@ -33,6 +36,7 @@ void loan::createdebt(name coopname, name username, checksum256 debt_hash, time_
d.amount = quantity;
d.created_at = time_point_sec(current_time_point());
d.repaid_at = repaid_at;
d.source_contract = payer;
});
Loan::summaries_index summaries(_loan, coopname.value);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
+56 -73
View File
@@ -1805,7 +1805,7 @@ type CapitalCommit {
data: JSON
"""Описание коммита"""
description: String!
description: String
"""Отображаемое имя пользователя"""
display_name: String
@@ -1814,7 +1814,7 @@ type CapitalCommit {
id: Int
"""Метаданные коммита"""
meta: String!
meta: String
"""Флаг присутствия записи в блокчейне"""
present: Boolean!
@@ -6161,6 +6161,53 @@ input GenerationContractSignedMetaDocumentInput {
version: String!
}
input GenerationConvertStatementGenerateDocumentInput {
"""Сумма для перевода в программу «Благорост»"""
blagorost_wallet_amount: String!
"""Номер блока, на котором был создан документ"""
block_num: Int
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String
"""Имя генератора, использованного для создания документа"""
generator: String
"""Язык документа"""
lang: String
"""Ссылки, связанные с документом"""
links: [String!]
"""Сумма для перевода в Цифровой Кошелёк"""
main_wallet_amount: String!
"""Хэш проекта"""
project_hash: String!
"""Часовой пояс, в котором был создан документ"""
timezone: String
"""Название документа"""
title: String
"""Признак перевода в программу «Благорост»"""
to_blagorost: Boolean!
"""Признак перевода в Цифровой Кошелёк"""
to_wallet: Boolean!
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String
}
input GenerationMoneyInvestStatementGenerateDocumentInput {
"""Сумма инвестирования"""
amount: String!
@@ -6272,56 +6319,6 @@ input GenerationMoneyInvestStatementSignedMetaDocumentInput {
version: String!
}
input GenerationToMainWalletConvertStatementGenerateDocumentInput {
"""Хэш приложения"""
appendix_hash: String!
"""Сумма для перевода на благорост кошелек"""
blagorost_wallet_amount: String!
"""Номер блока, на котором был создан документ"""
block_num: Int
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String
"""Имя генератора, использованного для создания документа"""
generator: String
"""Язык документа"""
lang: String
"""Ссылки, связанные с документом"""
links: [String!]
"""Сумма для перевода на основной кошелек"""
main_wallet_amount: String!
"""Хэш проекта"""
project_hash: String!
"""Часовой пояс, в котором был создан документ"""
timezone: String
"""Название документа"""
title: String
"""Перевод на благорост кошелек"""
to_blagorost: Boolean!
"""Перевод на основной кошелек"""
to_wallet: Boolean!
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String
}
input GetAccountInput {
"""Имя аккаунта пользователя"""
username: String!
@@ -7685,6 +7682,13 @@ type Mutation {
"""
capitalGenerateGenerationContract(data: GenerationContractGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationConvertStatement(data: GenerationConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление об инвестировании в генерацию
@@ -7713,27 +7717,6 @@ type Mutation {
"""
capitalGenerateGenerationPropertyInvestStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации из генерации в благорост
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationToCapitalizationConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации из генерации в основной кошелек
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationToMainWalletConvertStatement(data: GenerationToMainWalletConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации из генерации в проектный кошелек
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationToProjectConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать решение о получении займа
@@ -0,0 +1,64 @@
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
import { IsString, IsBoolean } from 'class-validator';
import { Cooperative } from 'cooptypes';
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
// утилита для выборки повторяющихся параметров из базовых интерфейсов
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id' | 'appendix_hash'>;
// интерфейс параметров для генерации
type action = Cooperative.Registry.GenerationConvertStatement.Action;
@InputType(`BaseGenerationConvertStatementMetaDocumentInput`)
class BaseGenerationConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
@Field({ description: 'Хэш проекта' })
@IsString()
project_hash!: string;
@Field({ description: 'Сумма для перевода в Цифровой Кошелёк' })
@IsString()
main_wallet_amount!: string;
@Field({ description: 'Сумма для перевода в программу «Благорост»' })
@IsString()
blagorost_wallet_amount!: string;
@Field({ description: 'Признак перевода в Цифровой Кошелёк' })
@IsBoolean()
to_wallet!: boolean;
@Field({ description: 'Признак перевода в программу «Благорост»' })
@IsBoolean()
to_blagorost!: boolean;
}
@InputType(`GenerationConvertStatementGenerateDocumentInput`)
export class GenerationConvertStatementGenerateDocumentInputDTO extends IntersectionType(
BaseGenerationConvertStatementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
constructor() {
super();
}
}
@InputType(`GenerationConvertStatementSignedMetaDocumentInput`)
export class GenerationConvertStatementSignedMetaDocumentInputDTO
extends IntersectionType(BaseGenerationConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
implements action {
@Field({ description: 'Хэш приложения к проекту' })
@IsString()
appendix_hash!: string;
}
@InputType(`GenerationConvertStatementSignedDocumentInput`)
export class GenerationConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => GenerationConvertStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация для документа заявления о конвертации целевого паевого взноса',
})
public readonly meta!: GenerationConvertStatementSignedMetaDocumentInputDTO;
}
@@ -1,64 +0,0 @@
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
import { IsString, IsBoolean } from 'class-validator';
import { Cooperative } from 'cooptypes';
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
// утилита для выборки повторяющихся параметров из базовых интерфейсов
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id'>;
// интерфейс параметров для генерации
type action = Cooperative.Registry.GenerationToMainWalletConvertStatement.Action;
@InputType(`BaseGenerationToMainWalletConvertStatementMetaDocumentInput`)
class BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
@Field({ description: 'Хэш приложения' })
@IsString()
appendix_hash!: string;
@Field({ description: 'Хэш проекта' })
@IsString()
project_hash!: string;
@Field({ description: 'Сумма для перевода на основной кошелек' })
@IsString()
main_wallet_amount!: string;
@Field({ description: 'Сумма для перевода на благорост кошелек' })
@IsString()
blagorost_wallet_amount!: string;
@Field({ description: 'Перевод на основной кошелек' })
@IsBoolean()
to_wallet!: boolean;
@Field({ description: 'Перевод на благорост кошелек' })
@IsBoolean()
to_blagorost!: boolean;
}
@InputType(`GenerationToMainWalletConvertStatementGenerateDocumentInput`)
export class GenerationToMainWalletConvertStatementGenerateDocumentInputDTO extends IntersectionType(
BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
constructor() {
super();
}
}
@InputType(`GenerationToMainWalletConvertStatementSignedMetaDocumentInput`)
export class GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO
extends IntersectionType(BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
implements action {}
@InputType(`GenerationToMainWalletConvertStatementSignedDocumentInput`)
export class GenerationToMainWalletConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация для документа заявления о переводе части целевого паевого взноса',
})
public readonly meta!: GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO;
}
@@ -1,5 +1,5 @@
import { Field, InputType } from '@nestjs/graphql';
import { ValidateNested } from 'class-validator';
import { IsBoolean, IsOptional, ValidateNested } from 'class-validator';
import { CreateInitOrganizationDataInputDTO } from '~/application/account/dto/create-organization-data-input.dto';
@InputType('Init')
@@ -9,4 +9,13 @@ export class InitDTO {
})
@ValidateNested()
organization_data!: CreateInitOrganizationDataInputDTO;
@Field(() => Boolean, {
nullable: true,
description:
'Признак того, что инициализация выполняется со стороны провайдера. При true coopback ставит init_by_server=true (org_data становится readonly для пользовательского визарда). Поле передаёт provider в callInitSystemMutation.',
})
@IsBoolean()
@IsOptional()
is_server_init?: boolean;
}
@@ -27,10 +27,14 @@ export class InitInteractor {
}
// Определяем тип инициализации:
// - Если mono не существует - это ПЕРВАЯ инициализация (может быть как серверная так и пользовательская)
// - Если existingMono.init_by_server уже установлен - сохраняем его (не меняем источник)
// - Если mono существует но init_by_server не установлен - значит это пользовательская инициализация
const isServerInit = !existingMono ? true : existingMono.init_by_server === true;
// - Если data.is_server_init === true (вызов от провайдера через server-secret) — ВСЕГДА серверная.
// Это разблокирует перезапись user-init данных провайдером при следующем initSystem.
// - Иначе сохраняем прежнюю логику: первая инициализация = серверная, повторная — наследует флаг.
const isServerInit = data.is_server_init === true
? true
: !existingMono
? true
: existingMono.init_by_server === true;
// Проверяем права на обновление:
// Пользователь не может обновлять данные, установленные сервером
@@ -58,11 +62,11 @@ export class InitInteractor {
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
}
// Устанавливаем флаг источника инициализации только если это первая инициализация
// - Для серверной инициализации (первый раз): устанавливаем true
// - Для пользовательской инициализации (первый раз): устанавливаем false
// - При повторной инициализации: НЕ меняем флаг (сохраняем изначальный источник)
if (!existingMono) {
// Устанавливаем флаг источника инициализации:
// - Первая инициализация: ставим isServerInit как есть (true для server-side, true для user-side при первом init — историческое поведение).
// - Повторная: если is_server_init=true пришёл от провайдера — поднимаем флаг в true (даже если до этого было user-init).
// Это нужно чтобы провайдер мог перезаписать данные после того как пользователь успел заполнить форму первым.
if (!existingMono || data.is_server_init === true) {
await this.monoStatusRepository.setInitByServer(isServerInit);
}
+8 -5
View File
@@ -93,8 +93,9 @@ const envVarsSchema = z.object({
.default('https://союз-русь.рф/anketa')
.describe('ссылка на анкету для получения членства в союзе кооперативов'),
IS_UNIONED: z
.boolean()
.default(true)
.string()
.default('true')
.transform((v) => v === 'true')
.describe('флаг, указывающий что требуется членство в союзе для подключения к кооперативной экономике'),
MATRIX_UNION_PERSON_ID: z.string().optional().describe('Matrix userId представителя союза для связи с кооперативами'),
MATRIX_UNION_NAME: z.string().default('СПО РУСЬ').describe('Название союза для подписания комнат связи'),
@@ -171,11 +172,13 @@ const envVarsSchema = z.object({
WHISPER_MODEL: z.string().default('whisper-1').describe('Модель Whisper для STT'),
WHISPER_LANGUAGE: z.string().default('ru').describe('Язык для Whisper STT'),
// Файловое хранилище (MinIO в dev/контуре кооператива; S3 в проде по плану E59-N)
// Файловое хранилище (MinIO в dev/контуре кооператива; S3 в проде по плану E59-N).
// MINIO_ENDPOINT без default: если не задан — file storage стартует в no-op,
// HeadBucket не делается, операции get/put отдают понятную ошибку.
MINIO_ENDPOINT: z
.string()
.default('http://minio:9000')
.describe('Endpoint S3-совместимого бэкенда; в compose — service name'),
.optional()
.describe('Endpoint S3-совместимого бэкенда; в compose — service name. Пусто = file storage отключён.'),
MINIO_ACCESS_KEY: z.string().default('minioadmin').describe('Access-key для MinIO/S3'),
MINIO_SECRET_KEY: z.string().default('minioadmin').describe('Secret-key для MinIO/S3'),
MINIO_BUCKET: z
@@ -2,4 +2,8 @@ import type { CreateOrganizationDataInputDomainInterface } from '~/domain/accoun
export interface InitInputDomainInterface {
organization_data: CreateOrganizationDataInputDomainInterface;
// Если true — инициализация инициирована провайдером (через server-secret),
// флаг init_by_server проставляется в true безусловно. Если undefined/false —
// сохраняется текущая логика: setInitByServer(true) только при первой init.
is_server_init?: boolean;
}
@@ -90,14 +90,16 @@ export class CommitOutputDTO extends BaseOutputDTO {
commit_hash!: string;
@Field(() => String, {
nullable: true,
description: 'Описание коммита',
})
description!: string;
description?: string;
@Field(() => String, {
nullable: true,
description: 'Метаданные коммита',
})
meta!: string;
meta?: string;
@Field(() => GraphQLJSON, {
nullable: true,
@@ -7,9 +7,11 @@ import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { GenerationToMainWalletConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-to-main-wallet-convert-statement-document.dto';
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GenerateDocumentInputDTO } from '~/application/document/dto/generate-document-input.dto';
@@ -57,60 +59,24 @@ export class DistributionManagementResolver {
// ============ ГЕНЕРАЦИЯ ДОКУМЕНТОВ ============
/**
* Мутация для генерации заявления о конвертации из генерации в основной кошелек
* Мутация для генерации заявления о конвертации целевого паевого взноса
* (универсальный шаблон: в Цифровой Кошелёк и/или в программу «Благорост»)
*/
@Mutation(() => GeneratedDocumentDTO, {
name: 'capitalGenerateGenerationToMainWalletConvertStatement',
description: 'Сгенерировать заявление о конвертации из генерации в основной кошелек',
name: 'capitalGenerateGenerationConvertStatement',
description: 'Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateGenerationToMainWalletConvertStatement(
@Args('data', { type: () => GenerationToMainWalletConvertStatementGenerateDocumentInputDTO })
data: GenerationToMainWalletConvertStatementGenerateDocumentInputDTO,
async generateGenerationConvertStatement(
@Args('data', { type: () => GenerationConvertStatementGenerateDocumentInputDTO })
data: GenerationConvertStatementGenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO
options: GenerateDocumentOptionsInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<GeneratedDocumentDTO> {
return this.distributionManagementService.generateGenerationToMainWalletConvertStatement(data, options);
}
/**
* Мутация для генерации заявления о конвертации из генерации в проектный кошелек
*/
@Mutation(() => GeneratedDocumentDTO, {
name: 'capitalGenerateGenerationToProjectConvertStatement',
description: 'Сгенерировать заявление о конвертации из генерации в проектный кошелек',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateGenerationToProjectConvertStatement(
@Args('data', { type: () => GenerateDocumentInputDTO })
data: GenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return this.distributionManagementService.generateGenerationToProjectConvertStatement(data, options);
}
/**
* Мутация для генерации заявления о конвертации из генерации в благорост
*/
@Mutation(() => GeneratedDocumentDTO, {
name: 'capitalGenerateGenerationToCapitalizationConvertStatement',
description: 'Сгенерировать заявление о конвертации из генерации в благорост',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateGenerationToCapitalizationConvertStatement(
@Args('data', { type: () => GenerateDocumentInputDTO })
data: GenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return this.distributionManagementService.generateGenerationToCapitalizationConvertStatement(data, options);
return this.distributionManagementService.generateGenerationConvertStatement(data, options, currentUser);
}
/**
@@ -5,10 +5,11 @@ import type { RefreshProgramInputDTO } from '../dto/distribution_management/refr
import type { TransactResult } from '@wharfkit/session';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { GenerationToMainWalletConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-to-main-wallet-convert-statement-document.dto';
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
import { DocumentInteractor } from '~/application/document/interactors/document.interactor';
import { Cooperative } from 'cooptypes';
import type { GenerateDocumentInputDTO } from '~/application/document/dto/generate-document-input.dto';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
/**
* Сервис уровня приложения для управления распределением в CAPITAL
@@ -40,50 +41,19 @@ export class DistributionManagementService {
// ============ МЕТОДЫ ГЕНЕРАЦИИ ДОКУМЕНТОВ ============
/**
* Генерация заявления о конвертации из генерации в основной кошелек
* Генерация заявления о конвертации целевого паевого взноса
* (универсальный шаблон: в Цифровой Кошелёк и/или в программу «Благорост»)
*/
async generateGenerationToMainWalletConvertStatement(
data: GenerationToMainWalletConvertStatementGenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO
async generateGenerationConvertStatement(
data: GenerationConvertStatementGenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO,
currentUser: MonoAccountDomainInterface
): Promise<GeneratedDocumentDTO> {
const enrichedData = await this.distributionManagementInteractor.prepareGenerationConvertStatementData(data, currentUser);
const document = await this.documentInteractor.generateDocument({
data: {
...data,
registry_id: Cooperative.Registry.GenerationToMainWalletConvertStatement.registry_id,
},
options,
});
return document as GeneratedDocumentDTO;
}
/**
* Генерация заявления о конвертации из генерации в проектный кошелек
*/
async generateGenerationToProjectConvertStatement(
data: GenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
const document = await this.documentInteractor.generateDocument({
data: {
...data,
registry_id: Cooperative.Registry.GenerationToProjectConvertStatement.registry_id,
},
options,
});
return document as GeneratedDocumentDTO;
}
/**
* Генерация заявления о конвертации из генерации в благорост
*/
async generateGenerationToCapitalizationConvertStatement(
data: GenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
const document = await this.documentInteractor.generateDocument({
data: {
...data,
registry_id: Cooperative.Registry.GenerationToCapitalizationConvertStatement.registry_id,
...enrichedData,
registry_id: Cooperative.Registry.GenerationConvertStatement.registry_id,
},
options,
});
@@ -125,7 +125,7 @@ export class MutationLogMapperService {
// Результаты и конвертация
capitalPushResult: LogEventType.RESULT_PUSHED,
capitalConvertSegment: LogEventType.SEGMENT_CONVERTED,
capitalGenerateGenerationToMainWalletConvertStatement: LogEventType.PROJECT_WITHDRAWAL,
capitalGenerateGenerationConvertStatement: LogEventType.PROJECT_WITHDRAWAL,
capitalGenerateCapitalizationToMainWalletConvertStatement: LogEventType.PROGRAM_WITHDRAWAL,
// Генерация - Stories, Issues, Cycles
@@ -41,6 +41,14 @@ export class TimeTrackingService {
await this.timeTrackingInteractor.recalcDoneEstimatesForContributorProject(contributorHash, projectHash);
}
/**
* Откатить time-entries отклонённого коммита обратно в uncommitted и нормализовать
* раскладку estimate-долей для затронутых задач.
*/
async revertEntriesForDeclinedCommit(commitHash: string): Promise<void> {
await this.timeTrackingInteractor.revertEntriesForDeclinedCommit(commitHash);
}
/**
* Получить статистику времени для участника по проекту (DTO версия)
*/
@@ -3,6 +3,10 @@ import { CapitalBlockchainPort, CAPITAL_BLOCKCHAIN_PORT } from '../../domain/int
import type { TransactResult } from '@wharfkit/session';
import type { FundProgramDomainInput } from '../../domain/actions/fund-program-domain-input.interface';
import type { RefreshProgramDomainInput } from '../../domain/actions/refresh-program-domain-input.interface';
import { APPENDIX_REPOSITORY, AppendixRepository } from '../../domain/repositories/appendix.repository';
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { Cooperative } from 'cooptypes';
/**
* Интерактор домена для распределения средств в CAPITAL контракте
@@ -12,7 +16,9 @@ import type { RefreshProgramDomainInput } from '../../domain/actions/refresh-pro
export class DistributionManagementInteractor {
constructor(
@Inject(CAPITAL_BLOCKCHAIN_PORT)
private readonly capitalBlockchainPort: CapitalBlockchainPort
private readonly capitalBlockchainPort: CapitalBlockchainPort,
@Inject(APPENDIX_REPOSITORY)
private readonly appendixRepository: AppendixRepository
) {}
/**
@@ -32,4 +38,31 @@ export class DistributionManagementInteractor {
return await this.capitalBlockchainPort.refreshProgram(data);
}
/**
* Подготавливает данные для генерации заявления о конвертации целевого паевого взноса.
* appendix_hash подтягивается по (username, project_hash) из подтверждённого приложения к проекту.
*/
async prepareGenerationConvertStatementData(
data: GenerationConvertStatementGenerateDocumentInputDTO,
currentUser: MonoAccountDomainInterface
): Promise<Cooperative.Registry.GenerationConvertStatement.Action> {
const projectHash = data.project_hash;
if (!projectHash) {
throw new Error('project_hash обязателен для генерации заявления о конвертации');
}
const userAppendix = await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
currentUser.username,
projectHash
);
if (!userAppendix) {
throw new Error(`Не найдено подтверждённое приложение пользователя ${currentUser.username} для проекта ${projectHash}`);
}
return {
...data,
appendix_hash: userAppendix.appendix_hash,
} as Cooperative.Registry.GenerationConvertStatement.Action;
}
}
@@ -358,6 +358,11 @@ export class GenerationInteractor {
commit.status = CommitStatus.DECLINED;
await this.commitRepository.save(commit);
// Откатываем time-entries обратно в uncommitted и нормализуем раскладку долей
// по затронутым DONE-задачам. Иначе часы остаются в total_committed_hours и
// не возвращаются в доступный пул.
await this.timeTrackingService.revertEntriesForDeclinedCommit(data.commit_hash);
// Создаём данные для блокчейна
const blockchainData: CapitalContract.Actions.CommitDecline.ICommitDecline = {
coopname: data.coopname,
@@ -429,6 +434,10 @@ export class GenerationInteractor {
// Сохранить изменения
await this.commitRepository.save(commit);
// Откатить time-entries и нормализовать раскладку (идемпотентно: если decline
// уже был обработан через локальный путь, revert вернёт 0 затронутых).
await this.timeTrackingService.revertEntriesForDeclinedCommit(actionPayload.commit_hash);
this.logger.debug(`Коммит ${actionPayload.commit_hash} отклонен`);
} catch (error: any) {
this.logger.error(`Ошибка при обработке отклонения коммита: ${error?.message}`, error?.stack);
@@ -0,0 +1,536 @@
import { TimeTrackingInteractor } from './time-tracking.interactor';
import type { TimeEntryRepository } from '../../domain/repositories/time-entry.repository';
import type { ProjectRepository } from '../../domain/repositories/project.repository';
import type { ContributorRepository } from '../../domain/repositories/contributor.repository';
import type { IssueRepository } from '../../domain/repositories/issue.repository';
import { TimeEntryDomainEntity } from '../../domain/entities/time-entry.entity';
import { IssueStatus } from '../../domain/enums/issue-status.enum';
import { IssuePriority } from '../../domain/enums/issue-priority.enum';
import { IssueDomainEntity } from '../../domain/entities/issue.entity';
import type { ContributorDomainEntity } from '../../domain/entities/contributor.entity';
import { ContributorStatus } from '../../domain/enums/contributor-status.enum';
// Сценарии калиброваны по проду voskhod (issue CC7-1, estimate=15, 3 creators)
// чтобы локально воспроизводить найденные баги и предотвращать регрессию.
type Mocked<T> = { [K in keyof T]: jest.Mock };
function makeContributor(username: string, hash: string, coopname = 'voskhod'): ContributorDomainEntity {
return {
contributor_hash: hash,
username,
coopname,
display_name: username,
status: ContributorStatus.ACTIVE,
hours_per_day: 8,
} as unknown as ContributorDomainEntity;
}
function makeIssue(opts: {
issue_hash: string;
project_hash?: string;
coopname?: string;
estimate: number;
status: IssueStatus;
creators: string[];
id?: string;
}): IssueDomainEntity {
return {
id: opts.id ?? 'TST-1',
issue_hash: opts.issue_hash,
project_hash: opts.project_hash ?? 'project-hash-1',
coopname: opts.coopname ?? 'voskhod',
title: 'Test issue',
priority: IssuePriority.MEDIUM,
status: opts.status,
estimate: opts.estimate,
creators: opts.creators,
created_by: opts.creators[0] ?? 'ant',
sort_order: 0,
metadata: { labels: [], attachments: [] },
} as unknown as IssueDomainEntity;
}
function makeEntry(opts: {
contributor_hash: string;
issue_hash: string;
hours: number;
is_committed: boolean;
entry_type?: 'hourly' | 'estimate';
estimate_snapshot?: number;
commit_hash?: string;
_id?: string;
date?: string;
}): TimeEntryDomainEntity {
return new TimeEntryDomainEntity({
_id: opts._id ?? `e-${Math.random()}`,
contributor_hash: opts.contributor_hash,
issue_hash: opts.issue_hash,
project_hash: 'project-hash-1',
coopname: 'voskhod',
date: opts.date ?? '2026-05-15',
hours: opts.hours,
is_committed: opts.is_committed,
entry_type: opts.entry_type ?? 'estimate',
estimate_snapshot: opts.estimate_snapshot,
commit_hash: opts.commit_hash,
block_num: 0,
present: false,
status: 'active',
});
}
function buildInteractor() {
const timeEntryRepository: Mocked<TimeEntryRepository> = {
create: jest.fn().mockImplementation(async (e: TimeEntryDomainEntity) => e),
findByContributorAndDate: jest.fn().mockResolvedValue([]),
findUncommittedByContributor: jest.fn().mockResolvedValue([]),
findUncommittedByProjectAndContributor: jest.fn().mockResolvedValue([]),
update: jest.fn().mockImplementation(async (e: TimeEntryDomainEntity) => e),
updateMany: jest.fn().mockResolvedValue(undefined),
getTotalUncommittedHours: jest.fn().mockResolvedValue(0),
getContributorProjectStats: jest
.fn()
.mockResolvedValue({ total_committed_hours: 0, total_uncommitted_hours: 0 }),
commitTimeEntries: jest.fn().mockResolvedValue(undefined),
revertCommittedEntriesByCommitHash: jest.fn().mockResolvedValue(0),
findCommittedByCommitHash: jest.fn().mockResolvedValue([]),
delete: jest.fn().mockResolvedValue(undefined),
deleteUncommittedByIssueHash: jest.fn().mockResolvedValue(undefined),
findProjectsByContributor: jest.fn().mockResolvedValue([]),
findContributorsByProject: jest.fn().mockResolvedValue([]),
findByProjectWithPagination: jest
.fn()
.mockResolvedValue({ items: [], totalCount: 0, currentPage: 1, totalPages: 0 }),
getAggregatedTimeEntriesByIssues: jest.fn().mockResolvedValue([]),
getAggregatedTimeEntriesCount: jest.fn().mockResolvedValue(0),
findByIssueAndType: jest.fn().mockResolvedValue([]),
getTotalEstimateHoursByIssue: jest.fn().mockResolvedValue({ total: 0, estimate_snapshot: 0 }),
hasCommittedTimeByIssueHash: jest.fn().mockResolvedValue(false),
updateProjectHashByIssueHash: jest.fn().mockResolvedValue(undefined),
getFactByIssues: jest.fn().mockResolvedValue(new Map()),
};
const contributorRepository: Partial<Mocked<ContributorRepository>> = {
findByUsernameAndCoopname: jest.fn().mockResolvedValue(null),
findOne: jest.fn().mockResolvedValue(null),
findByStatusAndCoopname: jest.fn().mockResolvedValue([]),
};
const issueRepository: Partial<Mocked<IssueRepository>> = {
findByIssueHash: jest.fn().mockResolvedValue(null),
findByStatus: jest.fn().mockResolvedValue([]),
findByStatusAndCreators: jest.fn().mockResolvedValue([]),
findCompletedByProjectAndCreators: jest.fn().mockResolvedValue([]),
};
const projectRepository: Partial<Mocked<ProjectRepository>> = {
findAll: jest.fn().mockResolvedValue([]),
findByHash: jest.fn().mockResolvedValue(null),
};
const logger = {
setContext: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
log: jest.fn(),
verbose: jest.fn(),
} as any;
const interactor = new TimeTrackingInteractor(
timeEntryRepository as unknown as TimeEntryRepository,
projectRepository as unknown as ProjectRepository,
contributorRepository as unknown as ContributorRepository,
issueRepository as unknown as IssueRepository,
logger
);
return { interactor, timeEntryRepository, contributorRepository, issueRepository, projectRepository };
}
function expectEntryCreate(
mock: jest.Mock,
expected: Partial<{ contributor_hash: string; hours: number; entry_type: string; is_committed: boolean }>
): void {
const calls = mock.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
const match = calls.find((entry) => {
if (expected.contributor_hash && entry.contributor_hash !== expected.contributor_hash) return false;
if (expected.hours !== undefined && Math.abs(entry.hours - expected.hours) > 1e-6) return false;
if (expected.entry_type !== undefined && entry.entry_type !== expected.entry_type) return false;
if (expected.is_committed !== undefined && entry.is_committed !== expected.is_committed) return false;
return true;
});
if (!match) {
const summary = calls
.map((e) => `{contributor=${e.contributor_hash}, hours=${e.hours}, type=${e.entry_type}, committed=${e.is_committed}}`)
.join('\n ');
throw new Error(`No create() call matched ${JSON.stringify(expected)}.\nActual calls:\n ${summary}`);
}
}
describe('TimeTrackingInteractor.applyExplicitEstimateToTimeEntries', () => {
it('делит estimate поровну между creators при первой установке', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expect(timeEntryRepository.create).toHaveBeenCalledTimes(3);
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5, entry_type: 'estimate', is_committed: false });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5, entry_type: 'estimate', is_committed: false });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5, entry_type: 'estimate', is_committed: false });
});
it('при estimate=0 только удаляет незакоммиченные, новых не создаёт', async () => {
const { interactor, timeEntryRepository } = buildInteractor();
const issue = makeIssue({ issue_hash: 'i1', estimate: 0, status: IssueStatus.DONE, creators: ['ant'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
it('БАГ #1: не даёт «двойную долю» creator-у, который уже закоммитил свою часть', async () => {
// Прод-сценарий CC7-1: ant закоммитил полную долю (5 ч). При recalc/applyExplicit
// его доля = 5 5 = 0 (а не 10/3 = 3.33 как было в баговой версии). Остальные
// получают свои 5 ч, без «бонусной перераздачи остатка».
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
// ant не должен получить uncommitted estimate (он уже закоммитил полную долю)
const createCalls = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
const antEntries = createCalls.filter((e) => e.contributor_hash === 'ant-hash');
expect(antEntries).toHaveLength(0);
// Остальные двое получают по 5 ч каждый, а не 10/3
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
expect(timeEntryRepository.create).toHaveBeenCalledTimes(2);
});
it('частичный committed уменьшает долю только этого creator', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
// Смуров закоммитил 3 из 5 → его остаток 2, у остальных по 5
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 3, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 2 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
expect(timeEntryRepository.create).toHaveBeenCalledTimes(3);
});
it('overcommitted (закоммичено больше доли) даёт 0, не отрицательное значение', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 8, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
const antCreates = timeEntryRepository.create.mock.calls
.map((c) => c[0] as TimeEntryDomainEntity)
.filter((e) => e.contributor_hash === 'ant-hash');
expect(antCreates).toHaveLength(0);
});
it('пустой список creators — удаляет uncommitted, не создаёт новых', async () => {
const { interactor, timeEntryRepository } = buildInteractor();
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: [] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
});
describe('TimeTrackingInteractor.recalcDoneEstimatesForContributorProject', () => {
it('no-op если раскладка уже совпадает с планом', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([issue]);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'dvl-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
it('лечит раскладку 10/3 (баговую) обратно к 5/5/5 когда есть закоммитившие', async () => {
// Воспроизведение прод-сценария CC7-1 после фикса: ant закоммитил 5 ч (committed),
// в БД остались баговые 3.333 uncommitted у всех. Recalc должен снести их и
// оставить ant=0, остальным по 5.
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([issue]);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'dvl-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
// ant не получает uncommitted (он уже закоммитил свою долю)
const antCreates = timeEntryRepository.create.mock.calls
.map((c) => c[0] as TimeEntryDomainEntity)
.filter((e) => e.contributor_hash === 'ant-hash');
expect(antCreates).toHaveLength(0);
});
it('пропускает задачи без estimate', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 0, status: IssueStatus.DONE, creators: ['ant'] }),
]);
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
});
describe('TimeTrackingInteractor.commitTime', () => {
it('БАГ #2: при partial split сохраняет entry_type=estimate и estimate_snapshot', async () => {
// Прод-сценарий: у Смурова 3.333 ч estimate uncommitted, коммит 3 ч → split.
// До фикса новая committed-запись создавалась с entry_type='hourly' (default).
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('smr', 'smr-hash'));
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] }),
]);
const entry = makeEntry({
contributor_hash: 'smr-hash',
issue_hash: 'i1',
hours: 5,
is_committed: false,
entry_type: 'estimate',
estimate_snapshot: 15,
_id: 'orig',
});
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([entry]);
await interactor.commitTime('smr-hash', 'project-hash-1', 3, 'commit-hash-1');
expect(timeEntryRepository.create).toHaveBeenCalledTimes(1);
const created = timeEntryRepository.create.mock.calls[0][0] as TimeEntryDomainEntity;
expect(created.is_committed).toBe(true);
expect(created.hours).toBe(3);
expect(created.entry_type).toBe('estimate');
expect(created.estimate_snapshot).toBe(15);
expect(created.commit_hash).toBe('commit-hash-1');
expect(timeEntryRepository.update).toHaveBeenCalledTimes(1);
const updated = timeEntryRepository.update.mock.calls[0][0] as TimeEntryDomainEntity;
expect(updated._id).toBe('orig');
expect(updated.hours).toBeCloseTo(2, 6);
});
it('full commit (entry.hours == requested) помечает оригинал, не создаёт split', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] }),
]);
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15, _id: 'orig' }),
]);
await interactor.commitTime('ant-hash', 'project-hash-1', 5, 'commit-hash-2');
expect(timeEntryRepository.create).not.toHaveBeenCalled();
expect(timeEntryRepository.commitTimeEntries).toHaveBeenCalledTimes(1);
});
it('коммит только по DONE задачам — отфильтровывает entries по активным задачам', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
// Только i1 в DONE, i2 в IN_PROGRESS
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant'] }),
]);
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate' }),
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i2', hours: 3, is_committed: false, entry_type: 'hourly' }),
]);
await interactor.commitTime('ant-hash', 'project-hash-1', 3, 'commit-hash-3');
// Должен коммитить из i1 (DONE), не из i2
const committedArgs = timeEntryRepository.commitTimeEntries.mock.calls[0]?.[0] as TimeEntryDomainEntity[] | undefined;
const splitCreate = timeEntryRepository.create.mock.calls[0]?.[0] as TimeEntryDomainEntity | undefined;
const touchedIssues = new Set<string>();
committedArgs?.forEach((e) => touchedIssues.add(e.issue_hash));
if (splitCreate) touchedIssues.add(splitCreate.issue_hash);
expect(touchedIssues.has('i2')).toBe(false);
});
});
describe('TimeTrackingInteractor.revertEntriesForDeclinedCommit', () => {
it('БАГ #3: вызывает revert + нормализует раскладку по затронутой DONE-задаче', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const reverted = makeEntry({
contributor_hash: 'ant-hash',
issue_hash: 'i1',
hours: 5,
is_committed: true,
entry_type: 'estimate',
estimate_snapshot: 15,
commit_hash: 'commit-bad',
});
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([reverted]);
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
issueRepository.findByIssueHash!.mockResolvedValue(
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] })
);
// После revert у ant'a 0 committed (revert убрал) — поэтому новая раскладка 5/5/5
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
await interactor.revertEntriesForDeclinedCommit('commit-bad');
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).toHaveBeenCalledWith('commit-bad');
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5, entry_type: 'estimate' });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5, entry_type: 'estimate' });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5, entry_type: 'estimate' });
});
it('идемпотентно: повторный вызов на коммит без записей — no-op', async () => {
const { interactor, timeEntryRepository } = buildInteractor();
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([]);
await interactor.revertEntriesForDeclinedCommit('commit-already-reverted');
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).not.toHaveBeenCalled();
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
});
it('не пересчитывает задачи которые больше не DONE', async () => {
const { interactor, timeEntryRepository, issueRepository } = buildInteractor();
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 3, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15, commit_hash: 'c1' }),
]);
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
issueRepository.findByIssueHash!.mockResolvedValue(
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.IN_PROGRESS, creators: ['ant'] })
);
await interactor.revertEntriesForDeclinedCommit('c1');
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).toHaveBeenCalled();
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
});
describe('integration scenario: CC7-1 прод-инцидент', () => {
it('после декларации estimate=15 на 3 creators даёт по 5 ч каждому, total uncommitted=15', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const issue = makeIssue({
issue_hash: 'cc7-1-hash',
estimate: 15,
status: IssueStatus.DONE,
creators: ['ant', 'zxfevlujlica', 'ipesgnlxmnwx'],
});
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
await interactor.applyExplicitEstimateToTimeEntries(issue);
const creates = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
expect(creates).toHaveLength(3);
const totalHours = creates.reduce((s, e) => s + e.hours, 0);
expect(totalHours).toBeCloseTo(15, 6);
creates.forEach((e) => {
expect(e.hours).toBeCloseTo(5, 6);
expect(e.entry_type).toBe('estimate');
expect(e.is_committed).toBe(false);
expect(e.estimate_snapshot).toBe(15);
});
});
it('сценарий full lifecycle: коммит → decline → revert восстанавливает доступное время', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const doneIssue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([doneIssue]);
issueRepository.findByIssueHash!.mockResolvedValue(doneIssue);
// 1. Стартовое состояние: 5/5/5 uncommitted
const initial = [
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15, _id: 'a1' }),
];
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue(initial);
// 2. ant коммитит свои 5 ч
await interactor.commitTime('ant-hash', 'project-hash-1', 5, 'commit-ant');
expect(timeEntryRepository.commitTimeEntries).toHaveBeenCalledTimes(1);
// 3. Симулируем что в БД теперь есть committed estimate 5 ч от ant'a
const committedEntry = makeEntry({
contributor_hash: 'ant-hash',
issue_hash: 'i1',
hours: 5,
is_committed: true,
entry_type: 'estimate',
estimate_snapshot: 15,
commit_hash: 'commit-ant',
});
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([committedEntry]);
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
// После revert у ant'а 0 committed estimate → новая раскладка 5/5/5
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
// 4. Decline → revert
timeEntryRepository.create.mockClear();
timeEntryRepository.deleteUncommittedByIssueHash.mockClear();
await interactor.revertEntriesForDeclinedCommit('commit-ant');
// 5. После revert: 5/5/5 uncommitted
const creates = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
const total = creates.reduce((s, e) => s + e.hours, 0);
expect(total).toBeCloseTo(15, 6);
expect(creates.every((e) => e.entry_type === 'estimate' && !e.is_committed)).toBe(true);
});
});
@@ -49,64 +49,116 @@ export class TimeTrackingInteractor {
}
/**
* Реакция на фактическое изменение оценки (вызывать только если сохранённое и новое значение
* различаются): снимаем все незакоммиченные билеты по задаче, затем при ненулевой оценке создаём
* новые записи estimate (поровну между исполнителями). При оценке 0 — только очистка.
* Реакция на фактическое изменение оценки: пересобирает незакоммиченные estimate-билеты
* задачи под текущий состав creators и новую оценку, учитывая личный committed-баланс
* каждого исполнителя. При оценке 0 — только очистка незакоммиченных.
*/
async applyExplicitEstimateToTimeEntries(issue: IssueDomainEntity): Promise<void> {
const newEstimate = issue.estimate ?? 0;
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
if (isNegligibleHours(newEstimate)) {
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
this.logger.debug(
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}), оценка снята или обнулена — незакоммиченные билеты по задаче удалены`
);
return;
}
await this.redistributeIssueEstimateEntries(issue, newEstimate, { force: true });
}
/**
* Пересобрать незакоммиченные estimate-билеты задачи: для каждого creator личная
* доля = estimate / N, минус его уже закоммиченные estimate-часы по этой же задаче.
* Закоммиченные записи не трогаются. «Остаток» одного creator не перераспределяется
* между другими — каждый учитывается изолированно.
*
* @param opts.force true — пересоздаёт всегда; false — пропускает no-op когда раскладка
* уже совпадает с планом.
* @returns true если что-то пересоздано/удалено, false если no-op
*/
private async redistributeIssueEstimateEntries(
issue: IssueDomainEntity,
estimate: number,
opts: { force: boolean }
): Promise<boolean> {
const creators = issue.creators || [];
if (creators.length === 0) {
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
this.logger.warn(
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}) без исполнителей — билеты очищены, начисление estimate пропущено`
`redistributeIssueEstimateEntries: задача ${issue.id} (${issue.issue_hash}) без исполнителей — билеты очищены`
);
return;
return true;
}
const hoursPerCreator = newEstimate / creators.length;
const date = new Date().toISOString().split('T')[0];
const sharePerCreator = estimate / creators.length;
const estimateEntries = await this.timeEntryRepository.findByIssueAndType(issue.issue_hash, 'estimate');
const committedByContributor = new Map<string, number>();
for (const entry of estimateEntries) {
if (!entry.is_committed) continue;
committedByContributor.set(
entry.contributor_hash,
(committedByContributor.get(entry.contributor_hash) || 0) + entry.hours
);
}
type Plan = { contributor_hash: string; uncommittedShare: number };
const plan: Plan[] = [];
for (const creatorUsername of creators) {
const contributor = await this.contributorRepository.findByUsernameAndCoopname(creatorUsername, issue.coopname);
if (!contributor) {
this.logger.warn(
`applyExplicitEstimateToTimeEntries: исполнитель ${creatorUsername} не найден в ${issue.coopname}, пропуск`
`redistributeIssueEstimateEntries: исполнитель ${creatorUsername} не найден в ${issue.coopname}, пропуск`
);
continue;
}
const myCommitted = committedByContributor.get(contributor.contributor_hash) || 0;
const uncommittedShare = Math.max(0, sharePerCreator - myCommitted);
plan.push({ contributor_hash: contributor.contributor_hash, uncommittedShare });
}
const estimateEntry = new TimeEntryDomainEntity({
_id: '',
contributor_hash: contributor.contributor_hash,
issue_hash: issue.issue_hash,
project_hash: issue.project_hash,
coopname: issue.coopname,
date,
hours: hoursPerCreator,
is_committed: false,
block_num: 0,
present: false,
status: 'active',
entry_type: 'estimate',
estimate_snapshot: newEstimate,
});
if (!opts.force) {
const currentUncommitted = estimateEntries.filter((e) => !e.is_committed);
const planNonZero = plan.filter((p) => p.uncommittedShare > HOURS_FLOAT_EPSILON);
const planByHash = new Map(planNonZero.map((p) => [p.contributor_hash, p.uncommittedShare]));
const isValid =
currentUncommitted.length === planNonZero.length &&
currentUncommitted.every((entry) => {
const expected = planByHash.get(entry.contributor_hash);
return expected !== undefined && hoursAlmostEqual(entry.hours, expected);
});
if (isValid) return false;
}
await this.timeEntryRepository.create(estimateEntry);
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
const date = new Date().toISOString().split('T')[0];
for (const item of plan) {
if (item.uncommittedShare <= HOURS_FLOAT_EPSILON) continue;
await this.timeEntryRepository.create(
new TimeEntryDomainEntity({
_id: '',
contributor_hash: item.contributor_hash,
issue_hash: issue.issue_hash,
project_hash: issue.project_hash,
coopname: issue.coopname,
date,
hours: item.uncommittedShare,
is_committed: false,
block_num: 0,
present: false,
status: 'active',
entry_type: 'estimate',
estimate_snapshot: estimate,
})
);
}
this.logger.debug(
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}), выставлена оценка ${newEstimate} ч — незакоммиченные билеты заменены`
`redistributeIssueEstimateEntries: задача ${issue.id} (${issue.issue_hash}), estimate=${estimate} ч, ` +
`share=${sharePerCreator} ч × ${creators.length} creators, незакоммиченные пересозданы (${plan.length} участников)`
);
return true;
}
/**
@@ -117,12 +169,38 @@ export class TimeTrackingInteractor {
await this.timeEntryRepository.deleteUncommittedByIssueHash(issueHash);
}
/**
* Откатить time-entries отклонённого коммита обратно в uncommitted и нормализовать
* раскладку estimate-долей для затронутых DONE-задач (если их состав creators
* актуален). Идемпотентно: вызов на коммит без time-entries — no-op.
*/
async revertEntriesForDeclinedCommit(commitHash: string): Promise<void> {
const reverted = await this.timeEntryRepository.findCommittedByCommitHash(commitHash);
if (reverted.length === 0) return;
await this.timeEntryRepository.revertCommittedEntriesByCommitHash(commitHash);
const issueHashes = [...new Set(reverted.map((e) => e.issue_hash))];
for (const issueHash of issueHashes) {
const issue = await this.issueRepository.findByIssueHash(issueHash);
if (!issue) continue;
if (issue.status !== IssueStatus.DONE) continue;
const estimate = issue.estimate ?? 0;
if (isNegligibleHours(estimate)) continue;
await this.redistributeIssueEstimateEntries(issue, estimate, { force: true });
}
this.logger.debug(
`revertEntriesForDeclinedCommit: коммит ${commitHash}, откачено ${reverted.length} записей, ` +
`пересчитано ${issueHashes.length} задач`
);
}
/**
* Лечебный пересчёт: для всех DONE-задач проекта, где участник сейчас в creators,
* сносит незакоммиченные estimate-билеты и распределяет оставшуюся часть estimate
* (estimate − уже закоммиченные часы по задаче) поровну между текущими creators.
* Идемпотентно. Вызывается, например, перед createCommit, чтобы вылечить расхождения,
* накопившиеся после смен creators/статусов, не прошедших через applyExplicitEstimate.
* пересобирает незакоммиченные estimate-билеты по личным долям creators
* (estimate / N − собственный committed). Идемпотентно. Вызывается перед createCommit
* и из миграций, чтобы вылечить расхождения после смен creators/статусов.
*/
async recalcDoneEstimatesForContributorProject(contributorHash: string, projectHash: string): Promise<void> {
const contributor = await this.contributorRepository.findOne({ contributor_hash: contributorHash });
@@ -135,76 +213,7 @@ export class TimeTrackingInteractor {
for (const issue of completedIssues) {
const estimate = issue.estimate ?? 0;
if (isNegligibleHours(estimate)) continue;
const creators = issue.creators || [];
if (creators.length === 0) continue;
const estimateEntries = await this.timeEntryRepository.findByIssueAndType(issue.issue_hash, 'estimate');
const committedTotal = estimateEntries
.filter((entry) => entry.is_committed)
.reduce((sum, entry) => sum + entry.hours, 0);
const uncommittedTotal = estimateEntries
.filter((entry) => !entry.is_committed)
.reduce((sum, entry) => sum + entry.hours, 0);
const remaining = estimate - committedTotal;
const expectedHoursPerCreator = remaining > HOURS_FLOAT_EPSILON ? remaining / creators.length : 0;
const currentCreatorHashes = new Set<string>();
for (const creatorUsername of creators) {
const creatorContributor = await this.contributorRepository.findByUsernameAndCoopname(
creatorUsername,
issue.coopname
);
if (creatorContributor) currentCreatorHashes.add(creatorContributor.contributor_hash);
}
const uncommittedEntries = estimateEntries.filter((entry) => !entry.is_committed);
const distributionLooksValid =
uncommittedEntries.length === currentCreatorHashes.size &&
uncommittedEntries.every(
(entry) =>
currentCreatorHashes.has(entry.contributor_hash) &&
hoursAlmostEqual(entry.hours, expectedHoursPerCreator)
) &&
hoursAlmostEqual(uncommittedTotal, remaining > HOURS_FLOAT_EPSILON ? remaining : 0);
if (distributionLooksValid) continue;
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
if (remaining <= HOURS_FLOAT_EPSILON) continue;
const date = new Date().toISOString().split('T')[0];
for (const creatorUsername of creators) {
const creatorContributor = await this.contributorRepository.findByUsernameAndCoopname(
creatorUsername,
issue.coopname
);
if (!creatorContributor) continue;
await this.timeEntryRepository.create(
new TimeEntryDomainEntity({
_id: '',
contributor_hash: creatorContributor.contributor_hash,
issue_hash: issue.issue_hash,
project_hash: issue.project_hash,
coopname: issue.coopname,
date,
hours: expectedHoursPerCreator,
is_committed: false,
block_num: 0,
present: false,
status: 'active',
entry_type: 'estimate',
estimate_snapshot: estimate,
})
);
}
this.logger.debug(
`recalcDoneEstimatesForContributorProject: задача ${issue.id} (${issue.issue_hash}) — пересчитаны estimate-билеты: ` +
`estimate=${estimate} ч, committed=${committedTotal} ч, distributed=${expectedHoursPerCreator} ч × ${creators.length}`
);
await this.redistributeIssueEstimateEntries(issue, estimate, { force: false });
}
}
@@ -801,7 +810,9 @@ export class TimeTrackingInteractor {
entriesToCommit.push(entry);
remainingHours -= entry.hours;
} else {
// Коммитим часть записи - создаём новую запись с оставшимся временем
// Коммитим часть записи - создаём новую запись с оставшимся временем.
// entry_type и estimate_snapshot копируем из оригинала — иначе recalc estimate
// не увидит закоммиченную долю (он фильтрует только entry_type='estimate').
const committedEntry = new TimeEntryDomainEntity({
_id: '',
contributor_hash: entry.contributor_hash,
@@ -812,6 +823,8 @@ export class TimeTrackingInteractor {
hours: remainingHours,
commit_hash: commitHash,
is_committed: true,
entry_type: entry.entry_type,
estimate_snapshot: entry.estimate_snapshot,
block_num: entry.block_num,
present: entry.present,
status: entry.status,
@@ -59,6 +59,21 @@ export interface TimeEntryRepository {
*/
commitTimeEntries(entries: TimeEntryDomainEntity[], commitHash: string): Promise<void>;
/**
* Найти закоммиченные time-entries по commit_hash. Используется при decline,
* чтобы понять какие задачи затронуты до отката.
*/
findCommittedByCommitHash(commitHash: string): Promise<TimeEntryDomainEntity[]>;
/**
* Откатить закоммиченные time-entries по commit_hash обратно в uncommitted
* (is_committed=false, commit_hash=NULL). Используется при decline коммита, чтобы
* часы вернулись в доступный пул. Идемпотентно.
*
* @returns количество затронутых записей
*/
revertCommittedEntriesByCommitHash(commitHash: string): Promise<number>;
/**
* Удалить запись времени
*/
@@ -121,6 +121,24 @@ export class TimeEntryTypeormRepository implements TimeEntryRepository {
await this.repository.update({ _id: In(ids) }, { commit_hash: commitHash, is_committed: true, _updated_at: new Date() });
}
async findCommittedByCommitHash(commitHash: string): Promise<TimeEntryDomainEntity[]> {
const entities = await this.repository.find({
where: { commit_hash: commitHash, is_committed: true },
});
return entities.map((entity) => this.toDomain(entity));
}
async revertCommittedEntriesByCommitHash(commitHash: string): Promise<number> {
const result = await this.repository
.createQueryBuilder()
.update(TimeEntryEntity)
.set({ is_committed: false, commit_hash: null as unknown as undefined, _updated_at: new Date() })
.where('commit_hash = :commitHash', { commitHash })
.andWhere('is_committed = true')
.execute();
return result.affected ?? 0;
}
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
@@ -3,8 +3,8 @@
* Передаётся в `FileStorageInfrastructureModule.forRoot(options)`.
*/
export interface FileStorageInfrastructureOptions {
/** URL S3-совместимого бэкенда. Для MinIO — `http://minio:9000`. */
endpoint: string;
/** URL S3-совместимого бэкенда. Для MinIO — `http://minio:9000`. Пусто/undefined = file storage отключён (no-op). */
endpoint?: string;
accessKey: string;
secretKey: string;
/** Имя физического бакета на сервере (например, `coop-voskhod`). */
@@ -44,24 +44,35 @@ import { signReadUrl } from './signing';
@Injectable()
export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicationBootstrap {
private readonly logger = new Logger(MinioFileStorageAdapter.name);
private readonly s3: S3Client;
private readonly s3: S3Client | null;
private readonly enabled: boolean;
constructor(
@Inject(FILE_STORAGE_OPTIONS)
private readonly opts: FileStorageInfrastructureOptions,
) {
this.s3 = new S3Client({
endpoint: opts.endpoint,
region: opts.region ?? 'us-east-1',
credentials: {
accessKeyId: opts.accessKey,
secretAccessKey: opts.secretKey,
},
forcePathStyle: opts.forcePathStyle ?? true,
});
this.enabled = typeof opts.endpoint === 'string' && opts.endpoint.length > 0;
this.s3 = this.enabled
? new S3Client({
endpoint: opts.endpoint,
region: opts.region ?? 'us-east-1',
credentials: {
accessKeyId: opts.accessKey,
secretAccessKey: opts.secretKey,
},
forcePathStyle: opts.forcePathStyle ?? true,
})
: null;
}
async onApplicationBootstrap(): Promise<void> {
if (!this.enabled || !this.s3) {
this.logger.warn(
'File storage не сконфигурирован (MINIO_ENDPOINT пуст) — модуль стартует в no-op режиме. ' +
'Загрузка/чтение файлов будут отклонены до настройки бэкенда.',
);
return;
}
await this.ensureBucketExists();
const registered = BucketRegistry.list();
this.logger.log(
@@ -73,9 +84,19 @@ export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicat
);
}
private assertEnabled(): S3Client {
if (!this.enabled || !this.s3) {
throw new InterFileStorageBackendUnavailableError(
'File storage не сконфигурирован: MINIO_ENDPOINT пуст. Задайте переменную окружения, чтобы включить загрузку/чтение файлов.',
);
}
return this.s3;
}
private async ensureBucketExists(): Promise<void> {
const s3 = this.assertEnabled();
try {
await this.s3.send(new HeadBucketCommand({ Bucket: this.opts.bucket }));
await s3.send(new HeadBucketCommand({ Bucket: this.opts.bucket }));
return;
} catch (e) {
if (!isNotFound(e)) {
@@ -86,7 +107,7 @@ export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicat
}
}
try {
await this.s3.send(new CreateBucketCommand({ Bucket: this.opts.bucket }));
await s3.send(new CreateBucketCommand({ Bucket: this.opts.bucket }));
this.logger.log(`Создан физический бакет '${this.opts.bucket}'`);
} catch (e) {
throw new InterFileStorageBackendUnavailableError(
@@ -97,8 +118,9 @@ export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicat
}
getBucket(spec: InterFileStorageBucketSpec): InterFileStorageBucket {
const s3 = this.assertEnabled();
validateSpec(spec);
return new MinioBucketHandle(this.s3, this.opts, spec);
return new MinioBucketHandle(s3, this.opts, spec);
}
/**
@@ -107,8 +129,9 @@ export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicat
* @throws InterFileStorageObjectNotFoundError, InterFileStorageBackendUnavailableError
*/
async fetchObjectForReadProxy(physicalKey: string): Promise<FileStorageObjectStream> {
const s3 = this.assertEnabled();
try {
const r = await this.s3.send(
const r = await s3.send(
new GetObjectCommand({ Bucket: this.opts.bucket, Key: physicalKey }),
);
return {
+8 -18
View File
@@ -460,6 +460,9 @@ export const AllTypesProps: Record<string,any> = {
},
GenerationContractSignedMetaDocumentInput:{
},
GenerationConvertStatementGenerateDocumentInput:{
},
GenerationMoneyInvestStatementGenerateDocumentInput:{
@@ -470,9 +473,6 @@ export const AllTypesProps: Record<string,any> = {
},
GenerationMoneyInvestStatementSignedMetaDocumentInput:{
},
GenerationToMainWalletConvertStatementGenerateDocumentInput:{
},
GetAccountInput:{
@@ -770,6 +770,10 @@ export const AllTypesProps: Record<string,any> = {
data:"GenerationContractGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationConvertStatement:{
data:"GenerationConvertStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationMoneyInvestStatement:{
data:"GenerationMoneyInvestStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -786,18 +790,6 @@ export const AllTypesProps: Record<string,any> = {
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationToCapitalizationConvertStatement:{
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationToMainWalletConvertStatement:{
data:"GenerationToMainWalletConvertStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationToProjectConvertStatement:{
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGetLoanDecision:{
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -3526,13 +3518,11 @@ export const ReturnTypes: Record<string,any> = {
capitalGenerateExpenseDecision:"GeneratedDocument",
capitalGenerateExpenseStatement:"GeneratedDocument",
capitalGenerateGenerationContract:"GeneratedDocument",
capitalGenerateGenerationConvertStatement:"GeneratedDocument",
capitalGenerateGenerationMoneyInvestStatement:"GeneratedDocument",
capitalGenerateGenerationPropertyInvestAct:"GeneratedDocument",
capitalGenerateGenerationPropertyInvestDecision:"GeneratedDocument",
capitalGenerateGenerationPropertyInvestStatement:"GeneratedDocument",
capitalGenerateGenerationToCapitalizationConvertStatement:"GeneratedDocument",
capitalGenerateGenerationToMainWalletConvertStatement:"GeneratedDocument",
capitalGenerateGenerationToProjectConvertStatement:"GeneratedDocument",
capitalGenerateGetLoanDecision:"GeneratedDocument",
capitalGenerateGetLoanStatement:"GeneratedDocument",
capitalGenerateProgramMoneyInvestStatement:"GeneratedDocument",
+143 -171
View File
@@ -5521,6 +5521,38 @@ export type ValueTypes = {
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version: string | Variable<any, string>
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string | Variable<any, string>,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null | Variable<any, string>,
/** Название кооператива, связанное с документом */
coopname: string | Variable<any, string>,
/** Дата и время создания документа */
created_at?: string | undefined | null | Variable<any, string>,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null | Variable<any, string>,
/** Язык документа */
lang?: string | undefined | null | Variable<any, string>,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null | Variable<any, string>,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string | Variable<any, string>,
/** Хэш проекта */
project_hash: string | Variable<any, string>,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null | Variable<any, string>,
/** Название документа */
title?: string | undefined | null | Variable<any, string>,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean | Variable<any, string>,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean | Variable<any, string>,
/** Имя пользователя, создавшего документ */
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null | Variable<any, string>
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -5597,40 +5629,6 @@ export type ValueTypes = {
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version: string | Variable<any, string>
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string | Variable<any, string>,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string | Variable<any, string>,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null | Variable<any, string>,
/** Название кооператива, связанное с документом */
coopname: string | Variable<any, string>,
/** Дата и время создания документа */
created_at?: string | undefined | null | Variable<any, string>,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null | Variable<any, string>,
/** Язык документа */
lang?: string | undefined | null | Variable<any, string>,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null | Variable<any, string>,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string | Variable<any, string>,
/** Хэш проекта */
project_hash: string | Variable<any, string>,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null | Variable<any, string>,
/** Название документа */
title?: string | undefined | null | Variable<any, string>,
/** Перевод на благорост кошелек */
to_blagorost: boolean | Variable<any, string>,
/** Перевод на основной кошелек */
to_wallet: boolean | Variable<any, string>,
/** Имя пользователя, создавшего документ */
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null | Variable<any, string>
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -6421,13 +6419,11 @@ capitalGenerateComponentGenerationContract?: [{ data: ValueTypes["ComponentGener
capitalGenerateExpenseDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateExpenseStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationContract?: [{ data: ValueTypes["GenerationContractGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationConvertStatement?: [{ data: ValueTypes["GenerationConvertStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationMoneyInvestStatement?: [{ data: ValueTypes["GenerationMoneyInvestStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestAct?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationToCapitalizationConvertStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationToMainWalletConvertStatement?: [{ data: ValueTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationToProjectConvertStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGetLoanDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGetLoanStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateProgramMoneyInvestStatement?: [{ data: ValueTypes["ProgramCapitalizationMoneyInvestStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
@@ -13829,6 +13825,38 @@ export type ResolverInputTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -13905,40 +13933,6 @@ export type ResolverInputTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Перевод на благорост кошелек */
to_blagorost: boolean,
/** Перевод на основной кошелек */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -14706,13 +14700,11 @@ capitalGenerateComponentGenerationContract?: [{ data: ResolverInputTypes["Compon
capitalGenerateExpenseDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateExpenseStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationContract?: [{ data: ResolverInputTypes["GenerationContractGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationConvertStatement?: [{ data: ResolverInputTypes["GenerationConvertStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationMoneyInvestStatement?: [{ data: ResolverInputTypes["GenerationMoneyInvestStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestAct?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationToCapitalizationConvertStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationToMainWalletConvertStatement?: [{ data: ResolverInputTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationToProjectConvertStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGetLoanDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGetLoanStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateProgramMoneyInvestStatement?: [{ data: ResolverInputTypes["ProgramCapitalizationMoneyInvestStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
@@ -18965,13 +18957,13 @@ export type ModelTypes = {
/** Обогащенные данные коммита (diff-патч, исходная ссылка и т.д.) */
data?: ModelTypes["JSON"] | undefined | null,
/** Описание коммита */
description: string,
description?: string | undefined | null,
/** Отображаемое имя пользователя */
display_name?: string | undefined | null,
/** ID в блокчейне */
id?: number | undefined | null,
/** Метаданные коммита */
meta: string,
meta?: string | undefined | null,
/** Флаг присутствия записи в блокчейне */
present: boolean,
/** Проект, к которому относится коммит */
@@ -21895,6 +21887,38 @@ export type ModelTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -21971,40 +21995,6 @@ export type ModelTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Перевод на благорост кошелек */
to_blagorost: boolean,
/** Перевод на основной кошелек */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -22869,6 +22859,10 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationContract: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member. */
capitalGenerateGenerationConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление об инвестировании в генерацию
Требуемые роли: chairman, member. */
@@ -22885,18 +22879,6 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationPropertyInvestStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в благорост
Требуемые роли: chairman, member. */
capitalGenerateGenerationToCapitalizationConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в основной кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToMainWalletConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в проектный кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToProjectConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать решение о получении займа
Требуемые роли: chairman, member. */
@@ -27626,13 +27608,13 @@ export type GraphQLTypes = {
/** Обогащенные данные коммита (diff-патч, исходная ссылка и т.д.) */
data?: GraphQLTypes["JSON"] | undefined | null,
/** Описание коммита */
description: string,
description?: string | undefined | null,
/** Отображаемое имя пользователя */
display_name?: string | undefined | null,
/** ID в блокчейне */
id?: number | undefined | null,
/** Метаданные коммита */
meta: string,
meta?: string | undefined | null,
/** Флаг присутствия записи в блокчейне */
present: boolean,
/** Проект, к которому относится коммит */
@@ -30712,6 +30694,38 @@ export type GraphQLTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -30788,40 +30802,6 @@ export type GraphQLTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Перевод на благорост кошелек */
to_blagorost: boolean,
/** Перевод на основной кошелек */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -31739,6 +31719,10 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationContract: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member. */
capitalGenerateGenerationConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление об инвестировании в генерацию
Требуемые роли: chairman, member. */
@@ -31755,18 +31739,6 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationPropertyInvestStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в благорост
Требуемые роли: chairman, member. */
capitalGenerateGenerationToCapitalizationConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в основной кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToMainWalletConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в проектный кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToProjectConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать решение о получении займа
Требуемые роли: chairman, member. */
@@ -35917,10 +35889,10 @@ type ZEUS_VARIABLES = {
["GenerationContractGenerateDocumentInput"]: ValueTypes["GenerationContractGenerateDocumentInput"];
["GenerationContractSignedDocumentInput"]: ValueTypes["GenerationContractSignedDocumentInput"];
["GenerationContractSignedMetaDocumentInput"]: ValueTypes["GenerationContractSignedMetaDocumentInput"];
["GenerationConvertStatementGenerateDocumentInput"]: ValueTypes["GenerationConvertStatementGenerateDocumentInput"];
["GenerationMoneyInvestStatementGenerateDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementGenerateDocumentInput"];
["GenerationMoneyInvestStatementSignedDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementSignedDocumentInput"];
["GenerationMoneyInvestStatementSignedMetaDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementSignedMetaDocumentInput"];
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: ValueTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"];
["GetAccountInput"]: ValueTypes["GetAccountInput"];
["GetAccountsInput"]: ValueTypes["GetAccountsInput"];
["GetBranchesInput"]: ValueTypes["GetBranchesInput"];
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cooptypes",
"type": "module",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
@@ -34,8 +34,8 @@ export interface Model {
to_blagorost: boolean
}
export const title = 'Заявление о переводе части целевого паевого взноса'
export const description = 'Форма заявления о переводе части целевого паевого взноса'
export const title = 'Заявление о трансляции паевого взноса из программы "Генерация"'
export const description = 'Форма заявления о трансляции паевого взноса из программы "Генерация" в Цифровой Кошелёк и/или в программу «Благорост»'
export const context = `<div class="digital-document"><div style="text-align: right"><p>{% trans 'to_council' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"</p><p>{% trans 'from_shareholder' %} {{ common_user.full_name_or_short_name }}</p></div><div style="text-align: center"><h2>{% trans 'statement_title' %}</h2><h3>{% trans 'statement_subtitle' %}</h3></div><p>{% trans 'statement_text' %} № {{ contributor_contract_number }} {% trans 'from_date' %} {{ contributor_contract_created_at }} {% trans 'and_appendix' %} № {{ appendix_short_hash }}, {% trans 'request_text' %} №{{ project_short_hash }} {% trans 'following_way' %}:</p><ol>{% if to_wallet %}<li>{% trans 'wallet_conversion_text', main_wallet_amount %}</li>{% endif %}{% if to_blagorost %}<li>{% trans 'blagorost_conversion_text', blagorost_wallet_amount %}</li>{% endif %}</ol><div style="margin-top: 40px;"><p>{% trans 'signed_by_digital_signature' %}</p><p style="text-align: right">{{ meta.created_at }}</p></div></div><style>.digital-document {padding: 20px;white-space: pre-wrap;}</style>`
@@ -1,22 +0,0 @@
import type { IGenerate, IMetaDocument } from '../../document'
export const registry_id = 1081
// Модель действия для генерации
export interface Action extends IGenerate {
registry_id: number
}
export type Meta = IMetaDocument & Action
// Модель данных документа
export interface Model {
meta: IMetaDocument
}
export const title = 'Заявление о конвертации средств генерации в проект'
export const description = 'Форма заявления о конвертации средств из генерации в конкретный проект'
export const context = '<div class="digital-document"><div style="text-align: center"><h2>ЗАЯВЛЕНИЕ О КОНВЕРТАЦИИ СРЕДСТВ ГЕНЕРАЦИИ В ПРОЕКТ</h2></div><p>Прошу конвертировать средства из генерации для инвестирования в проект.</p><p>Подпись: Иван Иванович</p></div>'
export const translations = {}
export const exampleData = {}
@@ -1,22 +0,0 @@
import type { IGenerate, IMetaDocument } from '../../document'
export const registry_id = 1082
// Модель действия для генерации
export interface Action extends IGenerate {
registry_id: number
}
export type Meta = IMetaDocument & Action
// Модель данных документа
export interface Model {
meta: IMetaDocument
}
export const title = 'Заявление о конвертации средств генерации в Благорост'
export const description = 'Форма заявления о конвертации средств из генерации в программу Благорост'
export const context = '<div class="digital-document"><div style="text-align: center"><h2>ЗАЯВЛЕНИЕ О КОНВЕРТАЦИИ СРЕДСТВ ГЕНЕРАЦИИ В благорост</h2></div><p>Прошу конвертировать средства из генерации для участия в программе благороста.</p><p>Подпись: Иван Иванович</p></div>'
export const translations = {}
export const exampleData = {}
@@ -61,9 +61,7 @@ export * as CapitalizationPropertyInvestStatement from './1070.CapitalizationPro
export * as CapitalizationPropertyInvestDecision from './1071.CapitalizationPropertyInvestDecision'
export * as CapitalizationPropertyInvestAct from './1072.CapitalizationPropertyInvestAct'
export * as GenerationToMainWalletConvertStatement from './1080.GenerationToMainWalletConvertStatement'
export * as GenerationToProjectConvertStatement from './1081.GenerationToProjectConvertStatement'
export * as GenerationToCapitalizationConvertStatement from './1082.GenerationToCapitalizationConvertStatement'
export * as GenerationConvertStatement from './1080.GenerationConvertStatement'
export * as CapitalizationToMainWalletConvertStatement from './1090.CapitalizationToMainWalletConvertStatement'
@@ -47,7 +47,7 @@ q-btn(
.row.items-center.q-gutter-x-sm
span.text-caption.text-grey-7 Удовлетворение результатом
span.text-body2.text-weight-medium.text-accent
| {{ formData.satisfaction_stars }} / 5
| {{ satisfactionLabel }}
q-rating(
v-model='formData.satisfaction_stars',
:max='5',
@@ -124,7 +124,12 @@ const formData = ref({
creator_hours: 0,
description: '',
review_text: '',
satisfaction_stars: 5,
satisfaction_stars: 0,
});
const satisfactionLabel = computed(() => {
const stars = formData.value.satisfaction_stars;
return stars > 0 ? `${stars} / 5` : 'не указано';
});
const commitBreakdown = computed(() => {
@@ -161,7 +166,7 @@ const commitCostCaption = computed(() => {
watch(showDialog, (isOpen) => {
if (isOpen) {
formData.value.creator_hours = props.uncommittedHours || 0;
formData.value.satisfaction_stars = 5;
formData.value.satisfaction_stars = 0;
formData.value.review_text = '';
}
});
@@ -172,7 +177,7 @@ const clear = () => {
creator_hours: props.uncommittedHours || 0,
description: '',
review_text: '',
satisfaction_stars: 5,
satisfaction_stars: 0,
};
};
@@ -180,6 +185,10 @@ const handleCreateCommit = async () => {
try {
isSubmitting.value = true;
const reviewText = formData.value.review_text.trim();
const stars = formData.value.satisfaction_stars;
const hasFeedback = stars >= 1 || reviewText.length > 0;
const commitDataPayload: ICreateCommitInput = {
coopname: system.info.coopname,
commit_hours: formData.value.creator_hours,
@@ -187,15 +196,17 @@ const handleCreateCommit = async () => {
username: session.username || createCommitInput.value.username,
description: '',
meta: JSON.stringify({}),
data: [
{
type: 'contribution_feedback',
data: {
review_text: formData.value.review_text.trim(),
satisfaction_stars: formData.value.satisfaction_stars,
},
},
],
data: hasFeedback
? [
{
type: 'contribution_feedback',
data: {
review_text: reviewText,
satisfaction_stars: stars,
},
},
]
: [],
};
await createCommit(commitDataPayload);
@@ -5,12 +5,12 @@ import type {
import { client } from 'src/shared/api/client';
import { Mutations } from '@coopenomics/sdk';
async function generateGenerationToProjectConvertStatement(
data: Mutations.Capital.GenerateGenerationToProjectConvertStatement.IInput['data'],
async function generateGenerationConvertStatement(
data: Mutations.Capital.GenerateGenerationConvertStatement.IInput['data'],
options?: IGenerateDocumentOptionsInput,
): Promise<IGeneratedDocumentOutput> {
const { [Mutations.Capital.GenerateGenerationToProjectConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationToProjectConvertStatement.mutation, {
const { [Mutations.Capital.GenerateGenerationConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationConvertStatement.mutation, {
variables: {
data,
options,
@@ -21,5 +21,5 @@ async function generateGenerationToProjectConvertStatement(
}
export const api = {
generateGenerationToProjectConvertStatement,
generateGenerationConvertStatement,
};
@@ -0,0 +1,5 @@
import { api } from '../api';
export const generateGenerationConvertStatementModel = {
api,
};
@@ -1,25 +0,0 @@
import type {
IGenerateDocumentOptionsInput,
IGeneratedDocumentOutput,
} from 'src/shared/lib/types/document';
import { client } from 'src/shared/api/client';
import { Mutations } from '@coopenomics/sdk';
async function generateGenerationToCapitalizationConvertStatement(
data: Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.IInput['data'],
options?: IGenerateDocumentOptionsInput,
): Promise<IGeneratedDocumentOutput> {
const { [Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.mutation, {
variables: {
data,
options,
},
});
return result;
}
export const api = {
generateGenerationToCapitalizationConvertStatement,
};
@@ -1,5 +0,0 @@
import { api } from '../api';
export const generateGenerationToCapitalizationConvertStatementModel = {
api,
};
@@ -1,2 +0,0 @@
// UI компоненты для генерации заявления о конвертации из генерации в благорост
export {};
@@ -1,25 +0,0 @@
import type {
IGenerateDocumentOptionsInput,
IGeneratedDocumentOutput,
} from 'src/shared/lib/types/document';
import { client } from 'src/shared/api/client';
import { Mutations } from '@coopenomics/sdk';
async function generateGenerationToMainWalletConvertStatement(
data: Mutations.Capital.GenerateGenerationToMainWalletConvertStatement.IInput['data'],
options?: IGenerateDocumentOptionsInput,
): Promise<IGeneratedDocumentOutput> {
const { [Mutations.Capital.GenerateGenerationToMainWalletConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationToMainWalletConvertStatement.mutation, {
variables: {
data,
options,
},
});
return result;
}
export const api = {
generateGenerationToMainWalletConvertStatement,
};
@@ -1,3 +0,0 @@
export * as model from './model';
export * as api from './api';
export * as ui from './ui';
@@ -1,5 +0,0 @@
import { api } from '../api';
export const generateGenerationToMainWalletConvertStatementModel = {
api,
};
@@ -1,3 +0,0 @@
export * as model from './model';
export * as api from './api';
export * as ui from './ui';
@@ -1,5 +0,0 @@
import { api } from '../api';
export const generateGenerationToProjectConvertStatementModel = {
api,
};
@@ -1,2 +0,0 @@
// UI компоненты для генерации заявления о конвертации из генерации в проектный кошелек
export {};
@@ -1,5 +1,3 @@
// Фичи для работы с распределением средств
export * as GenerateGenerationToMainWalletConvertStatement from './GenerateGenerationToMainWalletConvertStatement';
export * as GenerateGenerationToProjectConvertStatement from './GenerateGenerationToProjectConvertStatement';
export * as GenerateGenerationToCapitalizationConvertStatement from './GenerateGenerationToCapitalizationConvertStatement';
export * as GenerateGenerationConvertStatement from './GenerateGenerationConvertStatement';
export * as GenerateCapitalizationToMainWalletConvertStatement from './GenerateCapitalizationToMainWalletConvertStatement';
@@ -7,9 +7,9 @@ export type IConvertSegmentOutput =
Mutations.Capital.ConvertSegment.IOutput[typeof Mutations.Capital.ConvertSegment.name];
export type IGenerateConvertStatementInput =
Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.IInput;
Mutations.Capital.GenerateGenerationConvertStatement.IInput;
export type IGenerateConvertStatementOutput =
Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.IOutput[typeof Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.name];
Mutations.Capital.GenerateGenerationConvertStatement.IOutput[typeof Mutations.Capital.GenerateGenerationConvertStatement.name];
async function convertSegment(
@@ -29,8 +29,8 @@ async function generateConvertStatement(
data: IGenerateConvertStatementInput['data'],
options?: IGenerateConvertStatementInput['options'],
): Promise<IGenerateConvertStatementOutput> {
const { [Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.mutation, {
const { [Mutations.Capital.GenerateGenerationConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationConvertStatement.mutation, {
variables: {
data,
options,
@@ -30,10 +30,17 @@ export function useConvertSegment() {
capital_amount: number;
}
) {
// 1. Генерируем документ заявления о конвертации
// 1. Генерируем документ заявления о конвертации.
// Шаблон 1080 — универсальный: одно заявление с двумя суммами и двумя флагами.
// appendix_hash подтягивается на бекенде по (username, project_hash).
const generatedDocument = await api.generateConvertStatement({
coopname: segmentData.coopname,
username: segmentData.username,
project_hash: segmentData.project_hash,
main_wallet_amount: formatToEosioAsset(segmentData.wallet_amount),
blagorost_wallet_amount: formatToEosioAsset(segmentData.capital_amount),
to_wallet: segmentData.wallet_amount > 0,
to_blagorost: segmentData.capital_amount > 0,
});
// 2. Подписываем документ конвертации
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/desktop",
"version": "2026.5.13-alpha-3",
"version": "2026.5.14-2",
"description": "Рабочий стол кооператива — Vue 3 + Quasar Framework",
"productName": "Desktop App",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
+2
View File
@@ -4,3 +4,5 @@ shots/
# генерируются add-plain-participant при первом прогоне.
state/participants/*.json
!state/participants/.gitkeep
state/cooperatives/*.json
!state/cooperatives/.gitkeep

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