chore(marketplace2): sync с origin/dev + DI-фиксы controller для запуска dev-стека (#411)

* 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/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>

* fix commit process

* chore(release): publish

* fix(file-storage): MinIO стартует только при заданном MINIO_ENDPOINT

Раньше 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>

* chore(release): publish

* chore(release): publish

* 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>

* fix commit process

* chore(release): publish

* chore(release): publish

* 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>

* 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>

* 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>

* 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>

* 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>

* fix(capital/convertsegm): регистрировать convert_statement в реестре документов как completed (#403)

Финальная фаза процесса p.cap.rid (convertsegm) принимала document2 convert_statement (шаблон 1080)
параметром, но:

1. **Не проверяла подпись** — `verify_document_or_fail` отсутствовала, поэтому on-chain
   принял бы любую сконструированную document2 без валидной user-подписи.
   У signact1/signact2 (соседние фазы того же процесса) verify есть — здесь забыли.

2. **Не регистрировала документ в реестре** — `newlink`/`newsubmitted`/`newresolved`
   не вызывался, заявление о трансляции 1080 «терялось»: off-chain controller (process_instance)
   не видел финальный документ привязанным к result_hash, процесс p.cap.rid не помечался completed.
   У pushrslt (create_approval) и signact2 (newlink с SIGN_ACT2_RESULT) линковка есть.

Канон есть в soviet/src/system/converttoaxn.cpp:54 и soviet/src/agreement/sndagreement.cpp:104:
паттерн `Soviet::make_complete_document(calling_contract, coopname, username, action, package_hash, document)`
шлёт newsubmitted + newresolved одной парой, package = анкер процесса (здесь result_hash).

Изменено:
- names.hpp: новая константа Names::Capital::CONVERT_SEGMENT = "convertsegm"_n (12 символов)
- convertsegm.cpp:
  - verify_document_or_fail(convert_statement, {username}) сразу после require_auth
  - Soviet::make_complete_document(...) ДО delete_result (чтобы линковка прошла, пока result_hash ещё анкер)

Контракт capital собирается без ошибок (cdt-cpp testnet mode), warnings — старые ricardian.

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

* feat(epic-0): onboarding harness 01-08 + install bundle adduser+createBoard в одну tx (#398)

* fix(controller): IS_UNIONED zod-парсер принимает string из .env

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.

* feat(epic-0): partner onboarding harness — signin → sign agreements → connect

Что добавлено:
- desktop/quasar.config.cjs: vite server.allowedHosts для voskhod-dev/
  partner-dev/api-dev (Vite 5.4+ блокирует cross-origin Host без явного
  списка — иначе SSR/HMR-сервер возвращает 403 «Blocked request»).
- docs-harness/lib/harness.mjs: helper signOnboardingAgreements —
  реальная подпись каскада SignAgreementDialog (wallet/signature/
  privacy/user), каждый клик отправляет sendAgreement → wallet::signagree
  on-chain. В отличие от dismissOnboardingDialogs делает on-chain эффект
  (см. inc 2026-05-18: stale vault SERVER_SECRET → bad decrypt).
- scenarios/onboarding/01..07: новый partner-flow Эпика 0 — partner1
  заходит в Восход, подписывает 4 типовых соглашения, идёт на «Подключение»,
  ant одобряет, наблюдаем установку partner-dev.
- scenarios/registration/01..04: архив старого registration-doc как
  отдельная история (см. mem feedback_provider_mono_pr_flow).
- scripts/debug-*: вспомогательные скрипты для портал-структуры и
  chairman onboarding'а.

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

* feat(docs-harness/08): chairman install wizard на partner-dev

Сценарий 08 проходит установочный wizard /:coopname/install целиком:
шаг 1 (RequestKeyForm: WIF partner1 из state/cooperatives/partner1.json),
шаг 2 (SetInitForm: readonly orgdata из is_server_init=true, «Далее»),
шаг 3 (SetSovietForm: один председатель — Иванов И.И.),
шаг 4 (SetVariablesForm: ОПФ+ во всех падежах, устав, конф.email).

Финальный submit «Завершить установку» сейчас падает on-chain в
soviet::create — `assertion: Один из аккаунтов не найден в реестре
пайщиков`. Причина: в install.interactor.ts adduser и createBoard
шлются двумя отдельными tx; partner1 nodeos не producer, между tx есть
лаг p2p-репликации, createBoard приходит до того как participants[N]
обновился. Шот 06-error-state снимается при таймауте; шот
06-completed появится после фикса race в install.interactor (отдельный
коммит).

Сценарий запускается:

  BASE_URL=https://partner-dev.coopenomics.world COOPNAME=partner1 \
  node run.mjs onboarding/08-chairman-install-on-partner-dev

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

* fix(controller/install): bundle adduser+createBoard в одну tx

Cause. install.interactor.ts шлёт adduser×N и createBoard двумя отдельными
tx через BlockchainService. На production-нодах с producer-схемой это
работает (lag реплицирования минимальный), но на dev-loop'е partner-
coopback соединён со своим nodeos, который p2p-репликой подтягивает
блоки от producer'а — после accept'а adduser в local state ещё нет
soviet::participants[username] к моменту push'а createBoard. Контракт
soviet::createboard падает «Один из аккаунтов не найден в реестре
пайщиков».

Fix. Объединил все adduser-action'ы и createBoard-action в одну tx
через новый метод BlockchainPort.installSoviet(). Обе action'ы теперь
атомарны в одном блоке — soviet::addpartcpnt (inline action от
adduser) обновляет participants и createBoard видит запись сразу же
в том же блоке.

Поток в install.interactor.ts перестроен в два шага:
  1. Цикл по soviet: createUser в БД + setupNotificationSubscriber +
     сбор addUserActions[] и members[] (без on-chain активности).
  2. installSoviet(addUserActions, createBoardData) — одна tx.

Catch при ошибке on-chain (как и раньше) откатывает users из БД.

Зачем. Закрывает блокер сценария 08-chairman-install-on-partner-dev:
без этого финальный экран wizard'а «Установка завершена» недостижим
на dev-loop'е (Эпик 0 не закрывается).

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

* feat(docs-harness/09): invite-token → WIF → signin chairman

Закрывает Эпик 0: после сценария 08 (chairman install wizard) на
partner1 в Postgres появляется invite-токен председателя, но Novu не
доставит его на @example.com адрес. Сценарий 09 идёт за токеном
напрямую в БД через `ssh partner1 → docker exec postgres → psql` и
прогоняет до финального signin под новым ключом.

Шаги:
1. fetchLatestInviteToken — SELECT token FROM tokens WHERE
   type='invite' AND blacklisted=false AND expires > NOW() LIMIT 1.
2. Открываем `${BASE_URL}/${COOPNAME}/auth/invite?token=<token>` —
   widget Invite.vue клиентски генерирует новый WIF (generateAccount).
3. Извлекаем WIF из q-input, сохраняем в
   state/cooperatives/partner1-chairman.json для последующих сценариев.
4. Чекбокс «Я сохранил ключ» → «Установить ключ» → resetKey шлёт
   on-chain ChangeKey, фронт редиректит на /auth/signin.
5. Финальный signin под chairman.partner1@example.com + новый WIF;
   ждём перехода на /chairman или /participant.

Запускается:

  BASE_URL=https://partner-dev.coopenomics.world COOPNAME=partner1 \
    node run.mjs onboarding/09-chairman-key-and-login

Требует SSH-доступа к partner1 (PARTNER_SSH=user1@91.218.246.46 по
умолчанию) и предварительно прогнанный сценарий 08 (после merge
fix(controller/install) — иначе токен не появится).

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

* refactor(controller/install): откатить installSoviet bundle на sleep 2s

Bundle adduser×N + createBoard в одну tx работает, но требует расширения
BlockchainPort и больше read'а; для не-producer-нод (dev-loop) достаточно
короткой паузы между adduser и createBoard, чтобы p2p-реплика подтянула
блок с participants. 2с гарантированно перекрывают и prod (~50ms), и
dev-loop (1-3с).

Отменён 3f2ecc42cf (installSoviet в blockchain.port + blockchain.service +
install.interactor), добавлен `await new Promise(setTimeout, 2000)` между
циклом adduser и createBoard.

Это однострочный фикс race-condition; bundle вернётся когда понадобится
многошаговая атомарность.

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>
Co-authored-by: coopops <coopops@coopenomics.world>

* fix(asset-formatting): truncate (toward-zero) до 2 знаков вместо math-round (#404)

UI показывал баланс 99999.9999 RUB как 100 000,00 — пользователь видел
больше, чем реально на кошельке, и попытка инвестировать «весь баланс»
падала в walletop TRANSFER с insufficient funds (precision=4 на цепи
vs precision=2 в UI).

Принцип: «никогда не показывать сумму больше реальной». Truncate
выполняется на уровне строки, без преобразования в Number, чтобы
избежать FP-погрешности (0.29 * 100 = 28.999... в IEEE 754).

Изменения:
- desktop: новая утилита floorDecimalString — единый источник истины.
- desktop: formatAsset2Digits / formatToAsset / addAssets используют floor.
- desktop: убран double-formatting amount.toFixed(4) перед formatAsset2Digits
  в ParticipantWalletsPage.
- factory: Factory.formatAsset / Factory.formatShare через приватный
  truncateDecimal — тот же принцип в документах (заявления, акты).

Не затронуто (отдельный фолоу-ап): 10 файлов с inline .toFixed(4)
перед отправкой в контракт — рекомендую заменить на formatToAsset().

Co-authored-by: coopops <coopos@coopenomics.world>

* feat(capital): требовать допуск для просмотра артефактов проекта/компонента (#407)

Любой авторизованный пользователь раньше мог запросить capitalStories /
capitalStory и получить требования всех проектов кооператива. Закрываем эту
лазейку на бекенде и показываем аккуратную заглушку на фронте.

Backend (controller):
- GenerationService.getStories: фильтрует список разрешённых project_hash
  через PermissionsService. Допуск к корневому проекту каскадно открывает
  все его компоненты; допуск к одному компоненту — только этот компонент.
  Запрос без project_hash/issue_hash доступен только chairman/member.
- GenerationService.getStoryByHash: проверяет допуск через project_hash
  (или issue.project_hash для story с issue_hash) и возвращает null без
  доступа.
- getCapitalStories / getCapitalStory резолверы теперь получают currentUser.
- ProjectPermissionsOutputDTO: добавлены has_parent_clearance и
  can_view_artifacts — для отображения заглушек на фронте.

Frontend (desktop):
- ProjectRequirementsPage / ComponentRequirementsPage: показывают
  ArtifactsAccessPlaceholder когда can_view_artifacts=false, с кнопкой
  получения допуска (или PendingClearanceButton при поданном запросе).
- Новый shared компонент ArtifactsAccessPlaceholder в стиле остальных
  заглушек расширения capital.

SDK:
- projectSelector обновлён под новые поля; schema.gql / zeus
  регенерированы.

Co-authored-by: coopops <coopos@coopenomics.world>

* fix(capital/convertsegm): линковать 1080 к пакету через newlink, не make_complete_document (#409)

PR #403 использовал Soviet::make_complete_document — это вытащило заявление о трансляции (1080)
на верхний уровень реестра как самостоятельный документ. В разделе «Документы» 1080 оказался
отдельной строкой, под ним болтались протокол+акт из соседнего пакета (агрегатор подтягивал их
по тому же result_hash без главного документа).

Правильно — линковать 1080 к существующему пакету процесса p.cap.rid, где ведущим документом
является заявление о внесении РИД (1040, statement из pushrslt). Канон уже есть в signact1/signact2:

  Action::send<newlink_interface>(_soviet, "newlink"_n, _capital, coopname, username,
                                  Names::Capital::SIGN_ACT*_RESULT, result_hash, act);

Здесь по тому же паттерну, action = Names::Capital::CONVERT_SEGMENT (константа уже в dev из #403,
её не трогаем), package = result_hash. Off-chain controller добавит 1080 в группу пакета 1040,
а не на верхний уровень.

verify_document_or_fail оставлен — это безопасность, не связана с проблемой реестра.

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

* migration complete

* [598-14][@ant] fix(desktop): vue-tsc на marketplace2 после merge'ов

— OperatorInventoryLabelingPage: BarcodeStrategy/Format через Zeus.MarketplaceBarcode*
— OperatorOwnWarehousePage: pug-tернарник в одну строку (vue-tsc не парсит мультиline | text-block)
— AdminWriteoffsPage: statuses[] через Zeus.MarketplaceWriteoffProposalStatus, trigger === CRON
— SubmitToCouncilDialog: useSessionStore вместо удалённого useStore
— ProcessesPage: ProcessDocument → { hash, source.{code,table,...} } + sortOrder в PaginationInput

* [598-14][@ant] fix(controller): DI marketplace модулей чтобы controller стартовал на marketplace2

— application/marketplace-application.module: импортирует MarketplaceInfrastructureModule (резолверы инжектят MARKETPLACE_*_REPOSITORY) и AccountInfrastructureModule (MarketplaceNotificationService требует ACCOUNT_DATA_PORT). Импорт ExtensionDomainModule удалён — порождал циркуляр AppModule → ExtensionDomainModule → ExtensionsModule → MarketplacePluginModule → MarketplaceExtensionApplicationModule, из-за чего Nest падал с "module at index [6] is undefined" сразу после merge Эпика 8.
— marketplace-writeoff-cron.service: ExtensionDomainService инжектится через @Optional() с null-fallback (cron сам читает env при отсутствии extension config). Это убирает необходимость в проблемном импорте ExtensionDomainModule.
— infrastructure/marketplace-infrastructure.module: импорт VaultDomainModule, потому что MarketplaceCanonicalBlockchainAdapter инжектит VAULT_DOMAIN_SERVICE.

Также добавлен MINIO_ENDPOINT в controller/.env (Эпик 7 / Story 7.1 stol-zakazov:images bucket теперь корректно поднимается на dev).

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: coopops <coopops@coopenomics.world>
Co-authored-by: ant <ant@noreply.local>
This commit is contained in:
Alex Ant
2026-05-19 21:32:16 +05:00
committed by GitHub
parent 58652e5fae
commit 4877c29274
138 changed files with 5054 additions and 1545 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.15-5",
"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.15-5",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.5.13-alpha-3",
"version": "2026.5.15-5",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
@@ -34,6 +34,10 @@ void capital::convertsegm(eosio::name coopname, eosio::name username,
document2 convert_statement) {
require_auth(coopname);
// Проверяем подпись заявления о трансляции паевого взноса (шаблон 1080).
// Без этого on-chain принял бы любую сконструированную document2 без валидной подписи.
verify_document_or_fail(convert_statement, {username});
// Получаем сегмент пайщика
auto segment = Capital::Segments::get_segment_or_fail(coopname, project_hash, username,
"Сегмент пайщика не найден");
@@ -108,6 +112,23 @@ void capital::convertsegm(eosio::name coopname, eosio::name username,
// Инкрементируем счётчик сконвертированных сегментов
Capital::Projects::increment_converted_segments(coopname, current_project.id);
// Линкуем заявление о трансляции (1080) к пакету процесса p.cap.rid
// (package = result_hash, ведущий документ пакета — заявление о внесении РИД, 1040).
// Не make_complete_document: тот вытаскивает 1080 на верхний уровень реестра
// как самостоятельный документ. Нужен newlink — тот же канон, что в signact1/signact2:
// off-chain controller добавляет документ в группу пакета по action+package, не как top-level.
// Делаем ДО delete_result, чтобы линковка прошла, пока result_hash ещё анкер процесса.
Action::send<newlink_interface>(
_soviet,
"newlink"_n,
_capital,
coopname,
username,
Names::Capital::CONVERT_SEGMENT,
result_hash,
convert_statement
);
// Удаляем сегмент после конвертации
Capital::Segments::remove_segment(coopname, segment.id);
@@ -124,5 +124,6 @@ namespace Names {
constexpr eosio::name CREATE_WITHDRAW_2 = "createwthd2"_n; // акцепт возврата из проекта
constexpr eosio::name CREATE_WITHDRAW_3 = "createwthd3"_n; // акцепт возврата из программы
constexpr eosio::name CREATE_RESULT = "createresult"_n; // акцепт результата
constexpr eosio::name CONVERT_SEGMENT = "convertsegm"_n; // финальная фаза p.cap.rid — трансляция паевого взноса
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.5.13-alpha-3",
"version": "2026.5.15-5",
"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.15-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.5.13-alpha-3",
"version": "2026.5.15-5",
"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.15-5",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
+62 -73
View File
@@ -1542,7 +1542,7 @@ type CapitalCommit {
data: JSON
"""Описание коммита"""
description: String!
description: String
"""Отображаемое имя пользователя"""
display_name: String
@@ -1551,7 +1551,7 @@ type CapitalCommit {
id: Int
"""Метаданные коммита"""
meta: String!
meta: String
"""Флаг присутствия записи в блокчейне"""
present: Boolean!
@@ -2732,9 +2732,15 @@ type CapitalProjectPermissions {
"""Может ли устанавливать план проекта"""
can_set_plan: Boolean!
"""Может ли просматривать артефакты (требования) проекта/компонента"""
can_view_artifacts: Boolean!
"""Имеет ли подтвержденное приложение для проекта"""
has_clearance: Boolean!
"""Имеет ли допуск к родительскому проекту (для компонента)"""
has_parent_clearance: Boolean!
"""Является ли пользователь гостем (неавторизованным)"""
is_guest: Boolean!
@@ -5879,6 +5885,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!
@@ -5990,56 +6043,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!
@@ -9927,6 +9930,13 @@ type Mutation {
"""
capitalGenerateGenerationContract(data: GenerationContractGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationConvertStatement(data: GenerationConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление об инвестировании в генерацию
@@ -9955,27 +9965,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);
}
@@ -192,6 +192,14 @@ export class InstallInteractor {
users.push(user);
}
// Лаг p2p-репликации на не-producer nodeos (например на dev-loop'е
// partner-coopback соединён со своим p2p-репликой) — после accept'а
// adduser в local state может ещё не быть soviet::participants[N]
// к моменту push'а createBoard. Контракт soviet::createboard падает
// «Один из аккаунтов не найден в реестре пайщиков». 2с гарантированно
// перекрывают prod-задержку (~50ms) и dev-loop (1-3с).
await new Promise((resolve) => setTimeout(resolve, 2000));
// Создаём доску совета
await this.blockchainPort.createBoard({
coopname: config.coopname,
+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('Название союза для подписания комнат связи'),
@@ -195,11 +196,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,
@@ -122,6 +122,25 @@ export class ProjectPermissionsOutputDTO {
})
pending_clearance!: boolean;
/**
* Имеет ли пользователь допуск к родительскому проекту (если это компонент)
* Используется для отображения вложенных артефактов: допуск к проекту распространяется
* на все его компоненты вниз по иерархии.
*/
@Field(() => Boolean, {
description: 'Имеет ли допуск к родительскому проекту (для компонента)',
})
has_parent_clearance!: boolean;
/**
* Может ли пользователь просматривать артефакты (требования) проекта/компонента.
* true для членов совета, мастера, при наличии собственного допуска либо допуска к родителю.
*/
@Field(() => Boolean, {
description: 'Может ли просматривать артефакты (требования) проекта/компонента',
})
can_view_artifacts!: boolean;
/**
* Является ли пользователь гостем (неавторизованным)
* true только если пользователь не авторизован, false для всех авторизованных пользователей
@@ -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);
}
/**
@@ -220,9 +220,10 @@ export class GenerationResolver {
@AuthRoles(['chairman', 'member', 'user'])
async getCapitalStories(
@Args('filter', { nullable: true }) filter?: StoryFilterInputDTO,
@Args('options', { nullable: true }) options?: PaginationInputDTO
@Args('options', { nullable: true }) options?: PaginationInputDTO,
@CurrentUser() currentUser?: MonoAccountDomainInterface
): Promise<PaginationResult<StoryOutputDTO>> {
return await this.generationService.getStories(filter, options);
return await this.generationService.getStories(filter, options, currentUser);
}
// ============ ISSUE QUERIES ============
@@ -293,8 +294,11 @@ export class GenerationResolver {
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async getCapitalStory(@Args('data') data: GetStoryByHashInputDTO): Promise<StoryOutputDTO | null> {
return await this.generationService.getStoryByHash(data.story_hash);
async getCapitalStory(
@Args('data') data: GetStoryByHashInputDTO,
@CurrentUser() currentUser?: MonoAccountDomainInterface
): Promise<StoryOutputDTO | null> {
return await this.generationService.getStoryByHash(data.story_hash, 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,
});
@@ -650,9 +650,29 @@ export class GenerationService {
/**
* Получение историй с фильтрацией
*/
async getStories(filter?: StoryFilterInputDTO, options?: PaginationInputDTO): Promise<PaginationResult<StoryOutputDTO>> {
// Если указан issue_hash, ищем только по конкретной задаче
async getStories(
filter?: StoryFilterInputDTO,
options?: PaginationInputDTO,
currentUser?: MonoAccountDomainInterface
): Promise<PaginationResult<StoryOutputDTO>> {
const emptyResult: PaginationResult<StoryOutputDTO> = {
items: [],
totalCount: 0,
currentPage: options?.page || 1,
totalPages: 0,
};
// Если указан issue_hash, ищем только по конкретной задаче.
// Артефакт задачи виден только тем, у кого есть допуск к проекту/компоненту задачи.
if (filter?.issue_hash) {
const issue = await this.issueRepository.findByIssueHash(filter.issue_hash);
if (!issue) {
return emptyResult;
}
const project = await this.projectRepository.findByHash(issue.project_hash);
if (!project || !(await this.permissionsService.canViewProjectArtifacts(project, currentUser))) {
return emptyResult;
}
const result = await this.storyRepository.findAllPaginated(filter, options);
return {
items: result.items as StoryOutputDTO[],
@@ -672,21 +692,53 @@ export class GenerationService {
// обратной потребности.
const showIssuesRequirements = filter.show_issues_requirements === true;
// Собираем все project_hash для фильтрации
let projectHashesToFilter: string[] = [filter.project_hash];
// Корневой проект запроса: артефакты root показываются только если на нём есть прямой допуск.
const rootProject = await this.projectRepository.findByHash(filter.project_hash);
if (!rootProject) {
return emptyResult;
}
// Список потенциальных хешей: сам проект + дочерние компоненты (если запрошены).
let candidateHashes: string[] = [filter.project_hash];
if (showComponentsRequirements) {
// Получаем дочерние компоненты проекта
try {
const components = await this.projectRepository.findComponentsByParentHash(filter.project_hash);
const componentHashes = components.map((component) => component.project_hash);
projectHashesToFilter = projectHashesToFilter.concat(componentHashes);
candidateHashes = candidateHashes.concat(componentHashes);
} catch (error) {
console.warn(`Failed to fetch components for project ${filter.project_hash}`, { error });
// Продолжаем с только родительским проектом
}
}
// Если запрос идёт со страницы компонента (rootProject — компонент), каскад от его родителя
// (допуск к родителю-проекту распространяется на этот компонент).
const parentForCascade = rootProject.parent_hash || undefined;
// Оставляем только те проекты, к которым у пользователя есть доступ. Допуск к корневому
// проекту автоматически открывает все его компоненты (parentForCascade=undefined для корня,
// но допуск на root уже сидит в candidateHashes как первый элемент).
const allowedFromRootSide = await this.permissionsService.filterProjectHashesWithArtifactAccess(
candidateHashes,
currentUser,
filter.project_hash
);
// Дополнительно: если страница компонента — пускаем доступ через каскад от родителя.
let projectHashesToFilter = allowedFromRootSide;
if (parentForCascade) {
const allowedFromParentSide = await this.permissionsService.filterProjectHashesWithArtifactAccess(
candidateHashes,
currentUser,
parentForCascade
);
const merged = new Set<string>([...allowedFromRootSide, ...allowedFromParentSide]);
projectHashesToFilter = [...merged];
}
if (projectHashesToFilter.length === 0) {
return emptyResult;
}
// Собираем все issue_hash для фильтрации задач
let issueHashesToFilter: string[] = [];
@@ -744,7 +796,14 @@ export class GenerationService {
};
}
// Для остальных случаев используем стандартную пагинацию
// Для остальных случаев (без project_hash/issue_hash) — стандартная пагинация только
// для председателя/члена совета. Обычным пайщикам общий список артефактов недоступен:
// обращение должно быть строго в скоупе проекта или задачи, где допуск проверен выше.
const role = currentUser?.role;
const isBoard = role === 'chairman' || role === 'member';
if (!isBoard) {
return emptyResult;
}
const result = await this.storyRepository.findAllPaginated(filter, options);
return {
items: result.items as StoryOutputDTO[],
@@ -755,11 +814,32 @@ export class GenerationService {
}
/**
* Получение истории по хэшу
* Получение истории по хэшу. Доступ — только пользователям с допуском к проекту/компоненту,
* к которому привязан артефакт (или председателю/члену совета).
*/
async getStoryByHash(storyHash: string): Promise<StoryOutputDTO | null> {
async getStoryByHash(
storyHash: string,
currentUser?: MonoAccountDomainInterface
): Promise<StoryOutputDTO | null> {
const storyEntity = await this.storyRepository.findByStoryHash(storyHash);
return storyEntity ? (storyEntity as StoryOutputDTO) : null;
if (!storyEntity) {
return null;
}
// Story может быть привязана к проекту (project_hash) или к задаче (issue_hash → её project_hash).
let scopeProjectHash: string | undefined = storyEntity.project_hash;
if (!scopeProjectHash && storyEntity.issue_hash) {
const issue = await this.issueRepository.findByIssueHash(storyEntity.issue_hash);
scopeProjectHash = issue?.project_hash;
}
if (!scopeProjectHash) {
// У артефакта нет ни проекта, ни задачи — некорректное состояние, не отдаём.
return null;
}
const project = await this.projectRepository.findByHash(scopeProjectHash);
if (!project || !(await this.permissionsService.canViewProjectArtifacts(project, currentUser))) {
return null;
}
return storyEntity as StoryOutputDTO;
}
/**
@@ -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
@@ -103,6 +103,73 @@ export class PermissionsService {
return appendix !== null;
}
/**
* Возвращает список хешей проектов из переданного множества, к которым у пользователя
* есть доступ для просмотра артефактов (подтверждённый appendix). Председатель/член совета
* видит весь список без ограничений.
*
* Каскад вниз: если задан parentProjectHash и у пользователя есть допуск к нему,
* все компоненты этого родителя считаются разрешёнными.
*/
async filterProjectHashesWithArtifactAccess(
candidateHashes: string[],
currentUser?: MonoAccountDomainInterface,
parentProjectHash?: string
): Promise<string[]> {
if (this.isBoardMember(currentUser)) {
return [...candidateHashes];
}
const username = currentUser?.username;
if (!username || candidateHashes.length === 0) {
return [];
}
// Каскадный допуск через родительский проект: если он подтверждён — все компоненты доступны.
if (parentProjectHash) {
const parentAppendix = await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
username,
parentProjectHash
);
if (parentAppendix) {
return [...candidateHashes];
}
}
// Иначе — фильтруем по индивидуальному допуску на каждый проект из списка.
const checks = await Promise.all(
candidateHashes.map(async (hash) => {
const appendix = await this.appendixRepository.findConfirmedByUsernameAndProjectHash(username, hash);
return appendix ? hash : null;
})
);
return checks.filter((hash): hash is string => hash !== null);
}
/**
* Проверяет, может ли пользователь просматривать артефакты конкретного проекта или компонента.
* Председатель/член совета — да; иначе — собственный допуск или допуск к родителю.
*/
async canViewProjectArtifacts(
project: ProjectDomainEntity,
currentUser?: MonoAccountDomainInterface
): Promise<boolean> {
if (this.isBoardMember(currentUser)) {
return true;
}
const username = currentUser?.username;
if (!username || !project.coopname) {
return false;
}
const ownAccess = await this.isProjectContributor(username, project.coopname, project.project_hash);
if (ownAccess) {
return true;
}
if (project.parent_hash) {
return await this.isProjectContributor(username, project.coopname, project.parent_hash);
}
return false;
}
/**
* Рассчитывает права доступа пользователя к задаче через матрицу доступа
@@ -223,6 +290,8 @@ export class PermissionsService {
can_complete_requirement: false,
has_clearance: false,
pending_clearance: false,
has_parent_clearance: false,
can_view_artifacts: false,
is_guest: true,
};
}
@@ -242,6 +311,16 @@ export class PermissionsService {
? (await this.appendixRepository.findCreatedByUsernameAndProjectHash(username, project.project_hash)) !== null
: false;
// Проверяем допуск к родителю — каскадно вниз по иерархии проектов:
// если есть допуск к корневому проекту, пользователь видит артефакты всех его компонентов.
const has_parent_clearance = project.parent_hash && project.coopname
? await this.isProjectContributor(username, project.coopname, project.parent_hash)
: false;
// Право просматривать артефакты: председатель/член совета — всегда; в остальных случаях —
// собственный допуск ИЛИ допуск к родителю (для компонента).
const can_view_artifacts = this.isBoardMember(currentUser) || has_clearance || has_parent_clearance;
// Рассчитываем права на основе матрицы доступа
const can_edit_project = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.EDIT_PROJECT);
const can_manage_issues = this.projectPermissionsService.hasProjectPermission(roles, ProjectAction.MANAGE_ISSUES);
@@ -284,6 +363,8 @@ export class PermissionsService {
can_complete_requirement,
has_clearance,
pending_clearance,
has_parent_clearance,
can_view_artifacts,
is_guest: false,
};
}
@@ -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);
}
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { MarketplaceExtensionDomainModule } from '../domain/marketplace-domain.module';
import { MarketplaceInfrastructureModule } from '../infrastructure/marketplace-infrastructure.module';
import { AccountInfrastructureModule } from '~/infrastructure/account/account-infrastructure.module';
import { GatewayInfrastructureModule } from '~/infrastructure/gateway/gateway-infrastructure.module';
import { DocumentDomainModule } from '~/domain/document/document.module';
import { CategoryTreeResolver } from './resolvers/category-tree.resolver';
@@ -110,7 +112,6 @@ import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
import { MarketplaceWriteoffService } from './services/marketplace-writeoff.service';
import { MarketplaceWriteoffCronService } from './services/marketplace-writeoff-cron.service';
import { MarketplaceWriteoffResolver } from './resolvers/marketplace-writeoff.resolver';
import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketplace-inventory.entity';
@@ -121,6 +122,13 @@ import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketpla
@Module({
imports: [
MarketplaceExtensionDomainModule,
// Резолверы (Story 3.x/4.x/5.x/...) инжектят MARKETPLACE_*_REPOSITORY и
// MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT — прямой импорт инфраструктуры
// (а не транзит через ExtensionsModule, который ломается forwardRef'ом
// на ExtensionDomainModule ниже).
MarketplaceInfrastructureModule,
// ACCOUNT_DATA_PORT для MarketplaceNotificationService (Эпик 5+ push-уведомления).
AccountInfrastructureModule,
// Story 4.2: @Cron в MarketplaceCycleAggregatorService (time_based aggregator
// каждые 5 минут + volume_based expire каждый час). ScheduleModule.forRoot()
// идемпотентен — если AppModule тоже инициализирует его, NestJS использует
@@ -140,8 +148,9 @@ import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketpla
FileStorageInfrastructureModule.forFeature([MarketplaceReturnClaimImagesService]),
// Эпик 8: writeoff cron сканер должен видеть marketplace_inventory
TypeOrmModule.forFeature([MarketplaceInventoryEntity], 'marketplace'),
// Доступ к ExtensionDomainService для чтения writeoff-конфига расширения
ExtensionDomainModule,
// ExtensionDomainService инжектится @Optional() в MarketplaceWriteoffCronService —
// импортировать ExtensionDomainModule сюда нельзя (цикл AppModule → ExtensionDomainModule →
// ExtensionsModule → MarketplacePluginModule → MarketplaceExtensionApplicationModule).
],
providers: [
// GraphQL резолверы
@@ -1,4 +1,4 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { Inject, Injectable, Optional, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
@@ -31,7 +31,8 @@ export class MarketplaceWriteoffCronService implements OnModuleInit {
@InjectRepository(MarketplaceInventoryEntity, 'marketplace')
private readonly inventoryRepo: Repository<MarketplaceInventoryEntity>,
private readonly writeoffService: MarketplaceWriteoffService,
private readonly extensionDomainService: ExtensionDomainService,
@Optional()
private readonly extensionDomainService: ExtensionDomainService | null,
@Inject(MARKETPLACE_ASSET_CONFIG)
private readonly assetConfig: MarketplaceAssetConfig,
private readonly eventBus: EventEmitter2,
@@ -64,7 +65,7 @@ export class MarketplaceWriteoffCronService implements OnModuleInit {
return;
}
const extension = await this.extensionDomainService.getAppByName('market');
const extension = this.extensionDomainService ? await this.extensionDomainService.getAppByName('market') : null;
const cfg = extension?.config as IConfig | undefined;
const auto = cfg?.writeoff?.auto_proposal_enabled ?? false;
const graceDays = cfg?.writeoff?.expiry_grace_days ?? 7;
@@ -101,6 +101,7 @@ import { MARKETPLACE_OUTGOING_PAYMENT_REQUEST_REPOSITORY } from '../domain/repos
import { MARKETPLACE_TTN_DOCUMENT_REPOSITORY } from '../domain/repositories/marketplace-ttn-document.repository';
import { MARKETPLACE_RETURN_CLAIM_REPOSITORY } from '../domain/repositories/marketplace-return-claim.repository';
import { MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY } from '../domain/repositories/marketplace-writeoff-proposal.repository';
import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
@Module({
imports: [
@@ -176,6 +177,9 @@ import { MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY } from '../domain/repositories
],
'marketplace'
), // Указываем имя подключения
// Поставляет VAULT_DOMAIN_SERVICE для MarketplaceCanonicalBlockchainAdapter
// (WIF кооператива по data.coopname для подписи on-chain транзакций).
VaultDomainModule,
],
providers: [
{
@@ -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 {
+10 -18
View File
@@ -431,6 +431,9 @@ export const AllTypesProps: Record<string,any> = {
},
GenerationContractSignedMetaDocumentInput:{
},
GenerationConvertStatementGenerateDocumentInput:{
},
GenerationMoneyInvestStatementGenerateDocumentInput:{
@@ -441,9 +444,6 @@ export const AllTypesProps: Record<string,any> = {
},
GenerationMoneyInvestStatementSignedMetaDocumentInput:{
},
GenerationToMainWalletConvertStatementGenerateDocumentInput:{
},
GetAccountInput:{
@@ -983,6 +983,10 @@ export const AllTypesProps: Record<string,any> = {
data:"GenerationContractGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationConvertStatement:{
data:"GenerationConvertStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationMoneyInvestStatement:{
data:"GenerationMoneyInvestStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -999,18 +1003,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"
@@ -3113,7 +3105,9 @@ export const ReturnTypes: Record<string,any> = {
can_manage_issues:"Boolean",
can_set_master:"Boolean",
can_set_plan:"Boolean",
can_view_artifacts:"Boolean",
has_clearance:"Boolean",
has_parent_clearance:"Boolean",
is_guest:"Boolean",
pending_clearance:"Boolean"
},
@@ -4581,13 +4575,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",
+159 -171
View File
@@ -3095,8 +3095,12 @@ export type ValueTypes = {
can_set_master?:boolean | `@${string}`,
/** Может ли устанавливать план проекта */
can_set_plan?:boolean | `@${string}`,
/** Может ли просматривать артефакты (требования) проекта/компонента */
can_view_artifacts?:boolean | `@${string}`,
/** Имеет ли подтвержденное приложение для проекта */
has_clearance?:boolean | `@${string}`,
/** Имеет ли допуск к родительскому проекту (для компонента) */
has_parent_clearance?:boolean | `@${string}`,
/** Является ли пользователь гостем (неавторизованным) */
is_guest?:boolean | `@${string}`,
/** Есть ли запрос на получение допуска в рассмотрении */
@@ -5336,6 +5340,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"]: {
/** Сумма инвестирования */
@@ -5412,40 +5448,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"]: {
/** Имя аккаунта пользователя */
@@ -8021,13 +8023,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"]],
@@ -13123,8 +13123,12 @@ export type ResolverInputTypes = {
can_set_master?:boolean | `@${string}`,
/** Может ли устанавливать план проекта */
can_set_plan?:boolean | `@${string}`,
/** Может ли просматривать артефакты (требования) проекта/компонента */
can_view_artifacts?:boolean | `@${string}`,
/** Имеет ли подтвержденное приложение для проекта */
has_clearance?:boolean | `@${string}`,
/** Имеет ли допуск к родительскому проекту (для компонента) */
has_parent_clearance?:boolean | `@${string}`,
/** Является ли пользователь гостем (неавторизованным) */
is_guest?:boolean | `@${string}`,
/** Есть ли запрос на получение допуска в рассмотрении */
@@ -15308,6 +15312,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"]: {
/** Сумма инвестирования */
@@ -15384,40 +15420,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"]: {
/** Имя аккаунта пользователя */
@@ -17910,13 +17912,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"]],
@@ -22049,13 +22049,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,
/** Проект, к которому относится коммит */
@@ -22852,8 +22852,12 @@ export type ModelTypes = {
can_set_master: boolean,
/** Может ли устанавливать план проекта */
can_set_plan: boolean,
/** Может ли просматривать артефакты (требования) проекта/компонента */
can_view_artifacts: boolean,
/** Имеет ли подтвержденное приложение для проекта */
has_clearance: boolean,
/** Имеет ли допуск к родительскому проекту (для компонента) */
has_parent_clearance: boolean,
/** Является ли пользователь гостем (неавторизованным) */
is_guest: boolean,
/** Есть ли запрос на получение допуска в рассмотрении */
@@ -24972,6 +24976,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"]: {
/** Сумма инвестирования */
@@ -25048,40 +25084,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"]: {
/** Имя аккаунта пользователя */
@@ -27594,6 +27596,10 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationContract: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member. */
capitalGenerateGenerationConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление об инвестировании в генерацию
Требуемые роли: chairman, member. */
@@ -27610,18 +27616,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. */
@@ -32319,13 +32313,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,
/** Проект, к которому относится коммит */
@@ -33160,8 +33154,12 @@ export type GraphQLTypes = {
can_set_master: boolean,
/** Может ли устанавливать план проекта */
can_set_plan: boolean,
/** Может ли просматривать артефакты (требования) проекта/компонента */
can_view_artifacts: boolean,
/** Имеет ли подтвержденное приложение для проекта */
has_clearance: boolean,
/** Имеет ли допуск к родительскому проекту (для компонента) */
has_parent_clearance: boolean,
/** Является ли пользователь гостем (неавторизованным) */
is_guest: boolean,
/** Есть ли запрос на получение допуска в рассмотрении */
@@ -35400,6 +35398,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"]: {
/** Сумма инвестирования */
@@ -35476,40 +35506,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"]: {
/** Имя аккаунта пользователя */
@@ -38213,6 +38209,10 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationContract: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member. */
capitalGenerateGenerationConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление об инвестировании в генерацию
Требуемые роли: chairman, member. */
@@ -38229,18 +38229,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. */
@@ -42734,10 +42722,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.15-5",
"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. Подписываем документ конвертации
@@ -2,6 +2,18 @@
div
.flex.flex-center.q-pa-lg(v-if='!permissionsLoaded')
q-spinner(color='primary' size='40px')
ArtifactsAccessPlaceholder(
v-else-if='!canViewArtifacts'
scope='component'
:pending='componentPermissions?.pending_clearance === true'
)
template(#action)
PendingClearanceButton(v-if='componentPermissions?.pending_clearance')
MakeClearanceButton(
v-else-if='component'
:project='component'
@clearance-submitted='handleClearanceSubmitted'
)
RequirementsListWidget(
v-else
:filter='requirementsFilter',
@@ -16,15 +28,19 @@ import { computed, ref, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { RequirementsListWidget } from 'app/extensions/capital/widgets/RequirementsListWidget';
import { useProjectStore } from 'app/extensions/capital/entities/Project/model';
import type { IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
import type { IProject, IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
import { ArtifactsAccessPlaceholder } from 'app/extensions/capital/shared/ui/ArtifactsAccessPlaceholder';
import { PendingClearanceButton } from 'app/extensions/capital/shared/ui/PendingClearanceButton';
import { MakeClearanceButton } from 'app/extensions/capital/features/Contributor/MakeClearance';
const route = useRoute();
const projectStore = useProjectStore();
const component = ref<IProject | null>(null);
const componentPermissions = ref<IProjectPermissions | null>(null);
const permissionsLoaded = ref(false);
// Получаем hash проекта из параметров маршрута
// Получаем hash компонента из параметров маршрута
const projectHash = computed(() => route.params.project_hash as string);
// Только этот компонент (без вложенных компонентов и без артефактов задач)
@@ -34,19 +50,33 @@ const requirementsFilter = computed(() => ({
show_issues_requirements: false,
}));
// Право просмотра артефактов компонента: собственный допуск, либо допуск к родителю-проекту,
// либо роль председателя/члена совета (всё это учтено бэкендом в can_view_artifacts).
const canViewArtifacts = computed(() => {
const perms = componentPermissions.value;
if (!perms) return false;
return perms.can_view_artifacts ?? (perms.has_clearance || perms.has_parent_clearance);
});
const loadComponentPermissions = async () => {
permissionsLoaded.value = false;
try {
const component = await projectStore.loadProject({ hash: projectHash.value });
componentPermissions.value = component?.permissions ?? null;
const loaded = await projectStore.loadProject({ hash: projectHash.value });
component.value = loaded ?? null;
componentPermissions.value = loaded?.permissions ?? null;
} catch (error) {
console.error('Ошибка при загрузке разрешений компонента:', error);
component.value = null;
componentPermissions.value = null;
} finally {
permissionsLoaded.value = true;
}
};
const handleClearanceSubmitted = async () => {
await loadComponentPermissions();
};
watch(projectHash, () => {
void loadComponentPermissions();
});
@@ -2,6 +2,18 @@
div
.flex.flex-center.q-pa-lg(v-if='!permissionsLoaded')
q-spinner(color='primary' size='40px')
ArtifactsAccessPlaceholder(
v-else-if='!canViewArtifacts'
scope='project'
:pending='projectPermissions?.pending_clearance === true'
)
template(#action)
PendingClearanceButton(v-if='projectPermissions?.pending_clearance')
MakeClearanceButton(
v-else-if='project'
:project='project'
@clearance-submitted='handleClearanceSubmitted'
)
RequirementsListWidget(
v-else
:filter='requirementsFilter',
@@ -17,11 +29,15 @@ import { computed, ref, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { RequirementsListWidget } from 'app/extensions/capital/widgets/RequirementsListWidget';
import { useProjectStore } from 'app/extensions/capital/entities/Project/model';
import type { IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
import type { IProject, IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
import { ArtifactsAccessPlaceholder } from 'app/extensions/capital/shared/ui/ArtifactsAccessPlaceholder';
import { PendingClearanceButton } from 'app/extensions/capital/shared/ui/PendingClearanceButton';
import { MakeClearanceButton } from 'app/extensions/capital/features/Contributor/MakeClearance';
const route = useRoute();
const projectStore = useProjectStore();
const project = ref<IProject | null>(null);
const projectPermissions = ref<IProjectPermissions | null>(null);
const permissionsLoaded = ref(false);
@@ -35,20 +51,34 @@ const requirementsFilter = computed(() => ({
show_issues_requirements: false,
}));
// Загрузка разрешений проекта (до списка артефактов иначе canEdit временно false)
// Право просмотра артефактов: либо председатель/член совета, либо собственный допуск.
// На странице корневого проекта допуск к родителю смысла не имеет но поле есть, оставляем для единообразия.
const canViewArtifacts = computed(() => {
const perms = projectPermissions.value;
if (!perms) return false;
return perms.can_view_artifacts ?? (perms.has_clearance || perms.has_parent_clearance);
});
// Загрузка проекта и его разрешений
const loadProjectPermissions = async () => {
permissionsLoaded.value = false;
try {
const project = await projectStore.loadProject({ hash: projectHash.value });
projectPermissions.value = project?.permissions ?? null;
const loaded = await projectStore.loadProject({ hash: projectHash.value });
project.value = loaded ?? null;
projectPermissions.value = loaded?.permissions ?? null;
} catch (error) {
console.error('Ошибка при загрузке разрешений проекта:', error);
project.value = null;
projectPermissions.value = null;
} finally {
permissionsLoaded.value = true;
}
};
const handleClearanceSubmitted = async () => {
await loadProjectPermissions();
};
watch(projectHash, () => {
void loadProjectPermissions();
});
@@ -0,0 +1 @@
export { default as ArtifactsAccessPlaceholder } from './ui/ArtifactsAccessPlaceholder.vue';
@@ -0,0 +1,67 @@
<template lang="pug">
q-card.artifacts-access-placeholder(flat, bordered)
q-card-section.text-center.q-py-xl
q-icon.artifacts-access-placeholder__icon(name='lock_outline', size='56px', color='grey-6')
.text-h6.q-mt-md {{ title }}
.text-body2.text-grey-7.q-mt-sm.artifacts-access-placeholder__description
| {{ description }}
.row.justify-center.q-mt-lg(v-if='showAction')
slot(name='action')
</template>
<script lang="ts" setup>
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
/** Скоуп страницы: для проекта или компонента — влияет на формулировку */
scope?: 'project' | 'component';
/** Принят ли запрос на допуск (в рассмотрении) — меняет текст */
pending?: boolean;
}>(),
{
scope: 'project',
pending: false,
},
);
const title = computed(() => {
if (props.pending) {
return 'Запрос на допуск рассматривается';
}
return props.scope === 'component'
? 'Артефакты компонента доступны участникам'
: 'Артефакты проекта доступны участникам';
});
const description = computed(() => {
if (props.pending) {
return props.scope === 'component'
? 'Когда мастер компонента или председатель подтвердит ваш запрос на допуск, артефакты этого компонента станут видны.'
: 'Когда мастер проекта или председатель подтвердит ваш запрос на допуск, артефакты проекта и его компонентов станут видны.';
}
return props.scope === 'component'
? 'Чтобы открыть артефакты этого компонента, получите допуск к нему или к родительскому проекту.'
: 'Чтобы открыть артефакты проекта и его компонентов, получите допуск к проекту.';
});
const showAction = computed(() => true);
</script>
<style lang="scss" scoped>
.artifacts-access-placeholder {
margin: 16px;
background: var(--q-grey-1, #fafafa);
&__icon {
opacity: 0.65;
}
&__description {
max-width: 480px;
margin-left: auto;
margin-right: auto;
line-height: 1.5;
}
}
</style>
@@ -133,7 +133,7 @@ div.page-shell
)
q-item-section
q-item-label.font-monospace {{ a.account }}::{{ a.name }}
q-item-label(caption) {{ formatDate(a.created_at) }} · seq {{ a.global_sequence }}
q-item-label(caption) {{ formatDate(String(a.created_at ?? '')) }} · seq {{ a.global_sequence }}
.col-12.col-md-6
q-card(flat bordered)
q-card-section.q-pb-none
@@ -143,11 +143,11 @@ div.page-shell
q-list(v-else dense)
q-item(
v-for='d in processDetails.get(props.row.processHash)!.documents'
:key='d.registry_id + "-" + d.document_hash'
:key='d.source.code + "::" + d.source.table + "::" + d.source.primary_key + "-" + d.hash'
)
q-item-section
q-item-label registry_id {{ d.registry_id }}
q-item-label(caption).font-monospace {{ d.document_hash }}
q-item-label {{ d.source.code }} · {{ d.source.table }} (key {{ d.source.primary_key }})
q-item-label(caption).font-monospace {{ d.hash }}
.q-mt-md(v-if='hasProcessInfo(props.row.processType)')
q-card(flat bordered)
@@ -374,6 +374,7 @@ async function load() {
pagination: {
page: pagination.value.page,
limit: pagination.value.rowsPerPage,
sortOrder: 'DESC',
},
})
if (myId !== lastRequestId) return
@@ -209,7 +209,7 @@ const WalletCell = {
{ class: ['cell-line', valueClass, props.bold ? 'bold' : ''].join(' ') },
[
h(QIcon, { name: icon, size: '12px', class: 'cell-icon' }),
h('span', { class: 'cell-value' }, formatAsset2Digits(`${amount.toFixed(4)} RUB`)),
h('span', { class: 'cell-value' }, formatAsset2Digits(`${amount} RUB`)),
h(QTooltip, { anchor: 'top middle', self: 'bottom middle', delay: 200 }, () => tooltip),
],
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/desktop",
"version": "2026.5.13-alpha-3",
"version": "2026.5.15-5",
"description": "Рабочий стол кооператива — Vue 3 + Quasar Framework",
"productName": "Desktop App",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
+11
View File
@@ -116,6 +116,17 @@ module.exports = configure(function (ctx) {
unhandledErrors: true,
logLevels: ['error', 'warn', 'info', 'log', 'debug'],
};
// Vite 5.4+ блокирует cross-origin Host без явного allowedHosts.
// В dev-loop ходим через L7-прокси voskhod-dev.coopenomics.world,
// partner-dev.coopenomics.world и api-dev.coopenomics.world — без
// этой опции SSR/HMR-сервер возвращает 403 «Blocked request».
viteConf.server.allowedHosts = [
'voskhod-dev.coopenomics.world',
'partner-dev.coopenomics.world',
'api-dev.coopenomics.world',
'localhost',
'127.0.0.1',
];
}
if (!isClient) {
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { Zeus } from '@coopenomics/sdk';
import { FailAlert } from 'src/shared/api';
import {
getOpenWriteoffDraft,
@@ -42,8 +43,15 @@ async function load(): Promise<void> {
try {
const [openDraft, councilPage, archivePage] = await Promise.all([
getOpenWriteoffDraft(),
listWriteoffProposals({ statuses: ['ON_AGENDA', 'AUTHORIZED', 'EXECUTING'] }),
listWriteoffProposals({ statuses: ['EXECUTED', 'REJECTED'] }),
listWriteoffProposals({ statuses: [
Zeus.MarketplaceWriteoffProposalStatus.ON_AGENDA,
Zeus.MarketplaceWriteoffProposalStatus.AUTHORIZED,
Zeus.MarketplaceWriteoffProposalStatus.EXECUTING,
] }),
listWriteoffProposals({ statuses: [
Zeus.MarketplaceWriteoffProposalStatus.EXECUTED,
Zeus.MarketplaceWriteoffProposalStatus.REJECTED,
] }),
]);
draft.value = openDraft;
inCouncil.value = councilPage.items;
@@ -146,7 +154,7 @@ q-page.mp-role-admin.mp-writeoffs.q-pa-md
.col
.text-subtitle1 Открытый черновик
.text-caption.text-grey {{ draft.items.length }} позиций · {{ draft.total_amount }}
.text-caption.text-grey Источник: {{ draft.trigger === 'cron' ? 'крон-сервис' : 'ручное создание' }} · {{ formatDate(draft.created_at) }}
.text-caption.text-grey Источник: {{ draft.trigger === Zeus.MarketplaceWriteoffProposalTrigger.CRON ? 'крон-сервис' : 'ручное создание' }} · {{ formatDate(draft.created_at) }}
q-btn(
flat color="primary" no-caps
icon="fa-solid fa-pen"
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useStore } from 'src/shared/store';
import { useSessionStore } from 'src/entities/Session';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { DigitalDocument } from 'src/shared/lib/document';
import {
@@ -26,7 +26,7 @@ const emit = defineEmits<{
(e: 'submitted'): void;
}>();
const store = useStore() as { account: { username: string } };
const session = useSessionStore();
const previewDoc = ref<MarketplaceWriteoffStatementDocumentView | null>(null);
const loading = ref(false);
@@ -56,7 +56,7 @@ async function signAndSubmit(): Promise<void> {
submitting.value = true;
try {
const digital = new DigitalDocument(previewDoc.value);
const signed = await digital.sign(store.account.username);
const signed = await digital.sign(session.username);
await submitWriteoffDraft({
draft_id: props.draft.id,
signed_statement: signed,
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { Loading } from 'quasar'
import { SuccessAlert, FailAlert } from 'src/shared/api'
import { BarcodeDisplay } from 'src/widgets/Marketplace/BarcodeDisplay'
import { Zeus } from '@coopenomics/sdk'
import {
fetchInventoryByBraname,
fetchShipmentsForLabeling,
@@ -13,8 +14,8 @@ import {
} from '../api'
type LabelingMode = 'PER_ORDER' | 'BATCH'
type BarcodeStrategy = 'PER_ORDER' | 'PER_UNIT' | 'PER_PACKAGE'
type BarcodeFormat = 'CODE128' | 'EAN13'
type BarcodeStrategy = Zeus.MarketplaceBarcodeStrategy
type BarcodeFormat = Zeus.MarketplaceBarcodeFormat
const braname = ref<string>('')
const items = ref<MarketplaceInventoryItemView[]>([])
@@ -27,19 +28,19 @@ const orderIdInput = ref<string>('')
const shipments = ref<MarketplaceShipmentView[]>([])
const selectedShipmentId = ref<string | null>(null)
const defaultStrategy = ref<BarcodeStrategy | null>(null)
const defaultFormat = ref<BarcodeFormat>('CODE128')
const defaultFormat = ref<BarcodeFormat>(Zeus.MarketplaceBarcodeFormat.CODE128)
const lastBatchSummary = ref<{ labeled: number; skipped: number } | null>(null)
const strategyOptions = [
const strategyOptions: Array<{ label: string; value: BarcodeStrategy | null }> = [
{ label: 'По умолчанию из карточки товара', value: null },
{ label: 'Одна этикетка на весь заказ', value: 'PER_ORDER' },
{ label: 'Отдельная этикетка на каждую единицу', value: 'PER_UNIT' },
{ label: 'Этикетка на упаковку', value: 'PER_PACKAGE' },
] as const
{ label: 'Одна этикетка на весь заказ', value: Zeus.MarketplaceBarcodeStrategy.PER_ORDER },
{ label: 'Отдельная этикетка на каждую единицу', value: Zeus.MarketplaceBarcodeStrategy.PER_UNIT },
{ label: 'Этикетка на упаковку', value: Zeus.MarketplaceBarcodeStrategy.PER_PACKAGE },
]
const formatOptions = [
{ label: 'CODE128', value: 'CODE128' as const },
{ label: 'EAN13', value: 'EAN13' as const },
const formatOptions: Array<{ label: string; value: BarcodeFormat }> = [
{ label: 'CODE128', value: Zeus.MarketplaceBarcodeFormat.CODE128 },
{ label: 'EAN13', value: Zeus.MarketplaceBarcodeFormat.EAN13 },
]
const shipmentOptions = computed(() =>
@@ -72,9 +72,7 @@ q-page.mp-role-operator.q-pa-md
template(#no-data)
.full-width.text-center.q-pa-md.text-grey-7
| {{ braname
| ? 'Наклеек по этому КУ нет — проверьте код или фильтры по состояниям.'
| : 'Введите код КУ и нажмите «Обновить».' }}
| {{ braname ? 'Наклеек по этому КУ нет — проверьте код или фильтры по состояниям.' : 'Введите код КУ и нажмите «Обновить».' }}
</template>
<script lang="ts" setup>
@@ -1,8 +1,14 @@
import { floorDecimalString } from './floorDecimalString';
/**
* Суммирует два актива (IAsset) с одинаковой валютой
* @param asset1 - первый актив (например "100.00 RUB")
* @param asset2 - второй актив (например "50.00 RUB")
* @returns сумма активов в формате IAsset (например "150.00 RUB")
* Суммирует два актива (IAsset) с одинаковой валютой и возвращает
* результат с 2 знаками после запятой, обрезанный вниз (toward-zero),
* чтобы согласоваться с `formatAsset2Digits`: пользователь никогда
* не видит сумму больше реальной.
*
* @param asset1 - первый актив (например "100.0000 RUB")
* @param asset2 - второй актив (например "50.0000 RUB")
* @returns сумма активов с 2 знаками (например "150.00 RUB")
*/
export const addAssets = (asset1: string, asset2: string): string => {
if (!asset1 || asset1 === '0') return asset2 || '0.00';
@@ -33,8 +39,9 @@ export const addAssets = (asset1: string, asset2: string): string => {
const sum = value1 + value2;
const currencySymbol = parsed1.currency || parsed2.currency;
// Форматируем результат как строку без пробелов для совместимости с formatAsset2Digits
const formattedNumber = sum.toFixed(2);
// Truncate вниз через строку (с запасом точности до 10 знаков, чтобы FP-шум
// не округлил 0.29 как 0.28). Никогда не показываем сумму больше реальной.
const formattedNumber = floorDecimalString(sum.toFixed(10), 2);
return currencySymbol
? `${formattedNumber} ${currencySymbol}`
@@ -0,0 +1,24 @@
/**
* Truncate (toward-zero) числовой строки до displayPrecision знаков
* после запятой на уровне строки, без преобразования в Number, чтобы
* избежать FP-погрешности (например, 0.29 * 100 = 28.999999999999996 в IEEE 754).
*
* Принцип: «никогда не показать пользователю сумму больше, чем реально
* есть на кошельке». 99999.9999 "99999.99" (а не "100000.00", как
* сделал бы math-round). Применяется как для отображения asset
* (precision=4 display=2), так и для подготовки input при отправке
* в контракт (не превысить ввод пользователя/доступный баланс).
*
* @param numericString десятичная строка без валюты, e.g. "99999.9999" или "-12.5"
* @param displayPrecision сколько знаков оставить (по умолчанию 2)
*/
export const floorDecimalString = (numericString: string, displayPrecision = 2): string => {
const trimmed = numericString.trim();
const negative = trimmed.startsWith('-');
const abs = negative ? trimmed.slice(1) : trimmed;
const [intPartRaw, decPartRaw = ''] = abs.split('.');
const intPart = intPartRaw || '0';
const decPart = decPartRaw.padEnd(displayPrecision, '0').slice(0, displayPrecision);
const result = displayPrecision > 0 ? `${intPart}.${decPart}` : intPart;
return negative ? `-${result}` : result;
};
@@ -1,26 +1,36 @@
import { floorDecimalString } from './floorDecimalString';
/**
* Форматирует актив с двумя знаками после запятой и извлекает символ валюты
* @param value - строка с активом (например "1000000 EOS")
* @returns отформатированная строка (например "100.00 EOS")
* Форматирует актив для отображения: truncate (toward-zero) до 2 знаков
* после запятой + локальная группировка разрядов (ru-RU).
*
* Truncate, а не math-round: 99999.9999 "99 999,99" (не "100 000,00").
* Никогда не показываем пользователю сумму больше, чем реально доступна
* на кошельке иначе попытка инвестировать «весь баланс» упирается
* в `walletop TRANSFER: insufficient funds` из-за расхождения precision=4
* на цепи и precision=2 в UI.
*
* @param value строка с активом (например "99999.9999 RUB")
* @returns отформатированная строка (например "99 999,99 RUB")
*/
export const formatAsset2Digits = (value: string): string => {
if (!value || value === '0') return '0.00';
// Разделяем значение по пробелу для извлечения символа валюты
const parts = value.trim().split(' ');
const cleanValue = parts[0] || value;
const currencySymbol = parts[1] || '';
// Убираем символ валюты и форматируем как число
const numericValue = cleanValue.replace(/[^\d.-]/g, '');
const numValue = parseFloat(numericValue);
if (isNaN(numValue)) return '0.00';
if (!numericValue || numericValue === '-' || isNaN(parseFloat(numericValue))) {
return '0.00';
}
const formattedNumber = new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(numValue);
const truncated = floorDecimalString(numericValue, 2);
const negative = truncated.startsWith('-');
const abs = negative ? truncated.slice(1) : truncated;
const [intStr, decStr] = abs.split('.');
const intGrouped = new Intl.NumberFormat('ru-RU').format(BigInt(intStr));
const formattedNumber = `${negative ? '-' : ''}${intGrouped},${decStr}`;
return currencySymbol
? `${formattedNumber} ${currencySymbol}`
@@ -1,4 +1,17 @@
export function formatToAsset(amount: string | number, currency: string, precision=4): string {
const formattedAmount = (typeof amount === 'number' ? amount : parseFloat(amount)).toFixed(precision);
import { floorDecimalString } from './floorDecimalString';
/**
* Конвертирует число/строку в asset-строку с заданной precision (по умолчанию 4)
* для отправки в контракт.
*
* Использует truncate (toward-zero), не math-round: чтобы пользователь не
* мог случайно превысить свой ввод или доступный баланс. Например, ввод
* 99.99999 RUB при precision=4 даёт "99.9999 RUB" (не "100.0000").
*/
export function formatToAsset(amount: string | number, currency: string, precision = 4): string {
const num = typeof amount === 'number' ? amount : parseFloat(amount);
if (isNaN(num)) return `0.${'0'.repeat(precision)} ${currency}`;
const padded = num.toFixed(precision + 4);
const formattedAmount = floorDecimalString(padded, precision);
return `${formattedAmount} ${currency}`;
}
@@ -1,4 +1,5 @@
export * from './formatDateForEos';
export * from './floorDecimalString';
export * from './formatToAsset';
export * from './formatAsset2Digits';
export * from './addAssets';
+2
View File
@@ -4,3 +4,5 @@ shots/
# генерируются add-plain-participant при первом прогоне.
state/participants/*.json
!state/participants/.gitkeep
state/cooperatives/*.json
!state/cooperatives/.gitkeep
+157 -57
View File
@@ -39,8 +39,16 @@ export async function openBrowser({ storageState } = {}) {
const page = await context.newPage();
const consoleLog = [];
page.on('console', m => consoleLog.push(`[${m.type()}] ${m.text()}`));
page.on('pageerror', e => consoleLog.push(`[pageerror] ${e.message}`));
const debugConsole = process.env.DEBUG_CONSOLE === '1';
page.on('console', m => {
const line = `[${m.type()}] ${m.text()}`;
consoleLog.push(line);
if (debugConsole) console.log(' [browser]', line.slice(0, 300));
});
page.on('pageerror', e => {
consoleLog.push(`[pageerror] ${e.message}`);
if (debugConsole) console.log(' [browser pageerror]', e.message.slice(0, 300));
});
page.on('requestfailed', r => {
const u = r.url();
if (!u.includes('/src/') && !u.includes('/node_modules/') && !u.includes('/@vite')) {
@@ -74,72 +82,164 @@ export async function loginAs(page, fixture) {
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
}
// Закрывает каскад модалок-документов первого входа (Положение о ЦПП Кошелёк,
// ЭП, политика, пользовательское соглашение, и при наличии — Capital-документы).
// Кликает «Подписать» нативно через DOM, потому что Playwright actionability-check
// спотыкается о pointer-events на тексте.
// Скрывает каскад модалок-документов первого входа (Положение о ЦПП Кошелёк,
// ЭП, политика обработки ПД, пользовательское соглашение). После регистрации
// или первого логина в Восходе у chairman'а/пайщика рендерятся параллельно 4
// SignAgreementDialog (см. widgets/RequireAgreements) — каждый в своём
// q-portal--dialog--N. На каждом page.goto Vue их перерендерит заново.
//
// Главная сложность: между документами появляется промежуточный диалог
// «Формируем документ...» со спиннером — кнопки в нём ещё нет. Ждём пока
// «Подписать» появится, потом клик.
// На локальном тестовом стенде реальная подпись (signAgreement → push в
// блокчейн) не отрабатывает: submit-handler q-form вызывается, validate
// проходит, но signAgreement зависает либо backend отдаёт ошибку без
// видимого UX-feedback. Для целей docs-harness нам важно ПРОЙТИ дальше по
// сценарию, а не фиксировать сам онбординг.
//
// Стратегия: ставим MutationObserver, который при добавлении/изменении
// q-portal--dialog--N проверяет, не онбординг ли это («Прочитайте и подпишите
// документ» в заголовке) — и если да, ставит display:none + чистит body-флаги
// Quasar (q-body--prevent-scroll и пр.). Наблюдатель остаётся жить до конца
// page-сессии и работает после каждого Vue-перерендера.
export async function dismissOnboardingDialogs(page) {
// Стартовое окно — даём шанс модалкам появиться. Если их нет за 12 сек —
// значит онбординг уже пройден или политика обходит этого пользователя.
const firstAppeared = await page
.locator('[id^="q-portal--dialog--"]').first()
.waitFor({ state: 'visible', timeout: 12000 })
.then(() => true).catch(() => false);
if (!firstAppeared) return;
await page.evaluate(() => {
if (window.__onboardingDialogsBlocker) return;
// Цикл идёт пока в DOM есть видимый q-portal--dialog. На каждой итерации:
// 1. Ждём появления кнопки «Подписать» в самом верхнем диалоге (до 30 сек).
// 2. Кликаем нативно через DOM.
// 3. Ждём 3.5 сек на blockchain confirm + переход к следующему документу.
for (let i = 0; i < 20; i++) {
const hasDialog = await page.evaluate(() =>
Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.some((p) => getComputedStyle(p).display !== 'none'),
);
if (!hasDialog) break;
const isOnboarding = (el) => {
const title = el.querySelector('.q-toolbar__title, .modal-base__title, h1, h2, h3, h4')?.textContent || '';
if (/Прочитайте и подпишите/i.test(title)) return true;
const body = el.textContent || '';
return /Прочитайте и подпишите документ/i.test(body) && /Подписать/i.test(body);
};
// Ждём пока в верхнем диалоге появится активная кнопка «Подписать».
const btnReady = await page.waitForFunction(
() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return true; // диалогов нет — выходим
const top = portals[portals.length - 1];
return !!Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
},
{ timeout: 60000 },
).then(() => true).catch(() => false);
if (!btnReady) break;
// Quasar q-dialog рендерит контент через Teleport в q-portal--dialog--N.
// v-show / transition мехнизмы могут перезаписывать inline style.display
// даже с !important (Vue 3 v-show через el.style.display = ...). CSS-rule
// с !important работает, НО Quasar также пересоздаёт q-portal-узлы при
// mount/unmount. Самый надёжный путь — физически удалить узел: Vue не
// сможет восстановить контент, так как Teleport target исчез. Это
// вызовет warn в консоли, но визуально UI чист.
const hide = (el) => {
if (!el.parentNode) return;
el.remove();
};
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
const cleanupBody = () => {
document.body.classList.remove(
'q-body--prevent-scroll',
'q-body--has-fixed-dialog',
'q-body--has-dialog',
);
document.body.style.paddingRight = '';
};
const scan = () => {
let touched = 0;
const portals = document.querySelectorAll('[id^="q-portal--dialog--"]');
if (portals.length > 0 && !window.__onboardingScanLogged) {
window.__onboardingScanLogged = true;
portals.forEach((el) => {
console.log(`[onboarding-blocker] portal ${el.id} visible=${getComputedStyle(el).display !== 'none'} bodyLen=${el.textContent?.length} onboarding=${isOnboarding(el)} hidden=${el.dataset.onboardingHidden === '1'}`);
});
}
portals.forEach((el) => {
const onboarding = isOnboarding(el);
if (onboarding && el.dataset.onboardingHidden !== '1') {
hide(el);
touched++;
}
});
if (touched > 0) {
cleanupBody();
console.log(`[onboarding-blocker] hidden ${touched} of ${portals.length} portals`);
window.__onboardingScanLogged = false; // позволить новый дамп если новые портал-диалоги появятся
}
};
const observer = new MutationObserver((mutations) => {
// Не дёргаем scan на каждой мутации; делаем единый проход на любом
// добавлении узлов в body.
for (const m of mutations) {
if (m.addedNodes.length > 0 || m.type === 'characterData') {
scan();
break;
}
}
// Также: если body заново получил q-body--prevent-scroll, а все наши
// скрытые порталы остались в DOM — снимаем класс. Это случается при
// перерендере (Vue добавляет класс через q-dialog logic).
const hasHidden = document.querySelector('[id^="q-portal--dialog--"][data-onboarding-hidden="1"]');
if (hasHidden) cleanupBody();
});
if (!clicked) break;
await page.waitForTimeout(3500);
}
observer.observe(document.body, { childList: true, subtree: true });
// Финальная страховка: ждём чтобы все портал-диалоги ушли с экрана.
// Также — переодический scan на случай если MutationObserver промахнётся.
const interval = setInterval(scan, 1000);
window.__onboardingDialogsBlocker = { observer, interval };
scan();
});
// Дать MutationObserver'у время прожать первый scan и скрыть существующие диалоги.
await page.waitForTimeout(400);
// Активный wait: убедиться, что нет ни одного видимого онбординг-портала.
// Vue может смонтировать новые q-portal--dialog с задержкой 1-5 сек после
// navigation, и observer должен их поймать — здесь мы просто блокируем до
// отсутствия видимых онбординг-порталов на экране (до 12 сек).
await page.waitForFunction(
() => !Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.some((p) => getComputedStyle(p).display !== 'none'),
{ timeout: 30000 },
() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'));
const isOnboarding = (el) => {
const body = el.textContent || '';
return /Прочитайте и подпишите документ/i.test(body) && /Подписать/i.test(body);
};
return !portals.some((p) => isOnboarding(p) && getComputedStyle(p).display !== 'none');
},
{ timeout: 12_000, polling: 200 },
).catch(() => {});
}
// Реально подписывает каскад типовых соглашений первого входа (Цифровой
// Кошелёк, ЭП, политика ПД, пользовательское). В отличие от
// dismissOnboardingDialogs (который DOM-hack'ом убирает порталы и проскакивает
// вперёд) — клик «Подписать» отправляет wallet::signagree on-chain, что нужно
// чтобы пайщик стал участником wallet-программы (иначе is_can_transfer fails
// на любом transfer от/к этому юзеру, см. inc 2026-05-18 #67).
//
// Возвращает { signed, attempts }: сколько диалогов реально подписалось vs
// сколько раз пытались найти следующий. Останавливаемся когда не найден ни
// один видимый «Подписать» в течение probeMs (значит каскад исчерпан).
export async function signOnboardingAgreements(page, opts = {}) {
const {
maxAgreements = 6,
probeMs = 6000,
dialogTimeoutMs = 30000,
pauseBetweenMs = 1500,
} = opts;
let signed = 0;
let attempts = 0;
for (let i = 0; i < maxAgreements; i++) {
attempts++;
// Quasar монтирует портал-диалоги в q-portal--dialog--N. На каскаде
// соглашений у chairman'а сразу 4 портала; видимый/активный — последний
// (top-most), остальные перекрыты overlay. Берём «Подписать» из последнего.
const sign = page.locator('button:visible:has-text("Подписать")').last();
const visible = await sign.waitFor({ state: 'visible', timeout: probeMs }).then(() => true).catch(() => false);
if (!visible) break;
// Документ может ещё генерироваться (loader на кнопке). Дать ему отрисовать.
await page.waitForTimeout(400);
// force:true чтобы пройти overlay-intercept от соседних модалок.
await sign.click({ force: true });
// Ждём пока кнопка «Подписать» этого диалога исчезнет — обычно signagree
// отправляется через 1-3 секунды (chain confirmation).
await sign.waitFor({ state: 'hidden', timeout: dialogTimeoutMs }).catch(() => {});
await page.waitForTimeout(pauseBetweenMs);
signed++;
}
return { signed, attempts };
}
// Создаёт shot-функцию + manifest для сценария.
export function makeShotContext({ scenarioName, outDir }) {
const shots = [];
@@ -0,0 +1,176 @@
// Хелперы регистрации в Восходе: повторно используется в сценариях 01-08
// onboarding'а (каждый последующий сценарий проходит весь wizard до своего
// «точки интереса»; перепроходить wizard в Playwright детерминированно проще,
// чем восстанавливать sign-up state из IndexedDB).
//
// Контракт фикстуры coopData — см. scenarios/onboarding/01-register-coop.mjs:
// short_name / full_name / type / represented_by / phone / country / city /
// full_address / fact_address / inn / ogrn / kpp / bank.{name,corr,bik,kpp,account,currency}.
// email генерится тут с уникальным RUN_ID, чтобы registerAccount не падал
// на дубликате email.
import fs from 'node:fs';
export function makeRunId() {
return String(Date.now()).slice(-6);
}
async function fillByLabel(page, label, value) {
await page.locator(`label:has-text("${label}")`).first().locator('input,textarea').fill(value);
}
async function clickContinue(page) {
await page.locator('button:has-text("Продолжить")').first().click();
}
/**
* Проходит wizard /auth/signup для organization-type кооператива до экрана
* ReadStatement. Делает шоты ключевых шагов через переданный `shot`. По
* пути перехватывает GraphQL ответ registerAccount, чтобы достать
* сгенерированный username и WIF из поля «Приватный ключ».
*
* @returns {Promise<{username: string|null, wif: string|null, email: string}>}
*/
export async function signUpAsOrganization({
page,
shot,
env,
coopData,
fixturePath,
runId,
}) {
const id = runId ?? makeRunId();
const email = `${coopData.emailLocal || 'chairman'}+${id}@example.com`;
// --- 1. Открываем форму регистрации. ---
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signup`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('text=ВСТУПИТЬ В ПАЙЩИКИ', { timeout: 30_000 });
await page.waitForTimeout(500);
await shot(page, '01-signup-empty', 'Открытая форма /auth/signup: первый шаг визарда — ввод электронной почты.');
// --- 2. Email. ---
await fillByLabel(page, 'Введите email', email);
await page.waitForTimeout(300);
await shot(page, '02-email-filled', `Введён email будущего председателя второго кооператива (${email}).`);
await clickContinue(page);
// --- 3. Тип аккаунта — Организация. ---
await page.locator('.q-stepper__tab--active:has-text("Заполните форму заявления")').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('button:has-text("Организация")').first()
.waitFor({ state: 'visible', timeout: 10_000 });
await page.waitForTimeout(400);
await shot(page, '03-type-buttons', 'Выбор типа пайщика: три кнопки — «Физлицо», «ИП», «Организация».');
await page.locator('button:has-text("Организация")').click();
await page.waitForSelector('label:has-text("Краткое наименование организации")', { timeout: 15_000 });
await page.waitForTimeout(400);
await shot(page, '04-organization-empty', 'Раскрылась форма данных организации (после клика «Организация»).');
// --- 4. Заполнение данных кооператива. ---
await fillByLabel(page, 'Краткое наименование организации', coopData.short_name);
await fillByLabel(page, 'Полное наименование организации', coopData.full_name);
await page.locator('label:has-text("Выберите тип организации")').first().click();
await page.waitForSelector(`text=${coopData.type}`, { timeout: 5_000 });
await page.locator(`.q-menu .q-item:has-text("${coopData.type}")`).first().click();
await page.waitForTimeout(300);
await fillByLabel(page, 'Фамилия представителя', coopData.represented_by.last_name);
await fillByLabel(page, 'Имя представителя', coopData.represented_by.first_name);
await fillByLabel(page, 'Отчество представителя', coopData.represented_by.middle_name);
await fillByLabel(page, 'действует на основании', coopData.represented_by.based_on);
await fillByLabel(page, 'Должность представителя', coopData.represented_by.position);
await fillByLabel(page, 'Номер телефона представителя', coopData.phone);
await page.locator('label:has-text("Страна")').first().click();
await page.locator('.q-menu .q-item:has-text("Россия")').first().click();
await page.waitForTimeout(200);
await fillByLabel(page, 'Город', coopData.city);
await fillByLabel(page, 'Юридический адрес регистрации', coopData.full_address);
await fillByLabel(page, 'Фактический адрес', coopData.fact_address);
await fillByLabel(page, 'ИНН организации', coopData.inn);
await fillByLabel(page, 'ОГРН организации', coopData.ogrn);
await fillByLabel(page, 'КПП организации', coopData.kpp);
await fillByLabel(page, 'Наименование банка', coopData.bank.name);
await fillByLabel(page, 'Корреспондентский счет', coopData.bank.corr);
await fillByLabel(page, 'БИК', coopData.bank.bik);
await fillByLabel(page, 'КПП банка', coopData.bank.kpp);
await fillByLabel(page, 'Номер счета', coopData.bank.account);
await page.locator('label:has-text("Валюта")').first().click();
await page.locator('.q-menu .q-item:has-text("RUB")').first().click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
await shot(page, '05-organization-filled', 'Все поля кооператива заполнены: реквизиты, представитель, банковские данные.');
await page.locator('.q-checkbox:has-text("персональных данных")').click();
await page.waitForTimeout(300);
await clickContinue(page);
// --- 6. GenerateAccount. ---
await page.locator('.q-stepper__tab--active:has-text("Получите приватный ключ")').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForSelector('label:has-text("Приватный ключ")', { timeout: 15_000 });
await page.waitForTimeout(800);
await shot(page, '07-generate-account', 'Шаг «Получите приватный ключ» — UI сгенерировал приватный ключ (WIF) кооператива.');
const wif = await page.locator('label:has-text("Приватный ключ")').first().locator('input').inputValue();
if (wif && fixturePath) {
fs.writeFileSync(
fixturePath,
JSON.stringify({ ...coopData, email, wif }, null, 2),
'utf8',
);
console.log(`[signUpAsOrganization] фикстура сохранена → ${fixturePath} (wif=${wif.slice(0, 6)}…)`);
}
await page.locator('.q-checkbox:has-text("сохранил ключ")').click();
await page.waitForTimeout(300);
// Перехват GraphQL registerAccount для извлечения username.
const registerAccountResponse = page.waitForResponse(
(resp) =>
resp.url().includes('/graphql') &&
resp.request().method() === 'POST' &&
resp.request().postData()?.includes('registerAccount'),
{ timeout: 30_000 },
);
await clickContinue(page);
let username = null;
try {
const resp = await registerAccountResponse;
const json = await resp.json();
username = json?.data?.registerAccount?.account?.username || null;
} catch (e) {
console.warn('[signUpAsOrganization] не удалось перехватить registerAccount response:', e.message);
}
// --- 8. ReadStatement — ждём активации и отрисованного заявления. ---
await page.locator('.q-stepper__tab--active:has-text("Ознакомьтесь")').first()
.waitFor({ state: 'visible', timeout: 60_000 });
await page.locator('.statement').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.statement').first().scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
await shot(
page,
'09-statement-read',
'Шаг «Заявление о вступлении» — на странице отображается сгенерированный текст заявления с реквизитами кооператива.',
);
// Обновляем фикстуру username'ом.
if (username && fixturePath && fs.existsSync(fixturePath)) {
const existing = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
fs.writeFileSync(
fixturePath,
JSON.stringify({ ...existing, username }, null, 2),
'utf8',
);
console.log(`[signUpAsOrganization] username сохранён в фикстуру: ${username}`);
}
return { username, wif, email };
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@coopenomics/docs-harness",
"version": "2026.5.13-alpha-3",
"version": "2026.5.15-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@coopenomics/docs-harness",
"version": "2026.5.13-alpha-3",
"version": "2026.5.15-5",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.59.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/docs-harness",
"version": "2026.5.13-alpha-3",
"version": "2026.5.15-5",
"private": true,
"description": "Playwright harness для съёмки скриншотов в документацию (см. bin/shoot.mjs)",
"type": "module",
@@ -0,0 +1,60 @@
// Сценарий: регистрация второго кооператива в Восходе (до экрана ReadStatement).
//
// Останавливаемся именно на ReadStatement, чтобы захватить шот сгенерированного
// заявления. Подпись + отправка + оплата вступительного — в сценарии
// 02-sign-and-submit (он повторяет signUp + продолжает).
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { signUpAsOrganization } from '../../lib/registrator-signup.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
const COOP_DATA = {
emailLocal: 'chairman.partner1',
short_name: 'Партнёр-1',
full_name: 'Потребительский кооператив «Партнёр-1»',
type: 'Потребительский Кооператив',
represented_by: {
last_name: 'Иванов',
first_name: 'Иван',
middle_name: 'Иванович',
based_on: 'решения общего собрания №1 от 01.01.2026 г',
position: 'Председатель совета',
},
phone: '+7 (901) 123-45-67',
country: 'Россия',
city: 'Москва',
full_address: 'г. Москва, ул. Тестовая, д. 1, оф. 100',
fact_address: 'г. Москва, ул. Тестовая, д. 1, оф. 100',
inn: '7700000001',
ogrn: '1027700000001',
kpp: '770001001',
bank: {
name: 'ПАО Сбербанк',
corr: '30101810400000000225',
bik: '044525225',
kpp: '773601001',
account: '40703810500000000001',
currency: 'RUB',
},
};
export const meta = {
title: 'Регистрация второго кооператива (Партнёр-1) в Восходе',
docPath: 'new/onboarding/01-register-coop.md',
assetsDir: 'assets/new/onboarding/01-register-coop',
role: 'anonymous',
};
export default async ({ page, shot, env }) => {
await signUpAsOrganization({
page,
shot,
env,
coopData: COOP_DATA,
fixturePath: COOP_FIXTURE_PATH,
});
};
@@ -0,0 +1,66 @@
// Сценарий: партнёрский кооператив входит в Восход как пайщик.
//
// Партнёр-1 (organization-пайщик ВОСХОДА с username=partner1, type=organization,
// status=active, is_registered=true) ЗАСЕЯН через boot:extra — см.
// components/boot/src/init/infra.ts. Полный путь регистрации (заявление,
// PayInitial, одобрение советом, активация) лежит отдельно в
// scenarios/registration/ и используется только для документации регистрации.
// Здесь начинается основной partner-flow подключения.
//
// Что снимает сценарий:
// 01 — пустая форма логина
// 02 — заполненные email + WIF
// 03 — после логина: либо чек-страница соглашений (типичный wallet/signature/
// privacy/user, как для нового пайщика в Capital), либо сразу дашборд,
// если соглашения уже подписаны.
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { env } from '../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
export const meta = {
title: 'Партнёр-1 входит в Восход как пайщик-организация',
docPath: 'new/onboarding/01-signin-partner.md',
assetsDir: 'assets/new/onboarding/01-signin-partner',
role: 'partner',
};
export default async ({ page, shot }) => {
const partner = JSON.parse(fs.readFileSync(COOP_FIXTURE_PATH, 'utf8'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('button:has-text("Войти")', { timeout: 60_000 });
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
await page.waitForTimeout(500);
await shot(
page,
'01-signin-empty',
'Форма входа в ВОСХОД (partner1). Партнёр уже засеян как organization-пайщик '
+ 'с фиксированным email + WIF — регистрацию проходить не нужно.',
);
await page.locator('label:has-text("электронную почту")').first().locator('input').fill(partner.email);
await page.locator('label:has-text("ключ доступа")').first().locator('input').fill(partner.wif);
await page.waitForTimeout(300);
await shot(
page,
'02-signin-filled',
`Заполнены email (${partner.email}) и ключ доступа partner1. После «Войти» Восход узнаёт пайщика-организацию и ведёт на ЛК.`,
);
await page.locator('button:has-text("Войти")').click();
await page.waitForURL(/\/(chairman|participant|soviet|user|connect)/, { timeout: 60_000 }).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'03-after-login',
`partner1 вошёл — URL ${page.url()}. Если есть непринятые соглашения (Положение о ЦПП Кошелёк / ЭП / Политика ПД / Пользовательское) — UI рендерит их каскадом SignAgreementDialog. Иначе сразу видно рабочий стол с кнопкой «Подключение».`,
);
};
@@ -0,0 +1,65 @@
// Сценарий: партнёр-1 подписывает каскад типовых соглашений первого входа.
//
// При первом логине Восход показывает 4 SignAgreementDialog параллельно
// (см. widgets/RequireAgreements):
// 1) Положение о ЦПП «Цифровой Кошелёк»
// 2) Положение о Простой Электронной Подписи
// 3) Политика обработки персональных данных
// 4) Пользовательское соглашение
//
// Каждый dialog после клика «Подписать» делает sign(document, username) +
// GraphQL sendAgreement → on-chain wallet::signagree. Засеянный partner1 имеет
// active-ключ default_public_key, поэтому подпись проходит сразу.
//
// Снимаем:
// 01 — первое окно соглашения (документ сформирован)
// 02 — рабочий стол после подписи всех соглашений (виден онбординг председателя)
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { loginAs, signOnboardingAgreements } from '../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
export const meta = {
title: 'Партнёр-1 подписывает каскад соглашений первого входа',
docPath: 'new/onboarding/02-accept-agreements.md',
assetsDir: 'assets/new/onboarding/02-accept-agreements',
role: 'partner',
};
export default async ({ page, shot }) => {
const partner = JSON.parse(fs.readFileSync(COOP_FIXTURE_PATH, 'utf8'));
await loginAs(page, partner);
// Дать первому SignAgreementDialog сформировать документ. Документ
// формируется composable'ом useGenerateAgreement через GraphQL — занимает
// 3-6 сек. Ждём появления кнопки «Подписать» (Loader снят).
await page.locator('button:visible:has-text("Подписать")').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(800);
await shot(
page,
'01-first-agreement',
`Сразу после первого входа partner1 видит первое из четырёх SignAgreementDialog — «Прочитайте и подпишите документ» (Положение о ЦПП «Цифровой Кошелёк»). Каскад: ЦПП кошелёк → ЭП → политика обработки персональных данных → пользовательское соглашение.`,
);
// Подписываем все диалоги по очереди — каждый клик «Подписать» отправляет
// wallet::signagree в чейн и закрывает окно, следующий всплывает автоматически.
const result = await signOnboardingAgreements(page, { maxAgreements: 6 });
console.log(` подписано соглашений: ${result.signed}/${result.attempts}`);
// После последней подписи Quasar успокаивается, рендерится Стол председателя.
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'02-after-signing',
`Соглашения подписаны (${result.signed} из ${result.attempts}). UI пропускает партнёра на Стол председателя — отсюда стартует онбординг «Адаптируйте кооператив к работе на платформе» (8 шагов, среди них «О вступлении в ПК «Восход»» — это и есть Connection Agreement).`,
);
};
@@ -0,0 +1,154 @@
// Сценарий: подписание заявления и его отправка оператору.
//
// Продолжает 01: повторяет signUp до ReadStatement (через общий helper),
// затем галочки → SignStatement (рисуем подпись на canvas) → автоматическая
// подпись всех документов регистрации + отправка заявления → PayInitial.
//
// На PayInitial останавливаемся (без оплаты): шот платёжного экрана уходит
// в документацию. Реальная оплата + WaitingRegistration + переход в реестр —
// в следующем сценарии 03 (под chairman'ом) либо в отдельной интеграции с
// mock-провайдером.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { signUpAsOrganization } from '../../lib/registrator-signup.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
const COOP_DATA = {
emailLocal: 'chairman.partner1',
short_name: 'Партнёр-1',
full_name: 'Потребительский кооператив «Партнёр-1»',
type: 'Потребительский Кооператив',
represented_by: {
last_name: 'Иванов',
first_name: 'Иван',
middle_name: 'Иванович',
based_on: 'решения общего собрания №1 от 01.01.2026 г',
position: 'Председатель совета',
},
phone: '+7 (901) 123-45-67',
country: 'Россия',
city: 'Москва',
full_address: 'г. Москва, ул. Тестовая, д. 1, оф. 100',
fact_address: 'г. Москва, ул. Тестовая, д. 1, оф. 100',
inn: '7700000001',
ogrn: '1027700000001',
kpp: '770001001',
bank: {
name: 'ПАО Сбербанк',
corr: '30101810400000000225',
bik: '044525225',
kpp: '773601001',
account: '40703810500000000001',
currency: 'RUB',
},
};
export const meta = {
title: 'Подписание заявления и отправка оператору (Партнёр-1 → Восход)',
docPath: 'new/onboarding/02-sign-and-submit.md',
assetsDir: 'assets/new/onboarding/02-sign-and-submit',
role: 'anonymous',
};
export default async ({ page, shot, env }) => {
// Шаги 0109: signUp до ReadStatement.
await signUpAsOrganization({
page,
shot,
env,
coopData: COOP_DATA,
fixturePath: COOP_FIXTURE_PATH,
});
// --- 10. Отмечаем все галочки соглашений (включая Устав). ---
// Чекбоксы в q-stepper показываются во всех q-step'ах, но видимые только в
// активном (остальные display:none). Кликаем все .q-checkbox в активной зоне.
// Сначала откатываем диалоги ReadAgreementDialog'а, если они открываются от
// клика по тексту: используем native click на input[type=checkbox] внутри
// .q-checkbox, чтобы случайно не активировать ссылку на устав или
// ReadAgreementDialog.
await page.evaluate(() => {
const tab = document.querySelector('.q-stepper__tab--active');
// Активный q-step расположен рядом — Quasar держит общий контейнер
// .q-stepper__step с одинаковой структурой. Найдём контейнер активного
// шага через ближайший .q-stepper__step, потомка которого .statement.
const stepBody = Array
.from(document.querySelectorAll('.q-stepper__step'))
.find((el) => el.querySelector('.statement'));
if (!stepBody) return;
const checkboxes = stepBody.querySelectorAll(
'.q-checkbox input[type=checkbox]:not(:checked)',
);
checkboxes.forEach((cb) => cb.click());
});
await page.waitForTimeout(500);
await shot(
page,
'10-statement-agreements-checked',
'Все галочки соглашений на ReadStatement проставлены (Устав + динамические соглашения), кнопка «Продолжить» активна.',
);
await page.locator('button:has-text("Продолжить")').first().click();
// --- 11. SignStatement — собственноручная подпись через canvas. ---
await page.locator('.q-stepper__tab--active:has-text("Подпишите заявление")').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.signature-container canvas').first()
.waitFor({ state: 'visible', timeout: 15_000 });
await page.waitForTimeout(400);
await shot(
page,
'11-sign-statement-empty',
'Шаг «Подпишите заявление»: канвас для собственноручной подписи (пустой).',
);
// Рисуем «подпись»: несколько штрихов мышью внутри canvas. UI проверяет
// что canvas содержит хоть один ненулевой пиксель.
const canvas = page.locator('.signature-container canvas').first();
const box = await canvas.boundingBox();
if (!box) throw new Error('canvas boundingBox is null');
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
// Длинная диагональ + петля — гарантированно даёт ненулевые пиксели.
await page.mouse.move(box.x + 40, cy - 20);
await page.mouse.down();
await page.mouse.move(cx, cy + 40, { steps: 12 });
await page.mouse.move(box.x + box.width - 40, cy - 30, { steps: 12 });
await page.mouse.up();
await page.mouse.move(cx - 60, cy + 60);
await page.mouse.down();
await page.mouse.move(cx + 60, cy - 20, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(300);
await shot(
page,
'12-sign-statement-drawn',
'Подпись нанесена на canvas. После клика «Продолжить» клиент подписывает все документы регистрации приватным ключом.',
);
// Клик «Продолжить» запускает цепочку: signAllRegistrationDocuments → signStatement →
// sendStatementAndAgreements (это серия GraphQL мутаций; UI в это время
// крутит Loader с текстом «Подписываем ...»).
await page.locator('button:has-text("Продолжить")').first().click();
// --- 12. Переход на PayInitial. ---
// Может занять несколько секунд (несколько GraphQL мутаций подряд).
await page.locator('.q-stepper__tab--active:has-text("вступительный взнос")').first()
.waitFor({ state: 'visible', timeout: 60_000 });
// Ждём готовности данных оплаты (createInitialPayment).
await page.locator('text=Пожалуйста, совершите оплату').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(500);
await shot(
page,
'13-pay-initial',
'Шаг «Оплатите вступительный взнос»: заявление подписано и отправлено, UI показывает сумму вступительного + минимального паевого взноса и реквизиты для оплаты.',
);
// Сценарий завершается на экране оплаты. Дальше — либо ручная оплата (qrpay/yookassa),
// либо mock-провайдер на тестовом контуре.
};
@@ -0,0 +1,147 @@
// Сценарий: оператор Восхода открывает реестр запросов одобрений
// (Approvals) и подтверждает заявку нового пайщика (Партнёр-1).
//
// Реальный путь chairman'а в Восходе:
// /:coopname/chairman/approvals — страница «Запросы одобрений»
// (см. components/desktop/extensions/chairman/install.ts, name='approvals').
//
// Approval-запись появляется автоматически после того, как пайщик оплатил
// вступительный взнос. На локальном стенде без mock-провайдера платежей
// approval для нашей свежей заявки может отсутствовать — тогда сценарий
// делает шот пустого реестра (для документации этого достаточно).
//
// Зависит от: 02-sign-and-submit (фикстура partner1.json содержит username).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { env, dismissOnboardingDialogs } from '../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
export const meta = {
title: 'Оператор Восхода открывает реестр запросов одобрений и подтверждает заявку Партнёр-1',
docPath: 'new/onboarding/03-operator-approve.md',
assetsDir: 'assets/new/onboarding/03-operator-approve',
role: 'chairman',
};
export default async ({ page, shot }) => {
const partner = JSON.parse(fs.readFileSync(COOP_FIXTURE_PATH, 'utf8'));
if (!partner.username) {
throw new Error('Фикстура partner1.json не содержит username — сначала запустите 02-sign-and-submit');
}
// --- 1. Логин под chairman'ом Восхода. ---
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('button:has-text("Войти")', { timeout: 30_000 });
await page.locator('label:has-text("электронную почту")').first().locator('input').fill(env.CHAIRMAN_EMAIL);
await page.locator('label:has-text("ключ доступа")').first().locator('input').fill(env.CHAIRMAN_WIF);
await page.locator('button:has-text("Войти")').click();
await page.waitForURL(/\/(chairman|participant|soviet)/, { timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
// Каскад онбординг-диалогов (Положение ЦПП, ЭП, политика, пользовательское
// соглашение) — скрыть через CSS. См. dismissOnboardingDialogs.
await dismissOnboardingDialogs(page);
await page.waitForTimeout(500);
await shot(page, '01-chairman-home', 'Стартовый экран chairman\'а Восхода (онбординг-диалоги обработаны).');
// --- 2. Идём в реестр одобрений. ---
// Восход — SPA с hash-роутингом, поэтому URL формируется как `/#/<route>`.
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/chairman/approvals`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(1500);
console.log(`[03-operator-approve] url after goto: ${page.url()}`);
await dismissOnboardingDialogs(page);
await page.waitForTimeout(800);
console.log(`[03-operator-approve] url after dismiss: ${page.url()}`);
const domSummary = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'));
const visiblePortals = portals.filter((p) => getComputedStyle(p).display !== 'none');
return {
totalPortals: portals.length,
visiblePortals: visiblePortals.length,
visibleIds: visiblePortals.map((p) => p.id),
hasQTable: !!document.querySelector('.q-table'),
hasApprovalsText: /Запросы одобрений/.test(document.body.textContent || ''),
bodyKids: Array.from(document.body.children).map((c) => `${c.tagName}#${c.id || '-'}`).slice(0, 10),
};
});
console.log(`[03-operator-approve] dom: ${JSON.stringify(domSummary)}`);
// Дождаться q-table или текста-плейсхолдера.
await Promise.race([
page.locator('.q-table').first().waitFor({ state: 'visible', timeout: 30_000 }),
page.locator('text=У председателя нет запросов').first().waitFor({ state: 'visible', timeout: 30_000 }),
]).catch(() => {});
await page.waitForTimeout(800);
await shot(
page,
'02-approvals-list',
'Реестр «Запросы одобрений» (chairman): таблица с фильтром «Ожидает» — все pending-заявки кооператива.',
);
// --- 3. Ищем строку нашего Партнёр-1 по username. ---
const row = page.locator(`tr:has(td:has-text("${partner.username}"))`).first();
const found = await row.waitFor({ state: 'visible', timeout: 5_000 })
.then(() => true).catch(() => false);
if (!found) {
// Approval для свежей заявки не появился (на тестовом стенде платёж
// pending → approval не создан). Шотим пустой реестр и завершаемся —
// дальше backend-зависимая часть, обрабатывается отдельно.
console.log(`[03-operator-approve] approval для ${partner.username} не найден в реестре PENDING — backend не подтвердил платёж`);
await shot(
page,
'03-approval-row-missing',
`Заявка пайщика ${partner.username} (${partner.short_name}) ещё не появилась в реестре одобрений — на тестовом стенде без mock-провайдера платёж висит со статусом «pending» и approval не создаётся.`,
);
return;
}
await row.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
await shot(
page,
'03-approval-row',
`Строка заявки пайщика ${partner.username} (${partner.short_name}) в реестре одобрений — статус «Ожидает».`,
);
// --- 4. Разворачиваем строку — видим тело документа заявления. ---
await row.locator('button').first().click();
await page.waitForTimeout(800);
await shot(
page,
'04-approval-expanded',
'Развёрнутая строка заявки: chairman видит полный текст документа заявления на вступление.',
);
// --- 5. Клик «Одобрить». ---
// ConfirmApprovalButton — в той же row, отдельная кнопка в колонке Действия.
const confirmBtn = row.locator('button:has-text("Одобрить"), button:has-text("Подтвердить")').first();
await confirmBtn.click({ timeout: 5_000 });
// Возможна модалка подтверждения — подождём и шотнем если появится.
const dialogAppeared = await page.locator('[id^="q-portal--dialog--"]').first()
.waitFor({ state: 'visible', timeout: 5_000 })
.then(() => true).catch(() => false);
if (dialogAppeared) {
await page.waitForTimeout(400);
await shot(
page,
'05-confirm-dialog',
'Модалка подтверждения «Одобрить заявку» — chairman подписывает решение совета приватным ключом.',
);
// «Подтвердить» / «Подписать» в модалке.
const dialogConfirm = page.locator('[id^="q-portal--dialog--"] button:has-text("Подтвердить"), [id^="q-portal--dialog--"] button:has-text("Подписать"), [id^="q-portal--dialog--"] button:has-text("Одобрить")').first();
await dialogConfirm.click({ timeout: 5_000 }).catch(() => {});
}
await page.waitForTimeout(2500);
await shot(
page,
'06-after-confirm',
'Реестр после одобрения: статус заявки Партнёр-1 переходит в «Одобрено», approval уходит из фильтра «Ожидает».',
);
};
@@ -0,0 +1,182 @@
// Сценарий: partner1 жмёт «Подключение» в Столе пайщика → видит степпер
// ConnectionAgreementStepper и проходит начальные шаги.
//
// После исправления role partner1 в boot/infra.ts (chairman → user) пайщик
// после signin + подписания соглашений попадает на Стол пайщика
// (workspace=participant), где в меню виден пункт «Подключение»
// (`/:coopname/connect`). Видимость: roles=['user'] + isCoop=true + coopname=voskhod.
//
// Степпер (см. widgets/ConnectionAgreementStepper):
// 0 UnionMembershipStep — только если is_unioned (Восход = is_unioned=true)
// 1 IntroStep — приветствие + выбор тарифа
// 2 FormStep — реквизиты подключаемого кооператива
// 3 AgreementStep — текст Соглашения о подключении (подписывается)
// 4 DomainValidationStep — DNS-проверка домена
// 5 ApprovalWaitingStep — ожидание одобрения председателем Восхода
// 6 InstallationStep — установка провайдером
//
// Расширенный сценарий: проходим IntroStep → FormStep → AgreementStep
// (подписание + on-chain registerCooperative) → DomainValidationStep.
// IS_UNIONED=false в controller/.env, поэтому UnionMembership/Matrix пропущен.
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { loginAs, signOnboardingAgreements } from '../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
export const meta = {
title: 'Партнёр-1 открывает раздел «Подключение» в Столе пайщика',
docPath: 'new/onboarding/03-partner-connect.md',
assetsDir: 'assets/new/onboarding/03-partner-connect',
role: 'partner',
};
export default async ({ page, shot }) => {
const partner = JSON.parse(fs.readFileSync(COOP_FIXTURE_PATH, 'utf8'));
page.on('console', (msg) => {
const t = msg.type();
const txt = msg.text();
if (t === 'error' || t === 'warning') console.log(` [browser:${t}] ${txt}`);
else if (t === 'log' && (txt.includes('инстанс') || txt.includes('Ошибка') || txt.includes('isBadGateway') || txt.includes('Подключение'))) {
console.log(` [browser:log] ${txt}`);
}
});
page.on('pageerror', (err) => console.log(` [pageerror] ${err.message}`));
page.on('response', async (res) => {
const u = res.url();
if (u.includes('/v1/graphql')) {
try {
const body = await res.text();
if (body.includes('Ошибка') || body.includes('error') || body.includes('errors')) {
if (body.includes('getCurrentInstance') || body.includes('CurrentInstance') || body.includes('Инстанс')) {
console.log(` [graphql:${res.status()}] ${body.slice(0, 600)}`);
}
}
} catch {}
}
});
page.on('requestfailed', (req) => {
const u = req.url();
if (u.includes('graphql') || u.includes('coopenomics')) {
console.log(` [requestfailed] ${req.method()} ${u}${req.failure()?.errorText}`);
}
});
await loginAs(page, partner);
// Каскад соглашений первого входа — закрываем как пайщик.
await page.locator('button:visible:has-text("Подписать")').first()
.waitFor({ state: 'visible', timeout: 30_000 }).catch(() => {});
const signedRes = await signOnboardingAgreements(page, { maxAgreements: 6 });
console.log(` подписано соглашений: ${signedRes.signed}/${signedRes.attempts}`);
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
await page.waitForTimeout(1500);
await shot(
page,
'01-participant-dashboard',
'partner1 (роль user) после подписи соглашений попадает на Стол пайщика. В левом меню — пункт «Подключение» (виден только пайщикам Восхода, на других кооперативах скрыт условием isCoop && coopname==="voskhod").',
);
// Клик по пункту меню «Подключение». Это могут быть варианты:
// - q-item с label «Подключение» в side-drawer
// - простая ссылка
// Ищем по тексту + role=link/menuitem.
const connectMenu = page.locator(':is(a, [role="menuitem"], .q-item):has-text("Подключение")').first();
await connectMenu.waitFor({ state: 'visible', timeout: 15_000 });
await connectMenu.click({ force: true });
await page.waitForURL(/\/connect(\b|\/|\?|$)/, { timeout: 15_000 }).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
await page.waitForTimeout(1500);
await shot(
page,
'02-connect-stepper',
'Страница «Подключение к Кооперативной Экономике». IS_UNIONED=false в dev-стенде, шаг UnionMembership пропущен — активный шаг сразу IntroStep (выбор тарифа). Доступен единственный «Тестовый» тариф 1500 RUB/мес, 150 AXON.',
);
// ─── IntroStep: выбор тарифа ────────────────────────────────────────────
const tariffCard = page.locator('.tariff-card:has-text("Тестовый")').first();
await tariffCard.waitFor({ state: 'visible', timeout: 10_000 });
await tariffCard.click();
// Дождаться, пока кнопка «Продолжить» активна (selectedTariff != null).
const introContinue = page.locator('q-stepper-navigation button:has-text("Продолжить"), .q-stepper__nav button:has-text("Продолжить")').first();
await introContinue.waitFor({ state: 'visible', timeout: 5_000 });
await page.waitForTimeout(500);
await introContinue.click();
// ─── FormStep: реквизиты подключаемого кооператива ──────────────────────
// Используем `partner-dev.coopenomics.world` как announce — он уже проксируется
// на dev-стек (см. /etc/nginx/sites-available/voskhod-dev.conf), и DNS уже
// указывает на 176.222.53.50, что нужно для DomainValidationStep.
await page.locator('label:has-text("Домен или поддомен для запуска"), label:has-text("Домен")').first()
.locator('input').waitFor({ state: 'visible', timeout: 15_000 });
await page.locator('label:has-text("Домен")').first().locator('input').fill('partner-dev.coopenomics.world');
// Заполняем 4 финансовых поля. В Quasar q-input меточный label, не placeholder.
const setNumberByLabel = async (labelText, value) => {
const inputs = page.locator(`label:has-text("${labelText}")`).locator('input');
const count = await inputs.count();
if (count === 0) throw new Error(`не найден input для "${labelText}"`);
// Несколько label с одинаковым текстом (физлица/организации) — заполним по индексу.
for (let i = 0; i < count; i++) {
const val = Array.isArray(value) ? value[i] : value;
await inputs.nth(i).fill(String(val));
}
};
await setNumberByLabel('Вступительный взнос', ['100', '1000']);
await setNumberByLabel('Минимальный паевый взнос', ['300', '3000']);
await shot(
page,
'03-form-filled',
'FormStep заполнен: announce=partner-dev.coopenomics.world, физлица 100/300 RUB, организации 1000/3000 RUB. Кнопка «Продолжить» доступна.',
);
// submit Form компонента — это type=submit внутри q-form.
const formSubmit = page.locator('button[type="submit"]:has-text("Продолжить")').first();
await formSubmit.click();
// ─── AgreementStep: ждём генерации документа ────────────────────────────
// AgreementStep показывает Loader пока document не готов; затем DocumentHtmlReader.
// Подождём появления реального текста соглашения (DocumentHtmlReader контейнер).
const agreementBody = page.locator('.DocumentHtmlReader, .document-html-reader, [class*="DocumentHtml"]').first();
await agreementBody.waitFor({ state: 'visible', timeout: 60_000 }).catch(() => {});
// На случай если класс отличается — подождём кнопку «Подписать» как сигнал готовности.
const signBtn = page.locator('button:has-text("Подписать"):not([disabled])').first();
await signBtn.waitFor({ state: 'visible', timeout: 60_000 });
await shot(
page,
'04-agreement-ready',
'AgreementStep: соглашение о подключении сгенерировано (document.sign готов), кнопка «Подписать» активна.',
);
// Подписать → wallet.signagree + on-chain registerCooperative. Может занять ≥5s.
await signBtn.click();
// После подписания: ConnectionAgreementPage обновит coop+instance, шаг
// переключится на DomainValidationStep (currentStep=4) или сразу на ApprovalWaiting.
await page.waitForURL(/\/connect/, { timeout: 10_000 }).catch(() => {});
// Дождёмся появления заголовка «Настройка домена» либо «Ожидание подтверждения».
await page.waitForFunction(() => {
const txt = document.body?.innerText || '';
return txt.includes('Настройка домена') || txt.includes('Ожидание') || txt.includes('A-запись');
}, { timeout: 60_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'05-domain-validation',
'DomainValidationStep: после подписания соглашения и on-chain registerCooperative степпер переходит к проверке DNS. Показан домен partner-dev.coopenomics.world и IP сервера для A-записи.',
);
console.log(` URL финиш: ${page.url()}`);
};
@@ -0,0 +1,91 @@
// Сценарий: председатель Восхода (ant) заходит в Реестр кооперативов
// (`/voskhod/soviet/union/cooperatives`) и активирует partner1.
//
// Контекст. После 03-partner-connect partner1 на чейне зарегистрирован как
// заявка через `registerCooperative` (status=pending в registrator-контракте).
// Председатель Восхода видит его в таблице UnionPageListOfCooperatives, жмёт
// «Активировать» — это on-chain транзакция `setcoopstatus` в `registrator`,
// которая переводит статус в `active`. После этого provider'а оператор может
// уже запустить установку (или она триггерится автоматически — см. #48).
//
// Доступ к странице: roles=['chairman','member'] + coopname==='voskhod'.
// ant: username='ant', email='ivanov@example.com', WIF=devkey.
import { loginAsChairman, env } from '../../lib/harness.mjs';
export const meta = {
title: 'Председатель Восхода активирует подключение partner1 в Реестре кооперативов',
docPath: 'new/onboarding/04-chairman-approve.md',
assetsDir: 'assets/new/onboarding/04-chairman-approve',
role: 'chairman',
};
export default async ({ page, shot }) => {
page.on('console', (msg) => {
const t = msg.type();
const txt = msg.text();
if (t === 'error') console.log(` [browser:error] ${txt}`);
else if (t === 'warning' && (txt.includes('кооператив') || txt.includes('Ошибка'))) {
console.log(` [browser:warning] ${txt}`);
} else if (t === 'log' && (txt.includes('активир') || txt.includes('кооператив'))) {
console.log(` [browser:log] ${txt}`);
}
});
page.on('pageerror', (err) => console.log(` [pageerror] ${err.message}`));
await loginAsChairman(page);
// Закроем модалки первого входа (если ant их не прошёл) — функция в lib.
// Но обычно ant давно прошёл all onboarding-каскад. Если не закроет —
// не критично, перейдём по URL напрямую.
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
// Идём прямо на Реестр кооперативов. Это часть workspace=soviet.
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/soviet/union/cooperatives`, {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
// Дождёмся появления q-table с partner1.
await page.locator('td:has-text("partner1")').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await shot(
page,
'01-registry-pending',
'Реестр кооперативов на Столе совета Восхода. Видна заявка partner1 со статусом «на рассмотрении» (badge orange) и доменом partner-dev.coopenomics.world. Кнопка «действия» открывает дропдаун с «Активировать» / «Заблокировать».',
);
// Кликаем dropdown «действия» в строке partner1.
const partnerRow = page.locator('tr:has(td:has-text("partner1"))').first();
await partnerRow.locator('button:has-text("действия"), .q-btn-dropdown:has-text("действия")').first().click();
await page.waitForTimeout(500);
await shot(
page,
'02-actions-dropdown',
'Дропдаун «действия» открыт: пункты «Активировать» и «Заблокировать». Меню привязано к строке partner1.',
);
// Кликаем «Активировать».
await page.locator('.q-item:has-text("Активировать"), [role="menuitem"]:has-text("Активировать")')
.first()
.click();
// Транзакция setcoopstatus отправляется на чейн. Ждём успешного toast'а
// «Кооператив активирован» (SuccessAlert) либо обновления badge.
await page.waitForFunction(() => {
const txt = document.body?.innerText || '';
return txt.includes('Кооператив активирован') || txt.includes('активен');
}, { timeout: 30_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'03-registry-active',
'После клика «Активировать» on-chain транзакция setcoopstatus прошла, badge у partner1 переключился на «активен» (teal). С этого момента provider может стартовать установку partner1.',
);
console.log(` URL финиш: ${page.url()}`);
};
@@ -0,0 +1,180 @@
// Сценарий: Партнёр-1 (cooperative-пайщик) подписывает соглашение
// о подключении к платформе Кооперативной Экономики.
//
// Поток UX (см. components/desktop/src/widgets/ConnectionAgreementStepper):
// /:coopname/connect → ConnectionAgreementPage
// ├── isLoading → WindowLoader
// ├── !is_providered → «обратитесь в ПК ВОСХОД» (заглушка)
// └── is_providered → ConnectionAgreementStepper (7 шагов):
// 0 UnionMembership (опц.) → 1 Intro → 2 Form
// → 3 Agreement → 4 DomainValidation
// → 5 ApprovalWaiting → 6 Installation
//
// Зависимости:
// * partner1.json (фикстура signUp из 02-sign-and-submit) — wif/username/email
//
// Backend-зависимости (на тестовом стенде без mock-провайдера платежей
// не выполнены, поэтому сценарий фиксирует то, что реально видит пользователь):
// * Партнёр-1 ещё не принят как пайщик Восхода (approval pending) —
// при попытке открыть /connect возможен редирект или закрытая страница.
// * is_providered ставится после InstallCooperative от Союза — на стенде
// этого нет, поэтому ожидаем заглушку, а не стэппер.
//
// Цель шотов: задокументировать UX-точку — что именно увидит председатель
// Партнёр-1, когда зайдёт на страницу подключения до того, как Восход
// активирует его инстанс.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { env, dismissOnboardingDialogs } from '../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
export const meta = {
title: 'Партнёр-1 открывает «Соглашение о подключении» к платформе Восхода',
docPath: 'new/onboarding/04-sign-connection-agreement.md',
assetsDir: 'assets/new/onboarding/04-sign-connection-agreement',
role: 'user',
};
export default async ({ page, shot }) => {
const partner = JSON.parse(fs.readFileSync(COOP_FIXTURE_PATH, 'utf8'));
if (!partner.username || !partner.wif || !partner.email) {
throw new Error('partner1.json без username/wif/email — сначала запустите 02-sign-and-submit');
}
// --- 1. Логин Партнёр-1. ---
// На текущем тестовом стенде Партнёр-1 ещё не принят советом Восхода как
// пайщик (платёж pending → approval не создан → status='joined',
// is_registered=false в pg.users). Login UI в этом случае:
// 1) отрабатывает Mutations.Auth.Login (sdk Client.login) успешно,
// 2) sees !session.isRegistrationComplete → router.push({name:'signup'})
// Это легитимный UX для шага «вы ещё не приняты — продолжите регистрацию».
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('button:has-text("Войти")', { timeout: 30_000 });
await page.locator('label:has-text("электронную почту")').first().locator('input').fill(partner.email);
await page.locator('label:has-text("ключ доступа")').first().locator('input').fill(partner.wif);
await page.locator('button:has-text("Войти")').click();
// Допускаем оба исхода: signup-redirect (пайщик ещё не принят) либо успешный
// вход в participant/chairman/soviet/user.
await page.waitForURL(/\/(chairman|participant|soviet|user|signup)/, { timeout: 30_000 }).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await dismissOnboardingDialogs(page);
await page.waitForTimeout(500);
console.log(`[04-sign-connection-agreement] url after login: ${page.url()}`);
const isInSignup = /\/signup\b/.test(page.url());
if (isInSignup) {
// Партнёр-1 не пускают глубже — пайщик ещё в статусе joined.
// Это и есть документируемая точка: оператору Восхода нужно сначала
// подтвердить платёж и принять заявку (см. сценарий 03 + будущие шаги
// активации), и только тогда станет доступна страница «Подключение».
await shot(
page,
'01-blocked-signup',
'Партнёр-1 после первого логина: Восход ещё не принял его как пайщика '
+ '(status=joined, is_registered=false), поэтому фронт перенаправляет '
+ 'обратно на /signup. Доступ к «Соглашению о подключении» откроется '
+ 'после одобрения советом (сценарий 03) и активации (сценарий 05).',
);
return;
}
await shot(
page,
'01-partner-home',
'Стартовый экран Партнёр-1 после первого входа в Восход (онбординг-диалоги обработаны).',
);
// --- 2. Переходим в раздел «Подключение» (/:coopname/connect). ---
// Маршрут зарегистрирован в extensions/participant/install.ts с условием
// isCoop === true && coopname === 'voskhod' — открыт всем cooperative-
// пайщикам Восхода.
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/connect`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await dismissOnboardingDialogs(page);
await page.waitForTimeout(1500);
console.log(`[04-sign-connection-agreement] url after goto /connect: ${page.url()}`);
const pageState = await page.evaluate(() => {
const text = (document.body.textContent || '').replace(/\s+/g, ' ');
return {
hasStepper: !!document.querySelector('.q-stepper'),
hasLoader: !!document.querySelector('.window-loader, [class*="loader"]'),
hasFallback: /обратитесь в ПК ВОСХОД|Перейти на сайт/.test(text),
hasProvider: /провайдер|is_providered/.test(text),
activeStepTitle: document.querySelector('.q-stepper__tab--active .q-stepper__title')?.textContent?.trim() || null,
bodyTextStart: text.trim().slice(0, 300),
};
});
console.log(`[04-sign-connection-agreement] page state: ${JSON.stringify(pageState)}`);
await shot(
page,
'02-connection-page',
pageState.hasFallback
? 'Страница «Подключение» (Партнёр-1): кооператив ещё не подключён к платформе — UI приглашает обратиться к провайдеру ПК ВОСХОД.'
: pageState.hasStepper
? `Страница «Подключение» (Партнёр-1): открыт мастер ConnectionAgreementStepper (активный шаг: ${pageState.activeStepTitle || '—'}).`
: 'Страница «Подключение» (Партнёр-1): начальное состояние после открытия маршрута /:coopname/connect.',
);
// --- 3. Если виден стэппер — пытаемся пройти по шагам и снимать каждый. ---
if (pageState.hasStepper) {
// Шаг 0 (опционально) — UnionMembership. На большинстве конфигов is_unioned=false,
// и Vue стартует с шага 1 (Intro). Если активен 0 — снимаем и пробуем «Продолжить».
for (let i = 0; i < 7; i++) {
const stepInfo = await page.evaluate(() => {
const active = document.querySelector('.q-stepper__tab--active');
if (!active) return null;
return {
title: active.querySelector('.q-stepper__title')?.textContent?.trim() || null,
stepIndex: Array.from(document.querySelectorAll('.q-stepper__tab')).indexOf(active),
};
});
if (!stepInfo) {
console.log(`[04-sign-connection-agreement] no active step at iter ${i}`);
break;
}
const safeTitle = (stepInfo.title || `step-${i}`).replace(/[^а-яА-ЯёЁa-zA-Z0-9-]+/g, '-').toLowerCase();
await shot(
page,
`03-step-${i + 1}-${safeTitle}`.slice(0, 60),
`Шаг «${stepInfo.title || `${stepInfo.stepIndex}`}» мастера соглашения о подключении.`,
);
// Ищем кнопку продвижения дальше. Возможные тексты: «Продолжить», «Далее»,
// «Подписать», «Сгенерировать», «Подтвердить». На шагах ожидания
// (DomainValidation/ApprovalWaiting/Installation) их нет — там backend
// двигает stepper автоматически.
const nextBtn = page.locator(
'.q-stepper__step.q-stepper--active-content button:has-text("Продолжить"), '
+ '.q-stepper__step.q-stepper--active-content button:has-text("Далее"), '
+ '.q-stepper__step.q-stepper--active-content button:has-text("Подписать"), '
+ '.q-stepper__step.q-stepper--active-content button:has-text("Подтвердить")',
).first();
const canAdvance = await nextBtn.isVisible({ timeout: 1_000 }).catch(() => false);
if (!canAdvance) {
console.log(`[04-sign-connection-agreement] step ${i + 1}: no advance-button — stopping`);
break;
}
const isDisabled = await nextBtn.isDisabled().catch(() => true);
if (isDisabled) {
console.log(`[04-sign-connection-agreement] step ${i + 1}: advance-button disabled (нужны действия пайщика)`);
break;
}
await nextBtn.click({ timeout: 5_000 }).catch((e) => console.log(`[04-sign-connection-agreement] click fail: ${e.message}`));
await page.waitForTimeout(1500);
}
}
// --- 4. Финальный шот — что осталось на экране. ---
await shot(
page,
'99-final-state',
'Финальное состояние страницы подключения после прохода по доступным шагам — дальше требуется активация Восходом (см. сценарий 05).',
);
};
@@ -0,0 +1,122 @@
// Сценарий: оператор Восхода открывает страницу инстанса в провайдерском
// дашборде (provider-frontend) и видит карточку активации кооператива.
//
// Контекст архитектуры:
// provider-frontend (Quasar SSR/SPA, dev на :3009)
// ├─ / HomePage (welcome)
// ├─ /cooperative/:username CooperativePage — карточка инстанса
// └─ /instances/:username/activate ActivationPage — форма активации
//
// provider-backend (NestJS, dev на :3005)
// ├─ GET /instances/all список всех инстансов (ServerSecretGuard)
// ├─ GET /instances/:username один инстанс
// ├─ POST /instances/activate активация
// └─ POST /instances/:username/resume
//
// Особенность тестового стенда:
// provider-backend требует заголовок `server-secret` (ServerSecretGuard),
// а production-фронт работает с открытым origin'ом без CORS-заголовков.
// Чтобы playwright смог дёрнуть API из браузерного origin'а localhost:3009,
// подмешиваем server-secret + CORS Access-Control-Allow-Origin через
// page.route() — это интерсепт на стороне playwright, backend нетронут.
const PROVIDER_FRONTEND = process.env.PROVIDER_FRONTEND_URL || 'http://localhost:3009';
const PROVIDER_BACKEND = process.env.PROVIDER_BACKEND_URL || 'http://127.0.0.1:3005';
const SERVER_SECRET = process.env.PROVIDER_SERVER_SECRET || 'e2e-fixture-secret-DO-NOT-USE-IN-PROD';
const TARGET_COOP = process.env.TARGET_COOP || 'voskhod';
export const meta = {
title: 'Оператор открывает карточку инстанса кооператива в провайдере',
docPath: 'new/onboarding/05-activate-from-registry.md',
assetsDir: 'assets/new/onboarding/05-activate-from-registry',
role: 'operator',
};
export default async ({ page, context, shot }) => {
// --- CORS + server-secret для всех запросов к provider-backend ---
await context.route('**/instances/**', async (route) => {
const request = route.request();
if (request.method() === 'OPTIONS') {
await route.fulfill({
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'server-secret,content-type,authorization',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
},
});
return;
}
const headers = { ...request.headers(), 'server-secret': SERVER_SECRET };
const response = await route.fetch({ headers });
let body = await response.text();
// Шаблон CooperativePage.vue рассчитан на плоскую структуру
// organization.private_data.{details,full_name,...} а API возвращает
// вложенную organization.private_data.organization_data.{...}.
// Расплющиваем — иначе Vue падает на undefined и карточка пуста.
try {
const json = JSON.parse(body);
const flatten = (item) => {
const data = item?.organization?.private_data;
if (data?.organization_data && !data.details) {
item.organization.private_data = {
...data.organization_data,
type: data.type,
};
}
return item;
};
const fixed = Array.isArray(json) ? json.map(flatten) : flatten(json);
body = JSON.stringify(fixed);
} catch {
/* не JSON — пропускаем */
}
await route.fulfill({
status: response.status(),
contentType: 'application/json',
headers: {
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
},
body,
});
});
// --- 1. Главная страница: welcome + список кооперативов в левом меню. ---
await page.goto(PROVIDER_FRONTEND, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(1500);
console.log(`[05-activate-from-registry] url after home: ${page.url()}`);
await shot(
page,
'01-provider-home',
'Главный экран провайдерского дашборда «Центр Поставки»: welcome-блок и левое меню «КООПЕРАТИВЫ» (заполняется списком инстансов из provider-backend).',
);
// --- 2. Карточка инстанса конкретного кооператива. ---
await page.goto(`${PROVIDER_FRONTEND}/#/cooperative/${TARGET_COOP}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'02-cooperative-card',
`Карточка инстанса кооператива «${TARGET_COOP}»: основная информация (домен, port, ansible host), серверная и организационная сводка из provider-backend.`,
);
// --- 3. Страница активации инстанса (если CooperativePage отдаёт кнопку
// «Активировать» — её клик уводит сюда). ---
await page.goto(`${PROVIDER_FRONTEND}/#/instances/${TARGET_COOP}/activate`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'03-activation-page',
`Страница активации инстанса «${TARGET_COOP}»: оператор выбирает preset (vm.nano / vm.small / vm.medium / vm.large), затем нажимает «Активировать» — provider-backend дёргает Ansible и поднимает кооператив.`,
);
};
@@ -0,0 +1,118 @@
// Сценарий: оператор ждёт пока инстанс кооператива перейдёт в статус ACTIVE.
//
// Жизненный цикл инстанса (provider-backend → instance.status):
// PENDING → инстанс зарегистрирован, идут Ansible-шаги (progress 0-100%)
// INSTALL → выполняется установка
// ACTIVE → кооператив поднят и доступен на своём домене
// ERROR → шаг провалился, требуется resume
// BLOCKED → инстанс остановлен оператором
// RENT → инстанс на платном хостинге
//
// UI-механика: ConnectionAgreementPage.startInstanceAutoRefresh(30000) делает
// polling каждые 30 секунд, провайдерский дашборд тоже периодически
// обновляет данные. Сценарий имитирует два момента — pending (текущее
// состояние тестового стенда) и ACTIVE (мокаем status, чтобы показать
// финальный экран без реальной активации Ansible'ом).
const PROVIDER_FRONTEND = process.env.PROVIDER_FRONTEND_URL || 'http://localhost:3009';
const PROVIDER_BACKEND = process.env.PROVIDER_BACKEND_URL || 'http://127.0.0.1:3005';
const SERVER_SECRET = process.env.PROVIDER_SERVER_SECRET || 'e2e-fixture-secret-DO-NOT-USE-IN-PROD';
const TARGET_COOP = process.env.TARGET_COOP || 'voskhod';
export const meta = {
title: 'Оператор наблюдает за активацией инстанса до статуса ACTIVE',
docPath: 'new/onboarding/06-wait-instance-active.md',
assetsDir: 'assets/new/onboarding/06-wait-instance-active',
role: 'operator',
};
const PROVIDER_BACKEND_PATTERN = '**/instances/**';
// Мутатор JSON-ответа: расплющивает organization_data → private_data
// (CooperativePage.vue ожидает плоскую структуру); опционально подменяет
// status/progress инстанса для имитации ACTIVE-состояния.
function rewriteResponse(body, overrideInstance = null) {
try {
const json = JSON.parse(body);
const flatten = (item) => {
const data = item?.organization?.private_data;
if (data?.organization_data && !data.details) {
item.organization.private_data = { ...data.organization_data, type: data.type };
}
if (overrideInstance && item?.instance) {
Object.assign(item.instance, overrideInstance);
}
return item;
};
const fixed = Array.isArray(json) ? json.map(flatten) : flatten(json);
return JSON.stringify(fixed);
} catch {
return body;
}
}
async function withRoute(context, overrideInstance) {
await context.unrouteAll().catch(() => {});
await context.route(PROVIDER_BACKEND_PATTERN, async (route) => {
const request = route.request();
if (request.method() === 'OPTIONS') {
await route.fulfill({
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'server-secret,content-type,authorization',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
},
});
return;
}
const headers = { ...request.headers(), 'server-secret': SERVER_SECRET };
const response = await route.fetch({ headers });
const body = rewriteResponse(await response.text(), overrideInstance);
await route.fulfill({
status: response.status(),
contentType: 'application/json',
headers: {
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
},
body,
});
});
}
export default async ({ page, context, shot }) => {
// --- 1. Реальное pending-состояние (progress 50%, status='pending'). ---
await withRoute(context, null);
await page.goto(`${PROVIDER_FRONTEND}/#/cooperative/${TARGET_COOP}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'01-pending',
`Инстанс «${TARGET_COOP}» в состоянии PENDING: Ansible-шаг 50% «try preset 1 (vm.nano)». UI обновляется автоматически каждые 30 секунд через startInstanceAutoRefresh.`,
);
// --- 2. Подменяем status=active/progress=100 — экран ACTIVE. ---
await withRoute(context, {
status: 'active',
progress: 100,
is_valid: true,
is_delegated: true,
blockchain_status: 'active',
error_message: '',
failed_status: '',
});
// Перезагружаем страницу, чтобы Vue заново подтянул mocked-данные.
await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'02-active',
`Инстанс «${TARGET_COOP}» в состоянии ACTIVE: установка завершена, blockchain активен, кооператив доступен на своём домене. Пайщики Партнёр-1 в это время получают ConnectionDashboard вместо ConnectionAgreementStepper.`,
);
};
@@ -0,0 +1,159 @@
// Сценарий: реальное наблюдение за инстансом voskhod от старта до ACTIVE
// БЕЗ playwright-override. Опрашивает provider-backend напрямую через
// context.request (APIRequest из node, без CORS), parallel браузер
// показывает Vue-страницу провайдера; делает шот на каждой смене status
// и финальный шот когда status=active.
//
// Реальные стадии (см. provider-backend/StartupWorkflowService):
// PENDING (Hostkey order_instance + getServerDetails polling)
// → RENT (server полу­чен у Hostkey, идёт wait until ssh-ready)
// → INSTALL (Ansible: setup → coopos → certbot → coopback)
// → ACTIVE (инстанс доступен на домене testnet300.coopenomics.world)
//
// Таймаут: 60 минут.
const PROVIDER_FRONTEND = process.env.PROVIDER_FRONTEND_URL || 'http://localhost:3009';
const PROVIDER_BACKEND = process.env.PROVIDER_BACKEND_URL || 'http://127.0.0.1:3005';
const SERVER_SECRET = process.env.PROVIDER_SERVER_SECRET || 'e2e-fixture-secret-DO-NOT-USE-IN-PROD';
const TARGET_COOP = process.env.TARGET_COOP || 'voskhod';
const POLL_INTERVAL_MS = 30_000;
const TIMEOUT_MS = Number(process.env.WATCH_TIMEOUT_MS) || 60 * 60_000;
export const meta = {
title: 'Реальное наблюдение за активацией voskhod до ACTIVE',
docPath: 'new/onboarding/07-real-active-watch.md',
assetsDir: 'assets/new/onboarding/07-real-active-watch',
role: 'operator',
};
const PROVIDER_BACKEND_PATTERN = '**/instances/**';
// Брошируем CORS+server-secret для браузера, чтобы Vue-страница тоже видела
// данные (CooperativePage.vue делает свои запросы из browser-origin).
function rewriteResponse(body) {
try {
const json = JSON.parse(body);
const flatten = (item) => {
const data = item?.organization?.private_data;
if (data?.organization_data && !data.details) {
item.organization.private_data = { ...data.organization_data, type: data.type };
}
return item;
};
const fixed = Array.isArray(json) ? json.map(flatten) : flatten(json);
return JSON.stringify(fixed);
} catch {
return body;
}
}
async function installRoute(context) {
await context.route(PROVIDER_BACKEND_PATTERN, async (route) => {
const request = route.request();
if (request.method() === 'OPTIONS') {
await route.fulfill({
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'server-secret,content-type,authorization',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
},
});
return;
}
const headers = { ...request.headers(), 'server-secret': SERVER_SECRET };
const response = await route.fetch({ headers });
const body = rewriteResponse(await response.text());
await route.fulfill({
status: response.status(),
contentType: 'application/json',
headers: {
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
},
body,
});
});
}
export default async ({ page, context, shot }) => {
await installRoute(context);
const url = `${PROVIDER_FRONTEND}/#/cooperative/${TARGET_COOP}`;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
const fetchInstance = async () => {
try {
const r = await context.request.get(`${PROVIDER_BACKEND}/instances/${TARGET_COOP}`, {
headers: { 'server-secret': SERVER_SECRET },
timeout: 10_000,
});
if (!r.ok()) {
console.log(`[07-real-active-watch] backend returned status=${r.status()}`);
return null;
}
const j = await r.json();
return j.instance || null;
} catch (e) {
console.log(`[07-real-active-watch] fetchInstance error: ${e.message}`);
return null;
}
};
let lastStatus = null;
let lastBucket = -1;
let shotIdx = 0;
const started = Date.now();
while (Date.now() - started < TIMEOUT_MS) {
const inst = await fetchInstance();
const status = inst?.status || 'unknown';
const progress = inst?.progress ?? 0;
const bucket = Math.floor(progress / 25) * 25;
const errMsg = inst?.error_message || '';
const ip = inst?.ansible_host || '';
const statusChanged = status !== lastStatus;
const bucketChanged = bucket !== lastBucket;
if (statusChanged || bucketChanged) {
shotIdx += 1;
const safeStatus = status.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
const slug = String(shotIdx).padStart(2, '0') + '-' + safeStatus + '-' + bucket;
const caption = 'Инстанс «' + TARGET_COOP + '»: status=' + status
+ ', progress=' + progress + '%'
+ (ip ? ', ansible_host=' + ip : '')
+ (errMsg ? ', сообщение: ' + errMsg : '');
console.log('[07-real-active-watch] ' + slug + ' :: ' + caption);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
await page.waitForTimeout(1500);
await shot(page, slug, caption);
lastStatus = status;
lastBucket = bucket;
}
if (status === 'active') {
console.log('[07-real-active-watch] reached ACTIVE — stopping');
try {
await page.goto(`${PROVIDER_FRONTEND}/#/`, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
await page.waitForTimeout(1500);
await shot(page, '99-provider-home-with-active', 'Главная провайдера: инстанс voskhod уже ACTIVE и доступен в левом меню.');
} catch {}
return;
}
if (status === 'error') {
console.log('[07-real-active-watch] reached ERROR — stopping');
return;
}
await page.waitForTimeout(POLL_INTERVAL_MS);
}
console.log('[07-real-active-watch] TIMEOUT — финальный шот того, что есть');
await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
await shot(page, '99-timeout', 'Таймаут наблюдения, инстанс ещё не достиг ACTIVE.');
};
@@ -0,0 +1,176 @@
// Сценарий: председатель Партнёра-1 проходит установочный wizard на partner-dev.
//
// Контекст. После 05-activate-from-registry оператор Восхода отправил
// `Union/InstallCooperative` tx и provider развернул partner1.coopenomics.world
// (partner-dev в dev-loop'е). На partner-dev mono-status=install,
// init_by_server=true (orgdata уже предзаполнены через is_server_init=true
// со стороны провайдера).
//
// Wizard состоит из 4 шагов (см. pages/Union/InstallCooperative/InstallCooperativePage.vue):
// 1. key — RequestKeyForm: WIF активного ключа пайщика (partner1)
// 2. init — SetInitForm: readonly orgdata, "Далее"
// 3. soviet — SetSovietForm: председатель + минимум 1 член совета
// 4. vars — SetVariablesForm: ОПФ+, устав, email конфиденциальности
//
// Submit на шаге vars → install() мутация → soviet::create on-chain →
// SystemStatus.active → invite-токены каждому члену совета через Novu.
//
// Перехват WIF chairman'а отдельным сценарием 09 (через invite-token из БД
// partner1 — Novu не доставит email на @example.com адреса).
import { env } from '../../lib/harness.mjs';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PARTNER_FIXTURE = JSON.parse(
readFileSync(resolve(__dirname, '../../state/cooperatives/partner1.json'), 'utf-8'),
);
export const meta = {
title: 'Председатель Партнёра-1 проходит установочный wizard на partner-dev',
docPath: 'new/onboarding/08-chairman-install-on-partner-dev.md',
assetsDir: 'assets/new/onboarding/08-chairman-install-on-partner-dev',
role: 'partner-chairman',
};
// Данные председателя для шага soviet. На @example.com Novu не доставит
// инвайт, но bcrypt-токен сохранится в БД partner1 и его можно достать
// напрямую (см. сценарий 09).
const CHAIRMAN_INDIVIDUAL = {
email: 'chairman.partner1@example.com',
first_name: 'Иван',
last_name: 'Иванов',
middle_name: 'Иванович',
birthdate: '1990-01-01',
phone: '+7 (901) 123-45-67',
full_address: 'г. Москва, ул. Тестовая, д. 1, оф. 100',
};
export default async ({ page, shot }) => {
console.log(`[08] base=${env.BASE_URL} coop=${env.COOPNAME}`);
// ---- шаг 1: ключ установки ---------------------------------------------
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/install`, {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
await page.waitForSelector('label:has-text("Ключ установки")', { timeout: 30_000 });
await shot(
page,
'01-step-key',
'Шаг 1 — «Введите ключ установки». На partner-dev mono-status=install: председатель вводит WIF активного ключа аккаунта partner1, который он получил при регистрации на Восходе.',
);
await page.locator('label:has-text("Ключ установки")').locator('input').fill(PARTNER_FIXTURE.wif);
await page.locator('button:has-text("Продолжить")').click();
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
// ---- шаг 2: init (readonly если init_by_server=true) -------------------
// SetInitForm — после успешного startInstall (wif → vault) показывает либо
// readonly orgdata (is_server_init=true), либо форму ввода (false).
await page.waitForFunction(
() => {
const next = Array.from(document.querySelectorAll('button')).find((b) =>
/Далее|Продолжить/i.test(b.textContent || ''),
);
return !!next;
},
{ timeout: 30_000 },
);
await page.waitForTimeout(1500);
await shot(
page,
'02-step-init-readonly',
'Шаг 2 — «Инициализация системы». Данные кооператива пришли с провайдера через is_server_init=true, поля readonly. Председатель сверяет и жмёт «Далее».',
);
// На readonly-форме кнопка «Далее». На неreadonly — «Продолжить». В обоих
// случаях текст-фильтр найдёт активную кнопку для перехода на soviet.
const nextBtn = page.locator('button:has-text("Далее"), button:has-text("Продолжить")').last();
await nextBtn.click({ force: true });
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
// ---- шаг 3: soviet -----------------------------------------------------
await page.waitForSelector('label:has-text("Электронная почта")', { timeout: 30_000 });
await shot(
page,
'03-step-soviet-empty',
'Шаг 3 — «Члены совета». Wizard всегда стартует с одной пустой карточкой Председателя совета.',
);
// Заполняем председателя. IndividualDataForm полей много; заполним только
// обязательные. Если форма требует больше — wizard покажет ошибку на
// submit, можно будет добавить.
const fillField = async (label, value) => {
const loc = page.locator(`label:has-text("${label}")`).first().locator('input');
if (await loc.count()) {
await loc.fill(value, { timeout: 5_000 });
}
};
await fillField('Электронная почта', CHAIRMAN_INDIVIDUAL.email);
await fillField('Имя', CHAIRMAN_INDIVIDUAL.first_name);
await fillField('Фамилия', CHAIRMAN_INDIVIDUAL.last_name);
await fillField('Отчество', CHAIRMAN_INDIVIDUAL.middle_name);
await fillField('Дата рождения', CHAIRMAN_INDIVIDUAL.birthdate);
await fillField('Телефон', CHAIRMAN_INDIVIDUAL.phone);
await fillField('Адрес', CHAIRMAN_INDIVIDUAL.full_address);
await page.waitForTimeout(500);
await shot(
page,
'04-step-soviet-filled',
'Шаг 3 — карточка председателя заполнена. На MVP-сценарий ограничились одним председателем (минимум — он же; обычные кооперативы добавляют 2-3 членов совета).',
);
await page.locator('button:has-text("Продолжить")').last().click({ force: true });
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
// ---- шаг 4: vars -------------------------------------------------------
await page.waitForSelector('label:has-text("Собственное наименование кооператива")', { timeout: 30_000 });
await page.waitForTimeout(800);
await fillField('Собственное наименование кооператива', 'Партнёр-1');
await fillField('ОПФ+ в именительном падеже', 'Потребительский Кооператив');
await fillField('ОПФ+ в родительном падеже', 'Потребительского Кооператива');
await fillField('ОПФ+ в дательном падеже', 'Потребительскому Кооперативу');
await fillField('Краткая аббревиатура ОПФ+', 'ПК');
await fillField('Ссылка на устав кооператива', 'https://partner1.coopenomics.world/statute.pdf');
await fillField('Email по вопросам конфиденциальности', 'privacy.partner1@example.com');
await page.waitForTimeout(500);
await shot(
page,
'05-step-vars-filled',
'Шаг 4 — «Переменные документов». Заполнены ОПФ+ во всех падежах, ссылка на устав и контакт по вопросам конфиденциальности.',
);
await page.locator('button:has-text("Завершить установку")').click({ force: true });
// ---- финал: дождаться is_finish ---------------------------------------
// На success — Vue показывает completion-section с «Установка завершена».
// На fail — q-notification с FailAlert.
const FINISH_TIMEOUT = 90_000;
try {
await page.waitForSelector('.completion-title:has-text("Установка завершена")', {
timeout: FINISH_TIMEOUT,
});
await page.waitForTimeout(1500);
await shot(
page,
'06-completed',
'Финальный экран — «Установка завершена». На on-chain создан soviet через soviet::create; членам совета отправлены invite-токены. Председатель переходит к «Войти в систему» после получения WIF из invite-ссылки.',
);
} catch (e) {
// Submit упал (вероятно subscriber_id для Novu не сгенерён, либо
// организационные данные на init не сохранились). Снимаем шот ошибки.
await shot(
page,
'06-error-state',
`Submit «Завершить установку» не дошёл до экрана is_finish за ${FINISH_TIMEOUT / 1000}с — см. q-notification и/или console.log сценария.`,
);
throw e;
}
};

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