Merge remote-tracking branch 'origin/dev' into marketplace2
# Conflicts: # components/contracts/cpp/lib/core/ledger2/wallets.hpp # components/cooptypes/src/ledger2/wallets.generated.ts
This commit is contained in:
@@ -1,97 +1,148 @@
|
||||
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:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
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
|
||||
- name: Checkout repository at triggering SHA
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Debug info
|
||||
|
||||
- name: Resolve tag for triggering commit
|
||||
id: resolve_tag
|
||||
run: |
|
||||
echo "Текущая ветка:"
|
||||
git branch --show-current
|
||||
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: Set docker tags
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
echo "DOCKER_TAG=$TAG_NAME" >> $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
|
||||
else
|
||||
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
|
||||
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
|
||||
- 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
|
||||
@@ -104,39 +155,44 @@ jobs:
|
||||
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() }}
|
||||
if: success() && steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/*alpha* ]]; then
|
||||
if [[ "${{ env.DOCKER_TAG }}" == *alpha* ]]; then
|
||||
# Хук для тестнета (alpha теги)
|
||||
curl -X POST ${{ vars.TESTNET_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
else
|
||||
# Хук для продакшена (остальные теги)
|
||||
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
@@ -145,22 +201,23 @@ jobs:
|
||||
|
||||
# Уведомление в Telegram об успехе
|
||||
- name: Telegram notify success
|
||||
if: ${{ 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 ($GITHUB_REF) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
|
||||
-d text="✅ [GITHUB MONO] Успешная сборка контейнеров: $GITHUB_REPOSITORY (sha=${{ env.HEAD_SHA }}) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
|
||||
|
||||
# Уведомление в Telegram об ошибке
|
||||
# Уведомление в Telegram об ошибке (только если до этого был
|
||||
# резолвен тэг — иначе no-op запуск без сборки и без алертов).
|
||||
- name: Telegram notify failure
|
||||
if: ${{ 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 ($GITHUB_REF) [${{ env.DOCKER_TAG }}]"
|
||||
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY (sha=${{ env.HEAD_SHA }}) [${{ env.DOCKER_TAG }}]"
|
||||
@@ -31,6 +31,15 @@ on:
|
||||
- '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:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -39,10 +48,34 @@ 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: |
|
||||
case "${{ github.ref_name }}" in
|
||||
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
|
||||
main)
|
||||
echo "BUILD_MODE=prod" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=main" >> $GITHUB_ENV
|
||||
@@ -59,10 +92,11 @@ jobs:
|
||||
echo "EXTRA_TAG=" >> $GITHUB_ENV
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported branch ${{ github.ref_name }}" >&2
|
||||
echo "Unsupported ref: ref_name=$REF ref_type=$REF_TYPE resolved_branch='$BRANCH'" >&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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
-25
@@ -99,31 +99,6 @@ void capital::regcontrib(eosio::name coopname, eosio::name username, checksum256
|
||||
Capital::Contributors::create_contributor(coopname, username, contributor_hash, is_external_contract, contract_for_send, rate_per_hour, hours_per_day);
|
||||
}
|
||||
|
||||
// Открываем кошельки для пайщика если необходимо
|
||||
if (!has_program_wallet(coopname, username, _source_program)) {
|
||||
Action::send<openprogwall_interface>(
|
||||
_soviet,
|
||||
Names::External::OPEN_PROGRAM_WALLET,
|
||||
_capital,
|
||||
coopname,
|
||||
username,
|
||||
_source_program,
|
||||
uint64_t(0)
|
||||
);
|
||||
}
|
||||
|
||||
if (!has_program_wallet(coopname, username, _capital_program)) {
|
||||
Action::send<openprogwall_interface>(
|
||||
_soviet,
|
||||
Names::External::OPEN_PROGRAM_WALLET,
|
||||
_capital,
|
||||
coopname,
|
||||
username,
|
||||
_capital_program,
|
||||
uint64_t(0)
|
||||
);
|
||||
}
|
||||
|
||||
std::string memo = "";
|
||||
|
||||
if (is_external_contract) {
|
||||
|
||||
@@ -160,32 +160,19 @@ void ledger2::walletop(eosio::name coopname,
|
||||
" для кошелька " + wallet_id.to_string());
|
||||
};
|
||||
|
||||
// ── Post-mutation assert Σ L3 == L2 (Story 3.2; NFR2) ─────────────────
|
||||
// Последняя линия защиты инварианта L3 ⊆ L2. Считается только для
|
||||
// затронутых USER_SHARED-кошельков. Стоимость O(N_users) на кошелёк;
|
||||
// падение здесь = откат всей tx (включая бух-проводки в debit/credit).
|
||||
auto assert_sum_l3_equals_l2 = [&](eosio::name wallet_id) {
|
||||
if (wallet_id.value == 0) return;
|
||||
if (ledger2_get_wallet_kind(wallet_id) != WalletKind::USER_SHARED) return;
|
||||
|
||||
auto wallet_it = wallets.find(wallet_id.value);
|
||||
int64_t l2_avail = wallet_it == wallets.end() ? 0 : wallet_it->available.amount;
|
||||
int64_t l2_block = wallet_it == wallets.end() ? 0 : wallet_it->blocked.amount;
|
||||
|
||||
int64_t sum_avail = 0, sum_block = 0;
|
||||
auto idx = user_wallets.get_index<"bywallet"_n>();
|
||||
for (auto it = idx.lower_bound(wallet_id.value);
|
||||
it != idx.end() && it->wallet_name == wallet_id;
|
||||
++it) {
|
||||
sum_avail += it->available.amount;
|
||||
sum_block += it->blocked.amount;
|
||||
}
|
||||
eosio::check(sum_avail == l2_avail && sum_block == l2_block,
|
||||
std::string{"walletop: инвариант Σ L3 == L2 нарушен на "} +
|
||||
wallet_id.to_string() +
|
||||
" (L2: " + std::to_string(l2_avail) + "/" + std::to_string(l2_block) +
|
||||
", Σ L3: " + std::to_string(sum_avail) + "/" + std::to_string(sum_block) + ")");
|
||||
};
|
||||
// ── Post-mutation Σ L3 == L2 (Story 3.2; NFR2) ────────────────────────
|
||||
// ВРЕМЕННО СНЯТО (2026-05-12): runtime-сверка Σ L3 == L2 через полный
|
||||
// sweep secondary-индекса bywallet — O(N_users) на кошелёк на каждой
|
||||
// walletop-операции; на боевых coop'ах с сотнями пайщиков это
|
||||
// непропорциональная нагрузка на CPU billing, и сейчас на тестнете она
|
||||
// блокирует пользовательские tx из-за исторических расхождений после
|
||||
// миграций 048/049.
|
||||
//
|
||||
// walletop по построению применяет одно и то же `amount` к L2 и L3 (см.
|
||||
// ниже case'ы ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN), а sender-guard на
|
||||
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
|
||||
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
|
||||
// вне транзакционного hot path.
|
||||
|
||||
// Pre-flight cross-contract check (до мутаций — fail-fast).
|
||||
assert_program_signed(wallet_from);
|
||||
@@ -317,11 +304,6 @@ void ledger2::walletop(eosio::name coopname,
|
||||
}
|
||||
}
|
||||
|
||||
// Post-mutation: инвариант Σ L3 == L2 на каждом затронутом кошельке.
|
||||
// Проверяется только для USER_SHARED-кошельков с непустым username
|
||||
// (для COOPERATIVE и L2-only-mode инварианта L3⊆L2 нет).
|
||||
if (username.value != 0) {
|
||||
assert_sum_l3_equals_l2(wallet_from);
|
||||
assert_sum_l3_equals_l2(wallet_to);
|
||||
}
|
||||
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
|
||||
// временно — сверка делается на бэкенде вне hot path.
|
||||
}
|
||||
|
||||
@@ -380,30 +380,37 @@ inline void emplace_preimp_if_present(eosio::name self_name,
|
||||
* напрямую по согласованной с бухгалтером картине.
|
||||
*
|
||||
* ┌────────────────────────────────────── accounts2 ──────────────────────────────────────┐
|
||||
* │ 51 (BANK_ACCOUNT, А) = 176 800 — банк (хардкод 145 000) + 31 800 минП │
|
||||
* │ 51 (BANK_ACCOUNT, А) = 145 000 — 145 000 деньги │
|
||||
* │ 04 (INTANGIBLE_ASSETS, А) = 62 353 311 — имущ. Благорост 56 903 311 + preimp 5М4 │
|
||||
* │ 08 (NON_CURRENT_INVESTMENTS) = 543 400 — балансировка (свод НМА → 80) │
|
||||
* │ 08 (NON_CURRENT_INVESTMENTS) = 575 200 — свод (балансировка) + 31 800 минП │
|
||||
* │ 80 (SHARE_FUND, П) = 62 946 011 — 419 900 ЦК + 57 044 311 Благорост │
|
||||
* │ + 31 800 минП + 5 450 000 preimp Cr-side │
|
||||
* │ 86 (TARGET_RECEIPTS, П) = 127 500 — хоз.расходы 115К + legacy 861 12 500 │
|
||||
* │ │
|
||||
* │ Σ Dr = 176 800 + 62 353 311 + 543 400 = 63 073 511 │
|
||||
* │ Σ Dr = 145 000 + 62 353 311 + 575 200 = 63 073 511 │
|
||||
* │ Σ Cr = 62 946 011 + 127 500 = 63 073 511 ✓ │
|
||||
* └─────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* ┌────────────────────────────── wallets2 (L2-агрегаты) ─────────────────────────────┐
|
||||
* │ w.reg.minshr available = 31 800 (34 пайщика; minimum_amount; L3 → migrator-048)│
|
||||
* │ w.sov.mnused available = 31 800 (мин.паевые, ушедшие в 08; COOPERATIVE) │
|
||||
* │ w.wal.share available = 419 900 (5 пайщиков ЦК-остаток; L3 → migrator-049) │
|
||||
* │ w.cap.blago blocked = 57 044 311 (12 пайщиков pid=4; L3 → migrator-049) │
|
||||
* │ w.cap.preimp available = 5 450 000 (5 пайщиков; L3 — ниже, прямой emplace) │
|
||||
* │ w.sov.expns available = 127 500 (хоз.расходы из числа целевого; COOPERATIVE) │
|
||||
* └────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* L3 (userwallets) для USER_SHARED-кошельков заводит migrator (Phase 1 = 048
|
||||
* для w.reg.minshr; Phase 2 = 049 для w.wal.share / w.wal.member / w.cap.blago).
|
||||
* Здесь же — только 5 пред-импорт-преимп-записей, выпавших из progwallets
|
||||
* после ручных subbal'ов (см. ~/cleos.md, 5 пайщиков с РИД-взносами по
|
||||
* договорам УХД, не успевшим попасть в электронный учёт до миграции).
|
||||
* w.reg.minshr на voskhod НЕ создаётся: мин.паевые (31 800) уже инвестированы в
|
||||
* НМА (08), поэтому L2-аналитика лежит на w.sov.mnused (COOPERATIVE, без L3).
|
||||
* Бухгалтерия (Cr 80 += 31 800; Dr 08 += 31 800) при этом сохранена — обязательство
|
||||
* перед пайщиками на 80 счёте не теряется. Из-за этого migrator-048 (L3 для
|
||||
* w.reg.minshr) на voskhod не запускается; жёсткий guard в migrate3 отвергает
|
||||
* запись L3(voskhod, w.reg.minshr).
|
||||
*
|
||||
* L3 (userwallets) для USER_SHARED-кошельков заводит migrator (Phase 2 = 049
|
||||
* для w.wal.share / w.wal.member / w.cap.blago). Здесь же — только 5 пред-импорт-
|
||||
* преимп-записей, выпавших из progwallets после ручных subbal'ов (см. ~/cleos.md,
|
||||
* 5 пайщиков с РИД-взносами по договорам УХД, не успевшим попасть в электронный
|
||||
* учёт до миграции).
|
||||
*/
|
||||
inline void migrate_voskhod_facts(eosio::name self_name, const cooperative2& coop) {
|
||||
const eosio::name coopname = coop.username;
|
||||
@@ -412,18 +419,21 @@ inline void migrate_voskhod_facts(eosio::name self_name, const cooperative2& coo
|
||||
|
||||
// ───────── accounts2 ─────────
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::BANK_ACCOUNT,
|
||||
eosio::asset( 1'768'000'000LL, sym)); // 176 800.0000 RUB
|
||||
eosio::asset( 1'450'000'000LL, sym)); // 145 000.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::INTANGIBLE_ASSETS,
|
||||
eosio::asset( 623'533'110'000LL, sym)); // 62 353 311.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::NON_CURRENT_INVESTMENTS,
|
||||
eosio::asset( 5'434'000'000LL, sym)); // 543 400.0000 RUB
|
||||
eosio::asset( 5'752'000'000LL, sym)); // 575 200.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::SHARE_FUND,
|
||||
eosio::asset( 629'460'110'000LL, sym)); // 62 946 011.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::TARGET_RECEIPTS,
|
||||
eosio::asset( 1'275'000'000LL, sym)); // 127 500.0000 RUB
|
||||
|
||||
// ───────── wallets2 (L2) ─────────
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::MIN_SHARE_FUND,
|
||||
// Мин.паевые на voskhod сразу размещены на w.sov.mnused (COOPERATIVE):
|
||||
// бухгалтерски они уже потрачены через 08 (Dr 08 += 31 800), но обязательство
|
||||
// Cr 80 += 31 800 сохранено. L3 для w.reg.minshr НЕ создаётся — см. guard в migrate3.
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::MIN_SHARE_USED,
|
||||
eosio::asset( 318'000'000LL, sym), // 31 800.0000 RUB available
|
||||
zero);
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::SHARE_FUND_PAY,
|
||||
@@ -587,8 +597,11 @@ void ledger2::migrate(uint64_t from_coop_index, uint64_t limit) {
|
||||
if (idx < from_coop_index) continue;
|
||||
if (done >= limit) { reached_end = false; break; }
|
||||
|
||||
// Мигрируем только кооперативы (не отделения и не физ. записи).
|
||||
if (it->is_cooperative) {
|
||||
// Мигрируем только активные кооперативы (не отделения, не физ. записи,
|
||||
// не приостановленные/архивные). apply() всё равно отказывает не-active
|
||||
// через get_cooperative_or_fail — здесь явно пропускаем, чтобы курсор шёл.
|
||||
const bool is_active = it->status.has_value() && it->status.value() == "active"_n;
|
||||
if (it->is_cooperative && is_active) {
|
||||
migrate_one_coop(get_self(), *it);
|
||||
}
|
||||
++done;
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
*
|
||||
* Auto-delete: если переданы (available=0, blocked=0) — запись удаляется.
|
||||
*
|
||||
* Auth: `coopname@active`. Payer: `coopname` (NFR11 — RAM на коопе).
|
||||
* Auth: `ledger2@active` (через `get_self()`), payer: `ledger2`. Так миграция
|
||||
* работает для ЛЮБОГО кооператива без ключей от coopname — у нас по контракту
|
||||
* есть только своя подпись. RAM, расход на которую — это сам контракт.
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*/
|
||||
@@ -26,7 +28,7 @@ void ledger2::migrate3(eosio::name coopname,
|
||||
eosio::name username,
|
||||
eosio::asset available,
|
||||
eosio::asset blocked) {
|
||||
require_auth(coopname);
|
||||
require_auth(get_self());
|
||||
|
||||
eosio::check(coopname.value != 0, "migrate3: coopname пустой");
|
||||
get_cooperative_or_fail(coopname);
|
||||
@@ -34,6 +36,15 @@ void ledger2::migrate3(eosio::name coopname,
|
||||
eosio::check(wallet_name.value != 0, "migrate3: wallet_name пустой");
|
||||
eosio::check(ledger2_is_known_wallet(wallet_name),
|
||||
std::string{"migrate3: неизвестный wallet_name "} + wallet_name.to_string());
|
||||
|
||||
#ifndef IS_TESTNET
|
||||
// voskhod: мин.паевые перенесены на w.sov.mnused (COOPERATIVE) при миграции —
|
||||
// см. migrate_voskhod_facts. L3 для w.reg.minshr на voskhod не создаётся,
|
||||
// иначе сломается инвариант Σ L3 == L2 (L2 пуст, а 048 написал бы 34 L3-записи).
|
||||
eosio::check(!(coopname == "voskhod"_n && wallet_name == ledger2_wallets::MIN_SHARE_FUND),
|
||||
"migrate3: voskhod не использует w.reg.minshr — мин.паевые на w.sov.mnused "
|
||||
"(см. migrate_voskhod_facts); migrator-048 должен пропускать voskhod");
|
||||
#endif
|
||||
eosio::check(ledger2_get_wallet_kind(wallet_name) == WalletKind::USER_SHARED,
|
||||
std::string{"migrate3: wallet_name "} + wallet_name.to_string() +
|
||||
" не USER_SHARED — L3-запись не имеет смысла");
|
||||
@@ -57,7 +68,7 @@ void ledger2::migrate3(eosio::name coopname,
|
||||
if (it == idx.end()) {
|
||||
if (is_zero) return; // нечего создавать; безопасный no-op для повторов
|
||||
const uint64_t new_id = user_wallets.available_primary_key();
|
||||
user_wallets.emplace(coopname, [&](auto& uw) {
|
||||
user_wallets.emplace(get_self(), [&](auto& uw) {
|
||||
uw.id = new_id;
|
||||
uw.wallet_name = wallet_name;
|
||||
uw.username = username;
|
||||
@@ -69,7 +80,7 @@ void ledger2::migrate3(eosio::name coopname,
|
||||
idx.erase(it);
|
||||
} else {
|
||||
auto pri = user_wallets.find(it->id);
|
||||
user_wallets.modify(pri, coopname, [&](auto& uw) {
|
||||
user_wallets.modify(pri, get_self(), [&](auto& uw) {
|
||||
uw.available = available;
|
||||
uw.blocked = blocked;
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* Группы:
|
||||
* w.wal.* — Паевой фонд (журнал Cr 80) и возвраты пайщикам
|
||||
* w.reg.* — Регистрация (минимальный паевой, вступительные)
|
||||
* w.sov.* — Целевое финансирование (членские, делегатские)
|
||||
* w.sov.* — Совет-level фонды (целевое финансирование, использованные паевые)
|
||||
* w.cap.* — Программы паевого фонда (Благорост, Генератор) и займы
|
||||
* w.mkt.* — Маркетплейс (выплаты поставщикам)
|
||||
*
|
||||
@@ -52,10 +52,11 @@ struct ledger2_wallets {
|
||||
static constexpr eosio::name MIN_SHARE_FUND = "w.reg.minshr"_n; ///< Минимальный паевой взнос пайщика (USER_SHARED, без сверки соглашений)
|
||||
static constexpr eosio::name ENTRANCE_FEES = "w.reg.entry"_n; ///< Вступительные взносы (Cr 86, COOPERATIVE)
|
||||
|
||||
// soviet — членские (инфраструктура) + делегатские + хоз.расходы
|
||||
// soviet — членские (инфраструктура) + делегатские + хоз.расходы + использованные паевые
|
||||
static constexpr eosio::name INFRA_FEES = "w.sov.infra"_n; ///< Членские взносы за инфраструктуру кооп. платформы (COOPERATIVE)
|
||||
static constexpr eosio::name DELEGATE_FEES = "w.sov.delgte"_n; ///< Делегатские членские взносы (цель CONVERT_TO_AXN, COOPERATIVE)
|
||||
static constexpr eosio::name SOV_EXPENSES = "w.sov.expns"_n; ///< Хозяйственные расходы из числа целевого финансирования (COOPERATIVE)
|
||||
static constexpr eosio::name MIN_SHARE_USED = "w.sov.mnused"_n; ///< Использованные минимальные паевые взносы (Cr 80 source, перешедшие в 08; COOPERATIVE)
|
||||
|
||||
// capital — единые программные кошельки + займы + пред-импорт
|
||||
static constexpr eosio::name LOAN_ISSUED = "w.cap.loan"_n; ///< Выданные пайщикам беспроцентные займы (COOPERATIVE; Dr 58 / Cr 51)
|
||||
@@ -94,7 +95,7 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (7) — L3-разрез по пайщику
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
|
||||
@@ -104,12 +105,13 @@ inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::MARKETPLACE_MEMBER, "ЦПП «Стол Заказов» — программный членский у пайщика", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (7) — единый кооперативный баланс, без L3
|
||||
// COOPERATIVE (8) — единый кооперативный баланс, без L3
|
||||
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::DELEGATE_FEES, "Делегатские членские взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SOV_EXPENSES, "Хозяйственные расходы из числа целевого финансирования", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
|
||||
}};
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* (soviet::agreements3) и заполняет state как есть. Полные проверки делает
|
||||
* нормальный flow `signagree`.
|
||||
*
|
||||
* @param coopname Кооператив (auth: coopname@active, payer)
|
||||
* @param coopname Кооператив (ключей от него у нас нет — поэтому НЕ авторизатор)
|
||||
* @param username Пайщик
|
||||
* @param program_id Идентификатор программы
|
||||
* @param doc_hash Хэш документа из `agreements3.document.hash`
|
||||
@@ -22,7 +22,11 @@
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_wallet_actions
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p coopname (active)
|
||||
* @note Auth: `wallet@active` (через `get_self()`) ИЛИ `coopname@active`.
|
||||
* Первый путь — для централизованного migrator (раскатка из CI). Второй —
|
||||
* для self-service backfill из контроллера кооператива (BlockchainService
|
||||
* имеет WIF только coopname; для wallet@active отдельного ключа нет).
|
||||
* Payer: тот, кто подал auth (wallet или сам кооператив).
|
||||
*/
|
||||
[[eosio::action]] void wallet::migrate3(
|
||||
eosio::name coopname,
|
||||
@@ -33,7 +37,8 @@
|
||||
uint64_t draft_id,
|
||||
eosio::time_point signed_at
|
||||
) {
|
||||
require_auth(coopname);
|
||||
const eosio::name payer = has_auth(get_self()) ? get_self() : coopname;
|
||||
if (payer != get_self()) require_auth(coopname);
|
||||
|
||||
Wallet::users_index users(_wallet, coopname.value);
|
||||
auto user_it = users.find(username.value);
|
||||
@@ -47,12 +52,12 @@
|
||||
};
|
||||
|
||||
if (user_it == users.end()) {
|
||||
users.emplace(coopname, [&](auto &row) {
|
||||
users.emplace(payer, [&](auto &row) {
|
||||
row.username = username;
|
||||
row.programs = { pa };
|
||||
});
|
||||
} else {
|
||||
users.modify(user_it, coopname, [&](auto &row) {
|
||||
users.modify(user_it, payer, [&](auto &row) {
|
||||
auto it = std::find_if(
|
||||
row.programs.begin(), row.programs.end(),
|
||||
[&](const Wallet::program_agreement &p) { return p.program_id == program_id; });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
interface IChainAgreement {
|
||||
id: number;
|
||||
coopname: string;
|
||||
username: string;
|
||||
type: string;
|
||||
program_id: number;
|
||||
draft_id: number;
|
||||
version: number;
|
||||
document: {
|
||||
version?: string;
|
||||
hash?: string;
|
||||
doc_hash?: string;
|
||||
meta_hash?: string;
|
||||
meta?: string;
|
||||
signatures?: any[];
|
||||
};
|
||||
status: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill `agreements` таблицы из блокчейна (`soviet::agreements3`).
|
||||
*
|
||||
* На дев/тест-кооперативах controller был поднят со снапшота, и старые
|
||||
* подписи (status='') до момента запуска sync не попали в Postgres через
|
||||
* delta-stream. Фронту это видится так: пайщик подписывал когда-то, но
|
||||
* `RequireAgreements` снова просит подписать `user`/`signature`/`privacy`.
|
||||
*
|
||||
* Миграция читает `agreements3` целиком по `scope = COOPNAME` через RPC и
|
||||
* UPSERT'ит каждую запись по полю `id`. Не трогает domain-status (`status`),
|
||||
* чтобы не сломать состояния, выставленные нормальным flow — UPSERT с
|
||||
* `DO UPDATE` пишет только поля, известные из блокчейна.
|
||||
*
|
||||
* Источник истины — блокчейн (`BLOCKCHAIN_RPC` env). RPC недоступен / пусто —
|
||||
* падаем явно (не молча).
|
||||
*/
|
||||
export default {
|
||||
name: 'Backfill agreements из soviet::agreements3 в Postgres',
|
||||
|
||||
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
try {
|
||||
const rpcUrl = process.env.BLOCKCHAIN_RPC;
|
||||
const coopname = process.env.COOPNAME ?? 'voskhod';
|
||||
|
||||
if (!rpcUrl) {
|
||||
logger.error('BLOCKCHAIN_RPC env не задан — миграция не применяется');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Чтение soviet::agreements3[scope=${coopname}] из ${rpcUrl} …`);
|
||||
const rows = await fetchAllAgreements(rpcUrl, coopname, logger);
|
||||
logger.info(`Получено ${rows.length} записей agreements3.`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
logger.info('Нет agreements3 — нечего вгружать.');
|
||||
return true;
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
// Программные соглашения отдаются на чтение из wallet::users.programs[],
|
||||
// не из `agreements`. Чтобы не получить дубль во фронте — заливаем
|
||||
// в БД только непрограммные.
|
||||
if (Number(row.program_id) > 0) { skipped += 1; continue; }
|
||||
|
||||
const document = convertChainDocumentToDomain(row.document);
|
||||
const blockchainStatus = String(row.status ?? '');
|
||||
const updatedAt = new Date(row.updated_at);
|
||||
|
||||
const result: Array<{ inserted: boolean }> = await dataSource.query(
|
||||
`
|
||||
INSERT INTO agreements (
|
||||
block_num, present, status, _created_at, _updated_at,
|
||||
id, coopname, username, type, program_id, draft_id, version,
|
||||
document, blockchain_status, updated_at
|
||||
) VALUES (
|
||||
0, true, 'registered', now(), now(),
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8::json, $9, $10
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
coopname = EXCLUDED.coopname,
|
||||
username = EXCLUDED.username,
|
||||
type = EXCLUDED.type,
|
||||
program_id = EXCLUDED.program_id,
|
||||
draft_id = EXCLUDED.draft_id,
|
||||
version = EXCLUDED.version,
|
||||
document = EXCLUDED.document,
|
||||
blockchain_status = EXCLUDED.blockchain_status,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
present = true,
|
||||
_updated_at = now()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
`,
|
||||
[
|
||||
row.id,
|
||||
row.coopname,
|
||||
row.username,
|
||||
row.type,
|
||||
row.program_id,
|
||||
row.draft_id,
|
||||
row.version,
|
||||
JSON.stringify(document),
|
||||
blockchainStatus,
|
||||
updatedAt,
|
||||
],
|
||||
);
|
||||
if (result[0]?.inserted) inserted += 1; else updated += 1;
|
||||
}
|
||||
|
||||
logger.info(`Backfill завершён: insert ${inserted}, update ${updated}, skip programmatic ${skipped}, всего ${rows.length}.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
logger.error(`Ошибка backfill agreements: ${message}${stack ? `\n${stack}` : ''}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn(
|
||||
'Откат не выполняется: backfill идемпотентен по id, повторный up даст тот же результат.',
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function convertChainDocumentToDomain(chainDoc: IChainAgreement['document']) {
|
||||
return {
|
||||
version: chainDoc?.version,
|
||||
hash: chainDoc?.hash,
|
||||
doc_hash: chainDoc?.doc_hash,
|
||||
meta_hash: chainDoc?.meta_hash,
|
||||
meta:
|
||||
typeof chainDoc?.meta === 'string'
|
||||
? chainDoc.meta === ''
|
||||
? {}
|
||||
: safeJsonParse(chainDoc.meta)
|
||||
: (chainDoc?.meta ?? {}),
|
||||
signatures: chainDoc?.signatures ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function safeJsonParse(s: string): unknown {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllAgreements(
|
||||
rpcUrl: string,
|
||||
coopname: string,
|
||||
logger: MigrationLogger,
|
||||
): Promise<IChainAgreement[]> {
|
||||
const items: IChainAgreement[] = [];
|
||||
let lower_bound: string | undefined = '';
|
||||
|
||||
for (let page = 0; page < 10000; page++) {
|
||||
const body = {
|
||||
json: true,
|
||||
code: 'soviet',
|
||||
scope: coopname,
|
||||
table: 'agreements3',
|
||||
limit: 1000,
|
||||
lower_bound,
|
||||
};
|
||||
|
||||
const res = await fetch(`${rpcUrl.replace(/\/$/, '')}/v1/chain/get_table_rows`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`RPC ${res.status} ${res.statusText}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
rows: IChainAgreement[];
|
||||
more: boolean;
|
||||
next_key?: string;
|
||||
};
|
||||
|
||||
items.push(...data.rows);
|
||||
|
||||
if (!data.more) return items;
|
||||
if (!data.next_key) {
|
||||
logger.warn('RPC ответил more=true без next_key — прерываем пагинацию');
|
||||
return items;
|
||||
}
|
||||
lower_bound = data.next_key;
|
||||
}
|
||||
|
||||
throw new Error('Превышен лимит страниц при чтении soviet::agreements3');
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { BlockchainService } from '~/infrastructure/blockchain/blockchain.service';
|
||||
import type { VaultDomainService } from '~/domain/vault/services/vault-domain.service';
|
||||
import { WalletContract } from 'cooptypes';
|
||||
import config from '~/config/config';
|
||||
|
||||
type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
interface IDocSignature {
|
||||
signed_at?: string;
|
||||
signer?: string;
|
||||
}
|
||||
|
||||
interface IBlagorostDoc {
|
||||
hash?: string;
|
||||
doc_hash?: string;
|
||||
version?: string;
|
||||
signatures?: IDocSignature[];
|
||||
}
|
||||
|
||||
interface IRegcontribData {
|
||||
coopname: string;
|
||||
username: string;
|
||||
blagorost_agreement?: IBlagorostDoc | null;
|
||||
generator_agreement?: IBlagorostDoc | null;
|
||||
}
|
||||
|
||||
interface IProgramAgreementRow {
|
||||
program_id: number | string;
|
||||
}
|
||||
|
||||
interface IWalletUserRow {
|
||||
username: string;
|
||||
programs: IProgramAgreementRow[];
|
||||
}
|
||||
|
||||
interface ISovietProgramRow {
|
||||
id: number | string;
|
||||
draft_id: number | string;
|
||||
}
|
||||
|
||||
interface IDraftRow {
|
||||
id: number | string;
|
||||
version: number | string;
|
||||
}
|
||||
|
||||
const BLAGOROST_PROGRAM_ID = 4;
|
||||
const GENERATOR_PROGRAM_ID = 3;
|
||||
const ZERO_HASH = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
/**
|
||||
* Backfill `wallet::users.programs[]` для исторических контрибуторов: их
|
||||
* `regcontrib` приехал ДО того, как capital научился делать inline
|
||||
* `wallet::signagree`. Подписанные документы лежат в `blockchain_actions.data`
|
||||
* как audit trail, но в state контракта `wallet::users` их не оказалось —
|
||||
* фронт ругается «У пайщика нет кошелька в программе Благорост».
|
||||
*
|
||||
* Поток:
|
||||
* 1. SELECT всех capital::regcontrib из локального controller-Postgres.
|
||||
* 2. Группировка по (coopname, username) — последний `regcontrib` выигрывает
|
||||
* (после importcontrib обычно ещё один regcontrib).
|
||||
* 3. Для каждой пары через RPC проверяем `wallet::users.programs[]`.
|
||||
* 4. Если program_id=4 (Благорост) ещё нет и `data.blagorost_agreement`
|
||||
* валидный — пушим `wallet::migrate3` от `coopname@active` с реальным
|
||||
* `doc_hash` и `signed_at = signatures[0].signed_at`. То же для
|
||||
* program_id=3 (Генератор).
|
||||
*
|
||||
* Auth: `coopname@active` — после расширения `wallet::migrate3` (контракт
|
||||
* принимает либо `wallet@active`, либо `coopname@active`).
|
||||
*
|
||||
* Идемпотентно: повторный запуск увидит уже подписанных и пропустит их.
|
||||
*
|
||||
* Активна до 2027-05-01: к этому моменту все исторические orphan'ы должны
|
||||
* быть закрыты, а новые регистрации идут через корректный `regcontrib`.
|
||||
*/
|
||||
export default {
|
||||
name: 'Backfill wallet::users.programs[] из capital::regcontrib (Благорост/Генератор)',
|
||||
validUntil: new Date('2027-05-01T00:00:00Z'),
|
||||
|
||||
async up({
|
||||
dataSource,
|
||||
blockchain,
|
||||
vault,
|
||||
logger,
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
blockchain: BlockchainService;
|
||||
vault: VaultDomainService;
|
||||
logger: MigrationLogger;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const rpcUrl = config.blockchain.url;
|
||||
if (!rpcUrl) {
|
||||
logger.error('BLOCKCHAIN_RPC не задан — миграция не применяется');
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawRows = (await dataSource.query(
|
||||
`SELECT data
|
||||
FROM blockchain_actions
|
||||
WHERE account = 'capital' AND name = 'regcontrib'
|
||||
ORDER BY block_num ASC`,
|
||||
)) as Array<{ data: IRegcontribData }>;
|
||||
|
||||
logger.info(`regcontrib actions найдено: ${rawRows.length}`);
|
||||
if (rawRows.length === 0) {
|
||||
logger.info('Нет regcontrib в локальной БД — нечего бэкфиллить.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const latestByPair = new Map<string, IRegcontribData>();
|
||||
for (const r of rawRows) {
|
||||
latestByPair.set(`${r.data.coopname}/${r.data.username}`, r.data);
|
||||
}
|
||||
logger.info(`Уникальных (coopname, username): ${latestByPair.size}`);
|
||||
|
||||
const coopnames = Array.from(new Set([...latestByPair.values()].map((d) => d.coopname)));
|
||||
const draftVersions = await fetchDraftVersions(rpcUrl, logger);
|
||||
|
||||
let pushed = 0;
|
||||
let skipped = 0;
|
||||
let missing = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const coopname of coopnames) {
|
||||
const wif = await vault.getWif(coopname);
|
||||
if (!wif) {
|
||||
logger.warn(`WIF для ${coopname} не найден в vault — пропускаю кооператив`);
|
||||
continue;
|
||||
}
|
||||
blockchain.initialize(coopname, wif);
|
||||
|
||||
const usersPrograms = await fetchWalletUserPrograms(rpcUrl, coopname, logger);
|
||||
const programs = await fetchSovietPrograms(rpcUrl, coopname, logger);
|
||||
|
||||
for (const [pairKey, regData] of latestByPair) {
|
||||
if (regData.coopname !== coopname) continue;
|
||||
|
||||
const targets: Array<{ programId: number; doc: IBlagorostDoc }> = [];
|
||||
if (isValidAgreement(regData.blagorost_agreement)) {
|
||||
targets.push({ programId: BLAGOROST_PROGRAM_ID, doc: regData.blagorost_agreement! });
|
||||
}
|
||||
if (isValidAgreement(regData.generator_agreement)) {
|
||||
targets.push({ programId: GENERATOR_PROGRAM_ID, doc: regData.generator_agreement! });
|
||||
}
|
||||
if (targets.length === 0) continue;
|
||||
|
||||
const already = usersPrograms.get(regData.username) ?? new Set<number>();
|
||||
|
||||
for (const { programId, doc } of targets) {
|
||||
if (already.has(programId)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const program = programs.get(programId);
|
||||
if (!program) {
|
||||
missing += 1;
|
||||
logger.warn(
|
||||
`${pairKey}: программа program_id=${programId} не найдена в soviet::programs — skip`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const draftId = Number(program.draft_id);
|
||||
const version = draftId > 0 ? draftVersions.get(draftId) ?? 0 : 0;
|
||||
const docHash = (doc.hash ?? doc.doc_hash) as string;
|
||||
const signedAt =
|
||||
doc.signatures?.[0]?.signed_at ?? new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||
|
||||
const actionData: WalletContract.Actions.Migrate3.IMigrate3 = {
|
||||
coopname: regData.coopname,
|
||||
username: regData.username,
|
||||
program_id: programId,
|
||||
doc_hash: docHash,
|
||||
version,
|
||||
draft_id: draftId,
|
||||
signed_at: signedAt,
|
||||
};
|
||||
|
||||
try {
|
||||
await blockchain.transact({
|
||||
account: WalletContract.contractName.production,
|
||||
name: WalletContract.Actions.Migrate3.actionName,
|
||||
authorization: [{ actor: coopname, permission: 'active' }],
|
||||
data: actionData,
|
||||
});
|
||||
pushed += 1;
|
||||
logger.info(
|
||||
`migrate3 OK ${pairKey} program_id=${programId} draft_id=${draftId} ` +
|
||||
`ver=${version} hash=${docHash.slice(0, 12)}…`,
|
||||
);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`migrate3 FAIL ${pairKey} program_id=${programId}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Backfill завершён: push ${pushed}, уже подписаны ${skipped}, программа не найдена ${missing}, ` +
|
||||
`ошибок ${failed}.`,
|
||||
);
|
||||
return failed === 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
logger.error(`Ошибка backfill blagorost signagree: ${message}${stack ? `\n${stack}` : ''}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn(
|
||||
'Откат не выполняется: backfill идемпотентен и работает только для orphan-программ. ' +
|
||||
'Удаление записей wallet::users делается вручную через wallet::revokeagree.',
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function isValidAgreement(doc: IBlagorostDoc | null | undefined): doc is IBlagorostDoc {
|
||||
if (!doc) return false;
|
||||
const hash = doc.hash ?? doc.doc_hash;
|
||||
return typeof hash === 'string' && hash.length === 64 && hash !== ZERO_HASH;
|
||||
}
|
||||
|
||||
async function rpcGetTableRows<T>(
|
||||
rpcUrl: string,
|
||||
payload: Record<string, unknown>,
|
||||
logger: MigrationLogger,
|
||||
): Promise<T[]> {
|
||||
const items: T[] = [];
|
||||
let lower_bound: string | undefined = '';
|
||||
|
||||
for (let page = 0; page < 10_000; page++) {
|
||||
const res = await fetch(`${rpcUrl.replace(/\/$/, '')}/v1/chain/get_table_rows`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ json: true, limit: 1000, lower_bound, ...payload }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`RPC ${res.status} ${await res.text()}`);
|
||||
const data = (await res.json()) as { rows: T[]; more: boolean; next_key?: string };
|
||||
items.push(...data.rows);
|
||||
if (!data.more) return items;
|
||||
if (!data.next_key) {
|
||||
logger.warn('RPC ответил more=true без next_key — прерываем пагинацию');
|
||||
return items;
|
||||
}
|
||||
lower_bound = data.next_key;
|
||||
}
|
||||
throw new Error('Превышен лимит страниц get_table_rows');
|
||||
}
|
||||
|
||||
async function fetchWalletUserPrograms(
|
||||
rpcUrl: string,
|
||||
coopname: string,
|
||||
logger: MigrationLogger,
|
||||
): Promise<Map<string, Set<number>>> {
|
||||
const rows = await rpcGetTableRows<IWalletUserRow>(
|
||||
rpcUrl,
|
||||
{ code: 'wallet', scope: coopname, table: 'users' },
|
||||
logger,
|
||||
);
|
||||
const out = new Map<string, Set<number>>();
|
||||
for (const r of rows) {
|
||||
out.set(r.username, new Set(r.programs.map((p) => Number(p.program_id))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchSovietPrograms(
|
||||
rpcUrl: string,
|
||||
coopname: string,
|
||||
logger: MigrationLogger,
|
||||
): Promise<Map<number, ISovietProgramRow>> {
|
||||
const rows = await rpcGetTableRows<ISovietProgramRow>(
|
||||
rpcUrl,
|
||||
{ code: 'soviet', scope: coopname, table: 'programs' },
|
||||
logger,
|
||||
);
|
||||
const out = new Map<number, ISovietProgramRow>();
|
||||
for (const r of rows) out.set(Number(r.id), r);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchDraftVersions(
|
||||
rpcUrl: string,
|
||||
logger: MigrationLogger,
|
||||
): Promise<Map<number, number>> {
|
||||
const rows = await rpcGetTableRows<IDraftRow>(
|
||||
rpcUrl,
|
||||
{ code: 'draft', scope: 'draft', table: 'drafts' },
|
||||
logger,
|
||||
);
|
||||
const out = new Map<number, number>();
|
||||
for (const r of rows) out.set(Number(r.id), Number(r.version));
|
||||
return out;
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
|
||||
+1029
-165
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ import { ProcessRegistryDomainModule } from './domain/process-registry/process-r
|
||||
import { ParserDomainModule } from './domain/parser/parser-domain.module';
|
||||
import { SettingsInfrastructureModule } from './infrastructure/settings/settings-infrastructure.module';
|
||||
import { RegistrationDomainModule } from './domain/registration/registration-domain.module';
|
||||
import { OnboardingDomainModule } from './domain/onboarding/onboarding-domain.module';
|
||||
import { TokenDomainModule } from './domain/token/token-domain.module';
|
||||
import { MutationLogDomainModule } from './domain/mutation-log/mutation-log-domain.module';
|
||||
|
||||
@@ -74,6 +75,7 @@ import { UserModule } from './application/user/user.module';
|
||||
import { TokenApplicationModule } from './application/token/token-application.module';
|
||||
import { SettingsApplicationModule } from './application/settings/settings.module';
|
||||
import { RegistrationModule } from './application/registration/registration.module';
|
||||
import { OnboardingApplicationModule } from './application/onboarding/onboarding-application.module';
|
||||
import { SearchModule } from './application/search/search.module';
|
||||
import { MutationLoggingInterceptor } from './application/common/interceptors/mutation-logging.interceptor';
|
||||
import { MarketplacePluginModule } from './extensions/marketplace/marketplace-extension.module';
|
||||
@@ -105,6 +107,7 @@ import { MarketplaceCardsModule } from './extensions/marketplace-cards/marketpla
|
||||
// Domain modules
|
||||
AuthDomainModule,
|
||||
RegistrationDomainModule,
|
||||
OnboardingDomainModule,
|
||||
AgendaDomainModule,
|
||||
AccountDomainModule,
|
||||
AccountInfrastructureModule,
|
||||
@@ -158,6 +161,7 @@ import { MarketplaceCardsModule } from './extensions/marketplace-cards/marketpla
|
||||
TokenApplicationModule,
|
||||
SettingsApplicationModule,
|
||||
RegistrationModule,
|
||||
OnboardingApplicationModule,
|
||||
SearchModule,
|
||||
// Marketplace extensions
|
||||
MarketplacePluginModule,
|
||||
|
||||
@@ -121,7 +121,7 @@ export class AgreementService {
|
||||
* Программные DTO синтезируются: `id`/`document` отсутствуют (полный документ
|
||||
* лежит в action data, а не в state); `status=CONFIRMED` (запись в `users`
|
||||
* существует только для подписанных соглашений). Поле `type` берётся из
|
||||
* `soviet::coagreements[program_id].type` (тот же 'wallet'/'blagorost' и т.п.,
|
||||
* `soviet::coagreements[program_id].type` (тот же 'wallet'/'capital' и т.п.,
|
||||
* который виджет `RequireAgreements` использует для матчинга подписи); если
|
||||
* программа без коагримента — fallback `'programmatic'`.
|
||||
*/
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
@InputType('CompleteExtensionOnboardingStepInput')
|
||||
export class CompleteExtensionOnboardingStepInputDTO {
|
||||
@Field(() => String, { description: 'Имя расширения' })
|
||||
extension_name!: string;
|
||||
|
||||
@Field(() => String, { description: 'Ключ шага из реестра онбординга' })
|
||||
step_key!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Заголовок предлагаемого совету решения (опционально, если не задано — берётся default_title шага)',
|
||||
})
|
||||
title?: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Вопрос предлагаемого совету решения (для generator=free_decision)',
|
||||
})
|
||||
question?: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Текст принимаемого решения (для generator=free_decision)',
|
||||
})
|
||||
decision?: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Hash повестки общего собрания (для generator=meet)',
|
||||
})
|
||||
proposal_hash?: string;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Field, ObjectType, Int } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('ExtensionOnboardingStepState')
|
||||
export class ExtensionOnboardingStepStateDTO {
|
||||
@Field(() => String, { description: 'Идентификатор шага (соответствует config-полю onboarding_<step_key>_done)' })
|
||||
step_key!: string;
|
||||
|
||||
@Field(() => Boolean, { description: 'Завершён ли шаг (ратифицирован)' })
|
||||
done!: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Hash опубликованного документа шага' })
|
||||
hash!: string | null;
|
||||
|
||||
@Field(() => Int, { description: 'Порядок отображения' })
|
||||
order!: number;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Заголовок шага по умолчанию (если payload не передал свой)',
|
||||
})
|
||||
default_title!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('ExtensionOnboardingState')
|
||||
export class ExtensionOnboardingStateDTO {
|
||||
@Field(() => String, { description: 'Имя расширения' })
|
||||
extension_name!: string;
|
||||
|
||||
@Field(() => [ExtensionOnboardingStepStateDTO], { description: 'Список шагов онбординга в порядке отображения' })
|
||||
steps!: ExtensionOnboardingStepStateDTO[];
|
||||
|
||||
@Field(() => String, { description: 'Момент старта онбординга (ISO)' })
|
||||
onboarding_init_at!: string;
|
||||
|
||||
@Field(() => String, { description: 'Момент истечения TTL онбординга (ISO)' })
|
||||
onboarding_expire_at!: string;
|
||||
|
||||
@Field(() => Boolean, { description: 'Все шаги завершены' })
|
||||
all_done!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './extension-onboarding-state.dto';
|
||||
export * from './complete-extension-onboarding-step.input';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExtensionOnboardingResolver } from './resolvers/extension-onboarding.resolver';
|
||||
|
||||
/**
|
||||
* Application-модуль платформенного онбординга расширений: содержит
|
||||
* GraphQL-резолвер. ExtensionOnboardingService доступен глобально из
|
||||
* OnboardingDomainModule.
|
||||
*/
|
||||
@Module({
|
||||
providers: [ExtensionOnboardingResolver],
|
||||
})
|
||||
export class OnboardingApplicationModule {}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { ExtensionOnboardingService } from '~/domain/onboarding/services/extension-onboarding.service';
|
||||
import { ExtensionOnboardingStateDTO } from '../dto/extension-onboarding-state.dto';
|
||||
import { CompleteExtensionOnboardingStepInputDTO } from '../dto/complete-extension-onboarding-step.input';
|
||||
|
||||
/**
|
||||
* Универсальный GraphQL-резолвер платформенного онбординга кооператива
|
||||
* на расширение. Любое расширение, зарегистрировавшее шаги через
|
||||
* ONBOARDING_STEP_REGISTRATION_PORT в initialize(), сразу же
|
||||
* пользуется этим endpoint'ом без своего DTO/резолвера.
|
||||
*
|
||||
* Query open для chairman/member/user — frontend (включая обычного
|
||||
* пайщика) должен уметь увидеть, что кооператив ещё не онбординулся.
|
||||
* Mutation — только chairman (онбординг кооператива — компетенция
|
||||
* председателя).
|
||||
*/
|
||||
@Resolver()
|
||||
export class ExtensionOnboardingResolver {
|
||||
constructor(private readonly onboardingService: ExtensionOnboardingService) {}
|
||||
|
||||
@Query(() => ExtensionOnboardingStateDTO, {
|
||||
name: 'getExtensionOnboardingState',
|
||||
description: 'Получить состояние онбординга кооператива на расширение',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member', 'user'])
|
||||
async getState(
|
||||
@Args('extension_name', { type: () => String }) extension_name: string
|
||||
): Promise<ExtensionOnboardingStateDTO> {
|
||||
const state = await this.onboardingService.getState(extension_name);
|
||||
return state as ExtensionOnboardingStateDTO;
|
||||
}
|
||||
|
||||
@Mutation(() => ExtensionOnboardingStateDTO, {
|
||||
name: 'completeExtensionOnboardingStep',
|
||||
description:
|
||||
'Выполнить шаг онбординга кооператива на расширение (решение совета или общее собрание)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
async completeStep(
|
||||
@Args('data', { type: () => CompleteExtensionOnboardingStepInputDTO })
|
||||
data: CompleteExtensionOnboardingStepInputDTO,
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface
|
||||
): Promise<ExtensionOnboardingStateDTO> {
|
||||
const state = await this.onboardingService.completeStep(
|
||||
{
|
||||
extension_name: data.extension_name,
|
||||
step_key: data.step_key,
|
||||
title: data.title,
|
||||
question: data.question,
|
||||
decision: data.decision,
|
||||
proposal_hash: data.proposal_hash,
|
||||
},
|
||||
currentUser?.username
|
||||
);
|
||||
return state as ExtensionOnboardingStateDTO;
|
||||
}
|
||||
}
|
||||
+15
-8
@@ -262,7 +262,13 @@ export class ParticipantInteractor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует AgreementId в соответствующий DocumentType
|
||||
* Преобразует agreementId в соответствующий DocumentType.
|
||||
*
|
||||
* Платформенные оферты (signature/wallet/user/privacy) сопоставляются
|
||||
* по AgreementId enum явно. Оферты расширений (например 'blagorost_offer',
|
||||
* 'generator_offer' от capital) сопоставляются identity-фолбэком —
|
||||
* строковое значение agreementId должно совпасть с одним из значений
|
||||
* DocumentType. Это позволяет ядру не знать о капитал-специфике.
|
||||
*/
|
||||
private mapAgreementIdToDocumentType(agreementId: string): DocumentType | null {
|
||||
switch (agreementId) {
|
||||
@@ -274,14 +280,15 @@ export class ParticipantInteractor {
|
||||
return DocumentType.USER_AGREEMENT;
|
||||
case AgreementId.PRIVACY_AGREEMENT:
|
||||
return DocumentType.PRIVACY_AGREEMENT;
|
||||
case AgreementId.BLAGOROST_OFFER:
|
||||
return DocumentType.BLAGOROST_OFFER;
|
||||
case AgreementId.GENERATOR_OFFER:
|
||||
return DocumentType.GENERATOR_OFFER;
|
||||
default:
|
||||
this.logger.warn(`Неизвестный agreementId: ${agreementId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const documentTypeValues = Object.values(DocumentType) as string[];
|
||||
if (documentTypeValues.includes(agreementId)) {
|
||||
return agreementId as DocumentType;
|
||||
}
|
||||
|
||||
this.logger.warn(`Неизвестный agreementId: ${agreementId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async addParticipant(data: AddParticipantDomainInterface): Promise<AccountDomainEntity> {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ObjectType, Field, Int } from '@nestjs/graphql';
|
||||
import { IsArray, IsBoolean, IsNumber, IsString } from 'class-validator';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import type { IAgreementConfigItem } from '~/domain/registration/config/agreement-config.interface';
|
||||
|
||||
/**
|
||||
* GraphQL DTO спецификации оферты, требуемой при регистрации.
|
||||
*
|
||||
* Сливает платформенные оферты (signature/wallet/user/privacy) с
|
||||
* extension-зарегистрированными (например, capital → generator_offer,
|
||||
* blagorost_offer) — для SignUp на фронте конфигурация неотличима.
|
||||
*
|
||||
* Не путать с `AgreementDTO` (это уже on-chain подписанное соглашение
|
||||
* конкретного пайщика). Здесь — описание чекбокса и его документа,
|
||||
* который ещё предстоит сгенерировать и подписать.
|
||||
*/
|
||||
@ObjectType('RegistrationAgreement', {
|
||||
description: 'Описание оферты, которую пайщик должен принять при регистрации',
|
||||
})
|
||||
export class RegistrationAgreementDTO {
|
||||
@Field({ description: 'Строковый идентификатор оферты' })
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'Тип соглашения для on-chain sendAgreement' })
|
||||
@IsString()
|
||||
agreement_type!: string;
|
||||
|
||||
@Field(() => Int, { description: 'registry_id шаблона на фабрике документов' })
|
||||
@IsNumber()
|
||||
registry_id!: number;
|
||||
|
||||
@Field({ description: 'Человекочитаемое название оферты' })
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@Field({ description: 'Текст для галочки' })
|
||||
@IsString()
|
||||
checkbox_text!: string;
|
||||
|
||||
@Field({ description: 'Текст ссылки для открытия диалога чтения' })
|
||||
@IsString()
|
||||
link_text!: string;
|
||||
|
||||
@Field({ description: 'Нужно ли отправлять в блокчейн через sendAgreement' })
|
||||
@IsBoolean()
|
||||
is_blockchain_agreement!: boolean;
|
||||
|
||||
@Field({ description: 'Нужно ли линковать хеш документа в заявление' })
|
||||
@IsBoolean()
|
||||
link_to_statement!: boolean;
|
||||
|
||||
@Field(() => [AccountType], {
|
||||
description: 'Типы аккаунтов, для которых оферта применима',
|
||||
})
|
||||
@IsArray()
|
||||
applicable_account_types!: AccountType[];
|
||||
|
||||
@Field(() => Int, { description: 'Порядок отображения' })
|
||||
@IsNumber()
|
||||
order!: number;
|
||||
|
||||
constructor(data?: IAgreementConfigItem) {
|
||||
if (data) {
|
||||
this.id = data.id;
|
||||
this.agreement_type = data.agreement_type;
|
||||
this.registry_id = data.registry_id;
|
||||
this.title = data.title;
|
||||
this.checkbox_text = data.checkbox_text;
|
||||
this.link_text = data.link_text;
|
||||
this.is_blockchain_agreement = data.is_blockchain_agreement;
|
||||
this.link_to_statement = data.link_to_statement;
|
||||
this.applicable_account_types = data.applicable_account_types;
|
||||
this.order = data.order;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { GatewayPaymentDTO } from '~/application/gateway/dto/gateway-payment.dto
|
||||
import { CreateInitialPaymentInputDTO } from '~/application/gateway/dto/create-initial-payment-input.dto';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { RegistrationAgreementDTO } from '../dto/registration-agreement.dto';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
|
||||
// Пагинированные результаты
|
||||
const paginatedCandidatesResult = createPaginationResult(CandidateOutputDTO, 'PaginatedCandidates');
|
||||
@@ -47,6 +49,24 @@ export class RegistrationResolver {
|
||||
return await this.registrationService.getCandidates(currentUser, filter, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Список оферт, требуемых при регистрации для данного типа аккаунта
|
||||
* (и опционально программы). Сливает платформенные + extension-
|
||||
* зарегистрированные через AgreementRegistry.
|
||||
*/
|
||||
@Query(() => [RegistrationAgreementDTO], {
|
||||
name: 'getRegistrationAgreements',
|
||||
description:
|
||||
'Получить список оферт для регистрации пайщика заданного типа аккаунта и (опционально) программы. Сливает базовые платформенные оферты с теми, что зарегистрировали расширения.',
|
||||
})
|
||||
async getRegistrationAgreements(
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('account_type', { type: () => AccountType }) accountType: AccountType,
|
||||
@Args('program_key', { type: () => String, nullable: true }) programKey?: string
|
||||
): Promise<RegistrationAgreementDTO[]> {
|
||||
return this.registrationService.getRegistrationAgreements(coopname, accountType, programKey);
|
||||
}
|
||||
|
||||
@Mutation(() => GenerateRegistrationDocumentsOutputDTO, {
|
||||
name: 'generateRegistrationDocuments',
|
||||
description:
|
||||
|
||||
+20
-1
@@ -22,6 +22,9 @@ import { GeneratedDocumentDTO } from '~/application/document/dto/generated-docum
|
||||
import { ParticipantApplicationDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/participant-application-decision-document.dto';
|
||||
import { ACCOUNT_DATA_PORT, AccountDataPort } from '~/domain/account/ports/account-data.port';
|
||||
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { AGREEMENT_QUERY_PORT, AgreementQueryPort } from '~/domain/registration/ports/agreement-query.port';
|
||||
import { RegistrationAgreementDTO } from '../dto/registration-agreement.dto';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
|
||||
@Injectable()
|
||||
export class RegistrationService {
|
||||
@@ -35,9 +38,25 @@ export class RegistrationService {
|
||||
@Inject(USER_CERTIFICATE_DOMAIN_PORT)
|
||||
private readonly userCertificateDomainPort: UserCertificateDomainPort,
|
||||
@Inject(ACCOUNT_DATA_PORT)
|
||||
private readonly accountDataPort: AccountDataPort
|
||||
private readonly accountDataPort: AccountDataPort,
|
||||
@Inject(AGREEMENT_QUERY_PORT)
|
||||
private readonly agreementQueryPort: AgreementQueryPort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Возвращает список оферт, которые пайщик должен принять при
|
||||
* регистрации (сливает платформенные + extension-зарегистрированные).
|
||||
*/
|
||||
getRegistrationAgreements(
|
||||
coopname: string,
|
||||
accountType: AccountType,
|
||||
programKey?: string
|
||||
): RegistrationAgreementDTO[] {
|
||||
return this.agreementQueryPort
|
||||
.getAgreementsForAccountType(accountType, coopname, programKey)
|
||||
.map((item) => new RegistrationAgreementDTO(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация пакета документов для регистрации
|
||||
*/
|
||||
|
||||
@@ -110,19 +110,10 @@ export class SystemService {
|
||||
* @returns конфигурация программ регистрации
|
||||
*/
|
||||
public async getRegistrationConfig(coopname: string, accountType: AccountType): Promise<RegistrationConfigDTO> {
|
||||
const config = this.agreementConfigService.getCooperativeProgramsConfig(coopname);
|
||||
|
||||
if (!config) {
|
||||
return new RegistrationConfigDTO({
|
||||
requires_selection: false,
|
||||
programs: [],
|
||||
});
|
||||
}
|
||||
|
||||
const programs = this.agreementConfigService.getAvailablePrograms(coopname, accountType);
|
||||
|
||||
return new RegistrationConfigDTO({
|
||||
requires_selection: config.requires_selection && programs.length > 1,
|
||||
requires_selection: programs.length > 1,
|
||||
programs,
|
||||
});
|
||||
}
|
||||
|
||||
+32
-1
@@ -1,7 +1,7 @@
|
||||
// domain/appstore/appstore-lifecycle-domain.service.ts
|
||||
|
||||
import { Injectable, type INestApplication } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { ExtensionDomainService } from '~/domain/extension/services/extension-domain.service';
|
||||
import { ExtensionSchemaMigrationService } from './extension-schema-migration.service';
|
||||
import { AppRegistry } from '~/extensions/extensions.registry';
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
EXTENSION_APP_TERMINATE_EVENT,
|
||||
type ExtensionAppTerminatePayload,
|
||||
} from '~/domain/extension/extension-app-lifecycle.events';
|
||||
import {
|
||||
ONBOARDING_COMPLETED_EVENT,
|
||||
type OnboardingCompletedPayload,
|
||||
} from '~/domain/onboarding/events/onboarding-completed.event';
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionLifecycleDomainService<TConfig = any> {
|
||||
@@ -106,4 +110,31 @@ export class ExtensionLifecycleDomainService<TConfig = any> {
|
||||
await this.runApp(appName);
|
||||
this.logger.info(`Расширение ${appName} перезапущено.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-перезапуск расширения после завершения его L1-онбординга.
|
||||
*
|
||||
* Логика — раздел 7.1 плана C28-10 (вариант (в) auto-restart): сервис
|
||||
* онбординга расширения эмиттит ONBOARDING_COMPLETED_EVENT, когда все
|
||||
* _done флаги в extension.config встали; lifecycle ловит и перезапускает
|
||||
* расширение, чтобы initialize(config) увидел актуальный config и
|
||||
* перерегистрировал оферты/программы в AgreementRegistryService.
|
||||
*
|
||||
* Совет ничего не нажимает.
|
||||
*/
|
||||
@OnEvent(ONBOARDING_COMPLETED_EVENT)
|
||||
async onOnboardingCompleted(payload: OnboardingCompletedPayload) {
|
||||
const { extension_name } = payload;
|
||||
this.logger.info(
|
||||
`[ONBOARDING_COMPLETED] получено событие для ${extension_name}, инициирую restartApp`
|
||||
);
|
||||
try {
|
||||
await this.restartApp(extension_name);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[ONBOARDING_COMPLETED] restartApp(${extension_name}) упал: ${(error as Error).message}`,
|
||||
(error as Error).stack
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Платформенный TTL для L1-онбординга расширений.
|
||||
*
|
||||
* Один источник правды для capital и chairman (оба хардкодили
|
||||
* 30 * 24 * 60 * 60 * 1000 в своих модулях). При первом запуске
|
||||
* расширения сервис записывает `onboarding_expire_at` в config
|
||||
* плагина = startedAt + ONBOARDING_EXPIRY_MS; по истечении срока
|
||||
* `*-onboarding-events.service.isOnboardingExpired` возвращает true
|
||||
* и UI/процесс реагирует соответственно.
|
||||
*
|
||||
* Перенос в общий модуль (план C28-10 раздел 4.2) — чтобы изменение
|
||||
* политики (например, продление до 45 дней) не требовало правки
|
||||
* двух модулей и не дрейфовало по копиям.
|
||||
*/
|
||||
|
||||
export const ONBOARDING_EXPIRY_DAYS = 30;
|
||||
|
||||
export const ONBOARDING_EXPIRY_MS = ONBOARDING_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Время истечения L1-онбординга, отталкиваясь от момента старта.
|
||||
* Возвращает ISO-строку, потому что в config'е плагина хранится строкой.
|
||||
*/
|
||||
export function computeOnboardingExpiresAt(startedAt: Date): string {
|
||||
return new Date(startedAt.getTime() + ONBOARDING_EXPIRY_MS).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type OnboardingStepEventType = 'SOVIET_DECISION' | 'MEET_DECISION';
|
||||
|
||||
export type OnboardingStepGenerator = 'free_decision' | 'meet';
|
||||
|
||||
export interface IExtensionOnboardingStepSpec {
|
||||
extension_name: string;
|
||||
step_key: string;
|
||||
event_type: OnboardingStepEventType;
|
||||
vars_field: string;
|
||||
generator: OnboardingStepGenerator;
|
||||
default_title?: string;
|
||||
order: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './extension-onboarding-step-spec';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Событие: расширение завершило L1-онбординг
|
||||
* (все его шаги в extension.config.* проставлены в _done = true).
|
||||
*
|
||||
* Эмиттится сервисом онбординга расширения сразу после успешного
|
||||
* `completeStep`, когда последний `_done` флаг встал. Слушатель —
|
||||
* ExtensionLifecycleDomainService — автоматически вызывает
|
||||
* `restartApp(extension_name)`, чтобы расширение перерегистрировало
|
||||
* свои оферты/программы через `initialize(config)` с актуальным
|
||||
* содержимым extension.config (раздел 4.2 req 44 проекта 13;
|
||||
* принятая архитектурная предпосылка 7.1 в плане C28-10:
|
||||
* вариант (в) — auto-restart, без ручного действия совета).
|
||||
*/
|
||||
export const ONBOARDING_COMPLETED_EVENT = 'onboarding.completed' as const;
|
||||
|
||||
export interface OnboardingCompletedPayload {
|
||||
/** Имя расширения, у которого завершился L1 (совпадает с appName в AppRegistry). */
|
||||
extension_name: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './dto';
|
||||
export * from './ports';
|
||||
export * from './services';
|
||||
export * from './onboarding-domain.module';
|
||||
export * from './constants/onboarding-ttl';
|
||||
export * from './events/onboarding-completed.event';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module';
|
||||
import { FreeDecisionInfrastructureModule } from '~/infrastructure/free-decision/free-decision-infrastructure.module';
|
||||
import { DecisionTrackingInfrastructureModule } from '~/infrastructure/decision-tracking/decision-tracking-infrastructure.module';
|
||||
import { OnboardingStepsRegistryService } from './services/onboarding-steps-registry.service';
|
||||
import { ExtensionOnboardingService } from './services/extension-onboarding.service';
|
||||
import { ExtensionOnboardingEventsService } from './services/extension-onboarding-events.service';
|
||||
import { ONBOARDING_STEP_REGISTRATION_PORT } from './ports/onboarding-step-registration.port';
|
||||
import { ONBOARDING_STEP_QUERY_PORT } from './ports/onboarding-step-query.port';
|
||||
|
||||
/**
|
||||
* Глобальный domain-модуль платформенного онбординга кооператива на
|
||||
* расширение.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ExtensionDomainModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
],
|
||||
providers: [
|
||||
OnboardingStepsRegistryService,
|
||||
{
|
||||
provide: ONBOARDING_STEP_REGISTRATION_PORT,
|
||||
useExisting: OnboardingStepsRegistryService,
|
||||
},
|
||||
{
|
||||
provide: ONBOARDING_STEP_QUERY_PORT,
|
||||
useExisting: OnboardingStepsRegistryService,
|
||||
},
|
||||
ExtensionOnboardingService,
|
||||
ExtensionOnboardingEventsService,
|
||||
],
|
||||
exports: [
|
||||
OnboardingStepsRegistryService,
|
||||
ONBOARDING_STEP_REGISTRATION_PORT,
|
||||
ONBOARDING_STEP_QUERY_PORT,
|
||||
ExtensionOnboardingService,
|
||||
],
|
||||
})
|
||||
export class OnboardingDomainModule {}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './onboarding-step-registration.port';
|
||||
export * from './onboarding-step-query.port';
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
|
||||
|
||||
export interface OnboardingStepQueryPort {
|
||||
getStepsByExtension(extension_name: string): IExtensionOnboardingStepSpec[];
|
||||
getStep(
|
||||
extension_name: string,
|
||||
step_key: string
|
||||
): IExtensionOnboardingStepSpec | null;
|
||||
}
|
||||
|
||||
export const ONBOARDING_STEP_QUERY_PORT = Symbol('OnboardingStepQueryPort');
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
|
||||
|
||||
export interface OnboardingStepRegistrationPort {
|
||||
registerStep(spec: IExtensionOnboardingStepSpec): void;
|
||||
unregisterStepsByExtension(extension_name: string): void;
|
||||
}
|
||||
|
||||
export const ONBOARDING_STEP_REGISTRATION_PORT = Symbol(
|
||||
'OnboardingStepRegistrationPort'
|
||||
);
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import {
|
||||
EXTENSION_REPOSITORY,
|
||||
type ExtensionDomainRepository,
|
||||
} from '~/domain/extension/repositories/extension-domain.repository';
|
||||
import { DecisionTrackedEvent } from '~/domain/decision-tracking/events/decision-tracked.event';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import {
|
||||
ONBOARDING_COMPLETED_EVENT,
|
||||
type OnboardingCompletedPayload,
|
||||
} from '../events/onboarding-completed.event';
|
||||
import {
|
||||
ONBOARDING_STEP_QUERY_PORT,
|
||||
type OnboardingStepQueryPort,
|
||||
} from '../ports/onboarding-step-query.port';
|
||||
|
||||
/**
|
||||
* Расширения с собственным per-extension events-сервисом
|
||||
* (см. {Capital,Chairman}OnboardingEventsService). Generic-слушатель
|
||||
* пропускает их, чтобы избежать double-update флага и double-emit
|
||||
* ONBOARDING_COMPLETED. После удаления legacy сервисов в одном из
|
||||
* следующих эпиков (когда capital/chairman перестанут хардкодить свои
|
||||
* step-mapping'и) этот список схлопнётся до пустого.
|
||||
*/
|
||||
const LEGACY_EXTENSIONS_WITH_OWN_LISTENER: ReadonlyArray<string> = [
|
||||
'chairman',
|
||||
'capital',
|
||||
];
|
||||
|
||||
const doneKey = (step_key: string) => `onboarding_${step_key}_done`;
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionOnboardingEventsService {
|
||||
constructor(
|
||||
@Inject(EXTENSION_REPOSITORY)
|
||||
private readonly extensionRepository: ExtensionDomainRepository<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
@Inject(ONBOARDING_STEP_QUERY_PORT)
|
||||
private readonly stepsRegistry: OnboardingStepQueryPort,
|
||||
private readonly logger: WinstonLoggerService,
|
||||
private readonly eventEmitter: EventEmitter2
|
||||
) {
|
||||
this.logger.setContext(ExtensionOnboardingEventsService.name);
|
||||
}
|
||||
|
||||
@OnEvent(DecisionTrackedEvent.eventName)
|
||||
async handleDecisionTracked(event: DecisionTrackedEvent): Promise<void> {
|
||||
const { result } = event;
|
||||
if (!result.metadata?.onboarding_step || !result.metadata.extension) return;
|
||||
|
||||
const extension_name = String(result.metadata.extension);
|
||||
const step_key = String(result.metadata.onboarding_step);
|
||||
|
||||
if (LEGACY_EXTENSIONS_WITH_OWN_LISTENER.includes(extension_name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spec = this.stepsRegistry.getStep(extension_name, step_key);
|
||||
if (!spec) {
|
||||
this.logger.debug(
|
||||
`Шаг ${extension_name}/${step_key} не найден в OnboardingStepsRegistry — пропуск`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const plugin = await this.extensionRepository.findByName(extension_name);
|
||||
if (!plugin) {
|
||||
this.logger.warn(`Расширение ${extension_name} не найдено в репозитории`);
|
||||
return;
|
||||
}
|
||||
|
||||
const flagKey = doneKey(step_key);
|
||||
const wasAlreadyDone = Boolean(plugin.config[flagKey]);
|
||||
if (wasAlreadyDone) return;
|
||||
|
||||
const updatedConfig: Record<string, unknown> = {
|
||||
...plugin.config,
|
||||
[flagKey]: true,
|
||||
};
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
this.logger.info(`Онбординг ${extension_name}: ${flagKey} = true`);
|
||||
|
||||
const specs = this.stepsRegistry.getStepsByExtension(extension_name);
|
||||
const allDone =
|
||||
specs.length > 0 &&
|
||||
specs.every((s) => Boolean(updatedConfig[doneKey(s.step_key)]));
|
||||
if (allDone) {
|
||||
this.logger.info(
|
||||
`[ONBOARDING_COMPLETED] ${extension_name}: все шаги завершены`
|
||||
);
|
||||
this.eventEmitter.emit(ONBOARDING_COMPLETED_EVENT, {
|
||||
extension_name,
|
||||
} satisfies OnboardingCompletedPayload);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(
|
||||
`Ошибка обработки DecisionTrackedEvent для ${extension_name}/${step_key}: ${err.message}`,
|
||||
err.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import {
|
||||
EXTENSION_REPOSITORY,
|
||||
type ExtensionDomainRepository,
|
||||
} from '~/domain/extension/repositories/extension-domain.repository';
|
||||
import {
|
||||
FREE_DECISION_PORT,
|
||||
type FreeDecisionPort,
|
||||
} from '~/domain/free-decision/ports/free-decision.port';
|
||||
import {
|
||||
DECISION_TRACKING_PORT,
|
||||
type DecisionTrackingPort,
|
||||
} from '~/domain/decision-tracking/ports/decision-tracking.port';
|
||||
import { DecisionEventType } from '~/domain/decision-tracking/interfaces/tracking-rule-domain.interface';
|
||||
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
|
||||
import { computeOnboardingExpiresAt } from '../constants/onboarding-ttl';
|
||||
import {
|
||||
ONBOARDING_STEP_QUERY_PORT,
|
||||
type OnboardingStepQueryPort,
|
||||
} from '../ports/onboarding-step-query.port';
|
||||
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
|
||||
import config from '~/config/config';
|
||||
|
||||
export interface IExtensionOnboardingStepState {
|
||||
step_key: string;
|
||||
done: boolean;
|
||||
hash: string | null;
|
||||
order: number;
|
||||
default_title: string | null;
|
||||
}
|
||||
|
||||
export interface IExtensionOnboardingState {
|
||||
extension_name: string;
|
||||
steps: IExtensionOnboardingStepState[];
|
||||
onboarding_init_at: string;
|
||||
onboarding_expire_at: string;
|
||||
all_done: boolean;
|
||||
}
|
||||
|
||||
export interface ICompleteExtensionOnboardingStepInput {
|
||||
extension_name: string;
|
||||
step_key: string;
|
||||
title?: string;
|
||||
/** Текст вопроса предлагаемого решения. Обязателен для generator=free_decision. */
|
||||
question?: string;
|
||||
/** Текст принимаемого решения. Обязателен для generator=free_decision. */
|
||||
decision?: string;
|
||||
/** Hash повестки общего собрания. Обязателен для generator=meet. */
|
||||
proposal_hash?: string;
|
||||
}
|
||||
|
||||
const doneKey = (step_key: string) => `onboarding_${step_key}_done`;
|
||||
const hashKey = (step_key: string) => `onboarding_${step_key}_hash`;
|
||||
|
||||
/**
|
||||
* Generic-сервис онбординга кооператива на расширение.
|
||||
*
|
||||
* Шаги задаются расширением декларативно через
|
||||
* OnboardingStepsRegistry в момент initialize(). Платформенный слой
|
||||
* выполняет общий flow (free-decision/meet → tracking-rule →
|
||||
* сохранение hash в extension.config) и собирает state из полей
|
||||
* `onboarding_<step_key>_done` / `_hash` — те же ключи, что
|
||||
* используют legacy chairman/capital, чтобы старые и новые resolver'ы
|
||||
* сходились на единой config-истине.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ExtensionOnboardingService {
|
||||
constructor(
|
||||
@Inject(EXTENSION_REPOSITORY)
|
||||
private readonly extensionRepository: ExtensionDomainRepository<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
@Inject(FREE_DECISION_PORT)
|
||||
private readonly freeDecisionPort: FreeDecisionPort,
|
||||
@Inject(DECISION_TRACKING_PORT)
|
||||
private readonly decisionTrackingPort: DecisionTrackingPort,
|
||||
@Inject(ONBOARDING_STEP_QUERY_PORT)
|
||||
private readonly stepsRegistry: OnboardingStepQueryPort
|
||||
) {}
|
||||
|
||||
private async loadPlugin(extension_name: string) {
|
||||
const plugin = await this.extensionRepository.findByName(extension_name);
|
||||
if (!plugin) {
|
||||
throw new Error(`Расширение не найдено: ${extension_name}`);
|
||||
}
|
||||
const pluginConfig: Record<string, unknown> = { ...plugin.config };
|
||||
let needUpdate = false;
|
||||
|
||||
if (!pluginConfig.onboarding_init_at) {
|
||||
pluginConfig.onboarding_init_at = new Date().toISOString();
|
||||
needUpdate = true;
|
||||
}
|
||||
if (!pluginConfig.onboarding_expire_at) {
|
||||
const start = new Date(pluginConfig.onboarding_init_at as string);
|
||||
pluginConfig.onboarding_expire_at = computeOnboardingExpiresAt(start);
|
||||
needUpdate = true;
|
||||
}
|
||||
if (needUpdate) {
|
||||
await this.extensionRepository.update({ ...plugin, config: pluginConfig });
|
||||
}
|
||||
return { ...plugin, config: pluginConfig };
|
||||
}
|
||||
|
||||
public async getState(
|
||||
extension_name: string
|
||||
): Promise<IExtensionOnboardingState> {
|
||||
const plugin = await this.loadPlugin(extension_name);
|
||||
const specs = this.stepsRegistry.getStepsByExtension(extension_name);
|
||||
|
||||
const steps: IExtensionOnboardingStepState[] = specs.map((spec) => ({
|
||||
step_key: spec.step_key,
|
||||
done: Boolean(plugin.config[doneKey(spec.step_key)]),
|
||||
hash:
|
||||
(plugin.config[hashKey(spec.step_key)] as string | undefined) || null,
|
||||
order: spec.order,
|
||||
default_title: spec.default_title ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
extension_name,
|
||||
steps,
|
||||
onboarding_init_at:
|
||||
(plugin.config.onboarding_init_at as string | undefined) || '',
|
||||
onboarding_expire_at:
|
||||
(plugin.config.onboarding_expire_at as string | undefined) || '',
|
||||
all_done: steps.length > 0 && steps.every((s) => s.done),
|
||||
};
|
||||
}
|
||||
|
||||
public async completeStep(
|
||||
input: ICompleteExtensionOnboardingStepInput,
|
||||
username: string
|
||||
): Promise<IExtensionOnboardingState> {
|
||||
const spec = this.stepsRegistry.getStep(
|
||||
input.extension_name,
|
||||
input.step_key
|
||||
);
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
`Шаг онбординга не зарегистрирован: ${input.extension_name}/${input.step_key}`
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = await this.loadPlugin(input.extension_name);
|
||||
if (plugin.config[doneKey(spec.step_key)]) {
|
||||
return this.getState(input.extension_name);
|
||||
}
|
||||
|
||||
const storedHash = await this.runGenerator(spec, input, username);
|
||||
|
||||
const updatedConfig: Record<string, unknown> = {
|
||||
...plugin.config,
|
||||
[hashKey(spec.step_key)]: storedHash,
|
||||
};
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
|
||||
return this.getState(input.extension_name);
|
||||
}
|
||||
|
||||
private async runGenerator(
|
||||
spec: IExtensionOnboardingStepSpec,
|
||||
input: ICompleteExtensionOnboardingStepInput,
|
||||
username: string
|
||||
): Promise<string> {
|
||||
if (spec.generator === 'free_decision') {
|
||||
return this.runFreeDecisionGenerator(spec, input, username);
|
||||
}
|
||||
if (spec.generator === 'meet') {
|
||||
return this.runMeetGenerator(spec, input);
|
||||
}
|
||||
throw new Error(
|
||||
`Неизвестный generator шага онбординга: ${String(spec.generator)}`
|
||||
);
|
||||
}
|
||||
|
||||
private async runFreeDecisionGenerator(
|
||||
spec: IExtensionOnboardingStepSpec,
|
||||
input: ICompleteExtensionOnboardingStepInput,
|
||||
username: string
|
||||
): Promise<string> {
|
||||
if (!input.question || !input.decision) {
|
||||
throw new Error(
|
||||
`Шаг ${spec.extension_name}/${spec.step_key} (generator='free_decision') требует question и decision`
|
||||
);
|
||||
}
|
||||
const normalizedTitle =
|
||||
input.title?.trim().substring(0, 200) || spec.default_title;
|
||||
const project_id = uuid();
|
||||
|
||||
await this.freeDecisionPort.createProjectOfFreeDecision({
|
||||
id: project_id,
|
||||
title: normalizedTitle,
|
||||
question: input.question,
|
||||
decision: input.decision,
|
||||
});
|
||||
|
||||
const generatedDoc =
|
||||
await this.freeDecisionPort.generateProjectOfFreeDecisionDocument(
|
||||
{
|
||||
project_id,
|
||||
coopname: config.coopname,
|
||||
username,
|
||||
registry_id: Cooperative.Registry.ProjectFreeDecision.registry_id,
|
||||
title: normalizedTitle,
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const documentForPublish: ISignedDocumentDomainInterface = {
|
||||
version: (generatedDoc.meta as any)?.version || '1.0',
|
||||
hash: generatedDoc.hash,
|
||||
doc_hash: (generatedDoc.meta as any)?.doc_hash || generatedDoc.hash,
|
||||
meta_hash: (generatedDoc.meta as any)?.meta_hash || generatedDoc.hash,
|
||||
meta: generatedDoc.meta,
|
||||
signatures: (generatedDoc.meta as any)?.signatures || [],
|
||||
};
|
||||
|
||||
await this.freeDecisionPort.publishProjectOfFreeDecision({
|
||||
coopname: config.coopname,
|
||||
username,
|
||||
meta: JSON.stringify({
|
||||
step: spec.step_key,
|
||||
project_id,
|
||||
title: normalizedTitle,
|
||||
extension: input.extension_name,
|
||||
}),
|
||||
document: documentForPublish,
|
||||
});
|
||||
|
||||
await this.decisionTrackingPort.registerTrackingRule({
|
||||
hash: generatedDoc.hash,
|
||||
event_type: DecisionEventType.SOVIET_DECISION,
|
||||
vars_field: spec.vars_field,
|
||||
metadata: {
|
||||
onboarding_step: spec.step_key,
|
||||
project_id,
|
||||
extension: input.extension_name,
|
||||
},
|
||||
});
|
||||
|
||||
return generatedDoc.hash;
|
||||
}
|
||||
|
||||
private async runMeetGenerator(
|
||||
spec: IExtensionOnboardingStepSpec,
|
||||
input: ICompleteExtensionOnboardingStepInput
|
||||
): Promise<string> {
|
||||
if (!input.proposal_hash) {
|
||||
throw new Error(
|
||||
`Шаг ${spec.extension_name}/${spec.step_key} (generator='meet') требует proposal_hash`
|
||||
);
|
||||
}
|
||||
await this.decisionTrackingPort.registerTrackingRule({
|
||||
hash: input.proposal_hash,
|
||||
event_type: DecisionEventType.MEET_DECISION,
|
||||
vars_field: spec.vars_field,
|
||||
metadata: {
|
||||
onboarding_step: spec.step_key,
|
||||
extension: input.extension_name,
|
||||
},
|
||||
});
|
||||
return input.proposal_hash;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './onboarding-steps-registry.service';
|
||||
export * from './extension-onboarding.service';
|
||||
export * from './extension-onboarding-events.service';
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
|
||||
import type { OnboardingStepRegistrationPort } from '../ports/onboarding-step-registration.port';
|
||||
import type { OnboardingStepQueryPort } from '../ports/onboarding-step-query.port';
|
||||
|
||||
@Injectable()
|
||||
export class OnboardingStepsRegistryService
|
||||
implements OnboardingStepRegistrationPort, OnboardingStepQueryPort
|
||||
{
|
||||
private readonly steps: IExtensionOnboardingStepSpec[] = [];
|
||||
|
||||
public registerStep(spec: IExtensionOnboardingStepSpec): void {
|
||||
const duplicate = this.steps.find(
|
||||
(s) =>
|
||||
s.extension_name === spec.extension_name && s.step_key === spec.step_key
|
||||
);
|
||||
if (duplicate) {
|
||||
throw new Error(
|
||||
`Шаг онбординга уже зарегистрирован: ${spec.extension_name}/${spec.step_key}`
|
||||
);
|
||||
}
|
||||
this.steps.push({ ...spec });
|
||||
}
|
||||
|
||||
public unregisterStepsByExtension(extension_name: string): void {
|
||||
for (let i = this.steps.length - 1; i >= 0; i--) {
|
||||
if (this.steps[i]?.extension_name === extension_name) {
|
||||
this.steps.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getStepsByExtension(
|
||||
extension_name: string
|
||||
): IExtensionOnboardingStepSpec[] {
|
||||
return this.steps
|
||||
.filter((s) => s.extension_name === extension_name)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
public getStep(
|
||||
extension_name: string,
|
||||
step_key: string
|
||||
): IExtensionOnboardingStepSpec | null {
|
||||
return (
|
||||
this.steps.find(
|
||||
(s) => s.extension_name === extension_name && s.step_key === step_key
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
-20
@@ -1,14 +1,20 @@
|
||||
import type { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import { AgreementType, AgreementId, ProgramKey } from '../enum';
|
||||
|
||||
/**
|
||||
* Конфигурация одного соглашения при регистрации
|
||||
* Конфигурация одного соглашения при регистрации.
|
||||
*
|
||||
* Платформенные оферты (signature/wallet/user/privacy) хранят значения
|
||||
* `id`/`agreement_type` из ядерных enum'ов AgreementId/AgreementType
|
||||
* как строки. Оферты, поставляемые расширениями через
|
||||
* AgreementRegistrationPort, используют собственные строковые значения
|
||||
* (например 'blagorost_offer' от capital). Поэтому типы — `string`,
|
||||
* а не enum: ядро не знает значений, заданных расширениями.
|
||||
*/
|
||||
export interface IAgreementConfigItem {
|
||||
/**
|
||||
* Уникальный идентификатор типа соглашения
|
||||
*/
|
||||
id: AgreementId;
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* registry_id для генерации документа на фабрике документов
|
||||
@@ -18,7 +24,7 @@ export interface IAgreementConfigItem {
|
||||
/**
|
||||
* Тип соглашения для блокчейна
|
||||
*/
|
||||
agreement_type: AgreementType;
|
||||
agreement_type: string;
|
||||
|
||||
/**
|
||||
* Человекочитаемое название документа
|
||||
@@ -70,8 +76,11 @@ export interface IRegistrationAgreementsConfig {
|
||||
* Описание программы регистрации с её соглашениями
|
||||
*/
|
||||
export interface IRegistrationProgram {
|
||||
/** Уникальный ключ программы */
|
||||
key: ProgramKey;
|
||||
/**
|
||||
* Уникальный ключ программы. Расширения сами определяют пространство
|
||||
* имён ключей (например 'GENERATION'/'CAPITALIZATION' от capital).
|
||||
*/
|
||||
key: string;
|
||||
|
||||
/** Название программы для отображения */
|
||||
title: string;
|
||||
@@ -89,22 +98,9 @@ export interface IRegistrationProgram {
|
||||
applicable_account_types: AccountType[];
|
||||
|
||||
/** Список ID соглашений, которые требуются для этой программы */
|
||||
agreement_ids: AgreementId[];
|
||||
agreement_ids: string[];
|
||||
|
||||
/** Порядок отображения */
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация программ регистрации для кооператива
|
||||
*/
|
||||
export interface ICooperativeRegistrationPrograms {
|
||||
/** Название кооператива */
|
||||
coopname: string;
|
||||
|
||||
/** Доступные программы */
|
||||
programs: IRegistrationProgram[];
|
||||
|
||||
/** Нужен ли выбор программы (если false - используется первая подходящая) */
|
||||
requires_selection: boolean;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './agreement-config.interface';
|
||||
export * from './registration-agreements.config';
|
||||
export * from './registration-programs.config';
|
||||
|
||||
+11
-28
@@ -4,11 +4,18 @@ import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import { AgreementType, AgreementId } from '../enum';
|
||||
|
||||
/**
|
||||
* Конфигурация соглашений, требуемых при регистрации пайщика.
|
||||
* Платформенные соглашения, требуемые при регистрации пайщика в любом
|
||||
* кооперативе вне зависимости от подключённых расширений.
|
||||
*
|
||||
* Порядок (order) определяет последовательность отображения галочек на фронтенде.
|
||||
* is_blockchain_agreement указывает, нужно ли отправлять sendAgreement в блокчейн.
|
||||
* link_to_statement указывает, нужно ли линковать хеш документа в заявление.
|
||||
* Соглашения, специфичные для конкретных расширений (например, оферты
|
||||
* программ Capital — generator/blagorost), регистрируются самими
|
||||
* расширениями в `AgreementRegistryService` при инициализации и
|
||||
* сливаются с этим списком в `AgreementConfigurationService`.
|
||||
*
|
||||
* Порядок (order) определяет последовательность отображения галочек
|
||||
* на фронтенде. is_blockchain_agreement указывает, нужно ли отправлять
|
||||
* sendAgreement в блокчейн. link_to_statement указывает, нужно ли
|
||||
* линковать хеш документа в заявление.
|
||||
*/
|
||||
export const REGISTRATION_AGREEMENTS_CONFIG: IRegistrationAgreementsConfig = {
|
||||
agreements: [
|
||||
@@ -60,29 +67,5 @@ export const REGISTRATION_AGREEMENTS_CONFIG: IRegistrationAgreementsConfig = {
|
||||
applicable_account_types: [AccountType.individual, AccountType.organization, AccountType.entrepreneur],
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
id: AgreementId.BLAGOROST_OFFER,
|
||||
registry_id: Cooperative.Registry.BlagorostOffer.registry_id,
|
||||
agreement_type: AgreementType.CAPITAL,
|
||||
title: 'Оферта по целевой потребительской программе "Благорост"',
|
||||
checkbox_text: 'Я прочитал и принимаю',
|
||||
link_text: 'оферту по целевой потребительской программе "Благорост"',
|
||||
is_blockchain_agreement: true,
|
||||
link_to_statement: true,
|
||||
applicable_account_types: [AccountType.individual],
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
id: AgreementId.GENERATOR_OFFER,
|
||||
registry_id: Cooperative.Registry.GeneratorOffer.registry_id,
|
||||
agreement_type: AgreementType.GENERATOR,
|
||||
title: 'Оферта по целевой потребительской программе "Генератор"',
|
||||
checkbox_text: 'Я прочитал и принимаю',
|
||||
link_text: 'оферту по целевой потребительской программе "Генератор"',
|
||||
is_blockchain_agreement: true,
|
||||
link_to_statement: true,
|
||||
applicable_account_types: [],
|
||||
order: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { ICooperativeRegistrationPrograms } from './agreement-config.interface';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import { ProgramKey, AgreementId } from '../enum';
|
||||
|
||||
/**
|
||||
* Конфигурация программ регистрации для разных кооперативов.
|
||||
* Здесь определяются доступные программы, их описания и связанные соглашения.
|
||||
*/
|
||||
export const REGISTRATION_PROGRAMS_CONFIG: ICooperativeRegistrationPrograms[] = [
|
||||
{
|
||||
coopname: 'voskhod',
|
||||
requires_selection: true,
|
||||
programs: [
|
||||
{
|
||||
key: ProgramKey.GENERATION,
|
||||
title: 'Программа Генерация',
|
||||
description:
|
||||
'Участвовать в производстве Кооперативной Экономики через вклад временем, имуществом или деньгами в конкретные проекты.',
|
||||
applicable_account_types: [AccountType.individual, AccountType.entrepreneur],
|
||||
agreement_ids: [AgreementId.GENERATOR_OFFER],
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
key: ProgramKey.CAPITALIZATION,
|
||||
title: 'Программа Благорост',
|
||||
description:
|
||||
'Участвовать в производстве Кооперативной Экономики через вклад имуществом или денег в систему. Минимальный взнос 100 000 руб в течение 14 дней.',
|
||||
applicable_account_types: [AccountType.individual, AccountType.entrepreneur],
|
||||
agreement_ids: [AgreementId.BLAGOROST_OFFER],
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
|
||||
/**
|
||||
* Спецификация одной оферты, которую расширение регистрирует в платформе
|
||||
* через AgreementRegistrationPort на этапе initialize(config).
|
||||
*
|
||||
* Используется ядром (RegistrationDomain) для построения UI SignUp,
|
||||
* генерации документов фабрикой по registry_id и отправки sendAgreement
|
||||
* в блокчейн под валидным agreement_type.
|
||||
*/
|
||||
export interface AgreementRegistrationSpec {
|
||||
/**
|
||||
* Уникальный идентификатор оферты в рамках платформы.
|
||||
* Строка, потому что расширение само определяет своё пространство имён
|
||||
* (например, 'blagorost_offer', 'generator_offer', 'order_table_offer').
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* registry_id шаблона документа на фабрике — авторитативный источник
|
||||
* содержимого оферты. Платформа НЕ хранит type-string параллельно,
|
||||
* см. feedback agreements + document registry_id.
|
||||
*/
|
||||
registry_id: number;
|
||||
|
||||
/**
|
||||
* Тип соглашения для on-chain `agreements` (sendAgreement action).
|
||||
* Расширение предоставляет своё значение (например 'capital' для capital,
|
||||
* 'order_table' для Стола заказов).
|
||||
*/
|
||||
agreement_type: string;
|
||||
|
||||
/** Человекочитаемое название оферты для UI */
|
||||
title: string;
|
||||
|
||||
/** Текст для галочки на фронтенде */
|
||||
checkbox_text: string;
|
||||
|
||||
/** Текст ссылки для открытия диалога чтения */
|
||||
link_text: string;
|
||||
|
||||
/**
|
||||
* Типы аккаунтов, для которых эта оферта применима.
|
||||
* Пустой массив = «оферта программно сцепляется с программой, не показывается
|
||||
* как самостоятельный чекбокс в SignUp» (исторически так у GENERATOR_OFFER).
|
||||
*/
|
||||
applicable_account_types: AccountType[];
|
||||
|
||||
/** Порядок отображения относительно других оферт расширения */
|
||||
order: number;
|
||||
|
||||
/**
|
||||
* Имя расширения, зарегистрировавшего оферту.
|
||||
* Используется для tear-down: при EXTENSION_APP_TERMINATE_EVENT
|
||||
* реестр удаляет все записи с этим extension_name.
|
||||
*/
|
||||
extension_name: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './agreement-registration-spec.dto';
|
||||
export * from './program-registration-spec.dto';
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
|
||||
/**
|
||||
* Спецификация программы участия, которую расширение регистрирует
|
||||
* через AgreementRegistrationPort.registerProgram() при initialize(config).
|
||||
*
|
||||
* Программа предъявляется пайщику в SignUp экране SelectProgram
|
||||
* (если в кооперативе после фильтрации остаётся >1 программ; 0 — экран
|
||||
* пропускается, 1 — выбор автоматический).
|
||||
*/
|
||||
export interface ProgramRegistrationSpec {
|
||||
/**
|
||||
* Уникальный ключ программы в рамках платформы.
|
||||
* Строка, потому что расширения сами определяют пространство имён.
|
||||
*/
|
||||
key: string;
|
||||
|
||||
/** Название программы для отображения */
|
||||
title: string;
|
||||
|
||||
/** Описание программы */
|
||||
description: string;
|
||||
|
||||
/** URL изображения (опционально) */
|
||||
image_url?: string;
|
||||
|
||||
/** Минимальные требования (опционально) */
|
||||
requirements?: string;
|
||||
|
||||
/** Для каких типов аккаунтов программа применима */
|
||||
applicable_account_types: AccountType[];
|
||||
|
||||
/**
|
||||
* Идентификаторы оферт (AgreementRegistrationSpec.id),
|
||||
* которые должны быть подписаны участником этой программы.
|
||||
*/
|
||||
agreement_ids: string[];
|
||||
|
||||
/** Порядок отображения в SelectProgram */
|
||||
order: number;
|
||||
|
||||
/**
|
||||
* Имя расширения, зарегистрировавшего программу.
|
||||
* Используется для tear-down при EXTENSION_APP_TERMINATE_EVENT.
|
||||
*/
|
||||
extension_name: string;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* Идентификаторы соглашений для регистрации
|
||||
* Идентификаторы платформенных соглашений при регистрации.
|
||||
*
|
||||
* Оферты, специфичные для расширений (capital → 'blagorost_offer',
|
||||
* 'generator_offer' и т.п.), не входят в этот enum: расширения сами
|
||||
* задают строковые id через AgreementRegistrationPort и являются
|
||||
* единственным source-of-truth для этих значений.
|
||||
*/
|
||||
export enum AgreementId {
|
||||
/** Соглашение о порядке и правилах использования простой электронной подписи */
|
||||
@@ -13,10 +18,4 @@ export enum AgreementId {
|
||||
|
||||
/** Политика конфиденциальности */
|
||||
PRIVACY_AGREEMENT = 'privacy_agreement',
|
||||
|
||||
/** Соглашение о благороста */
|
||||
BLAGOROST_OFFER = 'blagorost_offer',
|
||||
|
||||
/** Оферта по целевой потребительской программе "Генератор" */
|
||||
GENERATOR_OFFER = 'generator_offer',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* Типы соглашений для отправки в блокчейн
|
||||
* Типы платформенных соглашений для отправки в блокчейн (sendAgreement).
|
||||
*
|
||||
* Типы оферт расширений (capital → 'capital', 'generator' и т.п.)
|
||||
* не входят в этот enum: расширения сами задают строковые agreement_type
|
||||
* через AgreementRegistrationPort и обращаются к sendAgreement напрямую
|
||||
* с этими значениями.
|
||||
*/
|
||||
export enum AgreementType {
|
||||
/** Соглашение о порядке и правилах использования простой электронной подписи */
|
||||
@@ -13,10 +18,4 @@ export enum AgreementType {
|
||||
|
||||
/** Политика конфиденциальности */
|
||||
PRIVACY = 'privacy',
|
||||
|
||||
/** Соглашение о благороста */
|
||||
CAPITAL = 'blagorost',
|
||||
|
||||
/** Оферта по целевой потребительской программе "Генератор" */
|
||||
GENERATOR = 'generator',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './config';
|
||||
export * from './dto';
|
||||
export * from './interfaces';
|
||||
export * from './ports';
|
||||
export * from './services';
|
||||
export * from './registration-domain.module';
|
||||
|
||||
+14
-7
@@ -1,6 +1,5 @@
|
||||
import type { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import type { GeneratedDocumentDomainInterface } from '~/domain/document/interfaces/generated-document-domain.interface';
|
||||
import { ProgramKey, type AgreementId, type AgreementType } from '../enum';
|
||||
|
||||
/**
|
||||
* Входные данные для генерации пакета документов при регистрации
|
||||
@@ -12,18 +11,26 @@ export interface IGenerateRegistrationDocumentsInput {
|
||||
username: string;
|
||||
/** Тип аккаунта пайщика */
|
||||
account_type: AccountType;
|
||||
/** Ключ выбранной программы регистрации (опционально) */
|
||||
program_key?: ProgramKey;
|
||||
/**
|
||||
* Ключ выбранной программы регистрации (опционально).
|
||||
* Строка, потому что значение задаёт расширение через
|
||||
* AgreementRegistrationPort.registerProgram.
|
||||
*/
|
||||
program_key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сгенерированный документ регистрации с метаданными из конфигурации
|
||||
* Сгенерированный документ регистрации с метаданными из конфигурации.
|
||||
*
|
||||
* Поля `id`/`agreement_type` — строки, потому что значения могут
|
||||
* приходить как из платформенного AgreementId/AgreementType, так и из
|
||||
* расширений (например 'blagorost_offer' от capital).
|
||||
*/
|
||||
export interface IGeneratedRegistrationDocument {
|
||||
/** Идентификатор соглашения (wallet_agreement, signature_agreement, etc.) */
|
||||
id: AgreementId;
|
||||
/** Идентификатор соглашения */
|
||||
id: string;
|
||||
/** Тип соглашения для блокчейна */
|
||||
agreement_type: AgreementType;
|
||||
agreement_type: string;
|
||||
/** Название документа */
|
||||
title: string;
|
||||
/** Текст для галочки на фронте */
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import type {
|
||||
IAgreementConfigItem,
|
||||
IRegistrationProgram,
|
||||
} from '../config/agreement-config.interface';
|
||||
|
||||
/**
|
||||
* Платформенный порт для чтения зарегистрированных оферт и программ
|
||||
* (вне зависимости от того, заданы они в ядре или расширением через
|
||||
* AgreementRegistrationPort).
|
||||
*
|
||||
* Резолверы и расширения должны инжектить этот порт (через
|
||||
* AGREEMENT_QUERY_PORT), а не AgreementConfigurationService напрямую,
|
||||
* чтобы граница «как читают конфиг» оставалась стабильной даже при
|
||||
* перестройке внутренней реализации (например, при выносе кеша или
|
||||
* добавлении coop-scoping в Phase 3).
|
||||
*
|
||||
* Контракт чтения, дополняющий AgreementRegistrationPort (запись):
|
||||
* — оба порта работают со строковыми id/key, не enum;
|
||||
* — applicable_account_types=[] у оферты означает «не показывать
|
||||
* как самостоятельный чекбокс в SignUp» (исторический контракт
|
||||
* GENERATOR_OFFER) — фильтр accountType вернёт пустой список,
|
||||
* а getAgreementsForProgram даст оферту независимо от типа;
|
||||
* — порт не делает coop-scoping (capital не привязан к
|
||||
* конкретному coopname в текущей модели), поэтому coopname в
|
||||
* getAgreementsForAccountType передаётся только для будущего
|
||||
* coop-аддитивного расширения через CooperativeConfigService.
|
||||
*/
|
||||
export interface AgreementQueryPort {
|
||||
/**
|
||||
* Соглашения, требуемые при регистрации пайщика заданного типа
|
||||
* аккаунта. Если задан programKey — добавляются оферты программы.
|
||||
*/
|
||||
getAgreementsForAccountType(
|
||||
accountType: AccountType,
|
||||
coopname?: string,
|
||||
programKey?: string
|
||||
): IAgreementConfigItem[];
|
||||
|
||||
/**
|
||||
* Оферта по строковому идентификатору, либо null если не найдена.
|
||||
*/
|
||||
getAgreementById(id: string): IAgreementConfigItem | null;
|
||||
|
||||
/**
|
||||
* Все оферты, привязанные к программе через её agreement_ids.
|
||||
*/
|
||||
getAgreementsForProgram(programKey: string): IAgreementConfigItem[];
|
||||
|
||||
/**
|
||||
* Программы, применимые к типу аккаунта в заданном кооперативе.
|
||||
*/
|
||||
getAvailablePrograms(coopname: string, accountType: AccountType): IRegistrationProgram[];
|
||||
}
|
||||
|
||||
/** DI-токен для AgreementQueryPort при использовании через @Inject(). */
|
||||
export const AGREEMENT_QUERY_PORT = Symbol('AgreementQueryPort');
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AgreementRegistrationSpec } from '../dto/agreement-registration-spec.dto';
|
||||
import type { ProgramRegistrationSpec } from '../dto/program-registration-spec.dto';
|
||||
|
||||
/**
|
||||
* Доменный порт, через который расширения регистрируют свои оферты
|
||||
* и программы участия в платформе на этапе lifecycle initialize(config).
|
||||
*
|
||||
* Архитектурное назначение — раздел 4 req 44 проекта 13:
|
||||
* ядро НЕ знает о конкретных расширениях, расширения сами наполняют
|
||||
* реестр в рантайме. После EXTENSION_APP_TERMINATE_EVENT записи
|
||||
* соответствующего расширения автоматически вычищаются реестром.
|
||||
*
|
||||
* Идемпотентность: повторный register с тем же id (или key) от того
|
||||
* же extension_name перезаписывает запись, не дублируя её.
|
||||
*/
|
||||
export interface AgreementRegistrationPort {
|
||||
/**
|
||||
* Зарегистрировать (или обновить) оферту расширения.
|
||||
* После вызова оферта становится видна сервисам построения UI/документов
|
||||
* и через AgreementConfigurationService.getAgreementsForAccountType.
|
||||
*/
|
||||
registerAgreement(spec: AgreementRegistrationSpec): void;
|
||||
|
||||
/**
|
||||
* Снять регистрацию оферты по id и extension_name.
|
||||
* Используется редко: основной tear-down делает реестр сам
|
||||
* по EXTENSION_APP_TERMINATE_EVENT.
|
||||
*/
|
||||
unregisterAgreement(id: string, extension_name: string): void;
|
||||
|
||||
/**
|
||||
* Зарегистрировать (или обновить) программу участия расширения.
|
||||
* Программа становится видна в SignUp/SelectProgram через
|
||||
* AgreementConfigurationService.getAvailablePrograms.
|
||||
*/
|
||||
registerProgram(spec: ProgramRegistrationSpec): void;
|
||||
|
||||
/**
|
||||
* Снять регистрацию программы по key и extension_name.
|
||||
*/
|
||||
unregisterProgram(key: string, extension_name: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DI-токен для AgreementRegistrationPort.
|
||||
* Расширения инжектируют его как @Inject(AGREEMENT_REGISTRATION_PORT).
|
||||
*/
|
||||
export const AGREEMENT_REGISTRATION_PORT = Symbol('AgreementRegistrationPort');
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './agreement-registration.port';
|
||||
export * from './agreement-query.port';
|
||||
export * from './candidate-data.port';
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Global, Module, forwardRef } from '@nestjs/common';
|
||||
import { AgreementConfigurationService, AGREEMENT_CONFIGURATION_SERVICE } from './services/agreement-configuration.service';
|
||||
import { AgreementRegistryService, AGREEMENT_REGISTRY_SERVICE } from './services/agreement-registry.service';
|
||||
import { RegistrationDocumentsService, REGISTRATION_DOCUMENTS_SERVICE } from './services/registration-documents.service';
|
||||
import { CooperativeConfigService } from './services/cooperative-config.service';
|
||||
import { AGREEMENT_REGISTRATION_PORT } from './ports/agreement-registration.port';
|
||||
import { AGREEMENT_QUERY_PORT } from './ports/agreement-query.port';
|
||||
import { DocumentModule } from '~/application/document/document.module';
|
||||
import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module';
|
||||
|
||||
@@ -22,6 +25,19 @@ import { ExtensionDomainModule } from '~/domain/extension/extension-domain.modul
|
||||
provide: AGREEMENT_CONFIGURATION_SERVICE,
|
||||
useExisting: AgreementConfigurationService,
|
||||
},
|
||||
AgreementRegistryService,
|
||||
{
|
||||
provide: AGREEMENT_REGISTRY_SERVICE,
|
||||
useExisting: AgreementRegistryService,
|
||||
},
|
||||
{
|
||||
provide: AGREEMENT_REGISTRATION_PORT,
|
||||
useExisting: AgreementRegistryService,
|
||||
},
|
||||
{
|
||||
provide: AGREEMENT_QUERY_PORT,
|
||||
useExisting: AgreementConfigurationService,
|
||||
},
|
||||
RegistrationDocumentsService,
|
||||
{
|
||||
provide: REGISTRATION_DOCUMENTS_SERVICE,
|
||||
@@ -31,6 +47,10 @@ import { ExtensionDomainModule } from '~/domain/extension/extension-domain.modul
|
||||
exports: [
|
||||
AgreementConfigurationService,
|
||||
AGREEMENT_CONFIGURATION_SERVICE,
|
||||
AgreementRegistryService,
|
||||
AGREEMENT_REGISTRY_SERVICE,
|
||||
AGREEMENT_REGISTRATION_PORT,
|
||||
AGREEMENT_QUERY_PORT,
|
||||
RegistrationDocumentsService,
|
||||
REGISTRATION_DOCUMENTS_SERVICE,
|
||||
],
|
||||
|
||||
+124
-98
@@ -1,65 +1,108 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REGISTRATION_AGREEMENTS_CONFIG } from '../config/registration-agreements.config';
|
||||
import { REGISTRATION_PROGRAMS_CONFIG } from '../config/registration-programs.config';
|
||||
import type {
|
||||
IAgreementConfigItem,
|
||||
ICooperativeRegistrationPrograms,
|
||||
IRegistrationProgram,
|
||||
} from '../config/agreement-config.interface';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import { AgreementId, ProgramKey } from '../enum';
|
||||
import { AgreementRegistryService, AGREEMENT_REGISTRY_SERVICE } from './agreement-registry.service';
|
||||
import type { AgreementRegistrationSpec } from '../dto/agreement-registration-spec.dto';
|
||||
import type { ProgramRegistrationSpec } from '../dto/program-registration-spec.dto';
|
||||
import { CooperativeConfigService } from './cooperative-config.service';
|
||||
|
||||
export const AGREEMENT_CONFIGURATION_SERVICE = Symbol('AgreementConfigurationService');
|
||||
|
||||
/**
|
||||
* Сервис для работы с конфигурацией соглашений при регистрации
|
||||
* Сервис для работы с конфигурацией соглашений при регистрации.
|
||||
*
|
||||
* После Эпика 1.3 раздел C28-10 источник правды двухуровневый:
|
||||
* 1) платформенные оферты (signature/wallet/user/privacy) —
|
||||
* из REGISTRATION_AGREEMENTS_CONFIG;
|
||||
* 2) extension-оферты и программы — из AgreementRegistryService,
|
||||
* наполняемого расширениями через AgreementRegistrationPort
|
||||
* на этапе initialize(config).
|
||||
*
|
||||
* Этот сервис сливает оба источника в форму IAgreementConfigItem /
|
||||
* IRegistrationProgram, которую ожидают вышестоящие consumers
|
||||
* (Resolver, SignUp, participant.interactor).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AgreementConfigurationService {
|
||||
constructor(private readonly cooperativeConfigService: CooperativeConfigService) {}
|
||||
constructor(
|
||||
private readonly cooperativeConfigService: CooperativeConfigService,
|
||||
@Inject(AGREEMENT_REGISTRY_SERVICE)
|
||||
private readonly agreementRegistry: AgreementRegistryService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Получить все соглашения для указанного типа аккаунта
|
||||
* @param accountType - тип аккаунта (individual, organization, entrepreneur)
|
||||
* @param coopname - название кооператива (для временного исключения blagorost_offer)
|
||||
* @param programKey - ключ выбранной программы регистрации (опционально)
|
||||
* @returns отсортированный по order массив конфигураций соглашений
|
||||
* Адаптер spec → config item. Поля is_blockchain_agreement / link_to_statement
|
||||
* для extension-оферт всегда true по контракту (план C28-10 раздел 1.1).
|
||||
*/
|
||||
private specToConfigItem(spec: AgreementRegistrationSpec): IAgreementConfigItem {
|
||||
return {
|
||||
id: spec.id,
|
||||
registry_id: spec.registry_id,
|
||||
agreement_type: spec.agreement_type,
|
||||
title: spec.title,
|
||||
checkbox_text: spec.checkbox_text,
|
||||
link_text: spec.link_text,
|
||||
is_blockchain_agreement: true,
|
||||
link_to_statement: true,
|
||||
applicable_account_types: spec.applicable_account_types,
|
||||
order: spec.order,
|
||||
};
|
||||
}
|
||||
|
||||
private programSpecToConfig(spec: ProgramRegistrationSpec): IRegistrationProgram {
|
||||
return {
|
||||
key: spec.key,
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
image_url: spec.image_url,
|
||||
requirements: spec.requirements,
|
||||
applicable_account_types: spec.applicable_account_types,
|
||||
agreement_ids: spec.agreement_ids,
|
||||
order: spec.order,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить соглашения по умолчанию для кооператива (без учета программ)
|
||||
* Соглашения по умолчанию для кооператива: платформенные базовые +
|
||||
* extension-оферты, применимые к типу аккаунта (без программы).
|
||||
*/
|
||||
private getDefaultAgreementsForCooperative(
|
||||
accountType: AccountType,
|
||||
coopname?: string
|
||||
): IAgreementConfigItem[] {
|
||||
const coopnameValue = coopname || '';
|
||||
const excludedAgreements = this.cooperativeConfigService.getExcludedFromBaseAgreements(coopnameValue);
|
||||
|
||||
// Получить базовые соглашения, исключая те, которые управляются отдельно
|
||||
const baseAgreements = REGISTRATION_AGREEMENTS_CONFIG.agreements
|
||||
.filter((a) => a.applicable_account_types.includes(accountType))
|
||||
.filter((a) => !excludedAgreements.includes(a.id));
|
||||
const baseAgreements = REGISTRATION_AGREEMENTS_CONFIG.agreements.filter((a) =>
|
||||
a.applicable_account_types.includes(accountType)
|
||||
);
|
||||
|
||||
const extensionAgreements = this.agreementRegistry
|
||||
.listAgreementsForAccountType(accountType)
|
||||
.map((spec) => this.specToConfigItem(spec));
|
||||
|
||||
// Добавить дополнительные соглашения по умолчанию для кооператива
|
||||
const additionalAgreements = this.cooperativeConfigService
|
||||
.getDefaultAdditionalAgreements(coopnameValue, accountType)
|
||||
.map(id => this.getAgreementById(id))
|
||||
.map((id) => this.getAgreementById(id))
|
||||
.filter((a): a is IAgreementConfigItem => a !== null);
|
||||
|
||||
return [...baseAgreements, ...additionalAgreements].sort((a, b) => a.order - b.order);
|
||||
return [...baseAgreements, ...extensionAgreements, ...additionalAgreements].sort(
|
||||
(a, b) => a.order - b.order
|
||||
);
|
||||
}
|
||||
|
||||
getAgreementsForAccountType(
|
||||
accountType: AccountType,
|
||||
coopname?: string,
|
||||
programKey?: ProgramKey
|
||||
programKey?: string
|
||||
): IAgreementConfigItem[] {
|
||||
// Получить соглашения по умолчанию для кооператива
|
||||
const defaultAgreements = this.getDefaultAgreementsForCooperative(accountType, coopname);
|
||||
|
||||
// Если указана программа - добавляем её соглашения
|
||||
if (programKey && coopname) {
|
||||
const programAgreements = this.getAgreementsForProgram(programKey, coopname);
|
||||
if (programKey) {
|
||||
const programAgreements = this.getAgreementsForProgram(programKey);
|
||||
return [...defaultAgreements, ...programAgreements].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
@@ -67,107 +110,90 @@ export class AgreementConfigurationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить соглашения, которые нужно отправить в блокчейн через sendAgreement
|
||||
* @param accountType - тип аккаунта
|
||||
* @param coopname - название кооператива (для временного исключения blagorost_offer)
|
||||
* @param programKey - ключ выбранной программы регистрации
|
||||
* @returns массив конфигураций blockchain agreements
|
||||
* Соглашения, которые нужно отправить в блокчейн через sendAgreement
|
||||
*/
|
||||
getBlockchainAgreements(accountType: AccountType, coopname?: string, programKey?: ProgramKey): IAgreementConfigItem[] {
|
||||
return this.getAgreementsForAccountType(accountType, coopname, programKey).filter((agreement) => agreement.is_blockchain_agreement);
|
||||
getBlockchainAgreements(
|
||||
accountType: AccountType,
|
||||
coopname?: string,
|
||||
programKey?: string
|
||||
): IAgreementConfigItem[] {
|
||||
return this.getAgreementsForAccountType(accountType, coopname, programKey).filter(
|
||||
(agreement) => agreement.is_blockchain_agreement
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить соглашения, которые нужно линковать в заявление на вступление
|
||||
* @param accountType - тип аккаунта
|
||||
* @param coopname - название кооператива (для временного исключения blagorost_offer)
|
||||
* @param programKey - ключ выбранной программы регистрации
|
||||
* @returns массив конфигураций соглашений для линковки
|
||||
* Соглашения, которые нужно линковать в заявление на вступление
|
||||
*/
|
||||
getLinkedAgreements(accountType: AccountType, coopname?: string, programKey?: ProgramKey): IAgreementConfigItem[] {
|
||||
return this.getAgreementsForAccountType(accountType, coopname, programKey).filter((agreement) => agreement.link_to_statement);
|
||||
getLinkedAgreements(
|
||||
accountType: AccountType,
|
||||
coopname?: string,
|
||||
programKey?: string
|
||||
): IAgreementConfigItem[] {
|
||||
return this.getAgreementsForAccountType(accountType, coopname, programKey).filter(
|
||||
(agreement) => agreement.link_to_statement
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить конфигурацию соглашения по идентификатору
|
||||
* @param id - идентификатор соглашения (wallet_agreement, signature_agreement, etc.)
|
||||
* @returns конфигурация соглашения или null
|
||||
* Конфигурация соглашения по идентификатору (платформенный или
|
||||
* extension-зарегистрированный).
|
||||
*/
|
||||
getAgreementById(id: AgreementId): IAgreementConfigItem | null {
|
||||
return REGISTRATION_AGREEMENTS_CONFIG.agreements.find((agreement) => agreement.id === id) || null;
|
||||
getAgreementById(id: string): IAgreementConfigItem | null {
|
||||
const baseMatch = REGISTRATION_AGREEMENTS_CONFIG.agreements.find(
|
||||
(agreement) => agreement.id === id
|
||||
);
|
||||
if (baseMatch) return baseMatch;
|
||||
|
||||
const spec = this.agreementRegistry.getAgreement(id);
|
||||
return spec ? this.specToConfigItem(spec) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все доступные соглашения (без фильтрации по типу аккаунта)
|
||||
* @returns все конфигурации соглашений
|
||||
* Все доступные соглашения (без фильтрации по типу аккаунта).
|
||||
*/
|
||||
getAllAgreements(): IAgreementConfigItem[] {
|
||||
return REGISTRATION_AGREEMENTS_CONFIG.agreements.sort((a, b) => a.order - b.order);
|
||||
const base = REGISTRATION_AGREEMENTS_CONFIG.agreements;
|
||||
const ext = this.agreementRegistry.listAgreements().map((spec) => this.specToConfigItem(spec));
|
||||
return [...base, ...ext].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, требуется ли соглашение для данного типа аккаунта
|
||||
* @param agreementId - идентификатор соглашения
|
||||
* @param accountType - тип аккаунта
|
||||
* @param coopname - название кооператива (для временного исключения blagorost_offer)
|
||||
* @returns true если соглашение требуется
|
||||
*/
|
||||
isAgreementRequired(agreementId: AgreementId, accountType: AccountType, coopname?: string, programKey?: ProgramKey): boolean {
|
||||
// Проверить, входит ли соглашение в список требуемых для данного кооператива и типа аккаунта
|
||||
isAgreementRequired(
|
||||
agreementId: string,
|
||||
accountType: AccountType,
|
||||
coopname?: string,
|
||||
programKey?: string
|
||||
): boolean {
|
||||
const requiredAgreements = this.getRequiredAgreementIds(accountType, coopname, programKey);
|
||||
return requiredAgreements.includes(agreementId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список идентификаторов всех требуемых соглашений для типа аккаунта
|
||||
* @param accountType - тип аккаунта
|
||||
* @param coopname - название кооператива (для временного исключения blagorost_offer)
|
||||
* @param programKey - ключ выбранной программы регистрации
|
||||
* @returns массив идентификаторов
|
||||
*/
|
||||
getRequiredAgreementIds(accountType: AccountType, coopname?: string, programKey?: ProgramKey): string[] {
|
||||
getRequiredAgreementIds(
|
||||
accountType: AccountType,
|
||||
coopname?: string,
|
||||
programKey?: string
|
||||
): string[] {
|
||||
return this.getAgreementsForAccountType(accountType, coopname, programKey).map((a) => a.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить доступные программы регистрации для кооператива и типа аккаунта
|
||||
* @param coopname - название кооператива
|
||||
* @param accountType - тип аккаунта
|
||||
* @returns массив доступных программ
|
||||
* Доступные программы регистрации для кооператива и типа аккаунта.
|
||||
* coopname в текущей модели не используется (registry даёт программы
|
||||
* одной инсталляции), но сохранён в сигнатуре ради совместимости
|
||||
* с GraphQL-резолвером и для будущего coop-scoping.
|
||||
*/
|
||||
getAvailablePrograms(coopname: string, accountType: AccountType): IRegistrationProgram[] {
|
||||
const config = REGISTRATION_PROGRAMS_CONFIG.find((c) => c.coopname === coopname);
|
||||
if (!config) return [];
|
||||
|
||||
return config.programs
|
||||
.filter((p) => p.applicable_account_types.includes(accountType))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
getAvailablePrograms(_coopname: string, accountType: AccountType): IRegistrationProgram[] {
|
||||
return this.agreementRegistry
|
||||
.listProgramsForAccountType(accountType)
|
||||
.map((spec) => this.programSpecToConfig(spec));
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить конфигурацию программ для кооператива
|
||||
* @param coopname - название кооператива
|
||||
* @returns конфигурация программ или null
|
||||
* Соглашения, привязанные к программе через её agreement_ids.
|
||||
*/
|
||||
getCooperativeProgramsConfig(coopname: string): ICooperativeRegistrationPrograms | null {
|
||||
return REGISTRATION_PROGRAMS_CONFIG.find((c) => c.coopname === coopname) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить соглашения для программы
|
||||
* @param programKey - ключ программы
|
||||
* @param coopname - название кооператива
|
||||
* @returns массив соглашений для программы
|
||||
*/
|
||||
getAgreementsForProgram(programKey: ProgramKey, coopname: string): IAgreementConfigItem[] {
|
||||
const config = REGISTRATION_PROGRAMS_CONFIG.find((c) => c.coopname === coopname);
|
||||
if (!config) return [];
|
||||
|
||||
const program = config.programs.find((p) => p.key === programKey);
|
||||
if (!program) return [];
|
||||
|
||||
return program.agreement_ids
|
||||
.map((id) => this.getAgreementById(id))
|
||||
.filter((a): a is IAgreementConfigItem => a !== null);
|
||||
getAgreementsForProgram(programKey: string): IAgreementConfigItem[] {
|
||||
return this.agreementRegistry
|
||||
.listAgreementsForProgram(programKey)
|
||||
.map((spec) => this.specToConfigItem(spec));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import type { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import {
|
||||
EXTENSION_APP_TERMINATE_EVENT,
|
||||
type ExtensionAppTerminatePayload,
|
||||
} from '~/domain/extension/extension-app-lifecycle.events';
|
||||
import type { AgreementRegistrationSpec } from '../dto/agreement-registration-spec.dto';
|
||||
import type { ProgramRegistrationSpec } from '../dto/program-registration-spec.dto';
|
||||
import type { AgreementRegistrationPort } from '../ports/agreement-registration.port';
|
||||
|
||||
/**
|
||||
* In-memory реестр оферт и программ, наполняемый расширениями
|
||||
* через AgreementRegistrationPort в lifecycle initialize(config).
|
||||
*
|
||||
* Tear-down автоматический: подписан на EXTENSION_APP_TERMINATE_EVENT —
|
||||
* при остановке расширения удаляет все записи с extension_name = appName.
|
||||
*
|
||||
* Идемпотентность: повторный register от того же extension_name с тем же id/key
|
||||
* перезаписывает spec. Конфликт id/key между разными extension_name
|
||||
* приводит к ConflictException — это симптом неконсистентной конфигурации
|
||||
* и должен быть замечен на этапе runApp.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AgreementRegistryService implements AgreementRegistrationPort {
|
||||
private readonly agreementMap = new Map<string, AgreementRegistrationSpec>();
|
||||
private readonly programMap = new Map<string, ProgramRegistrationSpec>();
|
||||
|
||||
constructor(private readonly logger: WinstonLoggerService) {
|
||||
this.logger.setContext(AgreementRegistryService.name);
|
||||
}
|
||||
|
||||
registerAgreement(spec: AgreementRegistrationSpec): void {
|
||||
const existing = this.agreementMap.get(spec.id);
|
||||
if (existing && existing.extension_name !== spec.extension_name) {
|
||||
throw new ConflictException(
|
||||
`Agreement id "${spec.id}" уже зарегистрирована расширением "${existing.extension_name}", ` +
|
||||
`повторная регистрация из "${spec.extension_name}" отклонена`
|
||||
);
|
||||
}
|
||||
this.agreementMap.set(spec.id, spec);
|
||||
this.logger.debug(
|
||||
`[REGISTRY] register agreement ${spec.id} (${spec.agreement_type}) от ${spec.extension_name}`
|
||||
);
|
||||
}
|
||||
|
||||
unregisterAgreement(id: string, extension_name: string): void {
|
||||
const existing = this.agreementMap.get(id);
|
||||
if (!existing) return;
|
||||
if (existing.extension_name !== extension_name) {
|
||||
this.logger.warn(
|
||||
`[REGISTRY] попытка ${extension_name} снять чужую регистрацию ${id} (владелец ${existing.extension_name}) проигнорирована`
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.agreementMap.delete(id);
|
||||
this.logger.debug(`[REGISTRY] unregister agreement ${id} от ${extension_name}`);
|
||||
}
|
||||
|
||||
registerProgram(spec: ProgramRegistrationSpec): void {
|
||||
const existing = this.programMap.get(spec.key);
|
||||
if (existing && existing.extension_name !== spec.extension_name) {
|
||||
throw new ConflictException(
|
||||
`Program key "${spec.key}" уже зарегистрирована расширением "${existing.extension_name}", ` +
|
||||
`повторная регистрация из "${spec.extension_name}" отклонена`
|
||||
);
|
||||
}
|
||||
this.programMap.set(spec.key, spec);
|
||||
this.logger.debug(`[REGISTRY] register program ${spec.key} от ${spec.extension_name}`);
|
||||
}
|
||||
|
||||
unregisterProgram(key: string, extension_name: string): void {
|
||||
const existing = this.programMap.get(key);
|
||||
if (!existing) return;
|
||||
if (existing.extension_name !== extension_name) {
|
||||
this.logger.warn(
|
||||
`[REGISTRY] попытка ${extension_name} снять чужую регистрацию программы ${key} (владелец ${existing.extension_name}) проигнорирована`
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.programMap.delete(key);
|
||||
this.logger.debug(`[REGISTRY] unregister program ${key} от ${extension_name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Реестр чистит свои записи при остановке расширения.
|
||||
* Слушатель — обязательный hook tear-down порта (раздел 4.2 req 44).
|
||||
*/
|
||||
@OnEvent(EXTENSION_APP_TERMINATE_EVENT)
|
||||
onExtensionTerminate(payload: ExtensionAppTerminatePayload): void {
|
||||
const removedAgreements: string[] = [];
|
||||
for (const [id, spec] of this.agreementMap.entries()) {
|
||||
if (spec.extension_name === payload.appName) {
|
||||
this.agreementMap.delete(id);
|
||||
removedAgreements.push(id);
|
||||
}
|
||||
}
|
||||
const removedPrograms: string[] = [];
|
||||
for (const [key, spec] of this.programMap.entries()) {
|
||||
if (spec.extension_name === payload.appName) {
|
||||
this.programMap.delete(key);
|
||||
removedPrograms.push(key);
|
||||
}
|
||||
}
|
||||
if (removedAgreements.length || removedPrograms.length) {
|
||||
this.logger.info(
|
||||
`[REGISTRY] terminate ${payload.appName}: снято ${removedAgreements.length} оферт, ${removedPrograms.length} программ`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить spec оферты по id, либо null если не зарегистрирована.
|
||||
*/
|
||||
getAgreement(id: string): AgreementRegistrationSpec | null {
|
||||
return this.agreementMap.get(id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить spec программы по key, либо null.
|
||||
*/
|
||||
getProgram(key: string): ProgramRegistrationSpec | null {
|
||||
return this.programMap.get(key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Все зарегистрированные оферты, отсортированные по order.
|
||||
*/
|
||||
listAgreements(): AgreementRegistrationSpec[] {
|
||||
return Array.from(this.agreementMap.values()).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Все зарегистрированные программы, отсортированные по order.
|
||||
*/
|
||||
listPrograms(): ProgramRegistrationSpec[] {
|
||||
return Array.from(this.programMap.values()).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Оферты, применимые к типу аккаунта (фильтр по applicable_account_types).
|
||||
* Если applicable_account_types пуст — оферта НЕ выпадает «общим списком»
|
||||
* (исторический контракт GENERATOR_OFFER: подключается через программу).
|
||||
*/
|
||||
listAgreementsForAccountType(accountType: AccountType): AgreementRegistrationSpec[] {
|
||||
return this.listAgreements().filter((spec) =>
|
||||
spec.applicable_account_types.includes(accountType)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Программы, применимые к типу аккаунта.
|
||||
*/
|
||||
listProgramsForAccountType(accountType: AccountType): ProgramRegistrationSpec[] {
|
||||
return this.listPrograms().filter((spec) =>
|
||||
spec.applicable_account_types.includes(accountType)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Все оферты, привязанные к программе по её agreement_ids.
|
||||
* Возвращает оферты в порядке указания в program.agreement_ids,
|
||||
* пропуская незарегистрированные (защита от рассинхрона).
|
||||
*/
|
||||
listAgreementsForProgram(programKey: string): AgreementRegistrationSpec[] {
|
||||
const program = this.getProgram(programKey);
|
||||
if (!program) return [];
|
||||
return program.agreement_ids
|
||||
.map((id) => this.getAgreement(id))
|
||||
.filter((spec): spec is AgreementRegistrationSpec => spec !== null);
|
||||
}
|
||||
}
|
||||
|
||||
/** DI-токен для AgreementRegistryService при использовании через @Inject(). */
|
||||
export const AGREEMENT_REGISTRY_SERVICE = Symbol('AgreementRegistryService');
|
||||
+10
-16
@@ -1,9 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AgreementId } from '../enum';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
|
||||
/**
|
||||
* Сервис для определения конфигурации кооперативов
|
||||
* Сервис для определения конфигурации кооперативов.
|
||||
*
|
||||
* После Эпика 1.3 фильтрация оферт по кооперативу больше не делается —
|
||||
* расширения сами решают, регистрировать ли свои оферты, поэтому метод
|
||||
* getExcludedFromBaseAgreements удалён. Метод hasPrograms/`getCooperativesWithPrograms`
|
||||
* остаётся для feature-флага в SignUp (стоит ли вообще опрашивать программы).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CooperativeConfigService {
|
||||
@@ -22,21 +26,11 @@ export class CooperativeConfigService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить дополнительные соглашения по умолчанию для кооператива
|
||||
* Дополнительные платформенные соглашения, которые включаются по
|
||||
* правилам кооператива (а не через расширения). Сейчас всегда пусто —
|
||||
* хук оставлен для будущей кастомизации.
|
||||
*/
|
||||
getDefaultAdditionalAgreements(coopname: string, accountType: AccountType): AgreementId[] {
|
||||
// if (coopname === 'voskhod' && accountType === AccountType.individual) {
|
||||
// return [AgreementId.BLAGOROST_OFFER];
|
||||
// }
|
||||
|
||||
getDefaultAdditionalAgreements(_coopname: string, _accountType: AccountType): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список соглашений, которые нужно исключить из базовых для данного кооператива
|
||||
*/
|
||||
getExcludedFromBaseAgreements(coopname: string): AgreementId[] {
|
||||
// Для всех кооперативов исключаем соглашения, которые добавляются через программы или дополнительные
|
||||
return [AgreementId.BLAGOROST_OFFER, AgreementId.GENERATOR_OFFER];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './agreement-configuration.service';
|
||||
export * from './agreement-registry.service';
|
||||
export * from './registration-documents.service';
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ export class RegistrationDocumentsService {
|
||||
private async generateDocumentParameters(
|
||||
coopname: string,
|
||||
username: string,
|
||||
program_key?: ProgramKey
|
||||
program_key?: string
|
||||
): Promise<void> {
|
||||
// Проверяем наличие реализации порта
|
||||
if (!this.udataDocumentParametersPort) {
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import type { OnboardingStepRegistrationPort } from '~/domain/onboarding/ports/onboarding-step-registration.port';
|
||||
|
||||
/**
|
||||
* Регистрирует 5 шагов онбординга capital в платформенном реестре.
|
||||
*
|
||||
* Нюанс: legacy enum CapitalOnboardingStepEnum использует значение
|
||||
* `blagorost_program`, при этом config-поле называется
|
||||
* `onboarding_blagorost_provision_done`. Платформенный сервис строит
|
||||
* имена флагов напрямую из step_key (`onboarding_<step_key>_done`),
|
||||
* поэтому здесь step_key = `'blagorost_provision'` — совпадает с
|
||||
* существующим config-полем. Старый capital resolver продолжает
|
||||
* работать со своим enum (`blagorost_program`); новый платформенный —
|
||||
* со своим step_key. Оба читают/пишут одни и те же config-флаги.
|
||||
*
|
||||
* Generic-листенер пропускает capital, всю обработку
|
||||
* DecisionTrackedEvent для capital продолжает делать
|
||||
* CapitalOnboardingEventsService.
|
||||
*/
|
||||
export function registerCapitalOnboardingSteps(
|
||||
port: OnboardingStepRegistrationPort
|
||||
): void {
|
||||
port.unregisterStepsByExtension('capital');
|
||||
|
||||
port.registerStep({
|
||||
extension_name: 'capital',
|
||||
step_key: 'generator_program_template',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'generator_program_template',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение шаблона программы «Генератор»',
|
||||
order: 10,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'capital',
|
||||
step_key: 'generation_contract_template',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'generation_contract_template',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение шаблона договора «Генерация»',
|
||||
order: 20,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'capital',
|
||||
step_key: 'generator_offer_template',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'generator_offer_template',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение шаблона публичной оферты «Генератор»',
|
||||
order: 30,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'capital',
|
||||
step_key: 'blagorost_provision',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'blagorost_provision',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение положения «Благорост»',
|
||||
order: 40,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'capital',
|
||||
step_key: 'blagorost_offer_template',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'blagorost_offer_template',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение шаблона публичной оферты «Благорост»',
|
||||
order: 50,
|
||||
});
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import type { AgreementRegistrationPort } from '~/domain/registration/ports/agreement-registration.port';
|
||||
import {
|
||||
BLAGOROST_AGREEMENT_TYPE,
|
||||
BLAGOROST_OFFER_AGREEMENT_ID,
|
||||
CAPITAL_EXTENSION_NAME,
|
||||
CAPITALIZATION_PROGRAM_KEY,
|
||||
GENERATION_PROGRAM_KEY,
|
||||
GENERATOR_AGREEMENT_TYPE,
|
||||
GENERATOR_OFFER_AGREEMENT_ID,
|
||||
} from '../../constants/capital-agreement-ids';
|
||||
import type { IConfig } from '../../capital-extension.module';
|
||||
|
||||
/**
|
||||
* Регистрация оферт и программ Capital в платформенном AgreementRegistry.
|
||||
*
|
||||
* Чистая функция, не имеющая зависимостей от плагина или nest-контейнера:
|
||||
* принимает `port` и `config`, выполняет series of register-вызовов.
|
||||
* Это упрощает unit-тестирование (не требует boot всей капитал-graph
|
||||
* с @octokit/rest, GitHub-схемами, базой и т.д.).
|
||||
*
|
||||
* Логика — Эпик 1.2 плана C28-10:
|
||||
* • если L1-онбординг ещё не завершён (любой из 5 _done = false) —
|
||||
* port не вызывается, реестр остаётся пустым для capital;
|
||||
* • при завершённом L1 — две оферты (generator/blagorost) и две
|
||||
* программы (generation/capitalization);
|
||||
* • идемпотентность гарантируется AgreementRegistryService.
|
||||
*/
|
||||
export function registerCapitalInAgreementRegistry(
|
||||
port: AgreementRegistrationPort,
|
||||
extensionConfig: IConfig
|
||||
): boolean {
|
||||
const onboardingDone =
|
||||
extensionConfig.onboarding_generator_program_template_done &&
|
||||
extensionConfig.onboarding_generation_contract_template_done &&
|
||||
extensionConfig.onboarding_generator_offer_template_done &&
|
||||
extensionConfig.onboarding_blagorost_provision_done &&
|
||||
extensionConfig.onboarding_blagorost_offer_template_done;
|
||||
|
||||
if (!onboardingDone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
port.registerAgreement({
|
||||
id: GENERATOR_OFFER_AGREEMENT_ID,
|
||||
registry_id: Cooperative.Registry.GeneratorOffer.registry_id,
|
||||
agreement_type: GENERATOR_AGREEMENT_TYPE,
|
||||
title: 'Оферта по целевой потребительской программе "Генератор"',
|
||||
checkbox_text: 'Я прочитал и принимаю',
|
||||
link_text: 'оферту по целевой потребительской программе "Генератор"',
|
||||
applicable_account_types: [],
|
||||
order: 6,
|
||||
extension_name: CAPITAL_EXTENSION_NAME,
|
||||
});
|
||||
|
||||
port.registerAgreement({
|
||||
id: BLAGOROST_OFFER_AGREEMENT_ID,
|
||||
registry_id: Cooperative.Registry.BlagorostOffer.registry_id,
|
||||
agreement_type: BLAGOROST_AGREEMENT_TYPE,
|
||||
title: 'Оферта по целевой потребительской программе "Благорост"',
|
||||
checkbox_text: 'Я прочитал и принимаю',
|
||||
link_text: 'оферту по целевой потребительской программе "Благорост"',
|
||||
// applicable_account_types: пусто — оферта подтягивается ТОЛЬКО через
|
||||
// программу CAPITALIZATION (agreement_ids ниже), не как дефолтная
|
||||
// для individual. Иначе при выборе GENERATION бэк попытался бы
|
||||
// сгенерить blagorost_offer как дефолтную, но generateDocumentParameters
|
||||
// под GENERATION зовёт только generateGeneratorOfferParameters →
|
||||
// Factory падает «Данные соглашения благороста не найдены в Udata».
|
||||
applicable_account_types: [],
|
||||
order: 5,
|
||||
extension_name: CAPITAL_EXTENSION_NAME,
|
||||
});
|
||||
|
||||
port.registerProgram({
|
||||
key: GENERATION_PROGRAM_KEY,
|
||||
title: 'Программа Генерация',
|
||||
description:
|
||||
'Участвовать в производстве Кооперативной Экономики через паевой взнос временем, имуществом или деньгами в конкретные проекты.',
|
||||
applicable_account_types: [AccountType.individual, AccountType.entrepreneur],
|
||||
agreement_ids: [GENERATOR_OFFER_AGREEMENT_ID],
|
||||
order: 1,
|
||||
extension_name: CAPITAL_EXTENSION_NAME,
|
||||
});
|
||||
|
||||
port.registerProgram({
|
||||
key: CAPITALIZATION_PROGRAM_KEY,
|
||||
title: 'Программа Благорост',
|
||||
description:
|
||||
'Участвовать в производстве Кооперативной Экономики через паевой взнос имуществом или денег в систему. Минимальный паевой взнос 100 000 руб в течение 14 дней.',
|
||||
applicable_account_types: [AccountType.individual, AccountType.entrepreneur],
|
||||
agreement_ids: [BLAGOROST_OFFER_AGREEMENT_ID],
|
||||
order: 2,
|
||||
extension_name: CAPITAL_EXTENSION_NAME,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
+43
-2
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { DecisionTrackedEvent } from '~/domain/decision-tracking/events/decision-tracked.event';
|
||||
import { ParticipantRegisteredEvent } from '~/domain/participant/interfaces/participant-registered-event.interface';
|
||||
import { ProgramKey } from '~/domain/registration/enum';
|
||||
@@ -16,6 +16,10 @@ import { ContributorDomainEntity } from '../../domain/entities/contributor.entit
|
||||
import { ContributorStatus } from '../../domain/enums/contributor-status.enum';
|
||||
import config from '~/config/config';
|
||||
import { generateRandomHash } from '~/utils/generate-hash.util';
|
||||
import {
|
||||
ONBOARDING_COMPLETED_EVENT,
|
||||
type OnboardingCompletedPayload,
|
||||
} from '~/domain/onboarding/events/onboarding-completed.event';
|
||||
|
||||
@Injectable()
|
||||
export class CapitalOnboardingEventsService {
|
||||
@@ -23,11 +27,24 @@ export class CapitalOnboardingEventsService {
|
||||
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
|
||||
@Inject(CONTRIBUTOR_REPOSITORY) private readonly contributorRepository: ContributorRepository,
|
||||
@Inject(ACCOUNT_DATA_PORT) private readonly accountDataPort: AccountDataPort,
|
||||
private readonly logger: WinstonLoggerService
|
||||
private readonly logger: WinstonLoggerService,
|
||||
private readonly eventEmitter: EventEmitter2
|
||||
) {
|
||||
this.logger.setContext(CapitalOnboardingEventsService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Все 5 шагов L1-онбординга capital, по чьим _done флагам определяется
|
||||
* завершённость L1 в кооперативе.
|
||||
*/
|
||||
private static readonly L1_DONE_FLAGS: ReadonlyArray<keyof IConfig> = [
|
||||
'onboarding_generator_program_template_done',
|
||||
'onboarding_generation_contract_template_done',
|
||||
'onboarding_generator_offer_template_done',
|
||||
'onboarding_blagorost_provision_done',
|
||||
'onboarding_blagorost_offer_template_done',
|
||||
];
|
||||
|
||||
@OnEvent(DecisionTrackedEvent.eventName)
|
||||
async handleDecisionTracked(event: DecisionTrackedEvent): Promise<void> {
|
||||
const { result } = event;
|
||||
@@ -54,6 +71,8 @@ export class CapitalOnboardingEventsService {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAlreadyDone = !!(plugin.config as any)[flagKey];
|
||||
|
||||
// Обновляем флаг завершения шага
|
||||
const updatedConfig = {
|
||||
...plugin.config,
|
||||
@@ -63,6 +82,21 @@ export class CapitalOnboardingEventsService {
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
|
||||
this.logger.info(`Флаг ${flagKey} установлен в true`);
|
||||
|
||||
// L1 auto-restart: если этим шагом флаг переходит из false → true И теперь
|
||||
// все 5 _done флагов стоят, эмиттим ONBOARDING_COMPLETED_EVENT.
|
||||
// ExtensionLifecycleDomainService подписан на него и сам сделает
|
||||
// restartApp('capital'), чтобы initialize(config) увидел свежий config
|
||||
// и зарегистрировал оферты/программы в платформенном реестре.
|
||||
// Совет ничего не нажимает (раздел 7.1 плана C28-10, вариант (в)).
|
||||
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
|
||||
this.logger.info(
|
||||
'[ONBOARDING_COMPLETED] capital L1 завершён последним шагом, эмиттим событие для auto-restart'
|
||||
);
|
||||
this.eventEmitter.emit(ONBOARDING_COMPLETED_EVENT, {
|
||||
extension_name: 'capital',
|
||||
} satisfies OnboardingCompletedPayload);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorObj = error as Error;
|
||||
this.logger.error(`Ошибка при обработке события завершения онбординга capital: ${errorObj.message}`, errorObj.stack);
|
||||
@@ -187,6 +221,13 @@ export class CapitalOnboardingEventsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка: все 5 шагов L1-онбординга capital завершены.
|
||||
*/
|
||||
private isL1Complete(cfg: IConfig): boolean {
|
||||
return CapitalOnboardingEventsService.L1_DONE_FLAGS.every((flag) => !!(cfg as any)[flag]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппинг шага онбординга в ключ флага конфигурации
|
||||
*/
|
||||
|
||||
+2
-2
@@ -13,6 +13,7 @@ import config from '~/config/config';
|
||||
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
|
||||
import { DecisionTrackingPort, DECISION_TRACKING_PORT } from '~/domain/decision-tracking/ports/decision-tracking.port';
|
||||
import { DecisionEventType } from '~/domain/decision-tracking/interfaces/tracking-rule-domain.interface';
|
||||
import { computeOnboardingExpiresAt } from '~/domain/onboarding/constants/onboarding-ttl';
|
||||
|
||||
type OnboardingFlagKey =
|
||||
| 'onboarding_generator_program_template_done'
|
||||
@@ -100,8 +101,7 @@ export class CapitalOnboardingService {
|
||||
|
||||
if (!pluginConfig.onboarding_expire_at) {
|
||||
const start = new Date(pluginConfig.onboarding_init_at);
|
||||
const expire = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
pluginConfig.onboarding_expire_at = expire.toISOString();
|
||||
pluginConfig.onboarding_expire_at = computeOnboardingExpiresAt(start);
|
||||
needUpdate = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +267,16 @@ import { UdataDocumentParametersService, UDATA_DOCUMENT_PARAMETERS_SERVICE } fro
|
||||
import { UdataDocumentParametersAdapter } from './infrastructure/adapters/udata-document-parameters.adapter';
|
||||
import { CapitalInterProjectCapitalClearanceAdapter } from './infrastructure/inter/capital-inter-project-capital-clearance.adapter';
|
||||
import { UDATA_DOCUMENT_PARAMETERS_PORT } from '~/domain/common/ports/udata-document-parameters.port';
|
||||
import {
|
||||
AGREEMENT_REGISTRATION_PORT,
|
||||
type AgreementRegistrationPort,
|
||||
} from '~/domain/registration/ports/agreement-registration.port';
|
||||
import { registerCapitalInAgreementRegistry } from './application/registration/register-capital-in-agreement-registry';
|
||||
import {
|
||||
ONBOARDING_STEP_REGISTRATION_PORT,
|
||||
type OnboardingStepRegistrationPort,
|
||||
} from '~/domain/onboarding/ports/onboarding-step-registration.port';
|
||||
import { registerCapitalOnboardingSteps } from './application/onboarding/register-capital-onboarding-steps';
|
||||
|
||||
// Репозитории
|
||||
import { ProjectTypeormRepository } from './infrastructure/repositories/project.typeorm-repository';
|
||||
@@ -459,12 +469,16 @@ export class CapitalPlugin extends BaseExtModule {
|
||||
private readonly programShareRegistrationScheduler: ProgramShareRegistrationSchedulerService,
|
||||
private readonly githubService: GitHubService,
|
||||
private readonly gitService: GitService,
|
||||
private readonly capitalDevelopmentRepositoryGitSync: CapitalDevelopmentRepositoryGitSyncService
|
||||
private readonly capitalDevelopmentRepositoryGitSync: CapitalDevelopmentRepositoryGitSyncService,
|
||||
@Inject(AGREEMENT_REGISTRATION_PORT) private readonly agreementRegistrationPort: AgreementRegistrationPort,
|
||||
@Inject(ONBOARDING_STEP_REGISTRATION_PORT)
|
||||
private readonly onboardingStepRegistration: OnboardingStepRegistrationPort
|
||||
) {
|
||||
super();
|
||||
this.logger.setContext(CapitalPlugin.name);
|
||||
}
|
||||
|
||||
|
||||
name = 'capital';
|
||||
plugin!: ExtensionDomainEntity<IConfig>; // ExtensionDomainEntity<IConfig>;
|
||||
|
||||
@@ -610,6 +624,37 @@ export class CapitalPlugin extends BaseExtModule {
|
||||
const stack = error instanceof Error ? error.stack : undefined
|
||||
this.logger.error(`Не удалось настроить планировщик regshare: ${message}`, stack)
|
||||
}
|
||||
|
||||
// Регистрация шагов онбординга capital в платформенном реестре
|
||||
try {
|
||||
registerCapitalOnboardingSteps(this.onboardingStepRegistration);
|
||||
this.logger.log('[CAPITAL.ONBOARDING] зарегистрировано 5 шагов онбординга capital');
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(
|
||||
`Не удалось зарегистрировать шаги онбординга capital: ${message}`,
|
||||
stack
|
||||
);
|
||||
}
|
||||
|
||||
// L2: регистрация оферт и программ capital в платформенном реестре.
|
||||
// Если L1 ещё не завершён — реестр остаётся пустым, SignUp не предлагает
|
||||
// программы capital (раздел 4.1 req 44 проекта 13).
|
||||
try {
|
||||
const registered = registerCapitalInAgreementRegistry(this.agreementRegistrationPort, extensionConfig as IConfig);
|
||||
if (registered) {
|
||||
this.logger.log('[CAPITAL.REGISTRY] зарегистрировано 2 оферты + 2 программы capital');
|
||||
} else {
|
||||
this.logger.log(
|
||||
'[CAPITAL.REGISTRY] L1 онбординг ещё не завершён — оферты и программы не регистрируются'
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`Не удалось зарегистрировать capital в AgreementRegistry: ${message}`, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Идентификаторы оферт и программ расширения Capital.
|
||||
*
|
||||
* Это локальный source-of-truth для строковых значений, которые
|
||||
* расширение регистрирует в платформенном AgreementRegistry
|
||||
* (см. register-capital-in-agreement-registry.ts).
|
||||
*
|
||||
* До Эпика 1.3 эти же значения дублируются в ядре в виде
|
||||
* `AgreementId.{BLAGOROST_OFFER,GENERATOR_OFFER}` и
|
||||
* `AgreementType.{CAPITAL,GENERATOR}`. После Эпика 1.3 ядерные
|
||||
* enum-записи будут удалены, а Capital — единственный их носитель.
|
||||
*
|
||||
* `*_PROGRAM_KEY` — ключи программ онбординга, приходящие в
|
||||
* `AgreementRegistry.registerProgram({ key })`. Значения совпадают
|
||||
* со строками ядерного `ProgramKey` enum (GENERATION/CAPITALIZATION),
|
||||
* чтобы consumers, сравнивающие `data.program_key` через
|
||||
* `ProgramKey.X`, работали без преобразований.
|
||||
*/
|
||||
|
||||
export const CAPITAL_EXTENSION_NAME = 'capital';
|
||||
|
||||
export const GENERATOR_OFFER_AGREEMENT_ID = 'generator_offer';
|
||||
export const BLAGOROST_OFFER_AGREEMENT_ID = 'blagorost_offer';
|
||||
|
||||
export const GENERATOR_AGREEMENT_TYPE = 'generator';
|
||||
// On-chain имя оферты Благорост в `soviet::coagreements` — 'capital' (program_id=4).
|
||||
// Контроллер обязан слать sndagreement/signagree с этим значением, иначе
|
||||
// `get_coagreement_or_fail` падает с «Соглашение указанного типа не найдено».
|
||||
export const BLAGOROST_AGREEMENT_TYPE = 'capital';
|
||||
|
||||
export const GENERATION_PROGRAM_KEY = 'GENERATION';
|
||||
export const CAPITALIZATION_PROGRAM_KEY = 'CAPITALIZATION';
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import type { OnboardingStepRegistrationPort } from '~/domain/onboarding/ports/onboarding-step-registration.port';
|
||||
|
||||
/**
|
||||
* Регистрирует 7 шагов онбординга chairman в платформенном реестре.
|
||||
*
|
||||
* Step_key'и совпадают с именами enum'а ChairmanOnboardingAgendaStepEnum и
|
||||
* с подстрокой существующих config-полей `onboarding_<step_key>_done/hash`,
|
||||
* чтобы legacy resolver (Chairman OnboardingResolver/Service) и
|
||||
* платформенный resolver (ExtensionOnboardingResolver) видели одно и то же
|
||||
* состояние.
|
||||
*
|
||||
* Generic-листенер ExtensionOnboardingEventsService пропускает chairman
|
||||
* (см. LEGACY_EXTENSIONS_WITH_OWN_LISTENER), всю обработку
|
||||
* DecisionTrackedEvent для chairman продолжает делать
|
||||
* ChairmanOnboardingEventsService. Регистрация шагов здесь нужна,
|
||||
* чтобы платформенный QUERY-резолвер возвращал состояние для chairman.
|
||||
*/
|
||||
export function registerChairmanOnboardingSteps(
|
||||
port: OnboardingStepRegistrationPort
|
||||
): void {
|
||||
port.unregisterStepsByExtension('chairman');
|
||||
|
||||
port.registerStep({
|
||||
extension_name: 'chairman',
|
||||
step_key: 'wallet_agreement',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'wallet_agreement',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение кошелькового соглашения',
|
||||
order: 10,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'chairman',
|
||||
step_key: 'signature_agreement',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'signature_agreement',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение соглашения о простой электронной подписи',
|
||||
order: 20,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'chairman',
|
||||
step_key: 'privacy_agreement',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'privacy_agreement',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение политики конфиденциальности',
|
||||
order: 30,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'chairman',
|
||||
step_key: 'user_agreement',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'user_agreement',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение пользовательского соглашения',
|
||||
order: 40,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'chairman',
|
||||
step_key: 'participant_application',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'participant_application',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Утверждение шаблонов заявлений участника',
|
||||
order: 50,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'chairman',
|
||||
step_key: 'voskhod_membership',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'voskhod_membership',
|
||||
generator: 'free_decision',
|
||||
default_title: 'Решение о вступлении в ПК «ВОСХОД»',
|
||||
order: 60,
|
||||
});
|
||||
port.registerStep({
|
||||
extension_name: 'chairman',
|
||||
step_key: 'general_meet',
|
||||
event_type: 'MEET_DECISION',
|
||||
vars_field: 'general_meet',
|
||||
generator: 'meet',
|
||||
default_title: 'Утверждение общим собранием',
|
||||
order: 70,
|
||||
});
|
||||
}
|
||||
+45
-3
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import {
|
||||
EXTENSION_REPOSITORY,
|
||||
ExtensionDomainRepository,
|
||||
@@ -8,6 +8,10 @@ import type { IConfig } from '../../chairman-extension.module';
|
||||
import type { ExtensionDomainEntity } from '~/domain/extension/entities/extension-domain.entity';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { DecisionTrackedEvent } from '~/domain/decision-tracking/events/decision-tracked.event';
|
||||
import {
|
||||
ONBOARDING_COMPLETED_EVENT,
|
||||
type OnboardingCompletedPayload,
|
||||
} from '~/domain/onboarding/events/onboarding-completed.event';
|
||||
|
||||
/**
|
||||
* Сервис обработки событий онбординга председателя
|
||||
@@ -17,11 +21,26 @@ import { DecisionTrackedEvent } from '~/domain/decision-tracking/events/decision
|
||||
export class ChairmanOnboardingEventsService {
|
||||
constructor(
|
||||
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
|
||||
private readonly logger: WinstonLoggerService
|
||||
private readonly logger: WinstonLoggerService,
|
||||
private readonly eventEmitter: EventEmitter2
|
||||
) {
|
||||
this.logger.setContext(ChairmanOnboardingEventsService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Все 7 шагов L1-онбординга chairman, по чьим _done флагам определяется
|
||||
* завершённость L1 кооператива.
|
||||
*/
|
||||
private static readonly L1_DONE_FLAGS: ReadonlyArray<keyof IConfig> = [
|
||||
'onboarding_wallet_agreement_done',
|
||||
'onboarding_signature_agreement_done',
|
||||
'onboarding_privacy_agreement_done',
|
||||
'onboarding_user_agreement_done',
|
||||
'onboarding_participant_application_done',
|
||||
'onboarding_voskhod_membership_done',
|
||||
'onboarding_general_meet_done',
|
||||
];
|
||||
|
||||
private async load(): Promise<ExtensionDomainEntity<IConfig> | null> {
|
||||
return this.extensionRepository.findByName('chairman');
|
||||
}
|
||||
@@ -65,20 +84,43 @@ export class ChairmanOnboardingEventsService {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAlreadyDone = !!(cfg as any)[flagKey];
|
||||
|
||||
// Обновляем флаг выполнения шага
|
||||
const patch: Partial<IConfig> = {
|
||||
[flagKey]: true,
|
||||
};
|
||||
|
||||
const updated: ExtensionDomainEntity<IConfig> = { ...plugin, config: { ...cfg, ...patch } };
|
||||
const updatedConfig = { ...cfg, ...patch };
|
||||
const updated: ExtensionDomainEntity<IConfig> = { ...plugin, config: updatedConfig };
|
||||
await this.extensionRepository.update(updated);
|
||||
|
||||
this.logger.info(`Онбординг обновлён: ${flagKey} = true (решение ${result.decision_id})`);
|
||||
|
||||
// L1 auto-restart: если переход false → true сделал L1 полностью
|
||||
// завершённым, эмиттим ONBOARDING_COMPLETED_EVENT.
|
||||
// У chairman нет L2-оферт в реестре сейчас, но событие важно как
|
||||
// сигнал «L1 кооператива закрыт» — раздел 7.1 C28-10.
|
||||
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
|
||||
this.logger.info(
|
||||
'[ONBOARDING_COMPLETED] chairman L1 завершён последним шагом, эмиттим событие'
|
||||
);
|
||||
this.eventEmitter.emit(ONBOARDING_COMPLETED_EVENT, {
|
||||
extension_name: 'chairman',
|
||||
} satisfies OnboardingCompletedPayload);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка при обработке события отслеживания решения: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка: все 7 шагов L1-онбординга chairman завершены.
|
||||
*/
|
||||
private isL1Complete(cfg: IConfig): boolean {
|
||||
return ChairmanOnboardingEventsService.L1_DONE_FLAGS.every((flag) => !!(cfg as any)[flag]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппинг шага онбординга в ключ флага конфигурации
|
||||
*/
|
||||
|
||||
@@ -54,6 +54,12 @@ import { DecisionTrackingInfrastructureModule } from '~/infrastructure/decision-
|
||||
// Символы для DI
|
||||
import { APPROVAL_REPOSITORY } from './domain/repositories/approval.repository';
|
||||
import { CHAIRMAN_BLOCKCHAIN_PORT } from './domain/interfaces/chairman-blockchain.port';
|
||||
import { computeOnboardingExpiresAt } from '~/domain/onboarding/constants/onboarding-ttl';
|
||||
import {
|
||||
ONBOARDING_STEP_REGISTRATION_PORT,
|
||||
type OnboardingStepRegistrationPort,
|
||||
} from '~/domain/onboarding/ports/onboarding-step-registration.port';
|
||||
import { registerChairmanOnboardingSteps } from './application/onboarding/register-chairman-onboarding-steps';
|
||||
|
||||
// Функция для описания полей в схеме конфигурации
|
||||
function describeField(description: DeserializedDescriptionOfExtension): string {
|
||||
@@ -194,6 +200,8 @@ export class ChairmanPlugin extends BaseExtModule {
|
||||
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
|
||||
@Inject(LOG_EXTENSION_REPOSITORY) private readonly logExtensionRepository: LogExtensionDomainRepository<ILog>,
|
||||
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort,
|
||||
@Inject(ONBOARDING_STEP_REGISTRATION_PORT)
|
||||
private readonly onboardingStepRegistration: OnboardingStepRegistrationPort,
|
||||
private readonly decisionExpiredNotificationService: DecisionExpiredNotificationService,
|
||||
private readonly logger: WinstonLoggerService
|
||||
) {
|
||||
@@ -226,8 +234,7 @@ export class ChairmanPlugin extends BaseExtModule {
|
||||
}
|
||||
if (!this.plugin.config.onboarding_expire_at) {
|
||||
const started = new Date(this.plugin.config.onboarding_init_at || nowIso);
|
||||
const expire = new Date(started.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
this.plugin.config.onboarding_expire_at = expire.toISOString();
|
||||
this.plugin.config.onboarding_expire_at = computeOnboardingExpiresAt(started);
|
||||
needUpdate = true;
|
||||
}
|
||||
|
||||
@@ -237,6 +244,9 @@ export class ChairmanPlugin extends BaseExtModule {
|
||||
|
||||
this.logger.info(`Инициализация ${this.name} с конфигурацией`, this.plugin.config);
|
||||
|
||||
// Регистрация шагов онбординга в платформенном реестре
|
||||
registerChairmanOnboardingSteps(this.onboardingStepRegistration);
|
||||
|
||||
// Инициализация сервиса проверки истекших решений
|
||||
await this.decisionExpiredNotificationService.initialize(this.plugin);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,18 @@ import { VaultEntity } from '../infrastructure/database/typeorm/entities/vault.e
|
||||
export interface Migration {
|
||||
name: string;
|
||||
validUntil?: Date; // Дата в UTC, до которой миграция должна применяться. Если не указана или null, миграция применяется всегда
|
||||
up: (services: { blockchain: BlockchainService; logger: MigrationLogger; dataSource: DataSource }) => Promise<boolean>;
|
||||
down?: (services: { blockchain: BlockchainService; logger: MigrationLogger; dataSource: DataSource }) => Promise<boolean>;
|
||||
up: (services: {
|
||||
blockchain: BlockchainService;
|
||||
vault: VaultDomainService;
|
||||
logger: MigrationLogger;
|
||||
dataSource: DataSource;
|
||||
}) => Promise<boolean>;
|
||||
down?: (services: {
|
||||
blockchain: BlockchainService;
|
||||
vault: VaultDomainService;
|
||||
logger: MigrationLogger;
|
||||
dataSource: DataSource;
|
||||
}) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export class MigrationManager {
|
||||
@@ -23,6 +33,7 @@ export class MigrationManager {
|
||||
private migrationRepository!: Repository<MigrationEntity>;
|
||||
private migrationDir!: string;
|
||||
private blockchainService: BlockchainService;
|
||||
private vaultDomainService!: VaultDomainService;
|
||||
|
||||
constructor() {
|
||||
this.migrationDir = path.join(process.cwd(), '/migrations');
|
||||
@@ -59,11 +70,11 @@ export class MigrationManager {
|
||||
|
||||
// Создаем настоящий VaultDomainService теперь, когда dataSource готов
|
||||
const vaultRepository = new VaultTypeormRepository(this.dataSource.getRepository(VaultEntity));
|
||||
const vaultDomainService = new VaultDomainService(vaultRepository);
|
||||
this.vaultDomainService = new VaultDomainService(vaultRepository);
|
||||
|
||||
// Пересоздаем BlockchainService с настоящим VaultDomainService
|
||||
const loggerService = new WinstonLoggerService();
|
||||
this.blockchainService = new BlockchainService(loggerService, vaultDomainService);
|
||||
this.blockchainService = new BlockchainService(loggerService, this.vaultDomainService);
|
||||
|
||||
logger.info('Миграционный менеджер инициализирован');
|
||||
}
|
||||
@@ -207,6 +218,7 @@ export class MigrationManager {
|
||||
// Предоставляем блокчейн-сервис, логгер и подключение к БД в миграцию
|
||||
const result = await migration.up({
|
||||
blockchain: this.blockchainService,
|
||||
vault: this.vaultDomainService,
|
||||
logger: migrationLogger,
|
||||
dataSource: this.dataSource,
|
||||
});
|
||||
@@ -237,6 +249,7 @@ export class MigrationManager {
|
||||
migrationLogger.info(`Выполнение отката миграции ${version}...`);
|
||||
const rollbackResult = await migration.down({
|
||||
blockchain: this.blockchainService,
|
||||
vault: this.vaultDomainService,
|
||||
logger: migrationLogger,
|
||||
dataSource: this.dataSource,
|
||||
});
|
||||
@@ -384,6 +397,7 @@ export class MigrationManager {
|
||||
// Выполняем откат
|
||||
const rollbackResult = await migration.down({
|
||||
blockchain: this.blockchainService,
|
||||
vault: this.vaultDomainService,
|
||||
logger: migrationLogger,
|
||||
dataSource: this.dataSource,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Unit-тесты CapitalOnboardingEventsService.handleDecisionTracked (Эпик 1.2).
|
||||
*
|
||||
* Фокус — эмит ONBOARDING_COMPLETED_EVENT, когда последний _done флаг L1
|
||||
* капитала переходит false → true. Идемпотентность по `wasAlreadyDone`
|
||||
* гарантирует, что повторный приход того же blockchain-события не
|
||||
* эмиттит лишний restart.
|
||||
*/
|
||||
|
||||
import { CapitalOnboardingEventsService } from '~/extensions/capital/application/services/onboarding-events.service';
|
||||
import { ONBOARDING_COMPLETED_EVENT } from '~/domain/onboarding/events/onboarding-completed.event';
|
||||
|
||||
function makeLoggerStub() {
|
||||
return {
|
||||
setContext: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeRepoStub(cfg: Record<string, any>) {
|
||||
const plugin = { name: 'capital', config: { ...cfg } } as any;
|
||||
return {
|
||||
findByName: jest.fn(async () => plugin),
|
||||
update: jest.fn(async (next: any) => {
|
||||
Object.assign(plugin.config, next.config);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmitterStub() {
|
||||
return { emit: jest.fn() } as any;
|
||||
}
|
||||
|
||||
function makeService(repo: any, emitter: any) {
|
||||
return new CapitalOnboardingEventsService(
|
||||
repo,
|
||||
undefined as any, // contributorRepository
|
||||
undefined as any, // accountDataPort
|
||||
makeLoggerStub(),
|
||||
emitter
|
||||
);
|
||||
}
|
||||
|
||||
function trackedEvent(step: string) {
|
||||
return {
|
||||
result: {
|
||||
metadata: { onboarding_step: step, extension: 'capital' },
|
||||
decision_id: '1',
|
||||
vars_field: 'x',
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('CapitalOnboardingEventsService.handleDecisionTracked', () => {
|
||||
it('эмиттит ONBOARDING_COMPLETED_EVENT когда последний из 5 _done встаёт', async () => {
|
||||
const repo = makeRepoStub({
|
||||
onboarding_generator_program_template_done: true,
|
||||
onboarding_generation_contract_template_done: true,
|
||||
onboarding_generator_offer_template_done: true,
|
||||
onboarding_blagorost_provision_done: true,
|
||||
onboarding_blagorost_offer_template_done: false,
|
||||
});
|
||||
const emitter = makeEmitterStub();
|
||||
const service = makeService(repo, emitter);
|
||||
|
||||
await service.handleDecisionTracked(trackedEvent('blagorost_offer_template'));
|
||||
|
||||
expect(emitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.emit).toHaveBeenCalledWith(ONBOARDING_COMPLETED_EVENT, {
|
||||
extension_name: 'capital',
|
||||
});
|
||||
});
|
||||
|
||||
it('НЕ эмиттит, если ставится не последний _done (остался хотя бы один false)', async () => {
|
||||
const repo = makeRepoStub({
|
||||
onboarding_generator_program_template_done: false,
|
||||
onboarding_generation_contract_template_done: false,
|
||||
onboarding_generator_offer_template_done: false,
|
||||
onboarding_blagorost_provision_done: false,
|
||||
onboarding_blagorost_offer_template_done: false,
|
||||
});
|
||||
const emitter = makeEmitterStub();
|
||||
const service = makeService(repo, emitter);
|
||||
|
||||
await service.handleDecisionTracked(trackedEvent('generator_program_template'));
|
||||
|
||||
expect(emitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('НЕ эмиттит повторно, если этот _done уже стоял (idempotency повторного события)', async () => {
|
||||
const repo = makeRepoStub({
|
||||
onboarding_generator_program_template_done: true,
|
||||
onboarding_generation_contract_template_done: true,
|
||||
onboarding_generator_offer_template_done: true,
|
||||
onboarding_blagorost_provision_done: true,
|
||||
onboarding_blagorost_offer_template_done: true, // уже стоял
|
||||
});
|
||||
const emitter = makeEmitterStub();
|
||||
const service = makeService(repo, emitter);
|
||||
|
||||
await service.handleDecisionTracked(trackedEvent('blagorost_offer_template'));
|
||||
|
||||
expect(emitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('игнорирует событие другого расширения', async () => {
|
||||
const repo = makeRepoStub({});
|
||||
const emitter = makeEmitterStub();
|
||||
const service = makeService(repo, emitter);
|
||||
|
||||
const event = {
|
||||
result: {
|
||||
metadata: { onboarding_step: 'wallet_agreement', extension: 'chairman' },
|
||||
decision_id: '1',
|
||||
vars_field: 'x',
|
||||
},
|
||||
} as any;
|
||||
await service.handleDecisionTracked(event);
|
||||
|
||||
expect(repo.findByName).not.toHaveBeenCalled();
|
||||
expect(emitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Unit-тесты registerCapitalInAgreementRegistry (Эпик 1.2).
|
||||
*
|
||||
* Тест чистой функции — не тянет за собой импорт CapitalPlugin
|
||||
* (там цепочка с @octokit/rest ESM, которая ломает jest-transform).
|
||||
*
|
||||
* Покрывают:
|
||||
* (a) L1 не завершён (хотя бы один _done = false) → port не вызывается,
|
||||
* функция возвращает false;
|
||||
* (b) L1 завершён → 2 registerAgreement (generator_offer registry_id 996,
|
||||
* blagorost_offer registry_id 1000) + 2 registerProgram (generation
|
||||
* → ['generator_offer'], capitalization → ['blagorost_offer']),
|
||||
* функция возвращает true;
|
||||
* (c) повторный вызов делает те же register-вызовы (реальная
|
||||
* идемпотентность гарантируется AgreementRegistryService).
|
||||
*/
|
||||
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { registerCapitalInAgreementRegistry } from '~/extensions/capital/application/registration/register-capital-in-agreement-registry';
|
||||
|
||||
const baseConfig = {
|
||||
onboarding_generator_program_template_done: false,
|
||||
onboarding_generation_contract_template_done: false,
|
||||
onboarding_generator_offer_template_done: false,
|
||||
onboarding_blagorost_provision_done: false,
|
||||
onboarding_blagorost_offer_template_done: false,
|
||||
} as any;
|
||||
|
||||
const allDoneConfig = {
|
||||
onboarding_generator_program_template_done: true,
|
||||
onboarding_generation_contract_template_done: true,
|
||||
onboarding_generator_offer_template_done: true,
|
||||
onboarding_blagorost_provision_done: true,
|
||||
onboarding_blagorost_offer_template_done: true,
|
||||
} as any;
|
||||
|
||||
function makePortStub() {
|
||||
return {
|
||||
registerAgreement: jest.fn(),
|
||||
unregisterAgreement: jest.fn(),
|
||||
registerProgram: jest.fn(),
|
||||
unregisterProgram: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('registerCapitalInAgreementRegistry', () => {
|
||||
it('возвращает false и не вызывает port если все _done = false', () => {
|
||||
const port = makePortStub();
|
||||
|
||||
const ok = registerCapitalInAgreementRegistry(port as any, baseConfig);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(port.registerAgreement).not.toHaveBeenCalled();
|
||||
expect(port.registerProgram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('возвращает false и не вызывает port если один _done = false', () => {
|
||||
const port = makePortStub();
|
||||
const cfg = { ...allDoneConfig, onboarding_blagorost_offer_template_done: false };
|
||||
|
||||
const ok = registerCapitalInAgreementRegistry(port as any, cfg);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(port.registerAgreement).not.toHaveBeenCalled();
|
||||
expect(port.registerProgram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('регистрирует 2 оферты и 2 программы когда все 5 _done = true', () => {
|
||||
const port = makePortStub();
|
||||
|
||||
const ok = registerCapitalInAgreementRegistry(port as any, allDoneConfig);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(port.registerAgreement).toHaveBeenCalledTimes(2);
|
||||
expect(port.registerProgram).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(port.registerAgreement).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'generator_offer',
|
||||
registry_id: Cooperative.Registry.GeneratorOffer.registry_id,
|
||||
agreement_type: 'generator',
|
||||
extension_name: 'capital',
|
||||
applicable_account_types: [],
|
||||
})
|
||||
);
|
||||
|
||||
expect(port.registerAgreement).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'blagorost_offer',
|
||||
registry_id: Cooperative.Registry.BlagorostOffer.registry_id,
|
||||
agreement_type: 'capital',
|
||||
extension_name: 'capital',
|
||||
// Пусто — оферта подтягивается через программу CAPITALIZATION,
|
||||
// не как дефолтная для individual (см. 8847a5c0939).
|
||||
applicable_account_types: [],
|
||||
})
|
||||
);
|
||||
|
||||
expect(port.registerProgram).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'GENERATION',
|
||||
agreement_ids: ['generator_offer'],
|
||||
extension_name: 'capital',
|
||||
})
|
||||
);
|
||||
|
||||
expect(port.registerProgram).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'CAPITALIZATION',
|
||||
agreement_ids: ['blagorost_offer'],
|
||||
extension_name: 'capital',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('повторный вызов воспроизводит те же register-вызовы', () => {
|
||||
const port = makePortStub();
|
||||
|
||||
registerCapitalInAgreementRegistry(port as any, allDoneConfig);
|
||||
registerCapitalInAgreementRegistry(port as any, allDoneConfig);
|
||||
|
||||
expect(port.registerAgreement).toHaveBeenCalledTimes(4);
|
||||
expect(port.registerProgram).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Unit-тесты ChairmanOnboardingEventsService.handleDecisionTracked (Эпик 1.2).
|
||||
*
|
||||
* Аналог теста capital — фокус на эмите ONBOARDING_COMPLETED_EVENT
|
||||
* только на переходе последнего из 7 _done флагов false → true.
|
||||
*/
|
||||
|
||||
import { ChairmanOnboardingEventsService } from '~/extensions/chairman/application/services/onboarding-events.service';
|
||||
import { ONBOARDING_COMPLETED_EVENT } from '~/domain/onboarding/events/onboarding-completed.event';
|
||||
|
||||
function makeLoggerStub() {
|
||||
return {
|
||||
setContext: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeRepoStub(cfg: Record<string, any>) {
|
||||
const plugin = { name: 'chairman', config: { ...cfg } } as any;
|
||||
return {
|
||||
findByName: jest.fn(async () => plugin),
|
||||
update: jest.fn(async (next: any) => {
|
||||
Object.assign(plugin.config, next.config);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmitterStub() {
|
||||
return { emit: jest.fn() } as any;
|
||||
}
|
||||
|
||||
function makeService(repo: any, emitter: any) {
|
||||
return new ChairmanOnboardingEventsService(repo, makeLoggerStub(), emitter);
|
||||
}
|
||||
|
||||
function trackedEvent(step: string) {
|
||||
return {
|
||||
result: {
|
||||
metadata: { onboarding_step: step, extension: 'chairman' },
|
||||
decision_id: '1',
|
||||
vars_field: 'x',
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('ChairmanOnboardingEventsService.handleDecisionTracked', () => {
|
||||
it('эмиттит ONBOARDING_COMPLETED_EVENT когда последний из 7 _done встаёт', async () => {
|
||||
const repo = makeRepoStub({
|
||||
onboarding_wallet_agreement_done: true,
|
||||
onboarding_signature_agreement_done: true,
|
||||
onboarding_privacy_agreement_done: true,
|
||||
onboarding_user_agreement_done: true,
|
||||
onboarding_participant_application_done: true,
|
||||
onboarding_voskhod_membership_done: true,
|
||||
onboarding_general_meet_done: false,
|
||||
});
|
||||
const emitter = makeEmitterStub();
|
||||
const service = makeService(repo, emitter);
|
||||
|
||||
await service.handleDecisionTracked(trackedEvent('general_meet'));
|
||||
|
||||
expect(emitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.emit).toHaveBeenCalledWith(ONBOARDING_COMPLETED_EVENT, {
|
||||
extension_name: 'chairman',
|
||||
});
|
||||
});
|
||||
|
||||
it('НЕ эмиттит на промежуточный шаг (остаются false-флаги)', async () => {
|
||||
const repo = makeRepoStub({
|
||||
onboarding_wallet_agreement_done: false,
|
||||
onboarding_signature_agreement_done: false,
|
||||
onboarding_privacy_agreement_done: false,
|
||||
onboarding_user_agreement_done: false,
|
||||
onboarding_participant_application_done: false,
|
||||
onboarding_voskhod_membership_done: false,
|
||||
onboarding_general_meet_done: false,
|
||||
});
|
||||
const emitter = makeEmitterStub();
|
||||
const service = makeService(repo, emitter);
|
||||
|
||||
await service.handleDecisionTracked(trackedEvent('wallet_agreement'));
|
||||
|
||||
expect(emitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('НЕ эмиттит повторно (idempotency: _done уже стоял)', async () => {
|
||||
const repo = makeRepoStub({
|
||||
onboarding_wallet_agreement_done: true,
|
||||
onboarding_signature_agreement_done: true,
|
||||
onboarding_privacy_agreement_done: true,
|
||||
onboarding_user_agreement_done: true,
|
||||
onboarding_participant_application_done: true,
|
||||
onboarding_voskhod_membership_done: true,
|
||||
onboarding_general_meet_done: true,
|
||||
});
|
||||
const emitter = makeEmitterStub();
|
||||
const service = makeService(repo, emitter);
|
||||
|
||||
await service.handleDecisionTracked(trackedEvent('general_meet'));
|
||||
|
||||
expect(emitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('игнорирует событие capital', async () => {
|
||||
const repo = makeRepoStub({});
|
||||
const emitter = makeEmitterStub();
|
||||
const service = makeService(repo, emitter);
|
||||
|
||||
const event = {
|
||||
result: {
|
||||
metadata: { onboarding_step: 'blagorost_offer_template', extension: 'capital' },
|
||||
decision_id: '1',
|
||||
vars_field: 'x',
|
||||
},
|
||||
} as any;
|
||||
await service.handleDecisionTracked(event);
|
||||
|
||||
expect(repo.findByName).not.toHaveBeenCalled();
|
||||
expect(emitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { OnboardingStepsRegistryService } from '~/domain/onboarding/services/onboarding-steps-registry.service';
|
||||
|
||||
describe('OnboardingStepsRegistryService', () => {
|
||||
let registry: OnboardingStepsRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new OnboardingStepsRegistryService();
|
||||
});
|
||||
|
||||
it('regsiterStep сохраняет спецификацию и возвращает её через getStep', () => {
|
||||
registry.registerStep({
|
||||
extension_name: 'stol_zakazov',
|
||||
step_key: 'public_offer',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'public_offer',
|
||||
generator: 'free_decision',
|
||||
order: 10,
|
||||
});
|
||||
|
||||
const spec = registry.getStep('stol_zakazov', 'public_offer');
|
||||
expect(spec).not.toBeNull();
|
||||
expect(spec?.vars_field).toBe('public_offer');
|
||||
});
|
||||
|
||||
it('getStepsByExtension возвращает шаги в порядке order', () => {
|
||||
registry.registerStep({
|
||||
extension_name: 'demo',
|
||||
step_key: 'second',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'second',
|
||||
generator: 'free_decision',
|
||||
order: 20,
|
||||
});
|
||||
registry.registerStep({
|
||||
extension_name: 'demo',
|
||||
step_key: 'first',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'first',
|
||||
generator: 'free_decision',
|
||||
order: 10,
|
||||
});
|
||||
registry.registerStep({
|
||||
extension_name: 'other',
|
||||
step_key: 'irrelevant',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'irrelevant',
|
||||
generator: 'free_decision',
|
||||
order: 5,
|
||||
});
|
||||
|
||||
const steps = registry.getStepsByExtension('demo');
|
||||
expect(steps.map((s) => s.step_key)).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('повторная регистрация той же пары (extension_name, step_key) бросает Error', () => {
|
||||
registry.registerStep({
|
||||
extension_name: 'demo',
|
||||
step_key: 'dup',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'dup',
|
||||
generator: 'free_decision',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
registry.registerStep({
|
||||
extension_name: 'demo',
|
||||
step_key: 'dup',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 'dup_v2',
|
||||
generator: 'free_decision',
|
||||
order: 2,
|
||||
})
|
||||
).toThrow(/уже зарегистрирован/);
|
||||
});
|
||||
|
||||
it('unregisterStepsByExtension чистит только шаги указанного расширения', () => {
|
||||
registry.registerStep({
|
||||
extension_name: 'a',
|
||||
step_key: 's1',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 's1',
|
||||
generator: 'free_decision',
|
||||
order: 1,
|
||||
});
|
||||
registry.registerStep({
|
||||
extension_name: 'b',
|
||||
step_key: 's1',
|
||||
event_type: 'SOVIET_DECISION',
|
||||
vars_field: 's1',
|
||||
generator: 'free_decision',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
registry.unregisterStepsByExtension('a');
|
||||
|
||||
expect(registry.getStep('a', 's1')).toBeNull();
|
||||
expect(registry.getStep('b', 's1')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('getStep возвращает null для незнакомой пары', () => {
|
||||
expect(registry.getStep('unknown', 'unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Юнит-тест TTL онбординга (Эпик 4.2).
|
||||
*
|
||||
* Проверяет:
|
||||
* (a) ONBOARDING_EXPIRY_DAYS = 30 (зафиксировано как платформенное значение);
|
||||
* (b) computeOnboardingExpiresAt возвращает корректную ISO-строку;
|
||||
* (c) разница между startedAt и expiresAt = 30 дней в миллисекундах,
|
||||
* что эквивалентно тому, что было захардкожено в capital и chairman
|
||||
* до консолидации.
|
||||
*/
|
||||
|
||||
import {
|
||||
ONBOARDING_EXPIRY_DAYS,
|
||||
ONBOARDING_EXPIRY_MS,
|
||||
computeOnboardingExpiresAt,
|
||||
} from '~/domain/onboarding/constants/onboarding-ttl';
|
||||
|
||||
describe('Onboarding TTL', () => {
|
||||
it('ONBOARDING_EXPIRY_DAYS = 30', () => {
|
||||
expect(ONBOARDING_EXPIRY_DAYS).toBe(30);
|
||||
});
|
||||
|
||||
it('ONBOARDING_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000', () => {
|
||||
expect(ONBOARDING_EXPIRY_MS).toBe(30 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('computeOnboardingExpiresAt возвращает ISO-строку через 30 дней от startedAt', () => {
|
||||
const startedAt = new Date('2026-01-01T00:00:00.000Z');
|
||||
const expiresAt = computeOnboardingExpiresAt(startedAt);
|
||||
|
||||
expect(expiresAt).toBe('2026-01-31T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('expiresAt − startedAt = ONBOARDING_EXPIRY_MS (eq. старому хардкоду)', () => {
|
||||
const startedAt = new Date('2026-05-12T12:34:56.789Z');
|
||||
const expiresAt = new Date(computeOnboardingExpiresAt(startedAt));
|
||||
|
||||
expect(expiresAt.getTime() - startedAt.getTime()).toBe(ONBOARDING_EXPIRY_MS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Контрактный тест AGREEMENT_QUERY_PORT (Эпик 2.1).
|
||||
*
|
||||
* Цели:
|
||||
* 1. TypeScript structural check: AgreementConfigurationService
|
||||
* реализует интерфейс AgreementQueryPort (без явного `implements`,
|
||||
* биндинг useExisting в registration-domain.module работает только
|
||||
* если структуры совпадают);
|
||||
* 2. Smoke: 4 базовые платформенные оферты возвращаются для individual;
|
||||
* 3. Smoke: при пустом AgreementRegistry список программ пуст.
|
||||
*
|
||||
* @nestjs/testing в репо не установлен — реальный binding-тест
|
||||
* (что Nest-контейнер выдаёт тот же singleton через @Inject) проверяется
|
||||
* косвенно через runtime smoke: coopback стартует без UnknownDependencies.
|
||||
*/
|
||||
|
||||
import { AgreementConfigurationService } from '~/domain/registration/services/agreement-configuration.service';
|
||||
import { AgreementRegistryService } from '~/domain/registration/services/agreement-registry.service';
|
||||
import { CooperativeConfigService } from '~/domain/registration/services/cooperative-config.service';
|
||||
import type { AgreementQueryPort } from '~/domain/registration/ports/agreement-query.port';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
|
||||
function makeLoggerStub() {
|
||||
return {
|
||||
setContext: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeService() {
|
||||
const cooperativeConfig = new CooperativeConfigService();
|
||||
const registry = new AgreementRegistryService(makeLoggerStub());
|
||||
return new AgreementConfigurationService(cooperativeConfig, registry);
|
||||
}
|
||||
|
||||
describe('AgreementQueryPort contract', () => {
|
||||
it('AgreementConfigurationService structurally реализует AgreementQueryPort', () => {
|
||||
const service = makeService();
|
||||
|
||||
// структурный assign: если хоть один метод не соответствует —
|
||||
// tsc не пропустит, и тест даже не запустится.
|
||||
const port: AgreementQueryPort = service;
|
||||
expect(typeof port.getAgreementsForAccountType).toBe('function');
|
||||
expect(typeof port.getAgreementById).toBe('function');
|
||||
expect(typeof port.getAgreementsForProgram).toBe('function');
|
||||
expect(typeof port.getAvailablePrograms).toBe('function');
|
||||
});
|
||||
|
||||
it('для individual возвращает 4 платформенные оферты (signature/wallet/user/privacy)', () => {
|
||||
const port: AgreementQueryPort = makeService();
|
||||
|
||||
const agreements = port.getAgreementsForAccountType(AccountType.individual, 'voskhod');
|
||||
const ids = agreements.map((a) => a.id).sort();
|
||||
|
||||
expect(ids).toEqual(
|
||||
['privacy_agreement', 'signature_agreement', 'user_agreement', 'wallet_agreement'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('при пустом реестре getAvailablePrograms возвращает пустой массив', () => {
|
||||
const port: AgreementQueryPort = makeService();
|
||||
|
||||
expect(port.getAvailablePrograms('voskhod', AccountType.individual)).toEqual([]);
|
||||
});
|
||||
|
||||
it('getAgreementById для платформенной оферты возвращает запись', () => {
|
||||
const port: AgreementQueryPort = makeService();
|
||||
|
||||
const wallet = port.getAgreementById('wallet_agreement');
|
||||
expect(wallet).not.toBeNull();
|
||||
expect(wallet?.agreement_type).toBe('wallet');
|
||||
});
|
||||
|
||||
it('getAgreementById для незарегистрированного id возвращает null', () => {
|
||||
const port: AgreementQueryPort = makeService();
|
||||
|
||||
expect(port.getAgreementById('does_not_exist')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Unit-тесты AgreementRegistryService (Эпик 1.1 плана C28-10).
|
||||
*
|
||||
* Покрывают:
|
||||
* (a) register/unregister оферты и программы — happy path;
|
||||
* (b) идемпотентность повторного register от того же extension_name;
|
||||
* (c) collision при попытке зарегистрировать тот же id/key из другого
|
||||
* extension_name → ConflictException;
|
||||
* (d) unregister чужой записи проигнорирован без удаления;
|
||||
* (e) listAgreementsForAccountType фильтрует по applicable_account_types;
|
||||
* (f) listAgreementsForProgram возвращает оферты в порядке agreement_ids;
|
||||
* (g) onExtensionTerminate чистит ровно записи этого extension_name,
|
||||
* чужие сохраняются.
|
||||
*
|
||||
* @OnEvent-декоратор сам по себе тут не проверяется — обработчик вызывается
|
||||
* напрямую как обычный метод, что соответствует unit-уровню.
|
||||
*/
|
||||
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import { AgreementRegistryService } from '~/domain/registration/services/agreement-registry.service';
|
||||
import type { AgreementRegistrationSpec } from '~/domain/registration/dto/agreement-registration-spec.dto';
|
||||
import type { ProgramRegistrationSpec } from '~/domain/registration/dto/program-registration-spec.dto';
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function makeLoggerStub() {
|
||||
return {
|
||||
setContext: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeAgreementSpec(
|
||||
partial: Partial<AgreementRegistrationSpec> & Pick<AgreementRegistrationSpec, 'id' | 'extension_name'>
|
||||
): AgreementRegistrationSpec {
|
||||
return {
|
||||
id: partial.id,
|
||||
registry_id: partial.registry_id ?? 1000,
|
||||
agreement_type: partial.agreement_type ?? 'test-agreement',
|
||||
title: partial.title ?? 'Оферта',
|
||||
checkbox_text: partial.checkbox_text ?? 'Я прочитал и принимаю',
|
||||
link_text: partial.link_text ?? 'оферту',
|
||||
applicable_account_types: partial.applicable_account_types ?? [AccountType.individual],
|
||||
order: partial.order ?? 1,
|
||||
extension_name: partial.extension_name,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProgramSpec(
|
||||
partial: Partial<ProgramRegistrationSpec> & Pick<ProgramRegistrationSpec, 'key' | 'extension_name'>
|
||||
): ProgramRegistrationSpec {
|
||||
return {
|
||||
key: partial.key,
|
||||
title: partial.title ?? 'Программа',
|
||||
description: partial.description ?? 'Описание',
|
||||
applicable_account_types: partial.applicable_account_types ?? [AccountType.individual],
|
||||
agreement_ids: partial.agreement_ids ?? [],
|
||||
order: partial.order ?? 1,
|
||||
extension_name: partial.extension_name,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('AgreementRegistryService', () => {
|
||||
let service: AgreementRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new AgreementRegistryService(makeLoggerStub());
|
||||
});
|
||||
|
||||
describe('registerAgreement', () => {
|
||||
it('регистрирует новую оферту и делает её доступной через getAgreement/listAgreements', () => {
|
||||
const spec = makeAgreementSpec({ id: 'capital_offer', extension_name: 'capital' });
|
||||
service.registerAgreement(spec);
|
||||
|
||||
expect(service.getAgreement('capital_offer')).toEqual(spec);
|
||||
expect(service.listAgreements()).toEqual([spec]);
|
||||
});
|
||||
|
||||
it('идемпотентен: повторный register от того же extension_name перезаписывает spec', () => {
|
||||
service.registerAgreement(
|
||||
makeAgreementSpec({ id: 'capital_offer', extension_name: 'capital', title: 'v1' })
|
||||
);
|
||||
service.registerAgreement(
|
||||
makeAgreementSpec({ id: 'capital_offer', extension_name: 'capital', title: 'v2' })
|
||||
);
|
||||
|
||||
expect(service.getAgreement('capital_offer')!.title).toBe('v2');
|
||||
expect(service.listAgreements()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('бросает ConflictException при попытке другого extension_name занять тот же id', () => {
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'shared_offer', extension_name: 'capital' }));
|
||||
|
||||
expect(() =>
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'shared_offer', extension_name: 'order-table' }))
|
||||
).toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('listAgreements возвращает оферты в порядке order возрастания', () => {
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'b', extension_name: 'capital', order: 2 }));
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'a', extension_name: 'capital', order: 1 }));
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'c', extension_name: 'capital', order: 3 }));
|
||||
|
||||
expect(service.listAgreements().map((s) => s.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterAgreement', () => {
|
||||
it('удаляет регистрацию владельца', () => {
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'capital_offer', extension_name: 'capital' }));
|
||||
service.unregisterAgreement('capital_offer', 'capital');
|
||||
|
||||
expect(service.getAgreement('capital_offer')).toBeNull();
|
||||
});
|
||||
|
||||
it('игнорирует попытку чужого extension_name снять регистрацию', () => {
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'capital_offer', extension_name: 'capital' }));
|
||||
service.unregisterAgreement('capital_offer', 'order-table');
|
||||
|
||||
expect(service.getAgreement('capital_offer')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('no-op для отсутствующего id', () => {
|
||||
expect(() => service.unregisterAgreement('missing', 'capital')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerProgram', () => {
|
||||
it('регистрирует программу и делает её доступной через getProgram/listPrograms', () => {
|
||||
const spec = makeProgramSpec({ key: 'generation', extension_name: 'capital' });
|
||||
service.registerProgram(spec);
|
||||
|
||||
expect(service.getProgram('generation')).toEqual(spec);
|
||||
expect(service.listPrograms()).toEqual([spec]);
|
||||
});
|
||||
|
||||
it('идемпотентен для того же extension_name', () => {
|
||||
service.registerProgram(
|
||||
makeProgramSpec({ key: 'generation', extension_name: 'capital', title: 'v1' })
|
||||
);
|
||||
service.registerProgram(
|
||||
makeProgramSpec({ key: 'generation', extension_name: 'capital', title: 'v2' })
|
||||
);
|
||||
|
||||
expect(service.getProgram('generation')!.title).toBe('v2');
|
||||
expect(service.listPrograms()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('бросает ConflictException при collision key между разными extension_name', () => {
|
||||
service.registerProgram(makeProgramSpec({ key: 'generation', extension_name: 'capital' }));
|
||||
|
||||
expect(() =>
|
||||
service.registerProgram(makeProgramSpec({ key: 'generation', extension_name: 'order-table' }))
|
||||
).toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAgreementsForAccountType', () => {
|
||||
it('возвращает только оферты, в applicable_account_types которых входит запрошенный тип', () => {
|
||||
service.registerAgreement(
|
||||
makeAgreementSpec({
|
||||
id: 'individual_only',
|
||||
extension_name: 'capital',
|
||||
applicable_account_types: [AccountType.individual],
|
||||
})
|
||||
);
|
||||
service.registerAgreement(
|
||||
makeAgreementSpec({
|
||||
id: 'org_only',
|
||||
extension_name: 'capital',
|
||||
applicable_account_types: [AccountType.organization],
|
||||
})
|
||||
);
|
||||
service.registerAgreement(
|
||||
makeAgreementSpec({
|
||||
id: 'all',
|
||||
extension_name: 'capital',
|
||||
applicable_account_types: [AccountType.individual, AccountType.organization],
|
||||
})
|
||||
);
|
||||
|
||||
const forIndividual = service.listAgreementsForAccountType(AccountType.individual).map((s) => s.id);
|
||||
expect(forIndividual.sort()).toEqual(['all', 'individual_only']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAgreementsForProgram', () => {
|
||||
it('возвращает оферты программы в порядке agreement_ids, пропуская незарегистрированные', () => {
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'a', extension_name: 'capital', order: 3 }));
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'b', extension_name: 'capital', order: 1 }));
|
||||
service.registerProgram(
|
||||
makeProgramSpec({
|
||||
key: 'generation',
|
||||
extension_name: 'capital',
|
||||
agreement_ids: ['b', 'a', 'missing'],
|
||||
})
|
||||
);
|
||||
|
||||
expect(service.listAgreementsForProgram('generation').map((s) => s.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('пустой массив для неизвестной программы', () => {
|
||||
expect(service.listAgreementsForProgram('unknown')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onExtensionTerminate', () => {
|
||||
it('чистит ровно записи завершённого расширения, чужие сохраняются', () => {
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'capital_offer', extension_name: 'capital' }));
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'order_offer', extension_name: 'order-table' }));
|
||||
service.registerProgram(makeProgramSpec({ key: 'generation', extension_name: 'capital' }));
|
||||
service.registerProgram(makeProgramSpec({ key: 'order_basic', extension_name: 'order-table' }));
|
||||
|
||||
service.onExtensionTerminate({ appName: 'capital' });
|
||||
|
||||
expect(service.getAgreement('capital_offer')).toBeNull();
|
||||
expect(service.getAgreement('order_offer')).not.toBeNull();
|
||||
expect(service.getProgram('generation')).toBeNull();
|
||||
expect(service.getProgram('order_basic')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('no-op если у расширения нет регистраций', () => {
|
||||
service.registerAgreement(makeAgreementSpec({ id: 'capital_offer', extension_name: 'capital' }));
|
||||
service.onExtensionTerminate({ appName: 'unused' });
|
||||
|
||||
expect(service.listAgreements()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -211,6 +211,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
generation_contract:"SignedDigitalDocumentInput",
|
||||
generator_offer:"SignedDigitalDocumentInput",
|
||||
storage_agreement:"SignedDigitalDocumentInput"
|
||||
},
|
||||
CompleteExtensionOnboardingStepInput:{
|
||||
|
||||
},
|
||||
CompleteProcessStepInput:{
|
||||
|
||||
@@ -922,6 +925,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
completeChairmanGeneralMeetStep:{
|
||||
data:"ChairmanOnboardingGeneralMeetInput"
|
||||
},
|
||||
completeExtensionOnboardingStep:{
|
||||
data:"CompleteExtensionOnboardingStepInput"
|
||||
},
|
||||
completeRequest:{
|
||||
data:"CompleteRequestInput"
|
||||
},
|
||||
@@ -1517,6 +1523,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
getExtensionLogs:{
|
||||
data:"GetExtensionLogsInput",
|
||||
options:"PaginationInput"
|
||||
},
|
||||
getExtensionOnboardingState:{
|
||||
|
||||
},
|
||||
getExtensions:{
|
||||
data:"GetExtensionsInput"
|
||||
@@ -1564,6 +1573,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
getProviderSubscriptionById:{
|
||||
|
||||
},
|
||||
getRegistrationAgreements:{
|
||||
account_type:"AccountType"
|
||||
},
|
||||
getRegistrationConfig:{
|
||||
account_type:"AccountType"
|
||||
@@ -3153,6 +3165,20 @@ export const ReturnTypes: Record<string,any> = {
|
||||
totalCount:"Int",
|
||||
totalPages:"Int"
|
||||
},
|
||||
ExtensionOnboardingState:{
|
||||
all_done:"Boolean",
|
||||
extension_name:"String",
|
||||
onboarding_expire_at:"String",
|
||||
onboarding_init_at:"String",
|
||||
steps:"ExtensionOnboardingStepState"
|
||||
},
|
||||
ExtensionOnboardingStepState:{
|
||||
default_title:"String",
|
||||
done:"Boolean",
|
||||
hash:"String",
|
||||
order:"Int",
|
||||
step_key:"String"
|
||||
},
|
||||
FieldError:{
|
||||
message:"String",
|
||||
path:"String"
|
||||
@@ -3548,6 +3574,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
completeCapitalOnboardingStep:"CapitalOnboardingState",
|
||||
completeChairmanAgendaStep:"ChairmanOnboardingState",
|
||||
completeChairmanGeneralMeetStep:"ChairmanOnboardingState",
|
||||
completeExtensionOnboardingStep:"ExtensionOnboardingState",
|
||||
completeRequest:"Transaction",
|
||||
confirmAgreement:"Transaction",
|
||||
confirmReceiveOnRequest:"Transaction",
|
||||
@@ -4132,6 +4159,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
getDesktop:"Desktop",
|
||||
getDocuments:"DocumentsAggregatePaginationResult",
|
||||
getExtensionLogs:"ExtensionLogsPaginationResult",
|
||||
getExtensionOnboardingState:"ExtensionOnboardingState",
|
||||
getExtensions:"Extension",
|
||||
getInstallationStatus:"InstallationStatus",
|
||||
getLedger:"LedgerState",
|
||||
@@ -4148,6 +4176,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
getProgramWallets:"ProgramWalletsPaginationResult",
|
||||
getProviderSubscriptionById:"ProviderSubscription",
|
||||
getProviderSubscriptions:"ProviderSubscription",
|
||||
getRegistrationAgreements:"RegistrationAgreement",
|
||||
getRegistrationConfig:"RegistrationConfig",
|
||||
getReport:"GeneratedReport",
|
||||
getReportCalendar:"ReportCalendarRow",
|
||||
@@ -4191,6 +4220,18 @@ export const ReturnTypes: Record<string,any> = {
|
||||
account:"Account",
|
||||
tokens:"Tokens"
|
||||
},
|
||||
RegistrationAgreement:{
|
||||
agreement_type:"String",
|
||||
applicable_account_types:"AccountType",
|
||||
checkbox_text:"String",
|
||||
id:"String",
|
||||
is_blockchain_agreement:"Boolean",
|
||||
link_text:"String",
|
||||
link_to_statement:"Boolean",
|
||||
order:"Int",
|
||||
registry_id:"Int",
|
||||
title:"String"
|
||||
},
|
||||
RegistrationConfig:{
|
||||
programs:"RegistrationProgram",
|
||||
requires_selection:"Boolean"
|
||||
|
||||
+1648
-452
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cooptypes",
|
||||
"type": "module",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const LEDGER2_WALLET_REGISTRY: readonly WalletMeta[] = [
|
||||
{ name: "w.sov.infra", human_name: "Членские взносы за инфраструктуру кооп. платформы", kind: "COOPERATIVE" },
|
||||
{ name: "w.sov.delgte", human_name: "Делегатские членские взносы", kind: "COOPERATIVE" },
|
||||
{ name: "w.sov.expns", human_name: "Хозяйственные расходы из числа целевого финансирования", kind: "COOPERATIVE" },
|
||||
{ name: "w.sov.mnused", human_name: "Использованные минимальные паевые взносы", kind: "COOPERATIVE" },
|
||||
{ name: "w.cap.loan", human_name: "Выданные пайщикам беспроцентные займы", kind: "COOPERATIVE" },
|
||||
{ name: "w.mkt.payout", human_name: "Выплаты поставщикам", kind: "COOPERATIVE" },
|
||||
] as const
|
||||
|
||||
@@ -27,6 +27,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_USER_SHARED_PRO
|
||||
"required_program_id": 3,
|
||||
"wallet_name": "w.cap.gen",
|
||||
},
|
||||
{
|
||||
"program_label": null,
|
||||
"required_program_id": 0,
|
||||
"wallet_name": "w.cap.preimp",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -57,6 +62,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
|
||||
"kind": "USER_SHARED",
|
||||
"name": "w.cap.gen",
|
||||
},
|
||||
{
|
||||
"human_name": "Первичный учёт РИД-взносов до перехода на электронный учёт",
|
||||
"kind": "USER_SHARED",
|
||||
"name": "w.cap.preimp",
|
||||
},
|
||||
{
|
||||
"human_name": "Вступительные взносы",
|
||||
"kind": "COOPERATIVE",
|
||||
@@ -77,6 +87,16 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
|
||||
"kind": "COOPERATIVE",
|
||||
"name": "w.sov.delgte",
|
||||
},
|
||||
{
|
||||
"human_name": "Хозяйственные расходы из числа целевого финансирования",
|
||||
"kind": "COOPERATIVE",
|
||||
"name": "w.sov.expns",
|
||||
},
|
||||
{
|
||||
"human_name": "Использованные минимальные паевые взносы",
|
||||
"kind": "COOPERATIVE",
|
||||
"name": "w.sov.mnused",
|
||||
},
|
||||
{
|
||||
"human_name": "Выданные пайщикам беспроцентные займы",
|
||||
"kind": "COOPERATIVE",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/desktop",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"description": "Рабочий стол кооператива — Vue 3 + Quasar Framework",
|
||||
"productName": "Desktop App",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
|
||||
@@ -164,12 +164,12 @@ export const useDesktopStore = defineStore(namespace, () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем, какие настройки использовать (авторизованный или неавторизованный пользователь)
|
||||
if (session.isAuth) {
|
||||
// Для авторизованных пользователей используем authorized_default_workspace
|
||||
// Authorized-рабочий стол выбираем только если пайщик принят советом
|
||||
// (status='active'). На промежуточных статусах юзер с WIF, но без участия —
|
||||
// должен видеть публичную главную, не дашборд (см. SessionStore.isFullyActive).
|
||||
if (session.isFullyActive) {
|
||||
defaultWorkspace = systemStore.info?.settings?.authorized_default_workspace || 'participant';
|
||||
} else {
|
||||
// Для неавторизованных пользователей используем non_authorized_default_workspace
|
||||
defaultWorkspace = systemStore.info?.settings?.non_authorized_default_workspace || 'participant';
|
||||
}
|
||||
|
||||
@@ -287,11 +287,11 @@ export const useDesktopStore = defineStore(namespace, () => {
|
||||
// Проверяем, есть ли настроенный маршрут для текущего рабочего стола
|
||||
let configuredRoute: string | undefined;
|
||||
|
||||
if (session.isAuth) {
|
||||
// Для авторизованных пользователей используем authorized_default_route
|
||||
// Authorized-маршрут — только для принятых советом (см. isFullyActive).
|
||||
// На created/joined/payed/registered отдаём non_authorized_default_route.
|
||||
if (session.isFullyActive) {
|
||||
configuredRoute = info?.settings?.authorized_default_route;
|
||||
} else {
|
||||
// Для неавторизованных пользователей используем non_authorized_default_route
|
||||
configuredRoute = info?.settings?.non_authorized_default_route;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ interface ISessionStore {
|
||||
clearAccount: () => void;
|
||||
// Computed свойства для текущего пользователя
|
||||
isRegistrationComplete: ComputedRef<boolean>;
|
||||
// status === 'active' — пайщик принят советом, можно показывать дашборд.
|
||||
// Промежуточные статусы (created/joined/payed/registered) — публичная главная.
|
||||
isFullyActive: ComputedRef<boolean>;
|
||||
isChairman: ComputedRef<boolean>;
|
||||
isMember: ComputedRef<boolean>;
|
||||
// Удобные геттеры для различных типов данных
|
||||
@@ -92,6 +95,14 @@ export const useSessionStore = defineStore('session', (): ISessionStore => {
|
||||
),
|
||||
);
|
||||
|
||||
// Пайщик принят советом (status === 'active' из users в моно).
|
||||
// Используется как порог для dashboard/wallet/документов. На промежуточных
|
||||
// статусах (created/joined/payed/registered) показываем публичную главную —
|
||||
// не редиректим, не дёргаем кошелёк, не открываем подписи оферт.
|
||||
const isFullyActive = computed(
|
||||
() => currentUserAccount.value?.user_account?.status === 'active',
|
||||
);
|
||||
|
||||
const isChairman = computed(() => {
|
||||
const chairman = currentUserAccount.value?.provider_account?.role === 'chairman';
|
||||
return chairman;
|
||||
@@ -140,6 +151,7 @@ export const useSessionStore = defineStore('session', (): ISessionStore => {
|
||||
setCurrentUserAccount,
|
||||
clearAccount,
|
||||
isRegistrationComplete,
|
||||
isFullyActive,
|
||||
isChairman,
|
||||
isMember,
|
||||
// Удобные геттеры для различных типов данных
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { client } from 'src/shared/api/client'
|
||||
import { Queries, Mutations } from '@coopenomics/sdk'
|
||||
import type {
|
||||
IExtensionOnboardingState,
|
||||
ICompleteExtensionOnboardingStepInput,
|
||||
} from '../model/types'
|
||||
|
||||
export async function fetchExtensionOnboardingState(
|
||||
extension_name: string
|
||||
): Promise<IExtensionOnboardingState> {
|
||||
const result = await client.Query(
|
||||
Queries.Onboarding.GetExtensionOnboardingState.query,
|
||||
{ variables: { extension_name } }
|
||||
)
|
||||
return result[Queries.Onboarding.GetExtensionOnboardingState.name]
|
||||
}
|
||||
|
||||
export async function completeExtensionOnboardingStep(
|
||||
data: ICompleteExtensionOnboardingStepInput
|
||||
): Promise<IExtensionOnboardingState> {
|
||||
const result = await client.Mutation(
|
||||
Mutations.Onboarding.CompleteExtensionOnboardingStep.mutation,
|
||||
{ variables: { data } }
|
||||
)
|
||||
return result[Mutations.Onboarding.CompleteExtensionOnboardingStep.name]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from './ui'
|
||||
export { useExtensionCooperativeOnboarding } from './model/composable'
|
||||
export type {
|
||||
IExtensionCooperativeOnboardingController,
|
||||
} from './model/composable'
|
||||
export type {
|
||||
IExtensionOnboardingState,
|
||||
IExtensionOnboardingStepState,
|
||||
ICompleteExtensionOnboardingStepInput,
|
||||
} from './model/types'
|
||||
export {
|
||||
fetchExtensionOnboardingState,
|
||||
completeExtensionOnboardingStep,
|
||||
} from './api'
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
|
||||
import {
|
||||
fetchExtensionOnboardingState,
|
||||
completeExtensionOnboardingStep,
|
||||
} from '../api'
|
||||
import type {
|
||||
IExtensionOnboardingState,
|
||||
IExtensionOnboardingStepState,
|
||||
ICompleteExtensionOnboardingStepInput,
|
||||
} from './types'
|
||||
|
||||
export interface IExtensionCooperativeOnboardingController {
|
||||
state: Ref<IExtensionOnboardingState | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
steps: ComputedRef<IExtensionOnboardingStepState[]>
|
||||
allDone: ComputedRef<boolean>
|
||||
expiresAt: ComputedRef<string>
|
||||
isExpired: ComputedRef<boolean>
|
||||
load(): Promise<void>
|
||||
completeStep(input: ICompleteExtensionOnboardingStepInput): Promise<void>
|
||||
reset(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Платформенный composable онбординга кооператива на расширение.
|
||||
*
|
||||
* Подгружает state расширения по `extension_name`, отдаёт реактивный
|
||||
* `steps[]` и флаг `allDone`. `completeStep` шлёт mutation и обновляет
|
||||
* локальное состояние результатом сервера — отдельный fetch не нужен.
|
||||
*
|
||||
* Используется в `<CooperativeOnboardingGate extension="...">`. Любое
|
||||
* расширение, у которого зарегистрированы шаги в платформенном реестре
|
||||
* (см. `ONBOARDING_STEP_REGISTRATION_PORT` в controller), сразу же
|
||||
* получает рабочий UI без своих api-обвязок.
|
||||
*/
|
||||
export function useExtensionCooperativeOnboarding(
|
||||
getExtensionName: () => string
|
||||
): IExtensionCooperativeOnboardingController {
|
||||
const state = ref<IExtensionOnboardingState | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const steps = computed<IExtensionOnboardingStepState[]>(
|
||||
() => state.value?.steps ?? []
|
||||
)
|
||||
const allDone = computed<boolean>(() => state.value?.all_done ?? false)
|
||||
const expiresAt = computed<string>(
|
||||
() => state.value?.onboarding_expire_at ?? ''
|
||||
)
|
||||
const isExpired = computed<boolean>(() => {
|
||||
if (!expiresAt.value) return false
|
||||
return new Date(expiresAt.value).getTime() < Date.now()
|
||||
})
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const extension_name = getExtensionName()
|
||||
if (!extension_name) return
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
state.value = await fetchExtensionOnboardingState(extension_name)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function completeStep(
|
||||
input: ICompleteExtensionOnboardingStepInput
|
||||
): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
state.value = await completeExtensionOnboardingStep(input)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
throw e
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
state.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => getExtensionName(),
|
||||
(next, prev) => {
|
||||
if (next && next !== prev) {
|
||||
reset()
|
||||
void load()
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
isLoading,
|
||||
error,
|
||||
steps,
|
||||
allDone,
|
||||
expiresAt,
|
||||
isExpired,
|
||||
load,
|
||||
completeStep,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Queries, Mutations } from '@coopenomics/sdk'
|
||||
|
||||
export type IExtensionOnboardingState = Queries.Onboarding.GetExtensionOnboardingState.IOutput[
|
||||
typeof Queries.Onboarding.GetExtensionOnboardingState.name
|
||||
]
|
||||
|
||||
export type IExtensionOnboardingStepState = IExtensionOnboardingState['steps'][number]
|
||||
|
||||
export type ICompleteExtensionOnboardingStepInput =
|
||||
Mutations.Onboarding.CompleteExtensionOnboardingStep.IInput['data']
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<template lang="pug">
|
||||
.cooperative-onboarding-gate
|
||||
template(v-if='isLoading && !state')
|
||||
.full-width.q-pa-lg.flex.flex-center
|
||||
q-spinner(size='32px', color='primary')
|
||||
template(v-else-if='error')
|
||||
q-banner(class='bg-negative text-white q-mb-md')
|
||||
| Не удалось загрузить состояние онбординга: {{ error }}
|
||||
template(#action)
|
||||
q-btn(flat, label='Повторить', @click='load')
|
||||
template(v-else-if='!allDone')
|
||||
slot(name='onboarding', :steps='steps', :on-propose='handlePropose', :all-done='allDone', :expires-at='expiresAt', :is-expired='isExpired')
|
||||
CooperativeOnboardingSteps(:steps='steps', @propose='handlePropose')
|
||||
template(v-else)
|
||||
slot
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useExtensionCooperativeOnboarding } from '../model/composable'
|
||||
import CooperativeOnboardingSteps from './CooperativeOnboardingSteps.vue'
|
||||
import type { IExtensionOnboardingStepState } from '../model/types'
|
||||
|
||||
const props = defineProps<{
|
||||
extension: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
propose: [step: IExtensionOnboardingStepState]
|
||||
ready: []
|
||||
}>()
|
||||
|
||||
const {
|
||||
state,
|
||||
isLoading,
|
||||
error,
|
||||
steps,
|
||||
allDone,
|
||||
expiresAt,
|
||||
isExpired,
|
||||
load,
|
||||
} = useExtensionCooperativeOnboarding(() => props.extension)
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
function handlePropose(step: IExtensionOnboardingStepState) {
|
||||
emit('propose', step)
|
||||
}
|
||||
|
||||
const _onReady = computed(() => {
|
||||
if (allDone.value) emit('ready')
|
||||
return allDone.value
|
||||
})
|
||||
void _onReady.value
|
||||
</script>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<template lang="pug">
|
||||
.cooperative-onboarding-steps.q-gutter-md
|
||||
q-card(v-for='step in steps', :key='step.step_key', flat, bordered)
|
||||
q-card-section
|
||||
.row.items-center.no-wrap.q-gutter-sm
|
||||
q-icon(
|
||||
:name='step.done ? "task_alt" : "radio_button_unchecked"',
|
||||
:color='step.done ? "positive" : "grey-6"',
|
||||
size='28px'
|
||||
)
|
||||
.col
|
||||
.text-subtitle1 {{ step.default_title || step.step_key }}
|
||||
.text-caption.text-grey-7(v-if='step.hash')
|
||||
| hash: {{ step.hash.substring(0, 12) }}…
|
||||
.row.q-mt-sm(v-if='!step.done')
|
||||
q-btn(
|
||||
flat,
|
||||
dense,
|
||||
color='primary',
|
||||
icon='campaign',
|
||||
:label='proposeLabel',
|
||||
@click='emit("propose", step)'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IExtensionOnboardingStepState } from '../model/types'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
steps: IExtensionOnboardingStepState[]
|
||||
proposeLabel?: string
|
||||
}>(),
|
||||
{
|
||||
proposeLabel: 'Создать предложение совету',
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
propose: [step: IExtensionOnboardingStepState]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CooperativeOnboardingGate } from './CooperativeOnboardingGate.vue'
|
||||
export { default as CooperativeOnboardingSteps } from './CooperativeOnboardingSteps.vue'
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './ui'
|
||||
export { useDocumentSigning } from './model/composable'
|
||||
export type {
|
||||
IDocumentSigningController,
|
||||
} from './model/composable'
|
||||
export type {
|
||||
IDocumentSigningOptions,
|
||||
ISigningDocument,
|
||||
IGeneratedRegistrationDocument,
|
||||
} from './model/types'
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
||||
import { Mutations } from '@coopenomics/sdk'
|
||||
import { client } from 'src/shared/api/client'
|
||||
import { DigitalDocument } from 'src/shared/lib/document'
|
||||
import type { IDocument } from 'src/shared/lib/types/document'
|
||||
import type {
|
||||
IDocumentSigningOptions,
|
||||
ISigningDocument,
|
||||
} from './types'
|
||||
|
||||
export interface IDocumentSigningController {
|
||||
documents: Ref<ISigningDocument[]>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<Error | null>
|
||||
allAccepted: ComputedRef<boolean>
|
||||
linkHashes: ComputedRef<string[]>
|
||||
load: () => Promise<ISigningDocument[]>
|
||||
setAccepted: (id: string, accepted: boolean) => void
|
||||
signAll: (
|
||||
username: string,
|
||||
onProgress?: (message: string) => void
|
||||
) => Promise<ISigningDocument[]>
|
||||
getById: (id: string) => ISigningDocument | undefined
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Платформенный composable для генерации и подписи пакета документов
|
||||
* пайщика. Работает на локальном state (без Pinia) — можно инстанциировать
|
||||
* независимо в любом месте: и в Registrator, и на странице расширения,
|
||||
* которое онбордит пайщика по своему flow.
|
||||
*
|
||||
* Не решает «когда показывать» — это делает место вызова. Composable
|
||||
* только генерирует пачку, отслеживает галочки и подписывает все
|
||||
* документы за один проход подписи.
|
||||
*/
|
||||
export function useDocumentSigning(
|
||||
getOpts: () => IDocumentSigningOptions
|
||||
): IDocumentSigningController {
|
||||
const documents = ref<ISigningDocument[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
const allAccepted = computed(() => {
|
||||
if (documents.value.length === 0) return false
|
||||
return documents.value.every((d) => d.accepted)
|
||||
})
|
||||
|
||||
const linkHashes = computed<string[]>(() => {
|
||||
return documents.value
|
||||
.filter((d) => d.link_to_statement && d.signed_document)
|
||||
.map((d) => (d.signed_document as IDocument).doc_hash)
|
||||
.filter((h): h is string => Boolean(h))
|
||||
})
|
||||
|
||||
const load = async (): Promise<ISigningDocument[]> => {
|
||||
const { coopname, username, accountType, programKey } = getOpts()
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const { [Mutations.Registration.GenerateRegistrationDocuments.name]: output } =
|
||||
await client.Mutation(
|
||||
Mutations.Registration.GenerateRegistrationDocuments.mutation,
|
||||
{
|
||||
variables: {
|
||||
data: {
|
||||
coopname,
|
||||
username,
|
||||
account_type: accountType,
|
||||
program_key: programKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
documents.value = output.documents.map((d) => ({
|
||||
...d,
|
||||
accepted: false,
|
||||
signed_document: undefined,
|
||||
}))
|
||||
|
||||
return documents.value
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
throw error.value
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setAccepted = (id: string, accepted: boolean) => {
|
||||
const doc = documents.value.find((d) => d.id === id)
|
||||
if (doc) doc.accepted = accepted
|
||||
}
|
||||
|
||||
const signAll = async (
|
||||
username: string,
|
||||
onProgress?: (message: string) => void
|
||||
): Promise<ISigningDocument[]> => {
|
||||
for (const doc of documents.value) {
|
||||
onProgress?.(`Подписываем ${doc.title}`)
|
||||
const digital = new DigitalDocument(doc.document)
|
||||
doc.signed_document = await digital.sign(username)
|
||||
}
|
||||
return [...documents.value]
|
||||
}
|
||||
|
||||
const getById = (id: string): ISigningDocument | undefined => {
|
||||
return documents.value.find((d) => d.id === id)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
documents.value = []
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
isLoading,
|
||||
error,
|
||||
allAccepted,
|
||||
linkHashes,
|
||||
load,
|
||||
setAccepted,
|
||||
signAll,
|
||||
getById,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Mutations, Zeus } from '@coopenomics/sdk'
|
||||
import type { IDocument } from 'src/shared/lib/types/document'
|
||||
|
||||
export type IGeneratedRegistrationDocument =
|
||||
Mutations.Registration.GenerateRegistrationDocuments.IOutput[
|
||||
typeof Mutations.Registration.GenerateRegistrationDocuments.name
|
||||
]['documents'][number]
|
||||
|
||||
export interface ISigningDocument extends IGeneratedRegistrationDocument {
|
||||
signed_document?: IDocument
|
||||
accepted?: boolean
|
||||
}
|
||||
|
||||
export interface IDocumentSigningOptions {
|
||||
coopname: string
|
||||
username: string
|
||||
accountType: Zeus.AccountType
|
||||
programKey?: string
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<template lang="pug">
|
||||
.documents-checklist.q-gutter-sm
|
||||
template(v-for='doc in documents', :key='doc.id')
|
||||
q-checkbox(
|
||||
:model-value='doc.accepted',
|
||||
@update:model-value='(val) => emit("update:accepted", doc.id, val)'
|
||||
).full-width
|
||||
| {{ doc.checkbox_text }}
|
||||
ReadAgreementDialog(
|
||||
v-if='doc.link_text',
|
||||
:agree='doc.accepted',
|
||||
@update:agree='(val) => emit("update:accepted", doc.id, val)',
|
||||
:text='doc.link_text'
|
||||
)
|
||||
// eslint-disable-next-line vue/no-v-html
|
||||
div(v-html='doc.document.html').q-mb-lg
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ReadAgreementDialog } from 'src/features/Agreementer/ReadAgreementDialog'
|
||||
import type { ISigningDocument } from '../model/types'
|
||||
|
||||
defineProps<{
|
||||
documents: ISigningDocument[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:accepted': [id: string, value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template lang="pug">
|
||||
.documents-sign-canvas
|
||||
.signature-container(
|
||||
ref='around',
|
||||
:class='{ "signature-started": signatureStarted }'
|
||||
)
|
||||
p.signature-hint {{ hint }}
|
||||
.q-mt-md.q-gutter-sm.row.justify-end
|
||||
q-btn(flat, label='Очистить', @click='clear')
|
||||
q-btn(color='primary', label='Подписать', @click='submit')
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Classes } from '@coopenomics/sdk'
|
||||
import { FailAlert } from 'src/shared/api'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
hint?: string
|
||||
}>(),
|
||||
{
|
||||
hint: 'Оставьте собственноручную подпись в рамке',
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
signed: [signature: string]
|
||||
}>()
|
||||
|
||||
const around = ref<HTMLElement | null>(null)
|
||||
const signatureStarted = ref(false)
|
||||
let canvasInst: Classes.Canvas | null = null
|
||||
|
||||
const attachStartHandler = () => {
|
||||
if (!canvasInst) return
|
||||
const cnv = canvasInst.canvas
|
||||
const handler = () => {
|
||||
signatureStarted.value = true
|
||||
cnv.removeEventListener('mousedown', handler)
|
||||
cnv.removeEventListener('touchstart', handler)
|
||||
}
|
||||
cnv.addEventListener('mousedown', handler)
|
||||
cnv.addEventListener('touchstart', handler)
|
||||
}
|
||||
|
||||
const initCanvas = () => {
|
||||
setTimeout(() => {
|
||||
if (around.value) {
|
||||
canvasInst = new Classes.Canvas(around.value, {
|
||||
lineWidth: 5,
|
||||
strokeStyle:
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--q-primary')
|
||||
.trim() || '#1976d2',
|
||||
})
|
||||
attachStartHandler()
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void nextTick(() => initCanvas())
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
canvasInst?.destroy()
|
||||
canvasInst = null
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
canvasInst?.clearCanvas()
|
||||
signatureStarted.value = false
|
||||
attachStartHandler()
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!canvasInst) {
|
||||
FailAlert('Пожалуйста, оставьте собственноручную подпись в окне')
|
||||
return
|
||||
}
|
||||
const sign = canvasInst.getSignature()
|
||||
const ctx = canvasInst.ctx
|
||||
const { width, height } = canvasInst.canvas
|
||||
const data = ctx.getImageData(0, 0, width, height).data
|
||||
const isEmpty = !data.some((channel) => channel !== 0)
|
||||
if (!sign || isEmpty) {
|
||||
FailAlert('Пожалуйста, оставьте собственноручную подпись в окне')
|
||||
return
|
||||
}
|
||||
emit('signed', sign)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signature-container {
|
||||
min-height: 300px;
|
||||
padding: 16px;
|
||||
border: 3px solid var(--q-primary);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 0 8px var(--q-primary),
|
||||
0 0 16px var(--q-primary),
|
||||
inset 0 0 8px rgba(255, 255, 255, 0.1);
|
||||
background: transparent;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.signature-container:hover {
|
||||
box-shadow:
|
||||
0 0 12px var(--q-primary),
|
||||
0 0 24px var(--q-primary),
|
||||
inset 0 0 12px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.signature-hint {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
color: var(--q-primary);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.body--dark .signature-hint {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: var(--q-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.signature-container.signature-started .signature-hint {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
pointer-events: none;
|
||||
transition: all 0.5s ease-out;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user