Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abcd34f23d | |||
| 83cac58fca | |||
| dbf46d88d0 | |||
| a0cb930b77 | |||
| 4a4145c4ed | |||
| 00860b3b18 | |||
| 8fe9509e97 | |||
| 848566bd69 | |||
| aeb4efe2b6 | |||
| 7ef138ba19 | |||
| e6880e149b | |||
| a9c1e1924a | |||
| 938c3ff042 | |||
| cd9ab32f04 | |||
| 47f616e980 | |||
| 908349ac67 | |||
| 64f8201eb6 | |||
| 5735e1fc36 | |||
| 4a6aa8c447 | |||
| b2a304e9de | |||
| 6eeb4567b1 | |||
| b92abb80a8 | |||
| dcd3c9293a | |||
| f952be5f83 | |||
| bd8eb34e43 | |||
| 7d9583d862 | |||
| 8847a5c093 | |||
| 37b7387830 | |||
| 968990c3ff | |||
| c32d265bb1 | |||
| a1bee6f630 | |||
| 2fd0087e93 | |||
| a967311d1e | |||
| c7db7a9c6f | |||
| 3db9854a76 | |||
| c970d879bc | |||
| 7d90d3af2d | |||
| 117c0c7c06 | |||
| 71b20e382d | |||
| 3561888781 | |||
| 2e3410b830 | |||
| de0f6c7956 | |||
| 5f41b74023 | |||
| c5eeef124c | |||
| e270b95996 | |||
| 8d8b01f148 | |||
| c5922f672f | |||
| bb1d8558e7 | |||
| e5151758fb | |||
| a575da57f8 | |||
| 9352c77cd7 | |||
| add7646720 | |||
| 0d8006d981 | |||
| f9d253117c | |||
| c6b144661f | |||
| a55005a212 | |||
| 5ae6f4963d | |||
| f64e028709 | |||
| 33f44dc823 | |||
| dd88047d76 | |||
| b9d06e4e68 | |||
| 8e263b7424 | |||
| 876d06046b | |||
| c5e23f15f9 | |||
| 996012399e | |||
| c4ffdae7ef | |||
| e60d339d2b | |||
| e7241d8a91 | |||
| 64c11820b0 | |||
| bfe5592ae9 | |||
| 9e94510981 | |||
| b432ad155d | |||
| eea5652d1f | |||
| 31da96dceb | |||
| 57cb8f4302 | |||
| f051196754 | |||
| 1c573bf131 | |||
| f35a13a382 | |||
| aced4966c8 | |||
| 978d94f6b7 | |||
| e5613405f4 | |||
| b41a42ad0a | |||
| 16ee154424 | |||
| a15432233e | |||
| ba96a650b8 | |||
| 4011628e2a | |||
| 1f9753cf8d | |||
| edd9f3ff91 | |||
| 8539b1e6fd | |||
| 477779c984 | |||
| 9491b6356d | |||
| 0a95748580 | |||
| cab6be45b3 | |||
| 59b25c2995 | |||
| adc8790c3e | |||
| 6796ed4109 | |||
| 4f9ff0c1d3 |
@@ -27,6 +27,12 @@ PG_HOST_PORT=5532
|
||||
COOPBACK_HOST_PORT=2998
|
||||
DESKTOP_HOST_PORT=2999
|
||||
OPENSEARCH_HOST_PORT=9200
|
||||
MINIO_HOST_PORT=9000
|
||||
MINIO_CONSOLE_HOST_PORT=9001
|
||||
|
||||
# MinIO root credentials (только для локального dev; на prod подменяются плейбуком).
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
|
||||
# URL для скриптов и кода, запускаемого с хоста (boot, networks.sh,
|
||||
# preactivate.sh, health.ts). Должны соответствовать host-портам выше.
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Debug info
|
||||
run: |
|
||||
echo "Текущая ветка:"
|
||||
git branch --show-current
|
||||
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
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Сначала собираем базовый образ с runtime
|
||||
- name: Build base image
|
||||
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
|
||||
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
|
||||
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
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.cooparser
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/parser\", \"run\", \"start\"]" >> Dockerfile.cooparser
|
||||
docker build -t dicoop/cooparser:${{ env.DOCKER_TAG }} -f Dockerfile.cooparser .
|
||||
docker push dicoop/cooparser:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/cooparser:${{ env.DOCKER_TAG }} dicoop/cooparser:latest
|
||||
docker push dicoop/cooparser:latest
|
||||
fi
|
||||
|
||||
- name: Build notificator image
|
||||
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
|
||||
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
|
||||
|
||||
# Отправка хука для деплоя
|
||||
- name: Trigger deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/*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
|
||||
# Хук для продакшена (остальные теги)
|
||||
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
fi
|
||||
|
||||
# Уведомление в Telegram об успехе
|
||||
- name: Telegram notify success
|
||||
if: ${{ success() }}
|
||||
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"
|
||||
|
||||
# Уведомление в Telegram об ошибке
|
||||
- name: Telegram notify failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]"
|
||||
@@ -1,18 +1,48 @@
|
||||
# .github/workflows/trigger-coopenomics.yml
|
||||
name: Trigger Contracts Docs Deploy
|
||||
|
||||
# Триггерит rebuild docs в coopenomics/coopenomics только по продакшн-
|
||||
# тэгам на main. Раньше дёргался на каждый push в dev/testnet/main/capital,
|
||||
# теперь — только формальный релиз (без -alpha/-beta/-rc/-test).
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testnet, main, capital] # или когда нужно триггерить
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_trigger: ${{ steps.check.outputs.should_trigger }}
|
||||
steps:
|
||||
- name: Checkout (для git branch --contains)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gate — main + non-alpha
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "Тэг $TAG — pre-release, docs deploy не дёргаем"
|
||||
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
|
||||
echo "Тэг $TAG не на main — docs deploy не дёргаем"
|
||||
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Тэг $TAG — продакшн релиз на main, дёргаем coopenomics"
|
||||
echo "should_trigger=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
trigger-coopenomics:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.should_trigger == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics # укажи правильный owner/repo
|
||||
repository: coopenomics/coopenomics
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
|
||||
@@ -7,12 +7,18 @@ name: Build contracts container
|
||||
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
|
||||
# контракты через `cleos set contract`.
|
||||
#
|
||||
# Триггер — push в одну из веток-окружений (dev / testnet / main).
|
||||
# Маппинг:
|
||||
# Триггер — push в одну из веток-окружений (dev / testnet / main) с
|
||||
# изменением .cpp/CMakeLists/build-скриптов. Маппинг:
|
||||
# dev → IS_TESTNET=ON, tag=dev
|
||||
# testnet → IS_TESTNET=ON, tag=testnet
|
||||
# main → IS_TESTNET=OFF, tag=main + latest
|
||||
#
|
||||
# ВАЖНО: тэги `v*` тут НЕ обрабатываются — они уезжают в `release.yaml`,
|
||||
# который делает релиз атомарно (контракты + контейнеры + webhook в
|
||||
# одном job'е). См. release.yaml для контекста — там в комментарии
|
||||
# описана причина (гонка build-contracts и build-containers по push'у
|
||||
# тэга, инцидент 2026-05-13 с ledger2 walletop sweep'ом).
|
||||
#
|
||||
# Сам compile идёт через CDT-образ `dicoop/blockchain_v5.1.1:dev`
|
||||
# (тот же, что использует `components/contracts/build.sh|build-all.sh`).
|
||||
# Артефакты копируются в slim alpine-образ.
|
||||
@@ -22,15 +28,12 @@ name: Build contracts container
|
||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testnet, main]
|
||||
paths:
|
||||
- 'components/contracts/cpp/**'
|
||||
- 'components/contracts/CMakeLists.txt'
|
||||
- 'components/contracts/build.sh'
|
||||
- 'components/contracts/build-all.sh'
|
||||
- 'components/contracts/docker/**'
|
||||
- '.github/workflows/build-contracts.yaml'
|
||||
# Только ручной запуск. Тэги (релизы) обрабатывает release.yaml,
|
||||
# который собирает контракты атомарно вместе с контейнерами и
|
||||
# webhook'ом — отдельная сборка по push'у в ветку только мешает
|
||||
# (две параллельные сборки на каждый release-bump). Здесь оставляем
|
||||
# workflow_dispatch для ручной пересборки `dicoop/contracts:<branch>`
|
||||
# (например, чтобы прокатить wasm на dev-ноду без релизного тэга).
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -44,19 +47,19 @@ jobs:
|
||||
run: |
|
||||
case "${{ github.ref_name }}" in
|
||||
main)
|
||||
echo "BUILD_MODE=prod" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=main" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=latest" >> $GITHUB_ENV
|
||||
echo "BUILD_MODE=prod" >> "$GITHUB_ENV"
|
||||
echo "DOCKER_TAG=main" >> "$GITHUB_ENV"
|
||||
echo "EXTRA_TAG=latest" >> "$GITHUB_ENV"
|
||||
;;
|
||||
testnet)
|
||||
echo "BUILD_MODE=test" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=testnet" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=" >> $GITHUB_ENV
|
||||
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
|
||||
echo "DOCKER_TAG=testnet" >> "$GITHUB_ENV"
|
||||
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
|
||||
;;
|
||||
dev)
|
||||
echo "BUILD_MODE=test" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=" >> $GITHUB_ENV
|
||||
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
|
||||
echo "DOCKER_TAG=dev" >> "$GITHUB_ENV"
|
||||
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported branch ${{ github.ref_name }}" >&2
|
||||
|
||||
@@ -1,34 +1,63 @@
|
||||
name: Publish Docs
|
||||
|
||||
# Публикация только по продакшн-тэгам, лежащим на main (без -alpha/-beta/-rc/-test).
|
||||
# Раньше триггерилось на каждый push в main/testnet/dev/reports/marketplace2 →
|
||||
# docs пересобирались по 5+ раз в день впустую. Теперь — только релиз.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- testnet
|
||||
- dev
|
||||
- reports
|
||||
- marketplace2
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_publish: ${{ steps.check.outputs.should_publish }}
|
||||
steps:
|
||||
- name: Checkout (для git branch --contains)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gate — main + non-alpha
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "Тэг $TAG — pre-release, docs не публикуем"
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
|
||||
echo "Тэг $TAG не на main — docs не публикуем"
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Тэг $TAG — продакшн релиз на main, публикуем docs"
|
||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-and-publish-docs:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.should_publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Версия pnpm берётся из `packageManager` корневого package.json,
|
||||
# синхронно с publish-packages.yaml и Dockerfile'ами.
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install Python requirements
|
||||
run: |
|
||||
python -m venv venv
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
name: Release
|
||||
|
||||
# Атомарный релизный workflow. Триггерится исключительно push'ем тэга
|
||||
# `v*`. В одном job'е по порядку:
|
||||
# 1) собирает контракты под mode (prod/test) и пушит `dicoop/contracts:<branch>`,
|
||||
# 2) собирает базовый образ + сервисные контейнеры и пушит `dicoop/<svc>:<tag>`,
|
||||
# 3) шлёт webhook деплоя (testnet/production по типу тэга).
|
||||
#
|
||||
# Зачем атомарно: раньше `build-contracts` и `build-containers`
|
||||
# крутились параллельно по push'у тэга, и webhook от build-containers
|
||||
# (~8 мин) обгонял окончание build-contracts (~11 мин) → ансибл на
|
||||
# тестнете подхватывал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
|
||||
# старым wasm. Попытка связать их через workflow_run упёрлась в
|
||||
# default-branch caveat и потерю head_sha в YAML-выражениях. Теперь
|
||||
# нет двух workflow'ов — нет гонки, нет fallback'ов.
|
||||
#
|
||||
# Триггер по веткам (dev/testnet/main без тэга) тут НЕ обрабатывается —
|
||||
# для CI-обновления `dicoop/contracts:<branch>` без релиза остаётся
|
||||
# `build-contracts.yaml`.
|
||||
#
|
||||
# Резолв ветки. Тэг семантики окружения не несёт (например
|
||||
# `v2026.5.13-alpha-2` может лежать на testnet или dev — определяет
|
||||
# именно ветка, на которую сделан merge перед тэгом). Поэтому ищем
|
||||
# первое совпадение SHA с remote-ветками main/testnet/dev.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve branch, build mode and tags
|
||||
run: |
|
||||
TAG_NAME="${{ github.ref_name }}"
|
||||
SHA="${{ github.sha }}"
|
||||
BRANCH=""
|
||||
for candidate in main testnet dev; do
|
||||
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
|
||||
BRANCH="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$BRANCH" in
|
||||
main)
|
||||
BUILD_MODE=prod
|
||||
CONTRACTS_TAG=main
|
||||
CONTRACTS_EXTRA=latest
|
||||
;;
|
||||
testnet)
|
||||
BUILD_MODE=test
|
||||
CONTRACTS_TAG=testnet
|
||||
CONTRACTS_EXTRA=
|
||||
;;
|
||||
dev)
|
||||
BUILD_MODE=test
|
||||
CONTRACTS_TAG=dev
|
||||
CONTRACTS_EXTRA=
|
||||
;;
|
||||
esac
|
||||
|
||||
# Прод-тэги (без -alpha/-beta/-rc/-test) уезжают на боевой
|
||||
# webhook + получают тэг :latest у сервисных образов.
|
||||
if [[ "$TAG_NAME" =~ -(alpha|beta|rc|test) ]]; then
|
||||
IS_PROD=false
|
||||
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
|
||||
else
|
||||
IS_PROD=true
|
||||
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "TAG_NAME=$TAG_NAME"
|
||||
echo "SHA=$SHA"
|
||||
echo "BRANCH=$BRANCH"
|
||||
echo "BUILD_MODE=$BUILD_MODE"
|
||||
echo "CONTRACTS_TAG=$CONTRACTS_TAG"
|
||||
echo "CONTRACTS_EXTRA=$CONTRACTS_EXTRA"
|
||||
echo "IS_PROD=$IS_PROD"
|
||||
echo "WEBHOOK_URL=$WEBHOOK_URL"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
echo "Release $TAG_NAME → branch=$BRANCH, build_mode=$BUILD_MODE, contracts:$CONTRACTS_TAG, prod=$IS_PROD"
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# === Этап 1: контракты ===
|
||||
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
- name: Stage contracts docker context
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
ROOT="docker/.context"
|
||||
rm -rf "$ROOT"
|
||||
mkdir -p "$ROOT/contracts"
|
||||
|
||||
# Имена контрактов = аргументы add_contract_build(...) только в
|
||||
# незакомменченных строках (CMake-комментарий начинается с `#`).
|
||||
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
|
||||
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
|
||||
echo "Contracts to package:"
|
||||
echo "$NAMES"
|
||||
|
||||
MANIFEST="$ROOT/contracts/manifest.json"
|
||||
|
||||
pack_one() {
|
||||
local NAME="$1" WASM="$2" ABI="$3"
|
||||
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
|
||||
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ROOT/contracts/$NAME"
|
||||
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
|
||||
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
|
||||
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
|
||||
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
|
||||
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
|
||||
"$NAME" "$SHA_WASM" "$SHA_ABI"
|
||||
}
|
||||
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
|
||||
printf ' "git_sha": "%s",\n' "$SHA"
|
||||
printf ' "tag": "%s",\n' "$TAG_NAME"
|
||||
printf ' "branch": "%s",\n' "$BRANCH"
|
||||
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf ' "contracts": [\n'
|
||||
|
||||
FIRST=1
|
||||
emit() {
|
||||
entry=$(pack_one "$@") || return 0
|
||||
[ $FIRST -eq 0 ] && printf ',\n'
|
||||
FIRST=0
|
||||
printf '%s' "$entry"
|
||||
}
|
||||
|
||||
for c in $NAMES; do
|
||||
if [ "$c" = "system" ]; then
|
||||
# У system многоконтрактная сборка: разносим под-контракты
|
||||
# eosio.* как полноценные элементы каталога, сам "system"
|
||||
# как имя пропускаем.
|
||||
for sub in build/contracts/system/contracts/eosio.*/; do
|
||||
[ -d "$sub" ] || continue
|
||||
sub_name=$(basename "$sub")
|
||||
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
|
||||
done
|
||||
else
|
||||
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n ]\n'
|
||||
printf '}\n'
|
||||
} > "$MANIFEST"
|
||||
|
||||
cp docker/Dockerfile "$ROOT/Dockerfile"
|
||||
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
|
||||
chmod +x "$ROOT/entrypoint.sh"
|
||||
|
||||
echo "--- manifest.json ---"
|
||||
cat "$MANIFEST"
|
||||
|
||||
- name: Build and push contracts image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
IMAGE="dicoop/contracts"
|
||||
SHORT_SHA="${SHA::7}"
|
||||
docker build \
|
||||
--label "org.opencontainers.image.revision=$SHA" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$CONTRACTS_TAG" \
|
||||
./docker/.context
|
||||
docker push "$IMAGE:$CONTRACTS_TAG"
|
||||
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
|
||||
if [ -n "$CONTRACTS_EXTRA" ]; then
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
|
||||
docker push "$IMAGE:$CONTRACTS_EXTRA"
|
||||
fi
|
||||
|
||||
- name: Verify pushed contracts image
|
||||
run: |
|
||||
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" list
|
||||
echo "---"
|
||||
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" sha256
|
||||
|
||||
# === Этап 2: контейнеры приложений ===
|
||||
|
||||
- name: Build and push base image
|
||||
run: |
|
||||
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
|
||||
docker push "dicoop/mono-base:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
- name: Build and push service images
|
||||
run: |
|
||||
build_service() {
|
||||
local SVC="$1" PKG="$2" CMD="$3"
|
||||
local DOCKERFILE="Dockerfile.$SVC"
|
||||
{
|
||||
echo "FROM dicoop/mono-base:$TAG_NAME"
|
||||
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
|
||||
} > "$DOCKERFILE"
|
||||
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
|
||||
docker push "dicoop/$SVC:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
|
||||
docker push "dicoop/$SVC:latest"
|
||||
fi
|
||||
}
|
||||
|
||||
build_service desktop '@coopenomics/desktop' start
|
||||
build_service coopback '@coopenomics/controller' start
|
||||
# TEMP: cooparser — пока не мигрировали потребителей на dicoop/parser
|
||||
# из отдельного coopenomics/parser repo.
|
||||
build_service cooparser '@coopenomics/parser' start
|
||||
build_service notificator 'coop-notificator' start
|
||||
build_service notifications '@coopenomics/notifications' sync
|
||||
|
||||
# === Этап 3: webhook деплоя ===
|
||||
|
||||
- name: Trigger deployment webhook
|
||||
run: |
|
||||
curl -X POST "$WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$TAG_NAME"
|
||||
|
||||
- name: Telegram notify success
|
||||
if: success()
|
||||
run: |
|
||||
EXTRA=""
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
EXTRA=" (с тегом latest)"
|
||||
fi
|
||||
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
-d "text=✅ [GITHUB MONO] Релиз $TAG_NAME ($BRANCH, contracts:$CONTRACTS_TAG, sha=${SHA::7})$EXTRA"
|
||||
|
||||
- name: Telegram notify failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
-d "text=❌ [GITHUB MONO] Ошибка релиза $TAG_NAME ($BRANCH, sha=${SHA::7})"
|
||||
+8
-2
@@ -35,7 +35,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN python3 -m venv /venv \
|
||||
&& /venv/bin/pip install --no-cache-dir WeasyPrint==67
|
||||
|
||||
RUN npm install -g pnpm lerna --no-fund --no-audit
|
||||
# pnpm пинится через `packageManager` поле в корневом package.json
|
||||
# (corepack-стандарт). Без пина CI каждый раз тянет latest, а pnpm 10
|
||||
# ужесточил политику build-скриптов и валит `--frozen-lockfile` с
|
||||
# ERR_PNPM_IGNORED_BUILDS на native-пакетах (electron / esbuild / …).
|
||||
RUN corepack enable && npm install -g lerna --no-fund --no-audit
|
||||
|
||||
# Сначала манифесты (для кэш-friendly install). `.dockerignore` уже
|
||||
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
|
||||
@@ -95,7 +99,9 @@ COPY --from=builder /app /app
|
||||
|
||||
# Глобальные pnpm/lerna — нужны чтобы потребители mono-base могли
|
||||
# делать `CMD ["pnpm","-F","<pkg>","run","start"]` в production.
|
||||
RUN npm install -g pnpm lerna --no-fund --no-audit
|
||||
# pnpm активируется через corepack (версия из `packageManager` корневого
|
||||
# package.json — синхронно со builder-стадией).
|
||||
RUN corepack enable && npm install -g lerna --no-fund --no-audit
|
||||
|
||||
# Sanity-check: WeasyPrint работает и виден через PATH.
|
||||
RUN weasyprint --version
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"version": "2026.5.14-2",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"version": "2026.5.14-2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"version": "2026.5.14-2",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
#include "src/expsub.cpp"
|
||||
#include "src/regcoop.cpp"
|
||||
#include "src/setcoop.cpp"
|
||||
#include "src/setpricing.cpp"
|
||||
#include "src/setglobals.cpp"
|
||||
#include "src/regclient.cpp"
|
||||
#include "src/delclient.cpp"
|
||||
#include "src/extendsub.cpp"
|
||||
#include "src/setattempt.cpp"
|
||||
|
||||
/**
|
||||
* \brief Миграция контракта.
|
||||
|
||||
@@ -247,6 +247,129 @@ public:
|
||||
std::optional<eosio::public_key> signing_key,
|
||||
std::optional<bool> active);
|
||||
|
||||
// ─── pricing & globals (v2.1) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Установить ставку плана `plan` для пакета `package_id` (FR1, D1).
|
||||
* \details Per-plan upsert в таблице `pricings` (scope = `package_id`):
|
||||
* row нет → `emplace`; row есть → `modify` с обновлённым
|
||||
* `hourly_rate` и `updated_at` = текущее время блока.
|
||||
* RAM payer — `get_self()` (`apps`-контракт; ВОСХОД как owner
|
||||
* аккаунта оплачивает RAM координационной плоскости).
|
||||
*
|
||||
* Story v2.1.1 — каркас без бизнес-валидации. Полная валидация
|
||||
* (`hourly_rate.amount > 0`, символ против `_root_govern_symbol`,
|
||||
* plan-whitelist, snapshot предыдущей ставки в audit_log_admin)
|
||||
* добавляется в Story v2.1.2.
|
||||
*
|
||||
* \param package_id scope таблицы `pricings` (Antelope `name`).
|
||||
* \param plan имя плана, PK (в MVP всегда `default`).
|
||||
* \param hourly_rate ставка за час (общая валюта экосистемы — RUB,4).
|
||||
* \note Авторизация: `require_auth(get_self())` — действие выполняется
|
||||
* контрактом от своего имени по запросу из `CA-admin` через
|
||||
* `KE write-adapter` (Story v2.1.3).
|
||||
*/
|
||||
[[eosio::action]] void setpricing(eosio::name package_id,
|
||||
eosio::name plan,
|
||||
eosio::asset hourly_rate);
|
||||
|
||||
/**
|
||||
* \brief Установить общесистемные параметры каталога (FR2, D2).
|
||||
* \details Полная замена singleton `globals` (scope = `get_self()`):
|
||||
* row нет → создаём, есть → перезаписываем все четыре поля.
|
||||
* RAM payer — `get_self()`. Story v2.1.1 — каркас без
|
||||
* валидации диапазонов; валидация и snapshot предыдущего
|
||||
* state'а добавляются в Story v2.1.2.
|
||||
*
|
||||
* \note Авторизация: `require_auth(get_self())`.
|
||||
*/
|
||||
[[eosio::action]] void setglobals(uint32_t min_payment_period_seconds,
|
||||
uint32_t free_trial_period_seconds,
|
||||
uint32_t lead_time_seconds,
|
||||
uint8_t retry_max);
|
||||
|
||||
// ─── clients (multi-tenant onboarding, v2.4) ────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Зарегистрировать кооператив-клиента каталога (FR7, D3).
|
||||
* \details `clients`-row {scope=catalog_operator, PK=client_coopname}
|
||||
* с `registered_at = now`. Идемпотентность строгая: повторный
|
||||
* вызов на ту же пару → `eosio_assert("client already registered")`.
|
||||
* RAM payer — `catalog_operator` (ВОСХОД оплачивает onboarding).
|
||||
* \param catalog_operator оператор каталога, подписывает транзакцию
|
||||
* и платит RAM (в MVP всегда `voskhod`).
|
||||
* \param client_coopname кооператив, который подключается к каталогу.
|
||||
* \note Авторизация: `require_auth(catalog_operator)`.
|
||||
*/
|
||||
[[eosio::action]] void regclient(eosio::name catalog_operator,
|
||||
eosio::name client_coopname);
|
||||
|
||||
/**
|
||||
* \brief Отозвать кооператив-клиента каталога (FR8, D3).
|
||||
* \details Удаляет row по PK; row нет → `eosio_assert("client not found")`
|
||||
* (не идемпотентно сознательно — отличаем «уже отзывали» от
|
||||
* «никогда не регистрировали»). Эффекты на CA-стороне:
|
||||
* инвалидация membership-cache (D9 hybrid TTL) + добавление
|
||||
* активных JWT в `JwtRevokeList` (FR15) живут off-chain.
|
||||
* \note Авторизация: `require_auth(catalog_operator)`.
|
||||
*/
|
||||
[[eosio::action]] void delclient(eosio::name catalog_operator,
|
||||
eosio::name client_coopname);
|
||||
|
||||
// ─── subscription extend / retry-counter (D4, v2.5/v2.6) ────────────
|
||||
|
||||
/**
|
||||
* \brief Идемпотентно продлить подписку через `charge_intent_id` (FR24, D4, D5).
|
||||
* \details Находит `regsub` по `(subscriber, package_id)`; проверяет,
|
||||
* что `last_charge_intent_id != charge_intent_id`. Иначе —
|
||||
* `eosio_assert("already extended")` (это **ok-сигнал** для
|
||||
* recovery-worker'а: дубль ack'а из кабинета или повтор после
|
||||
* CA-crash). На успешном пути:
|
||||
* - `end_at += period_seconds` (extend от текущей границы,
|
||||
* не от now — important для непрерывности подписки),
|
||||
* - `last_charge_intent_id = charge_intent_id`,
|
||||
* - `attempt = 0` (счётчик retry'ев сбрасывается на удачном
|
||||
* charge'е),
|
||||
* - `updated_at = now`.
|
||||
*
|
||||
* Story v2.5.2 (TS write-port `extendByChargeIntent`) ожидает
|
||||
* именно эту семантику; `AlreadyExtendedError` маппится с
|
||||
* on-chain assert'а.
|
||||
*
|
||||
* \param catalog_operator оператор каталога (платит RAM, подписывает).
|
||||
* \param subscriber кооператив-подписчик.
|
||||
* \param package_id пакет.
|
||||
* \param period_seconds на сколько продлить (в секундах). Должен быть > 0.
|
||||
* \param charge_intent_id UUIDv5 от `(coopname, package_id, period_start_at)`.
|
||||
* \note Авторизация: `require_auth(catalog_operator)`.
|
||||
*/
|
||||
[[eosio::action]] void extendsub(eosio::name catalog_operator,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id,
|
||||
uint32_t period_seconds,
|
||||
eosio::checksum256 charge_intent_id);
|
||||
|
||||
/**
|
||||
* \brief Установить `attempt`-счётчик для подписки (D4 retry-management).
|
||||
* \details Простая запись uint8 в существующую `regsub`-row. Используется
|
||||
* pricing-watcher'ом CA, когда `ack=declined` или charge провалился
|
||||
* (типичный путь: `setattempt(coopname, package_id, current+1)`).
|
||||
* Каскад с `extendsub` не нужен — последний сам сбросит attempt
|
||||
* в 0 при успехе.
|
||||
*
|
||||
* Альтернатива (on-chain incrattempt без передачи нового значения)
|
||||
* могла бы избежать сравнения counter'ов на гонках, но усложняет
|
||||
* контракт и редко полезна: pricing-watcher tick — единственный
|
||||
* путь, и он сериализован per-(coopname, package_id) на стороне
|
||||
* watcher-state в Postgres.
|
||||
*
|
||||
* \note Авторизация: `require_auth(catalog_operator)`.
|
||||
*/
|
||||
[[eosio::action]] void setattempt(eosio::name catalog_operator,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id,
|
||||
uint8_t attempt);
|
||||
|
||||
// ─── service tables ─────────────────────────────────────────────────
|
||||
|
||||
struct [[eosio::table, eosio::contract(APPS)]] counts : counts_base {};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* \brief Отозвать кооператив-клиента каталога (Story v2.4.1, FR8).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Что делает действие:
|
||||
* 1. Требует `catalog_operator @ active` — `require_auth(catalog_operator)`.
|
||||
* 2. Валидирует, что `client_coopname` непустое.
|
||||
* 3. Удаляет row по primary key. Если row нет → `eosio_assert("client not found")`.
|
||||
* Идемпотентность сознательно НЕ соблюдается: повторный `delclient`
|
||||
* должен вернуть явную ошибку, чтобы вышестоящие watcher'ы
|
||||
* (cache-invalidation, JWT-revocation в CA) могли отличить «уже
|
||||
* отзывали» от «никогда не было».
|
||||
*
|
||||
* **Эффекты на TS-стороне** (живут в `apps-catalog`, не на цепи):
|
||||
* - membership-cache для `client_coopname` инвалидируется через
|
||||
* hybrid-TTL (D9): критический путь забирает уже актуальный ответ
|
||||
* из chain'а, read-API получает stale-ответ ≤ 60s.
|
||||
* - JWT с `coopname=client_coopname` помещаются в `JwtRevokeList`
|
||||
* (FR15): пока не истечёт TTL токена — он отозван по jti.
|
||||
*
|
||||
* RAM payer возвращается `catalog_operator`. `subs` записи кооператива
|
||||
* НЕ удаляются автоматически — это решение оператора, и история
|
||||
* подписок остаётся в `subs` для аудита (флаг `active=false`
|
||||
* выставляется отдельным `expsub`-вызовом).
|
||||
*/
|
||||
void apps::delclient(eosio::name catalog_operator,
|
||||
eosio::name client_coopname) {
|
||||
require_auth(catalog_operator);
|
||||
|
||||
eosio::check(catalog_operator.value != 0, "catalog_operator must not be empty");
|
||||
eosio::check(client_coopname.value != 0, "client_coopname must not be empty");
|
||||
|
||||
clients_index clients(get_self(), catalog_operator.value);
|
||||
auto it = clients.find(client_coopname.value);
|
||||
eosio::check(it != clients.end(), "client not found");
|
||||
|
||||
clients.erase(it);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* \brief Идемпотентное продление подписки через `charge_intent_id`
|
||||
* (D4, D5; story v2.5/v2.6, FR24).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Что делает действие:
|
||||
* 1. Требует подпись `catalog_operator @ active`.
|
||||
* 2. Валидирует входы: `subscriber`, `package_id` непустые,
|
||||
* `period_seconds > 0`.
|
||||
* 3. Находит `regsub` по `(subscriber, package_id)` через secondary
|
||||
* index `bycooppkg`. Если нет — `eosio_assert("subscription not found")`
|
||||
* (вызов до `regsub` — программная ошибка CA-стороны, а не штатный
|
||||
* control-flow).
|
||||
* 4. Сверяет `last_charge_intent_id != charge_intent_id`. Если уже
|
||||
* этот intent был применён — `eosio_assert("already extended")`.
|
||||
* Это и есть double-emit prevention уровня цепи (D5):
|
||||
* UUIDv5-детерминированный id + on-chain unique-by-last проверка
|
||||
* закрывают двойное продление при race'ах watcher-recovery
|
||||
* или повторных webhook'ах из кабинета.
|
||||
* 5. На успешном пути модифицирует `regsub`:
|
||||
* - `end_at += period_seconds` (extend ОТ текущей границы, не от now —
|
||||
* это важно для непрерывности оплаченных интервалов; см. AR12).
|
||||
* - `last_charge_intent_id = charge_intent_id`.
|
||||
* - `attempt = 0` (счётчик retry'ев сбрасывается на удачном charge'е).
|
||||
* - `updated_at = now`.
|
||||
*
|
||||
* RAM payer — `catalog_operator` (оператор оплачивает координацию;
|
||||
* клиент-кооператив за `regsub`-row не платит).
|
||||
*/
|
||||
void apps::extendsub(eosio::name catalog_operator,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id,
|
||||
uint32_t period_seconds,
|
||||
eosio::checksum256 charge_intent_id) {
|
||||
require_auth(catalog_operator);
|
||||
|
||||
eosio::check(subscriber.value != 0, "subscriber must not be empty");
|
||||
eosio::check(package_id.value != 0, "package_id must not be empty");
|
||||
eosio::check(period_seconds > 0, "period_seconds must be positive");
|
||||
|
||||
subs_index subs(_apps, _apps.value);
|
||||
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
|
||||
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
|
||||
auto it = by_cooppkg.find(key);
|
||||
eosio::check(it != by_cooppkg.end(), "subscription not found");
|
||||
|
||||
eosio::check(it->last_charge_intent_id != charge_intent_id,
|
||||
"already extended");
|
||||
|
||||
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
by_cooppkg.modify(it, catalog_operator, [&](auto &s) {
|
||||
s.end_at = eosio::time_point_sec(s.end_at.sec_since_epoch() + period_seconds);
|
||||
s.last_charge_intent_id = charge_intent_id;
|
||||
s.attempt = 0;
|
||||
s.updated_at = now;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* \brief Зарегистрировать кооператив-клиента каталога (Story v2.4.1).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Что делает действие:
|
||||
* 1. Требует подпись `catalog_operator @ active` —
|
||||
* `require_auth(catalog_operator)`. В MVP это всегда `voskhod`,
|
||||
* но контракт не хардкодит имя — на проверке authority единственное
|
||||
* допустимое подписавшее лицо это и есть scope записи.
|
||||
* 2. Валидирует, что `client_coopname` непустое (eosio::name(0) —
|
||||
* особое значение «отсутствие», не валидное имя).
|
||||
* 3. Идемпотентно с проверкой: row нет → `emplace`. Row есть →
|
||||
* `eosio_assert("client already registered")`. Это намеренный
|
||||
* «строгий» upsert — повторный `regclient` без явного `delclient`
|
||||
* должен ловить ошибки оператора (двойной вызов из-за UI-сбоя).
|
||||
*
|
||||
* RAM payer — `catalog_operator`. ВОСХОД оплачивает RAM записи как
|
||||
* часть стоимости подключения кооператива; клиент за хранение `clients`-row
|
||||
* не платит.
|
||||
*
|
||||
* Что **не делает** (выносится в TS-сторону, Story v2.4.5):
|
||||
* - Не валидирует наличие `coops`-записи у `client_coopname`. Кооператив
|
||||
* может быть подключён к каталогу до того, как оформлен в `coops`
|
||||
* (signing_key, chain_id — это уровень subnet-операций). Каталог
|
||||
* проверит это сам перед issue'ем JWT.
|
||||
* - Не выпускает события / webhook'и. Audit-trail off-chain в
|
||||
* `audit_log_admin` через `regsub`-watcher CA.
|
||||
*/
|
||||
void apps::regclient(eosio::name catalog_operator,
|
||||
eosio::name client_coopname) {
|
||||
require_auth(catalog_operator);
|
||||
|
||||
eosio::check(catalog_operator.value != 0, "catalog_operator must not be empty");
|
||||
eosio::check(client_coopname.value != 0, "client_coopname must not be empty");
|
||||
|
||||
clients_index clients(get_self(), catalog_operator.value);
|
||||
auto it = clients.find(client_coopname.value);
|
||||
eosio::check(it == clients.end(), "client already registered");
|
||||
|
||||
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
clients.emplace(catalog_operator, [&](auto &c) {
|
||||
c.client_coopname = client_coopname;
|
||||
c.registered_at = now;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* \brief Установить `attempt`-счётчик в `regsub` (D4 retry-management).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Pricing-watcher CA вызывает это действие, когда `ack=declined` или
|
||||
* charge не дошёл, для отметки факта повторной попытки. Контракт
|
||||
* не вычисляет `current+1` сам — watcher держит state в Postgres
|
||||
* (charge_intents.attempt) и передаёт нужное значение явно. Так проще:
|
||||
* любая ошибочная гонка фиксируется на уровне Postgres-инкремента,
|
||||
* а контракт остаётся stateless относительно retry-логики.
|
||||
*
|
||||
* - `extendsub` сам сбрасывает `attempt=0` при удачном charge'е, поэтому
|
||||
* pricing-watcher не должен дёргать `setattempt(0)` после успеха.
|
||||
* - При `attempt > globals.retry_max` watcher эмитит
|
||||
* `SubscriptionExpiredError` (story v2.6.9) и прекращает попытки;
|
||||
* контракт это знание не дублирует — `retry_max` хранится в
|
||||
* `globals` singleton и читается только off-chain.
|
||||
*/
|
||||
void apps::setattempt(eosio::name catalog_operator,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id,
|
||||
uint8_t attempt) {
|
||||
require_auth(catalog_operator);
|
||||
|
||||
eosio::check(subscriber.value != 0, "subscriber must not be empty");
|
||||
eosio::check(package_id.value != 0, "package_id must not be empty");
|
||||
|
||||
subs_index subs(_apps, _apps.value);
|
||||
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
|
||||
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
|
||||
auto it = by_cooppkg.find(key);
|
||||
eosio::check(it != by_cooppkg.end(), "subscription not found");
|
||||
|
||||
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
by_cooppkg.modify(it, catalog_operator, [&](auto &s) {
|
||||
s.attempt = attempt;
|
||||
s.updated_at = now;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* \brief Установка общесистемных параметров каталога (singleton-перезапись).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Story v2.1.1: каркас без валидации диапазонов. Что делает действие:
|
||||
* 1. Требует `apps@active` — `require_auth(get_self())`.
|
||||
* 2. Полная перезапись `globals` singleton (scope = `get_self()`,
|
||||
* PK у singleton нет): четыре поля заполняются переданными значениями.
|
||||
*
|
||||
* Что **не делает** (выносится в Story v2.1.2):
|
||||
* - Проверка диапазонов: `min_payment_period_seconds >= 60`,
|
||||
* `free_trial_period_seconds <= 31 * 86400`, `retry_max <= 8` и т.п.
|
||||
* - Snapshot предыдущего state'а в audit_log_admin — off-chain
|
||||
* (journal-less invariant).
|
||||
*
|
||||
* RAM payer — `get_self()`. ВОСХОД как owner аккаунта `apps` оплачивает
|
||||
* хранение singleton'а (одна запись, минимальный RAM).
|
||||
*/
|
||||
void apps::setglobals(uint32_t min_payment_period_seconds,
|
||||
uint32_t free_trial_period_seconds,
|
||||
uint32_t lead_time_seconds,
|
||||
uint8_t retry_max) {
|
||||
require_auth(get_self());
|
||||
|
||||
globals_singleton globals(get_self(), get_self().value);
|
||||
globals.set(globals_row{
|
||||
.min_payment_period_seconds = min_payment_period_seconds,
|
||||
.free_trial_period_seconds = free_trial_period_seconds,
|
||||
.lead_time_seconds = lead_time_seconds,
|
||||
.retry_max = retry_max,
|
||||
},
|
||||
get_self());
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* \brief Установка ставки плана для пакета (per-plan upsert) с валидацией (Story v2.1.2).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Что делает действие:
|
||||
* 1. Требует `apps@active` — `require_auth(get_self())`.
|
||||
* 2. Валидирует входы: `package_id` и `plan` непустые (FR4 — сама длина
|
||||
* ≤ 13 уже гарантирована типом `eosio::name`, но `name(0)` — особое
|
||||
* значение «отсутствие», его явно отсекаем для удобной ошибки).
|
||||
* 3. Валидирует `hourly_rate.amount > 0` (FR1) — отрицательная или
|
||||
* нулевая ставка не имеет экономического смысла и блокирует
|
||||
* корректный расчёт `charge.required` в watcher'е.
|
||||
* 4. В таблице `pricings` (scope = `package_id`) находит запись по `plan`:
|
||||
* - row нет → `emplace` с переданными `hourly_rate` и `updated_at = now`.
|
||||
* - row есть → `modify`: обновляет `hourly_rate` и `updated_at`.
|
||||
*
|
||||
* **Snapshot policy (FR2, journal-less invariant).** Действие НИКОГДА не
|
||||
* трогает таблицу `subs`: поле `regsub.end_at` существующих подписок
|
||||
* остаётся как есть — перерасчёта прошлого нет. Текущий оплаченный
|
||||
* период подписок зафиксирован в момент `regsub`/`extendsub` по тогдашней
|
||||
* ставке, и ретро-изменения тарифа на него не влияют. Snapshot для
|
||||
* audit-журнала живёт off-chain в `audit_log_admin` (журнал-less
|
||||
* инвариант — на цепи нет log-таблиц, история восстанавливается из
|
||||
* trace'ов Antelope).
|
||||
*
|
||||
* Что **намеренно не валидируется здесь:**
|
||||
* - Символ `hourly_rate.symbol` против `_root_govern_symbol` (`RUB,4`).
|
||||
* Cross-currency расчёт не предусмотрен MVP, но жёсткая привязка
|
||||
* к одному символу даст ложноотрицательные ошибки при будущих
|
||||
* multi-currency сценариях. Адаптер CA-стороны нормализует символ
|
||||
* до отправки.
|
||||
* - Whitelist имён `plan`. В MVP только `default`, но on-chain
|
||||
* хранилище должно оставаться открытым для будущих tier-планов
|
||||
* без миграции контракта.
|
||||
*
|
||||
* RAM payer — `get_self()` (`apps`-контракт). Фактически платит ВОСХОД
|
||||
* как owner аккаунта `apps`; pricing-таблицы — часть координационной
|
||||
* плоскости, оплачиваемой оператором каталога.
|
||||
*/
|
||||
void apps::setpricing(eosio::name package_id,
|
||||
eosio::name plan,
|
||||
eosio::asset hourly_rate) {
|
||||
require_auth(get_self());
|
||||
|
||||
eosio::check(package_id.value != 0, "package_id must not be empty");
|
||||
eosio::check(plan.value != 0, "plan must not be empty");
|
||||
eosio::check(hourly_rate.amount > 0, "hourly_rate must be positive");
|
||||
|
||||
pricings_index pricings(get_self(), package_id.value);
|
||||
auto it = pricings.find(plan.value);
|
||||
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
|
||||
if (it == pricings.end()) {
|
||||
pricings.emplace(get_self(), [&](auto &p) {
|
||||
p.plan = plan;
|
||||
p.hourly_rate = hourly_rate;
|
||||
p.updated_at = now;
|
||||
});
|
||||
} else {
|
||||
pricings.modify(it, get_self(), [&](auto &p) {
|
||||
p.hourly_rate = hourly_rate;
|
||||
p.updated_at = now;
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -62,7 +62,27 @@ void capital::importcontrib(eosio::name coopname, eosio::name username, checksum
|
||||
// Пополнение кошелька программы благороста
|
||||
std::string internal_memo = Capital::Memo::get_import_contributor_memo(contributor_hash, contribution_amount);
|
||||
|
||||
// Увеличиваем паевой фонд через ledger2: ISSUE BLAGOROST_FUND, Dr 51 / Cr 80.
|
||||
// Если у пайщика существует пред-импорт-учёт РИД (`w.cap.preimp`) — сжигаем
|
||||
// его полностью через `o.cap.drppre` (Dr 80 / Cr 04, BURN PREIMP_FUND) ДО
|
||||
// основного импорта. После drppre основной `o.cap.import` переоткрывает
|
||||
// учёт на полный `contribution_amount` (включая возможную доплату/списание).
|
||||
// Один process_hash на обе операции — бэкенд видит цельную цепочку IMPORT.
|
||||
{
|
||||
userwallets_index user_wallets(_ledger2, coopname.value);
|
||||
auto idx = user_wallets.get_index<"byuserwallet"_n>();
|
||||
auto preimp_it = idx.find(combine_ids(ledger2_wallets::PREIMP_FUND.value, username.value));
|
||||
if (preimp_it != idx.end() && preimp_it->available.amount > 0) {
|
||||
eosio::check(preimp_it->blocked.amount == 0,
|
||||
"preimp.blocked > 0 не поддерживается при импорте — обратиться в поддержку");
|
||||
Ledger2::apply(
|
||||
_capital, coopname, operations::capital::DROP_PREIMP,
|
||||
preimp_it->available, username, contributor_hash,
|
||||
std::string{"Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт"}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Увеличиваем паевой фонд через ledger2: ISSUE BLAGOROST_FUND, Dr 04 / Cr 80.
|
||||
Ledger2::apply(_capital, coopname, operations::capital::IMPORT, contribution_amount, username, contributor_hash, internal_memo);
|
||||
|
||||
|
||||
}
|
||||
-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.
|
||||
}
|
||||
|
||||
@@ -95,10 +95,10 @@ inline LegacyBalances read_legacy_balances(eosio::name coopname) {
|
||||
case Ledger::accounts::SHARE_FUND: r.share = total; break; // 80
|
||||
case Ledger::accounts::ENTRANCE_FEES: r.entry = total; break; // 861
|
||||
default:
|
||||
// Все прочие legacy-id в проде нулевые. Ненулевое — сигнал.
|
||||
eosio::check(false,
|
||||
std::string{"migrate: неожиданный ненулевой legacy acc id="} +
|
||||
std::to_string(acc_it->id));
|
||||
// Прочие legacy-id (862..867 — RESERVE/INDIVISIBLE/ECONOMIC/MUTUAL/DEVELOPMENT/DELEGATE_FEES)
|
||||
// в ledger2 как отдельные кошельки не выделены. Намеренно пропускаем —
|
||||
// не падаем, чтобы не блокировать миграцию кооператива.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
@@ -232,67 +232,230 @@ inline void emplace_wallet_only(eosio::name self_name,
|
||||
|
||||
#ifndef IS_TESTNET
|
||||
/**
|
||||
* Спец-ветка миграции для voskhod — РУЧНОЙ ХАРДКОД сумм по кошелькам.
|
||||
* Прямой emplace в wallets2 с произвольным available/blocked. Используется
|
||||
* только из voskhod-спец-ветки: там L2-агрегаты нужно завести строго по
|
||||
* фактическому распределению chain (Благорост — blocked, не available).
|
||||
*/
|
||||
inline void emplace_wallet_balance(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
eosio::name wallet_id,
|
||||
const eosio::asset& available,
|
||||
const eosio::asset& blocked) {
|
||||
if (available.amount == 0 && blocked.amount == 0) return;
|
||||
|
||||
wallets2_index wallets(self_name, coopname.value);
|
||||
const auto human_view = ledger2_get_wallet_human_name(wallet_id);
|
||||
eosio::check(!human_view.empty(),
|
||||
std::string{"migrate: unknown wallet "} + wallet_id.to_string());
|
||||
|
||||
auto it = wallets.find(wallet_id.value);
|
||||
eosio::check(it == wallets.end(),
|
||||
std::string{"migrate: wallet "} + wallet_id.to_string() + " уже создан");
|
||||
wallets.emplace(self_name, [&](auto& w) {
|
||||
w.id = wallet_id;
|
||||
w.name = std::string(human_view);
|
||||
w.available = available;
|
||||
w.blocked = blocked;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Прямой emplace в accounts2 БЕЗ бух-проводок. Используется только из
|
||||
* voskhod-спец-ветки: после хардкода в кошельках замыкаем баланс прямой
|
||||
* записью в accounts2 (отдельные «миграционные» операции под Dr 04 / Dr 08
|
||||
* усложнили бы общий реестр).
|
||||
*
|
||||
* Тип счёта определяется планом (LEDGER2_ACCOUNT_MAP):
|
||||
* - ACTIVE — записываем `balance` в `debit_balance`, `credit_balance = 0`;
|
||||
* - PASSIVE — записываем `balance` в `credit_balance`, `debit_balance = 0`.
|
||||
*/
|
||||
inline void emplace_account_balance(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
uint64_t account_id,
|
||||
const eosio::asset& balance) {
|
||||
if (balance.amount == 0) return;
|
||||
const auto* meta = ledger2_find_account_meta(account_id);
|
||||
eosio::check(meta != nullptr,
|
||||
std::string{"migrate: unknown account_id "} + std::to_string(account_id));
|
||||
|
||||
accounts2_index accounts(self_name, coopname.value);
|
||||
auto it = accounts.find(account_id);
|
||||
eosio::check(it == accounts.end(),
|
||||
std::string{"migrate: account "} + std::to_string(account_id) + " уже создан");
|
||||
|
||||
const eosio::asset zero(0, balance.symbol);
|
||||
const uint8_t type_u8 = static_cast<uint8_t>(meta->type);
|
||||
|
||||
accounts.emplace(self_name, [&](auto& a) {
|
||||
a.id = account_id;
|
||||
a.name = std::string(meta->name);
|
||||
a.account_type = type_u8;
|
||||
if (meta->type == AccountType::PASSIVE) {
|
||||
a.debit_balance = zero;
|
||||
a.credit_balance = balance;
|
||||
} else { // ACTIVE / ACTIVE_PASSIVE
|
||||
a.debit_balance = balance;
|
||||
a.credit_balance = zero;
|
||||
}
|
||||
a.balance = account2::compute_balance(type_u8, a.debit_balance, a.credit_balance);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Прямой emplace L3-записи в userwallets БЕЗ бух-проводок. Только для
|
||||
* `kind == USER_SHARED`. Записи с (0,0) — пропускаются.
|
||||
*/
|
||||
inline void emplace_userwallet_only(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
eosio::name wallet_id,
|
||||
eosio::name username,
|
||||
const eosio::asset& available,
|
||||
const eosio::asset& blocked) {
|
||||
if (available.amount == 0 && blocked.amount == 0) return;
|
||||
|
||||
const WalletKind kind = ledger2_get_wallet_kind(wallet_id);
|
||||
eosio::check(kind == WalletKind::USER_SHARED,
|
||||
std::string{"migrate: userwallets допустим только для USER_SHARED, wallet="} +
|
||||
wallet_id.to_string());
|
||||
|
||||
userwallets_index user_wallets(self_name, coopname.value);
|
||||
auto idx = user_wallets.get_index<"byuserwallet"_n>();
|
||||
const auto key = combine_ids(wallet_id.value, username.value);
|
||||
auto it = idx.find(key);
|
||||
eosio::check(it == idx.end(),
|
||||
std::string{"migrate: userwallet (wallet="} + wallet_id.to_string() +
|
||||
", user=" + username.to_string() + ") уже существует");
|
||||
user_wallets.emplace(self_name, [&](auto& uw) {
|
||||
uw.id = user_wallets.available_primary_key();
|
||||
uw.wallet_name = wallet_id;
|
||||
uw.username = username;
|
||||
uw.available = available;
|
||||
uw.blocked = blocked;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Заводит L3-запись `w.cap.preimp` для пайщика, если он существует в
|
||||
* `soviet::participants` коопа. Если нет (тестнет/чужой контекст) — silently skip.
|
||||
*
|
||||
* Использовать ИСКЛЮЧИТЕЛЬНО для воскходных пред-импорт-РИД-учётов (5 пайщиков
|
||||
* по договору УХД, заведённых до перехода на электронный учёт: ЭГОЛ/Анацко/
|
||||
* Кочетков/Мильшин/Манакин). Их деньги списаны с `progwallets[pid=1]` cleos-
|
||||
* командами; здесь фиксируем их как РИД-взносы на отдельном кошельке для
|
||||
* последующего drppre при importcontr.
|
||||
*/
|
||||
inline void emplace_preimp_if_present(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
eosio::name username,
|
||||
int64_t amount_raw) {
|
||||
participants_index parts(_soviet, coopname.value);
|
||||
if (parts.find(username.value) == parts.end()) {
|
||||
return; // тестнет / иной контекст — пропускаем без падения
|
||||
}
|
||||
const eosio::asset amt(amount_raw, _root_govern_symbol);
|
||||
const eosio::asset zero(0, _root_govern_symbol);
|
||||
emplace_userwallet_only(self_name, coopname, ledger2_wallets::PREIMP_FUND, username, amt, zero);
|
||||
}
|
||||
#endif // !IS_TESTNET
|
||||
|
||||
#ifndef IS_TESTNET
|
||||
/**
|
||||
* Спец-ветка миграции для voskhod — РУЧНОЙ ХАРДКОД балансов (accounts2 +
|
||||
* wallets2 + L3 для пред-импорт-учётов).
|
||||
*
|
||||
* Включена только в prod-сборке (без IS_TESTNET). На тестнете voskhod идёт
|
||||
* по стандартному арифметическому пути наравне с остальными кооперативами,
|
||||
* чтобы любые invariant-несостыковки давали внятную ошибку, а не маскировались
|
||||
* заранее заведёнными суммами.
|
||||
* заранее заведёнными суммами. Дополнительная защита внутри ветки —
|
||||
* `participants.find()`-guard в emplace_preimp_if_present, чтобы случайный
|
||||
* запуск voskhod-веточки в чужом окружении не падал.
|
||||
*
|
||||
* Цифры заведены вручную после сверки с фактическими данными mainnet
|
||||
* (snapshot 2026-04-29). При необходимости править — править прямо здесь,
|
||||
* пересобирать ledger2.wasm и катить.
|
||||
* (snapshot 2026-05-10) и согласованы с председателем (см. project-memory
|
||||
* `voskhod migrate2 — финальный план миграции`).
|
||||
*
|
||||
* Причина хардкода: legacy::accounts и soviet::progwallets рассинхронизированы
|
||||
* (деньги Благороста, прошедшие через 51, разошлись 80 vs progwallets,
|
||||
* есть бумажный шлейф на 51, и т.п.). Вместо арифметической сшивки —
|
||||
* заводим суммы вручную по тому, как кооператив должен выглядеть в ledger2.
|
||||
* (имущ. Благорост ~57M на legacy 80 без отражения в 04, бумажный шлейф на 51,
|
||||
* непокрытые хоз.расходы на 86). Арифметической сшивки нет — заводим суммы
|
||||
* напрямую по согласованной с бухгалтером картине.
|
||||
*
|
||||
* Имущественная часть на legacy 80 (РИД ~56.8M) сюда НЕ заводится:
|
||||
* она остаётся осадком на legacy 80 до Phase 2 (ADR-009), когда Благорост
|
||||
* схлопнется в w.cap.blago и заберёт имущество.
|
||||
* ┌────────────────────────────────────── accounts2 ──────────────────────────────────────┐
|
||||
* │ 51 (BANK_ACCOUNT, А) = 145 000 — 145 000 деньги │
|
||||
* │ 04 (INTANGIBLE_ASSETS, А) = 62 353 311 — имущ. Благорост 56 903 311 + preimp 5М4 │
|
||||
* │ 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 = 145 000 + 62 353 311 + 575 200 = 63 073 511 │
|
||||
* │ Σ Cr = 62 946 011 + 127 500 = 63 073 511 ✓ │
|
||||
* └─────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Бух-баланс по этой таблице:
|
||||
* Dr 51 = w.reg.minshr + w.wal.share + w.reg.entry = 31800 + 569900 + 12500 = 614200
|
||||
* Cr 80 = w.reg.minshr + w.wal.share = 31800 + 569900 = 601700
|
||||
* Cr 86 = w.reg.entry = 12500
|
||||
* Σ Dr = Σ Cr = 614200 ✓
|
||||
* ┌────────────────────────────── wallets2 (L2-агрегаты) ─────────────────────────────┐
|
||||
* │ 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) │
|
||||
* └────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Σ Благороста на w.cap.blago (57 044 311) и Генератора на w.cap.gen (0) идут
|
||||
* прямым emplace без бух-проводок (параллельный progwallets-учёт).
|
||||
* 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;
|
||||
const eosio::symbol sym = _root_govern_symbol;
|
||||
const eosio::name coopname = coop.username;
|
||||
const eosio::symbol sym = _root_govern_symbol;
|
||||
const eosio::asset zero(0, sym);
|
||||
|
||||
// ┌────────────────────────────────────────────────────────────────────┐
|
||||
// │ ТАБЛИЦА МИГРАЦИИ ВОСХОДА — править здесь │
|
||||
// │ amount задаётся в "копейках" (× 10000), т.к. RUB = 4 знака │
|
||||
// │ 31 800.0000 RUB → 318'000'000 │
|
||||
// └────────────────────────────────────────────────────────────────────┘
|
||||
// ───────── accounts2 ─────────
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::BANK_ACCOUNT,
|
||||
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'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
|
||||
|
||||
// 1. w.reg.minshr — Минимальные паевые взносы (Dr 51 / Cr 80)
|
||||
const eosio::asset W_REG_MINSHR = eosio::asset( 318'000'000LL, sym); // 31 800.0000 RUB
|
||||
// ───────── wallets2 (L2) ─────────
|
||||
// Мин.паевые на 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,
|
||||
eosio::asset( 4'199'000'000LL, sym), // 419 900.0000 RUB available
|
||||
zero);
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::BLAGOROST_FUND,
|
||||
zero,
|
||||
eosio::asset(570'443'110'000LL, sym)); // 57 044 311.0000 RUB blocked
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::PREIMP_FUND,
|
||||
eosio::asset(54'500'000'000LL, sym), // 5 450 000.0000 RUB available
|
||||
zero);
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::SOV_EXPENSES,
|
||||
eosio::asset( 1'275'000'000LL, sym), // 127 500.0000 RUB available
|
||||
zero);
|
||||
|
||||
// 2. w.wal.share — Цифровой Кошелёк, паевая часть (Dr 51 / Cr 80)
|
||||
const eosio::asset W_WAL_SHARE = eosio::asset( 5'699'000'000LL, sym); // 569 900.0000 RUB
|
||||
|
||||
// 3. w.reg.entry — Вступительные взносы (Dr 51 / Cr 86)
|
||||
const eosio::asset W_REG_ENTRY = eosio::asset( 125'000'000LL, sym); // 12 500.0000 RUB
|
||||
|
||||
// 4. w.cap.blago — ЦПП «Благорост» — единый кошелёк программы (emplace, БЕЗ проводок); ADR-009
|
||||
const eosio::asset W_CAP_BLAGO = eosio::asset(570'443'110'000LL, sym); // 57 044 311.0000 RUB
|
||||
|
||||
// 5. w.cap.gen — ЦПП «Генератор» — единый кошелёк программы (emplace, БЕЗ проводок); 0 → не создаётся
|
||||
const eosio::asset W_CAP_GEN = eosio::asset( 0LL, sym); // 0.0000 RUB
|
||||
|
||||
// ── применяем таблицу ───────────────────────────────────────────────
|
||||
send_transit(self_name, coopname, operations::migration::MIN_SHARE, W_REG_MINSHR);
|
||||
send_transit(self_name, coopname, operations::migration::SHARE, W_WAL_SHARE);
|
||||
send_transit(self_name, coopname, operations::migration::ENTRY, W_REG_ENTRY);
|
||||
|
||||
emplace_wallet_only(self_name, coopname, ledger2_wallets::BLAGOROST_FUND, W_CAP_BLAGO);
|
||||
emplace_wallet_only(self_name, coopname, ledger2_wallets::GENERATOR_FUND, W_CAP_GEN);
|
||||
// ───────── L3: преимп-учёты (5 пайщиков с РИД-взносами по договорам УХД) ─────────
|
||||
// Сумма по таблице = 5 450 000 RUB = wallets2[w.cap.preimp].available ✓
|
||||
emplace_preimp_if_present(self_name, coopname, "honruwpdxtty"_n, 500'000'000LL); // 50 000.0000 ЭГОЛ
|
||||
emplace_preimp_if_present(self_name, coopname, "zlvsujtoctal"_n, 50'000'000'000LL); // 5 000 000.0000 Анацко
|
||||
emplace_preimp_if_present(self_name, coopname, "yxkjufikzxri"_n, 1'000'000'000LL); // 100 000.0000 Кочетков
|
||||
emplace_preimp_if_present(self_name, coopname, "vvqamckynxod"_n, 1'000'000'000LL); // 100 000.0000 Мильшин
|
||||
emplace_preimp_if_present(self_name, coopname, "hntppjjknmsu"_n, 2'000'000'000LL); // 200 000.0000 Манакин
|
||||
}
|
||||
#endif // !IS_TESTNET
|
||||
|
||||
@@ -330,6 +493,28 @@ inline void migrate_one_coop(eosio::name self_name, const cooperative2& coop) {
|
||||
|
||||
// ----- A. Бухгалтерский перенос legacy::accounts через 3 TRANSIT_* -----
|
||||
if (b.cash.amount > 0 || b.share.amount > 0 || b.entry.amount > 0) {
|
||||
#ifdef IS_TESTNET
|
||||
// На тестнете терпим грязные данные: clamp'им вместо abort.
|
||||
// Тестовые остатки (entry > cash, РИД-часть на 80, Σmin > share) — игнорятся.
|
||||
const int64_t entry_amt = b.entry.amount < b.cash.amount ? b.entry.amount : b.cash.amount;
|
||||
const int64_t share_money_amt = b.cash.amount - entry_amt;
|
||||
const eosio::asset entry_eff(entry_amt, _root_govern_symbol);
|
||||
const eosio::asset share_money(share_money_amt, _root_govern_symbol);
|
||||
|
||||
eosio::asset min_total = compute_min_total_by_type(coopname, coop);
|
||||
if (min_total.amount > share_money.amount) {
|
||||
min_total = share_money;
|
||||
}
|
||||
|
||||
const eosio::asset share_remain(
|
||||
share_money.amount - min_total.amount,
|
||||
_root_govern_symbol
|
||||
);
|
||||
|
||||
send_transit(self_name, coopname, operations::migration::MIN_SHARE, min_total);
|
||||
send_transit(self_name, coopname, operations::migration::SHARE, share_remain);
|
||||
send_transit(self_name, coopname, operations::migration::ENTRY, entry_eff);
|
||||
#else
|
||||
// Деньги на паевой фонд: всё на 51 сверх вступительных.
|
||||
const eosio::asset share_money(
|
||||
b.cash.amount >= b.entry.amount ? b.cash.amount - b.entry.amount : 0,
|
||||
@@ -368,6 +553,7 @@ inline void migrate_one_coop(eosio::name self_name, const cooperative2& coop) {
|
||||
send_transit(self_name, coopname, operations::migration::MIN_SHARE, min_total);
|
||||
send_transit(self_name, coopname, operations::migration::SHARE, share_remain);
|
||||
send_transit(self_name, coopname, operations::migration::ENTRY, b.entry);
|
||||
#endif
|
||||
}
|
||||
|
||||
// ----- B. Программные кошельки — прямой emplace, без Dr/Cr -----
|
||||
@@ -411,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;
|
||||
});
|
||||
|
||||
@@ -68,11 +68,13 @@ namespace operations {
|
||||
|
||||
// capital
|
||||
namespace capital {
|
||||
inline constexpr eosio::name IMPORT = "o.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (Dr 51 / Cr 80, ISSUE BLAGOROST_FUND).
|
||||
inline constexpr eosio::name IMPORT = "o.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (Dr 04 / Cr 80, ISSUE BLAGOROST_FUND). Только РИД-имущество — деньги через INVEST.
|
||||
inline constexpr eosio::name INVEST = "o.cap.invest"_n; ///< Инвестиция в ЦПП Благорост (TRANSFER SHARE_FUND_PAY → BLAGOROST_FUND, без Dr/Cr).
|
||||
inline constexpr eosio::name COMMIT_RID = "o.cap.commit"_n; ///< Коммит РИД (Dr 08 / Cr 80, ISSUE GENERATOR_FUND).
|
||||
inline constexpr eosio::name ACCEPT_RID = "o.cap.accept"_n; ///< Приём РИД в НМА (Dr 04 / Cr 08, NONE — только бухпроводка, кошелёк остаётся на GENERATOR_FUND до конвертации сегмента).
|
||||
inline constexpr eosio::name ACCEPT_PROPERTY = "o.cap.actprp"_n; ///< Акт-2 имущественный паевой взнос (Dr 51 / Cr 80, ISSUE BLAGOROST_FUND).
|
||||
inline constexpr eosio::name ACCEPT_PROPERTY = "o.cap.actprp"_n; ///< Акт-2 имущественный паевой взнос (Dr 04 / Cr 80, ISSUE BLAGOROST_FUND).
|
||||
inline constexpr eosio::name PREIMP = "o.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (Dr 04 / Cr 80, ISSUE PREIMP_FUND).
|
||||
inline constexpr eosio::name DROP_PREIMP = "o.cap.drppre"_n; ///< Закрытие пред-импорт-учёта при переходе на электронный учёт (Dr 80 / Cr 04, BURN PREIMP_FUND). Вызывается из capital::importcontr перед o.cap.import.
|
||||
inline constexpr eosio::name LEND = "o.cap.lend"_n; ///< Выдача беспроцентного займа пайщику (Dr 58 / Cr 51, ISSUE LOAN_ISSUED).
|
||||
inline constexpr eosio::name REPAY = "o.cap.repay"_n; ///< Возврат займа пайщика по акту-2 (Dr 80 / Cr 58, TRANSFER LOAN_ISSUED → SHARE_FUND_PAY).
|
||||
inline constexpr eosio::name WITHDRAW_FROM_CAPITAL = "o.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY, без Dr/Cr).
|
||||
@@ -187,9 +189,11 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат паевого взноса пайщику" },
|
||||
|
||||
// 5. Импорт пайщика Благорост (offline): Dr 51 / Cr 80, ISSUE BLAGOROST_FUND (ADR-009: единый кошелёк программы)
|
||||
// 5. Импорт пайщика Благорост (offline): Dr 04 / Cr 80, ISSUE BLAGOROST_FUND (ADR-009: единый кошелёк программы).
|
||||
// Импорт фиксирует РИД-имущество пайщика как НМА — поэтому Dr 04, не Dr 51.
|
||||
// Денежные взносы в Благорост идут через `o.cap.invest` (TRANSFER SHARE_FUND_PAY → BLAGOROST_FUND).
|
||||
{ operations::capital::IMPORT, processes::capital::IMPORT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::BLAGOROST_FUND,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::SHARE_FUND,
|
||||
"Паевой взнос по целевой потребительской программе «Благорост» (офлайн-импорт)" },
|
||||
|
||||
// 6. Инвестиция из Цифрового Кошелька в Благорост: TRANSFER SHARE_FUND_PAY → BLAGOROST_FUND (без Dr/Cr — оба счёта 80)
|
||||
@@ -212,11 +216,31 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::NON_CURRENT_INVESTMENTS,
|
||||
"Приём результата интеллектуальной деятельности в паевой фонд" },
|
||||
|
||||
// 9. Акт-2 имущественный паевой взнос: Dr 51 / Cr 80, ISSUE BLAGOROST_FUND (ADR-009)
|
||||
// 9. Акт-2 имущественный паевой взнос: Dr 04 / Cr 80, ISSUE BLAGOROST_FUND (ADR-009).
|
||||
// Имущественный (РИД) — Dr 04 (НМА), не Dr 51 (банк). Денежный паевой —
|
||||
// через o.wal.depcpl или o.cap.invest.
|
||||
{ operations::capital::ACCEPT_PROPERTY, processes::capital::PROPERTY, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::BLAGOROST_FUND,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::SHARE_FUND,
|
||||
"Паевой взнос (не денежный) по программе «Благорост»" },
|
||||
|
||||
// 9a. Первичный учёт РИД-взноса: Dr 04 / Cr 80, ISSUE PREIMP_FUND.
|
||||
// Пайщик внёс РИД-имущество ДО перехода кооператива на электронный учёт.
|
||||
// Балансы фиксируются на отдельном кошельке `w.cap.preimp`, чтобы при
|
||||
// `capital::importcontr` их можно было обнулить через `o.cap.drppre` и
|
||||
// переоткрыть на полный объём через `o.cap.import` под единый Благорост-фонд.
|
||||
{ operations::capital::PREIMP, processes::capital::PREIMP, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::PREIMP_FUND,
|
||||
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::SHARE_FUND,
|
||||
"Первичный учёт РИД-взноса до перехода на электронный учёт" },
|
||||
|
||||
// 9b. Закрытие пред-импорт-учёта: Dr 80 / Cr 04, BURN PREIMP_FUND.
|
||||
// Вызывается из `capital::importcontr` ДО `o.cap.import`, если у пайщика
|
||||
// есть запись в `userwallets[w.cap.preimp]`. После закрытия `o.cap.import`
|
||||
// переоткрывает учёт на полный объём (включая возможную доплату).
|
||||
{ operations::capital::DROP_PREIMP, processes::capital::IMPORT, WalletOp::BURN,
|
||||
ledger2_wallets::PREIMP_FUND, eosio::name{},
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::INTANGIBLE_ASSETS,
|
||||
"Закрытие пред-импорт-учёта РИД-взноса при переходе на электронный учёт" },
|
||||
|
||||
// 10. Выдача беспроцентного займа пайщику: Dr 58 / Cr 51, ISSUE LOAN_ISSUED
|
||||
{ operations::capital::LEND, processes::capital::DEBT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::LOAN_ISSUED,
|
||||
ledger2_accounts::FINANCIAL_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
|
||||
@@ -46,11 +46,12 @@ namespace processes {
|
||||
|
||||
// capital
|
||||
namespace capital {
|
||||
inline constexpr eosio::name IMPORT = "p.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (одноактовый).
|
||||
inline constexpr eosio::name IMPORT = "p.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (o.cap.drppre (опц.) + o.cap.import).
|
||||
inline constexpr eosio::name INVEST = "p.cap.invest"_n; ///< Инвестиция в ЦПП Благорост (wallet-only 2001→9001).
|
||||
inline constexpr eosio::name DEBT = "p.cap.debt"_n; ///< Займ пайщику (o.cap.lend + o.cap.repay).
|
||||
inline constexpr eosio::name RID = "p.cap.rid"_n; ///< Приём РИД в паевой фонд: o.cap.commit (коммиты) + o.cap.accept (акт-2) + опц. o.cap.repay + o.cap.cnvshr/o.cap.cnvbl (финальная конвертация сегмента). Анкер процесса — result_hash.
|
||||
inline constexpr eosio::name PROPERTY = "p.cap.prop"_n; ///< Приём имущественного паевого взноса (одноактовый).
|
||||
inline constexpr eosio::name PREIMP = "p.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (одноактовый, anchor = preimp register hash).
|
||||
inline constexpr eosio::name WTHCAP = "p.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (одноактовый).
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* Группы:
|
||||
* w.wal.* — Паевой фонд (журнал Cr 80) и возвраты пайщикам
|
||||
* w.reg.* — Регистрация (минимальный паевой, вступительные)
|
||||
* w.sov.* — Целевое финансирование (членские, делегатские)
|
||||
* w.sov.* — Совет-level фонды (целевое финансирование, использованные паевые)
|
||||
* w.cap.* — Программы паевого фонда (Благорост, Генератор) и займы
|
||||
* w.mkt.* — Маркетплейс (выплаты поставщикам)
|
||||
*
|
||||
@@ -52,14 +52,17 @@ 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 — единые программные кошельки + займы
|
||||
// capital — единые программные кошельки + займы + пред-импорт
|
||||
static constexpr eosio::name LOAN_ISSUED = "w.cap.loan"_n; ///< Выданные пайщикам беспроцентные займы (COOPERATIVE; Dr 58 / Cr 51)
|
||||
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
|
||||
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
|
||||
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
|
||||
|
||||
// marketplace — выплаты
|
||||
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
|
||||
@@ -91,19 +94,22 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 11> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (5) — L3-разрез по пайщику
|
||||
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (6) — L3-разрез по пайщику
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::CK_MEMBER, "ЦК — членская часть пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (6) — единый кооперативный баланс, без 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 },
|
||||
}};
|
||||
@@ -218,12 +224,13 @@ struct Ledger2WalletProgramMapping {
|
||||
uint64_t required_program_id; // 0 = исключение (без проверки)
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletProgramMapping, 5> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
|
||||
inline constexpr std::array<Ledger2WalletProgramMapping, 6> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, 0 /* w.reg.minshr — без проверки */ },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, 1 /* ЦК */ },
|
||||
{ ledger2_wallets::CK_MEMBER, 1 /* ЦК */ },
|
||||
{ ledger2_wallets::BLAGOROST_FUND, 4 /* Благорост */ },
|
||||
{ ledger2_wallets::GENERATOR_FUND, 3 /* Генератор */ },
|
||||
{ ledger2_wallets::PREIMP_FUND, 0 /* w.cap.preimp — РИД-учёт до перехода на электронный учёт, без проверки */ },
|
||||
}};
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,3 +74,6 @@
|
||||
#include "table_apps_releases.hpp"
|
||||
#include "table_apps_subs.hpp"
|
||||
#include "table_apps_coops.hpp"
|
||||
#include "table_apps_pricings.hpp"
|
||||
#include "table_apps_globals.hpp"
|
||||
#include "table_apps_clients.hpp"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/time.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Реестр кооперативов-клиентов каталога приложений (D3, FR7, FR8, AR3).
|
||||
*
|
||||
* Хранит факт «оператор каталога подключил кооператив `client_coopname`
|
||||
* к каталогу». Сама подписка пакета хранится в `subs`; `clients` —
|
||||
* это уровень onboarding'а, отвечающий на вопрос «может ли вообще
|
||||
* этот кооператив пользоваться каталогом и его API».
|
||||
*
|
||||
* Идентификация:
|
||||
* - scope = `catalog_operator` (`eosio::name`). В MVP всегда `voskhod`
|
||||
* (single-operator forever по решению brief'а), но scope-based
|
||||
* layout не блокирует replica-режим: другой operator → другой scope,
|
||||
* миграция данных не нужна.
|
||||
* - PK = `client_coopname.value` (один кооператив = одна запись
|
||||
* в scope; повторный `regclient` → `eosio_assert("client already registered")`).
|
||||
*
|
||||
* RAM payer — `catalog_operator` (тот, кто подписал транзакцию).
|
||||
* Это ВОСХОД в MVP — он оплачивает «координацию», а не клиенты.
|
||||
* `delclient` освобождает RAM назад оператору.
|
||||
*
|
||||
* Зачем отдельная таблица, а не флаг в общей `coops`. `coops` —
|
||||
* не «общая таблица», а сущность из системы регистратора и совет-плоскости
|
||||
* (subnet-signing-keys, chain_id подсетей). Класть туда catalog-specific
|
||||
* флаги — ломать инкапсуляцию контракта; будущая ротация владельца коопа
|
||||
* не должна зависеть от состояния каталога приложений.
|
||||
*
|
||||
* \see architecture-v2 D3 — выбор scope-per-operator vs single-table.
|
||||
* \see architecture-v2 D4 — interaction с `regsub` (membership check).
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] client {
|
||||
name client_coopname; ///< primary key — кооператив-клиент
|
||||
time_point_sec registered_at; ///< таймштамп `regclient`-вызова
|
||||
|
||||
uint64_t primary_key() const { return client_coopname.value; }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<"clients"_n, client> clients_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/singleton.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Общесистемные параметры каталога приложений (D2, FR2).
|
||||
*
|
||||
* Singleton — одна запись на контракт. Хранит четыре параметра pull-биллинга
|
||||
* и retry-каденса:
|
||||
*
|
||||
* - `min_payment_period_seconds` — минимальный шаг pull-биллинга
|
||||
* (обычно 1 час; используется при квантизации `charge.required`-периодов).
|
||||
* - `free_trial_period_seconds` — период бесплатного использования
|
||||
* после `regclient` до первого регулярного charge'а.
|
||||
* - `lead_time_seconds` — за какое время до окончания текущего
|
||||
* оплаченного интервала CA-watcher эмитит `charge.required` (D6).
|
||||
* - `retry_max` — максимум transport-retry'ев при недоступности кабинета;
|
||||
* после исчерпания CA переключается на суточный billing-retry с
|
||||
* `attempt`-инкрементом (two-level retry pattern).
|
||||
*
|
||||
* Scope = `apps`-контракт (`get_self()`); singleton без PK.
|
||||
* RAM payer — `get_self()`. Изменения только через action `setglobals`.
|
||||
*
|
||||
* \see architecture-v2 D2 — почему singleton, а не поле в общей `coops`.
|
||||
* \see Story v2.1.1 — каркас (полная замена); v2.1.2 — валидация диапазонов.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] globals_row {
|
||||
uint32_t min_payment_period_seconds = 0;
|
||||
uint32_t free_trial_period_seconds = 0;
|
||||
uint32_t lead_time_seconds = 0;
|
||||
uint8_t retry_max = 0;
|
||||
};
|
||||
|
||||
typedef eosio::singleton<"globals"_n, globals_row> globals_singleton;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/time.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Прайс-лист пакета: `{plan -> hourly_rate}` (D1, FR1, AR5).
|
||||
*
|
||||
* Pricing-as-source on-chain: ставка плана хранится в блокчейне и читается
|
||||
* каталогом через parser2 read-adapter без локального shadow-state.
|
||||
* Изменения операторских цен через action `setpricing` атомарно
|
||||
* фиксируют `hourly_rate` и `updated_at`-снапшот для аудита.
|
||||
*
|
||||
* Идентификация:
|
||||
* - scope = `package_id` (один shard на пакет).
|
||||
* - PK = `plan.value` (в MVP всегда `default`; tier-планы добавляются
|
||||
* записями в тот же scope без миграции).
|
||||
*
|
||||
* RAM payer — `apps`-контракт (`get_self()`). Фактически оплачивается
|
||||
* ВОСХОДом как owner аккаунта `apps` (catalog-operator). Кооперативы-клиенты
|
||||
* за хранение прайс-листа не платят — это часть инварианта
|
||||
* «координацию оплачивает оператор каталога».
|
||||
*
|
||||
* \see /home/admin/apps-catalog/docs/architecture-v2-pricing-multitenancy.md
|
||||
* D1 (Layout `pricings`-таблицы — scope-based per package).
|
||||
* \see Story v2.1.1 — каркас; v2.1.2 — валидация и snapshot policy.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] pricing {
|
||||
name plan; ///< primary key (Antelope name, ≤12 chars)
|
||||
asset hourly_rate; ///< ставка за час (общая валюта экосистемы)
|
||||
time_point_sec updated_at; ///< таймштамп последнего `setpricing`
|
||||
|
||||
uint64_t primary_key() const { return plan.value; }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<"pricings"_n, pricing> pricings_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -35,6 +35,24 @@ using namespace eosio;
|
||||
* для аудита истечения подписки. Cleanup просроченных записей — задача
|
||||
* отдельного периодического действия (вне MVP).
|
||||
*
|
||||
* **D4 v2-extension** — два новых поля для координации pull-биллинга
|
||||
* (Story v2.6 / D4 / D5):
|
||||
* - `attempt` — billing-retry counter уровня `regsub`. Инкрементируется
|
||||
* pricing-watcher'ом CA через `setattempt`, когда `ack=declined`
|
||||
* или charge провалился. Сбрасывается в `0` при успешном `extendsub`.
|
||||
* - `last_charge_intent_id` — UUIDv5 последнего применённого
|
||||
* charge.intent. Используется `extendsub` для double-emit prevention:
|
||||
* повторный вызов с тем же id отсекается `eosio_assert("already extended")`.
|
||||
* Дефолт — нулевой checksum256 (т.е. ещё ни одного extend не было).
|
||||
*
|
||||
* Поля добавлены в конец struct'а как nullable-by-default — старые
|
||||
* MVP-row'ы (если бы они были) интерпретировались бы с `attempt=0` и
|
||||
* нулевым `last_charge_intent_id`. Pre-deploy guard
|
||||
* `scripts/v2-migration-guard.ts` (Story v2.7.5) блокирует прод-деплой
|
||||
* при count > 0 production-scope записей, чтобы исключить
|
||||
* deserialization-сюрпризы; в MVP/dev таблица пустая и миграция
|
||||
* безопасна.
|
||||
*
|
||||
* Indexing:
|
||||
* - PK `id` — auto-inc.
|
||||
* - `bycooppkg` — composite (coopname, package_id) для idempotent upsert
|
||||
@@ -43,6 +61,8 @@ using namespace eosio;
|
||||
* - `byexpires` — sorted by `end_at` для будущего auto-cleanup.
|
||||
*
|
||||
* \see lib/domain/table_apps_coops.hpp — chain_id ↔ кооператив.
|
||||
* \see architecture-v2 D4 — runtime-nullable migration policy.
|
||||
* \see architecture-v2 D5 — UUIDv5-based double-emit prevention.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] sub {
|
||||
uint64_t id;
|
||||
@@ -55,6 +75,8 @@ struct [[eosio::table, eosio::contract(APPS)]] sub {
|
||||
time_point_sec end_at;
|
||||
time_point_sec created_at;
|
||||
time_point_sec updated_at;
|
||||
uint8_t attempt = 0; ///< D4: billing-retry counter
|
||||
checksum256 last_charge_intent_id = {}; ///< D4: UUIDv5 последнего extendsub
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint128_t by_cooppkg() const { return ((uint128_t)coopname.value << 64) | package_id.value; }
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Стандарт «Запрос маркетплейса» — обмен имуществом между пайщиками через
|
||||
# кооператив с двойной конвертацией (деньги → имущество и обратно).
|
||||
#
|
||||
# Многоступенчатый процесс (10+ узлов состояний). Две ledger2-операции:
|
||||
# • o.mkt.supply — подтверждение поставки на КУ (ISSUE → w.wal.share, Дт 51 / Кт 80)
|
||||
# • o.mkt.recv — выплата поставщику после получения (TRANSFER w.wal.share → w.mkt.payout, Дт 80 / Кт 51)
|
||||
#
|
||||
# Источники правды в коде:
|
||||
# • cpp/marketplace/marketplace.hpp — actions
|
||||
# • cpp/marketplace/src/deliver_on_offer/ — реализация
|
||||
# • cpp/lib/domain/table_marketplace_requests.hpp — entity requests
|
||||
# • cpp/lib/core/ledger2/operations.hpp — o.mkt.supply, o.mkt.recv
|
||||
# • cpp/lib/core/ledger2/processes.hpp — processes::marketplace::REQUEST
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
|
||||
process_type: p.mkt.reqst
|
||||
id: public_marketplace_request_process
|
||||
title: Исполнение заказа на поставку имущества
|
||||
slug: request
|
||||
status: proposed
|
||||
contract: marketplace
|
||||
summary: >
|
||||
Пайщик-заказчик размещает заявку на товар, поставщик принимает её, совет
|
||||
авторизует обе стороны (взнос и возврат), товар поставляется и принимается
|
||||
с гарантийным периодом. После завершения поставщик получает выплату.
|
||||
purpose: >
|
||||
«Исполнение заказа на поставку имущества» — кооператив принимает
|
||||
имущество у одного пайщика и передаёт его другому. Контрагентом
|
||||
для обеих сторон выступает сам кооператив: совет одновременно
|
||||
авторизует приём от поставщика и выдачу заказчику, кооперативный
|
||||
участок физически принимает имущество и передаёт его заказчику,
|
||||
поставщику начисляется выплата после подтверждения получения
|
||||
и истечения гарантийного срока.
|
||||
roles:
|
||||
- contributor # заказчик (money-contributor) и поставщик (product-contributor)
|
||||
- soviet
|
||||
- chairman # представитель кооперативного участка (КУ)
|
||||
|
||||
# ── Секция 2. Действия контракта ────────────────────────────────────────────
|
||||
actions:
|
||||
- name: marketplace::orderoffer
|
||||
human: Создать заявку
|
||||
actor: contributor
|
||||
role: opener
|
||||
purpose: >
|
||||
Заказчик размещает заявку на товар: блокирует денежные средства,
|
||||
подписывает документы конвертации входящей и возврата товара.
|
||||
- name: marketplace::accept
|
||||
human: Принять заявку
|
||||
actor: contributor
|
||||
role: progress
|
||||
purpose: >
|
||||
Поставщик принимает заявку: подписывает встречную конвертацию и
|
||||
заявление о внесении имущественного паевого взноса. В совет
|
||||
отправляются две повестки.
|
||||
- name: marketplace::authcontrib
|
||||
human: Авторизовать взнос имуществом
|
||||
actor: soviet
|
||||
role: progress
|
||||
purpose: Совет авторизует имущественный взнос поставщика.
|
||||
- name: marketplace::authreturn
|
||||
human: Авторизовать возврат
|
||||
actor: soviet
|
||||
role: progress
|
||||
purpose: >
|
||||
Совет авторизует возврат имущества (если придётся). Когда обе
|
||||
авторизации получены, статус заявки → `authorized`.
|
||||
- name: marketplace::supply
|
||||
human: Поставить имущество
|
||||
actor: contributor
|
||||
role: progress
|
||||
purpose: >
|
||||
Поставщик поставляет имущество на кооперативный участок и подписывает
|
||||
акт поставки.
|
||||
- name: marketplace::supplcnf
|
||||
human: Подтвердить поставку
|
||||
actor: chairman
|
||||
role: progress
|
||||
purpose: >
|
||||
Представитель кооперативного участка подтверждает приём имущества.
|
||||
Применяется ledger2-операция o.mkt.supply — имущество поступает в
|
||||
SHARE_FUND с проводкой Дт 51 / Кт 80.
|
||||
- name: marketplace::delivered
|
||||
human: Подготовить к выдаче
|
||||
actor: chairman
|
||||
role: progress
|
||||
purpose: >
|
||||
Кооперативный участок переводит имущество в статус «готово к выдаче».
|
||||
- name: marketplace::receive
|
||||
human: Получить имущество
|
||||
actor: contributor
|
||||
role: progress
|
||||
purpose: >
|
||||
Заказчик получает имущество на КУ и подписывает первый акт получения.
|
||||
- name: marketplace::receivecnf
|
||||
human: Подтвердить получение
|
||||
actor: contributor
|
||||
role: progress
|
||||
purpose: >
|
||||
Заказчик подписывает второй акт — приём имущества подтверждён.
|
||||
Применяется ledger2-операция o.mkt.recv — выплата поставщику
|
||||
(TRANSFER w.wal.share → w.mkt.payout, Дт 80 / Кт 51), устанавливается гарантийный период.
|
||||
- name: marketplace::complete
|
||||
human: Завершить
|
||||
actor: chairman
|
||||
role: closer
|
||||
purpose: >
|
||||
После истечения гарантийного периода заявка завершается, запись удаляется.
|
||||
- name: marketplace::declineacc
|
||||
human: Отклонить советом
|
||||
actor: soviet
|
||||
role: reject
|
||||
purpose: >
|
||||
Совет отклонил приём заявки. Заблокированные средства возвращаются
|
||||
заказчику, запись удаляется.
|
||||
- name: marketplace::decline
|
||||
human: Отказ на этапе принятия
|
||||
actor: contributor
|
||||
role: reject
|
||||
purpose: >
|
||||
Заявка отменена в статусе `active` или `accepted` (до авторизации).
|
||||
Заблокированные средства возвращаются заказчику.
|
||||
- name: marketplace::cancel
|
||||
human: Отменить с комиссией
|
||||
actor: contributor
|
||||
role: reject
|
||||
purpose: >
|
||||
Отмена инициатором (заказчик/поставщик) до подтверждения поставки.
|
||||
Удерживается комиссия за отмену, остаток возвращается.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: marketplace::request
|
||||
entity_human: Заявка маркетплейса
|
||||
entity_source: cpp/marketplace/src/deliver_on_offer/
|
||||
|
||||
states:
|
||||
- name: active
|
||||
human: Заявка размещена
|
||||
description: >
|
||||
Заказчик создал заявку, средства заблокированы на его кошельке.
|
||||
kind: normal
|
||||
- name: accepted
|
||||
human: Поставщик принял
|
||||
description: >
|
||||
Поставщик принял заявку, в совете созданы две повестки авторизации.
|
||||
kind: normal
|
||||
- name: authorized
|
||||
human: Авторизовано советом
|
||||
description: Получены обе авторизации совета — заявка готова к поставке.
|
||||
kind: normal
|
||||
- name: supplied1
|
||||
human: Имущество поставлено
|
||||
description: Поставщик передал имущество на кооперативный участок.
|
||||
kind: normal
|
||||
- name: supplied2
|
||||
human: Поставка подтверждена
|
||||
description: >
|
||||
КУ подтвердил приём имущества. Применена o.mkt.supply — имущество
|
||||
в SHARE_FUND.
|
||||
kind: normal
|
||||
- name: delivered
|
||||
human: Готово к выдаче
|
||||
description: КУ подготовил имущество к выдаче заказчику.
|
||||
kind: normal
|
||||
- name: received1
|
||||
human: Заказчик получил
|
||||
description: Заказчик получил имущество и подписал акт-1.
|
||||
kind: normal
|
||||
- name: received2
|
||||
human: Получение подтверждено
|
||||
description: >
|
||||
Заказчик подписал акт-2. Применена o.mkt.recv — поставщик получил
|
||||
выплату. Установлен гарантийный период.
|
||||
kind: normal
|
||||
- name: completed
|
||||
human: Завершено
|
||||
description: >
|
||||
Гарантийный период истёк, заявка закрыта, запись удалена.
|
||||
kind: final
|
||||
- name: removed
|
||||
human: Отклонено / отменено
|
||||
description: >
|
||||
Заявка прервана: средства возвращены, запись удалена. Операции
|
||||
ledger2 не создавались (или возвращены).
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
transitions:
|
||||
- from: "∅"
|
||||
to: active
|
||||
action: marketplace::orderoffer
|
||||
actor: contributor
|
||||
guards:
|
||||
- Заказчик активен. Достаточный остаток на SHARE_FUND_PAY.
|
||||
- Подписаны RETURN_STMT и CONVERT_FROM.
|
||||
|
||||
- from: active
|
||||
to: accepted
|
||||
action: marketplace::accept
|
||||
actor: contributor
|
||||
guards:
|
||||
- Поставщик активен.
|
||||
- Подписаны CONVERT_TO и CONTRIB_STMT.
|
||||
|
||||
- from: accepted
|
||||
to: authorized
|
||||
action: marketplace::authcontrib
|
||||
actor: soviet
|
||||
guards:
|
||||
- Подписана CONTRIB_AUTH.
|
||||
- Также получена RETURN_AUTH.
|
||||
|
||||
- from: accepted
|
||||
to: authorized
|
||||
action: marketplace::authreturn
|
||||
actor: soviet
|
||||
guards:
|
||||
- Подписана RETURN_AUTH.
|
||||
- Также получена CONTRIB_AUTH.
|
||||
|
||||
- from: authorized
|
||||
to: supplied1
|
||||
action: marketplace::supply
|
||||
actor: contributor
|
||||
guards:
|
||||
- Поставщик подписал акт поставки.
|
||||
|
||||
- from: supplied1
|
||||
to: supplied2
|
||||
action: marketplace::supplcnf
|
||||
actor: chairman
|
||||
ledger_code: o.mkt.supply
|
||||
operations:
|
||||
- o.mkt.supply
|
||||
guards:
|
||||
- Представитель КУ подтвердил приём имущества.
|
||||
|
||||
- from: supplied2
|
||||
to: delivered
|
||||
action: marketplace::delivered
|
||||
actor: chairman
|
||||
guards:
|
||||
- КУ подготовил имущество к выдаче.
|
||||
|
||||
- from: delivered
|
||||
to: received1
|
||||
action: marketplace::receive
|
||||
actor: contributor
|
||||
guards:
|
||||
- Заказчик получил имущество.
|
||||
|
||||
- from: received1
|
||||
to: received2
|
||||
action: marketplace::receivecnf
|
||||
actor: contributor
|
||||
ledger_code: o.mkt.recv
|
||||
operations:
|
||||
- o.mkt.recv
|
||||
guards:
|
||||
- Заказчик подписал акт-2 получения.
|
||||
|
||||
- from: received2
|
||||
to: completed
|
||||
action: marketplace::complete
|
||||
actor: chairman
|
||||
guards:
|
||||
- Гарантийный период истёк.
|
||||
|
||||
- from: active
|
||||
to: removed
|
||||
action: marketplace::decline
|
||||
actor: contributor
|
||||
guards:
|
||||
- Заявка отменена до принятия.
|
||||
- from: accepted
|
||||
to: removed
|
||||
action: marketplace::declineacc
|
||||
actor: soviet
|
||||
guards:
|
||||
- Совет отклонил заявку до авторизации.
|
||||
- from: accepted
|
||||
to: removed
|
||||
action: marketplace::cancel
|
||||
actor: contributor
|
||||
guards:
|
||||
- Отмена с удержанием комиссии до supplied2.
|
||||
|
||||
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
|
||||
scenario:
|
||||
steps:
|
||||
- step: 1
|
||||
title: Создание заявки
|
||||
actor: contributor
|
||||
action: marketplace::orderoffer
|
||||
description: >
|
||||
Заказчик размещает заявку на товар: блокирует средства, подписывает
|
||||
RETURN_STMT (возврат) и CONVERT_FROM (входящая конвертация).
|
||||
- step: 2
|
||||
title: Принятие поставщиком
|
||||
actor: contributor
|
||||
action: marketplace::accept
|
||||
description: >
|
||||
Поставщик подписывает CONVERT_TO и CONTRIB_STMT, в совете создаются
|
||||
две повестки.
|
||||
- step: 3
|
||||
title: Авторизация советом
|
||||
actor: soviet
|
||||
action: marketplace::authcontrib
|
||||
description: >
|
||||
Совет подписывает CONTRIB_AUTH (взнос имуществом) и RETURN_AUTH
|
||||
(возврат имущества). По обеим авторизациям статус → `authorized`.
|
||||
- step: 4
|
||||
title: Поставка имущества
|
||||
actor: contributor
|
||||
action: marketplace::supply
|
||||
description: Поставщик доставляет имущество на КУ и подписывает SUPPLY_ACT.
|
||||
- step: 5
|
||||
title: Подтверждение поставки
|
||||
actor: chairman
|
||||
action: marketplace::supplcnf
|
||||
description: >
|
||||
Представитель КУ подтверждает приём (SUPPLY_ACT_CONF). Применяется
|
||||
o.mkt.supply — имущество поступает в SHARE_FUND (Дт 51 / Кт 80).
|
||||
- step: 6
|
||||
title: Получение заказчиком
|
||||
actor: contributor
|
||||
action: marketplace::receive
|
||||
description: Заказчик получает имущество, подписывает RECEIVE_ACT.
|
||||
- step: 7
|
||||
title: Подтверждение получения
|
||||
actor: contributor
|
||||
action: marketplace::receivecnf
|
||||
description: >
|
||||
Заказчик подписывает RECEIVE_ACT_CONF. Применяется o.mkt.recv —
|
||||
выплата поставщику (TRANSFER w.wal.share → w.mkt.payout, Дт 80 / Кт 51), установлен
|
||||
гарантийный период.
|
||||
- step: 8
|
||||
title: Завершение по гарантии
|
||||
actor: chairman
|
||||
action: marketplace::complete
|
||||
description: По истечении гарантийного срока заявка закрывается.
|
||||
|
||||
alternatives:
|
||||
- branch: Отказ совета
|
||||
at_step: 3
|
||||
action: marketplace::declineacc
|
||||
actor: soviet
|
||||
description: Совет отклоняет — средства возвращаются, запись удаляется.
|
||||
- branch: Отказ заказчика до принятия
|
||||
at_step: 1
|
||||
action: marketplace::decline
|
||||
actor: contributor
|
||||
description: Отмена до accept — средства возвращаются.
|
||||
- branch: Отмена с комиссией
|
||||
at_step: 4
|
||||
action: marketplace::cancel
|
||||
actor: contributor
|
||||
description: Отмена до supplied2 — удерживается комиссия.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
- step: 1
|
||||
title: Заявление о возврате имущества (RETURN_STMT)
|
||||
template: mkt-return-stmt
|
||||
registry_id: 0
|
||||
signed_by: [contributor]
|
||||
stored_in: requests.documents
|
||||
- step: 1
|
||||
title: Конвертация входящих средств (CONVERT_FROM)
|
||||
template: mkt-convert-from
|
||||
registry_id: 0
|
||||
signed_by: [contributor]
|
||||
stored_in: requests.documents
|
||||
- step: 2
|
||||
title: Конвертация исходящих (CONVERT_TO)
|
||||
template: mkt-convert-to
|
||||
registry_id: 0
|
||||
signed_by: [contributor]
|
||||
stored_in: requests.documents
|
||||
- step: 2
|
||||
title: Заявление об имущественном паевом взносе поставщика (CONTRIB_STMT)
|
||||
template: mkt-contrib-stmt
|
||||
registry_id: 0
|
||||
signed_by: [contributor]
|
||||
stored_in: requests.documents
|
||||
- step: 3
|
||||
title: Авторизация совета на имущественный взнос (CONTRIB_AUTH)
|
||||
template: mkt-contrib-auth
|
||||
registry_id: 0
|
||||
signed_by: [soviet]
|
||||
stored_in: requests.documents
|
||||
- step: 3
|
||||
title: Авторизация совета на возврат имущества (RETURN_AUTH)
|
||||
template: mkt-return-auth
|
||||
registry_id: 0
|
||||
signed_by: [soviet]
|
||||
stored_in: requests.documents
|
||||
- step: 4
|
||||
title: Акт поставки (SUPPLY_ACT)
|
||||
template: mkt-supply-act
|
||||
registry_id: 0
|
||||
signed_by: [contributor]
|
||||
stored_in: requests.documents
|
||||
- step: 5
|
||||
title: Акт подтверждения поставки (SUPPLY_ACT_CONF)
|
||||
template: mkt-supply-act-conf
|
||||
registry_id: 0
|
||||
signed_by: [chairman]
|
||||
stored_in: requests.documents
|
||||
- step: 6
|
||||
title: Акт получения (RECEIVE_ACT)
|
||||
template: mkt-receive-act
|
||||
registry_id: 0
|
||||
signed_by: [contributor]
|
||||
stored_in: requests.documents
|
||||
- step: 7
|
||||
title: Акт подтверждения получения (RECEIVE_ACT_CONF)
|
||||
template: mkt-receive-act-conf
|
||||
registry_id: 0
|
||||
signed_by: [contributor]
|
||||
stored_in: requests.documents
|
||||
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
operations:
|
||||
- ledger_code: o.mkt.supply
|
||||
human_name: Подтверждение поставки
|
||||
wallet_op: ISSUE
|
||||
wallet_from: ''
|
||||
wallet_to: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
|
||||
debit: 51 # Расчётный счёт
|
||||
credit: 80 # Паевой фонд (складочный капитал)
|
||||
amount_ref: request.total_cost
|
||||
triggered_by: marketplace::supplcnf
|
||||
description: >
|
||||
Имущество поступило на кооперативный участок. В ledger2 фиксируется
|
||||
приход в SHARE_FUND поставщика — основание для последующей выплаты
|
||||
из паевого фонда.
|
||||
|
||||
- ledger_code: o.mkt.recv
|
||||
human_name: Подтверждение получения — выплата поставщику
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
|
||||
wallet_to: w.mkt.payout # Выплаты поставщикам
|
||||
debit: 80 # Паевой фонд
|
||||
credit: 51 # Расчётный счёт
|
||||
amount_ref: request.base_cost
|
||||
triggered_by: marketplace::receivecnf
|
||||
description: >
|
||||
После подтверждения получения заказчиком средства уходят с SHARE_FUND
|
||||
поставщика на кошелёк выплат поставщикам — фактическая денежная
|
||||
выплата за поставленное имущество.
|
||||
|
||||
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
|
||||
related:
|
||||
- process_type: p.wal.depo
|
||||
id: public_wallet_deposit_process
|
||||
relation: provides
|
||||
note: >
|
||||
Заказчик и поставщик должны иметь остаток на SHARE_FUND_PAY — его
|
||||
создаёт «Внесение паевого взноса» (p.wal.depo).
|
||||
@@ -49,6 +49,7 @@
|
||||
#include "src/fund/fundwithdraw.cpp"
|
||||
|
||||
#include "src/participant/addparticipant.cpp"
|
||||
#include "src/participant/setminamt.cpp"
|
||||
#include "src/participant/block.cpp"
|
||||
#include "src/participant/cancelreg.cpp"
|
||||
#include "src/participant/unblock.cpp"
|
||||
|
||||
@@ -139,6 +139,9 @@ public:
|
||||
//regaccount.cpp
|
||||
[[eosio::action]] void addpartcpnt(eosio::name coopname, eosio::name username, eosio::name braname, eosio::name type, eosio::time_point_sec created_at, eosio::asset initial, eosio::asset minimum, bool spread_initial);
|
||||
|
||||
//setminamt.cpp
|
||||
[[eosio::action]] void setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum);
|
||||
|
||||
//automator.cpp
|
||||
[[eosio::action]] void automate(eosio::name coopname, uint64_t board_id, eosio::name member, eosio::name action_type, eosio::name provider, std::string encrypted_private_key);
|
||||
[[eosio::action]] void disautomate(eosio::name coopname, uint64_t board_id, eosio::name member, uint64_t automation_id );
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @brief Корректировка minimum_amount у уже принятого пайщика.
|
||||
* Технический фикс для случаев, когда поле рассинхронизировано с кооп-параметром
|
||||
* (например, у voskhod::ant исторически осталось 1 RUB вместо 300 RUB).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Наименование пайщика
|
||||
* @param minimum Новая сумма minimum_amount
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта @p coopname
|
||||
*/
|
||||
void soviet::setminamt(eosio::name coopname, eosio::name username, eosio::asset minimum) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto cooperative = get_cooperative_or_fail(coopname);
|
||||
cooperative.check_symbol_or_fail(minimum);
|
||||
eosio::check(minimum.amount > 0, "minimum_amount должен быть положительным");
|
||||
|
||||
participants_index participants(_soviet, coopname.value);
|
||||
auto p = participants.find(username.value);
|
||||
eosio::check(p != participants.end(), "Пайщик не найден");
|
||||
|
||||
participants.modify(p, coopname, [&](auto &m){
|
||||
m.minimum_amount = minimum;
|
||||
});
|
||||
}
|
||||
@@ -17,10 +17,6 @@ void soviet::converttoaxn(eosio::name coopname, eosio::asset amount, document2 s
|
||||
|
||||
eosio::asset axon_quantity(axon_amount, _root_symbol);
|
||||
|
||||
// Списываем RUB с кошелька provider (_wallet_program)
|
||||
Wallet::sub_available_funds(_soviet, _provider, coopname, amount, _wallet_program, "Конвертация RUB в AXON");
|
||||
|
||||
|
||||
// Пополняем кошелёк членских взносов в ledger
|
||||
std::string memo = "Членский взнос из числа средств паевого взноса по соглашению о подключении к платформе Кооперативной Экономики от пайщика с username=" + coopname.to_string();
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ void soviet::addbal(eosio::name coopname, eosio::name username, uint64_t program
|
||||
b.coopname = coopname;
|
||||
b.username = username;
|
||||
b.agreement_id = 0;
|
||||
b.available = asset(0, quantity.symbol) + quantity;
|
||||
b.available = quantity;
|
||||
b.blocked = asset(0, quantity.symbol);
|
||||
b.membership_contribution = asset(0, quantity.symbol);
|
||||
});
|
||||
@@ -54,6 +54,4 @@ void soviet::addbal(eosio::name coopname, eosio::name username, uint64_t program
|
||||
p.available = p.available.value_or(asset(0, quantity.symbol)) + quantity;
|
||||
p.share_contributions = p.share_contributions.value_or(asset(0, quantity.symbol)) + quantity;
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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.4.30-alpha-1",
|
||||
"version": "2026.5.14-2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -84,3 +84,16 @@ OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
WHISPER_MODEL=whisper-1
|
||||
WHISPER_LANGUAGE=ru
|
||||
|
||||
# FILE STORAGE (MinIO в dev compose; на prod подменяется плейбуком).
|
||||
# Endpoint S3-совместимого бэкенда. В dev compose service name = minio.
|
||||
# Для запуска контроллера на хосте (вне docker) — http://127.0.0.1:9000.
|
||||
MINIO_ENDPOINT=http://minio:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
# Имя физического бакета. По умолчанию `coop-${COOPNAME}`, можно перекрыть.
|
||||
MINIO_BUCKET=
|
||||
# HMAC-секрет для подписи URL отдачи. Если пусто — берётся SERVER_SECRET.
|
||||
FILE_STORAGE_SIGNING_SECRET=
|
||||
# База публичного URL контроллера для read-URL. Если пусто — берётся BACKEND_URL.
|
||||
FILE_STORAGE_PUBLIC_BASE_URL=
|
||||
|
||||
@@ -8,6 +8,9 @@ module.exports = {
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
restoreMocks: true,
|
||||
// Интеграционные тесты против внешних сервисов (MinIO и т.п.) — не часть штатного `jest`-прогона.
|
||||
// Запускаются явно через `npm run test:integration:file-storage`.
|
||||
testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.spec\\.ts$'],
|
||||
coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.ts', 'tests'],
|
||||
coverageReporters: ['text', 'lcov', 'clover', 'html'],
|
||||
moduleNameMapper: {
|
||||
|
||||
@@ -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.4.30-alpha-1",
|
||||
"version": "2026.5.14-2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"version": "2026.5.14-2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"version": "2026.5.14-2",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
@@ -22,6 +22,7 @@
|
||||
"dev": "nodemon --watch src --watch migrations --ext ts,js,env --exec 'ts-node -r tsconfig-paths/register' src/index.ts",
|
||||
"test": "jest -i --testPathPattern=tests/unit",
|
||||
"test:integration": "echo 'Integration tests run via @coopenomics/boot' && exit 0",
|
||||
"test:integration:file-storage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testRegex 'tests/file-storage/.*\\.integration\\.spec\\.ts$' --testPathIgnorePatterns='/node_modules/' --testTimeout=60000 -i",
|
||||
"test:watch": "jest -i --watchAll",
|
||||
"coverage": "jest -i --coverage",
|
||||
"coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls",
|
||||
@@ -71,6 +72,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
"@aws-sdk/client-s3": "^3.1035.0",
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
@@ -193,6 +195,7 @@
|
||||
"@graphql-codegen/typescript": "^4.1.1",
|
||||
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
|
||||
"@graphql-codegen/typescript-operations": "^4.3.1",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@redocly/cli": "^1.18.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.26",
|
||||
|
||||
+1070
-223
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import { EventsInfrastructureModule } from './infrastructure/events/events.modul
|
||||
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
|
||||
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
|
||||
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
|
||||
import { FileStorageInfrastructureModule } from './infrastructure/file-storage';
|
||||
|
||||
// Domain modules
|
||||
import { AccountDomainModule } from './domain/account/account-domain.module';
|
||||
@@ -41,6 +42,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 +76,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';
|
||||
|
||||
@@ -100,9 +103,18 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
EventsInfrastructureModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
FileStorageInfrastructureModule.forRoot({
|
||||
endpoint: config.file_storage.endpoint,
|
||||
accessKey: config.file_storage.access_key,
|
||||
secretKey: config.file_storage.secret_key,
|
||||
bucket: config.file_storage.bucket,
|
||||
signingSecret: config.file_storage.signing_secret,
|
||||
publicBaseUrl: config.file_storage.public_base_url,
|
||||
}),
|
||||
// Domain modules
|
||||
AuthDomainModule,
|
||||
RegistrationDomainModule,
|
||||
OnboardingDomainModule,
|
||||
AgendaDomainModule,
|
||||
AccountDomainModule,
|
||||
AccountInfrastructureModule,
|
||||
@@ -156,6 +168,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
TokenApplicationModule,
|
||||
SettingsApplicationModule,
|
||||
RegistrationModule,
|
||||
OnboardingApplicationModule,
|
||||
SearchModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -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'`.
|
||||
*/
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsString, IsBoolean } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
|
||||
// утилита для выборки повторяющихся параметров из базовых интерфейсов
|
||||
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id' | 'appendix_hash'>;
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.GenerationConvertStatement.Action;
|
||||
|
||||
@InputType(`BaseGenerationConvertStatementMetaDocumentInput`)
|
||||
class BaseGenerationConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Хэш проекта' })
|
||||
@IsString()
|
||||
project_hash!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода в Цифровой Кошелёк' })
|
||||
@IsString()
|
||||
main_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода в программу «Благорост»' })
|
||||
@IsString()
|
||||
blagorost_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Признак перевода в Цифровой Кошелёк' })
|
||||
@IsBoolean()
|
||||
to_wallet!: boolean;
|
||||
|
||||
@Field({ description: 'Признак перевода в программу «Благорост»' })
|
||||
@IsBoolean()
|
||||
to_blagorost!: boolean;
|
||||
}
|
||||
|
||||
@InputType(`GenerationConvertStatementGenerateDocumentInput`)
|
||||
export class GenerationConvertStatementGenerateDocumentInputDTO extends IntersectionType(
|
||||
BaseGenerationConvertStatementMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
) {
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`GenerationConvertStatementSignedMetaDocumentInput`)
|
||||
export class GenerationConvertStatementSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseGenerationConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {
|
||||
@Field({ description: 'Хэш приложения к проекту' })
|
||||
@IsString()
|
||||
appendix_hash!: string;
|
||||
}
|
||||
|
||||
@InputType(`GenerationConvertStatementSignedDocumentInput`)
|
||||
export class GenerationConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => GenerationConvertStatementSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаинформация для документа заявления о конвертации целевого паевого взноса',
|
||||
})
|
||||
public readonly meta!: GenerationConvertStatementSignedMetaDocumentInputDTO;
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsString, IsBoolean } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
|
||||
// утилита для выборки повторяющихся параметров из базовых интерфейсов
|
||||
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id'>;
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.GenerationToMainWalletConvertStatement.Action;
|
||||
|
||||
@InputType(`BaseGenerationToMainWalletConvertStatementMetaDocumentInput`)
|
||||
class BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Хэш приложения' })
|
||||
@IsString()
|
||||
appendix_hash!: string;
|
||||
|
||||
@Field({ description: 'Хэш проекта' })
|
||||
@IsString()
|
||||
project_hash!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода на основной кошелек' })
|
||||
@IsString()
|
||||
main_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода на благорост кошелек' })
|
||||
@IsString()
|
||||
blagorost_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Перевод на основной кошелек' })
|
||||
@IsBoolean()
|
||||
to_wallet!: boolean;
|
||||
|
||||
@Field({ description: 'Перевод на благорост кошелек' })
|
||||
@IsBoolean()
|
||||
to_blagorost!: boolean;
|
||||
}
|
||||
|
||||
@InputType(`GenerationToMainWalletConvertStatementGenerateDocumentInput`)
|
||||
export class GenerationToMainWalletConvertStatementGenerateDocumentInputDTO extends IntersectionType(
|
||||
BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
) {
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`GenerationToMainWalletConvertStatementSignedMetaDocumentInput`)
|
||||
export class GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {}
|
||||
|
||||
@InputType(`GenerationToMainWalletConvertStatementSignedDocumentInput`)
|
||||
export class GenerationToMainWalletConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаинформация для документа заявления о переводе части целевого паевого взноса',
|
||||
})
|
||||
public readonly meta!: GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO;
|
||||
}
|
||||
+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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация пакета документов для регистрации
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, ValidateNested } from 'class-validator';
|
||||
import { CreateInitOrganizationDataInputDTO } from '~/application/account/dto/create-organization-data-input.dto';
|
||||
|
||||
@InputType('Init')
|
||||
@@ -9,4 +9,13 @@ export class InitDTO {
|
||||
})
|
||||
@ValidateNested()
|
||||
organization_data!: CreateInitOrganizationDataInputDTO;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Признак того, что инициализация выполняется со стороны провайдера. При true coopback ставит init_by_server=true (org_data становится readonly для пользовательского визарда). Поле передаёт provider в callInitSystemMutation.',
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_server_init?: boolean;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,14 @@ export class InitInteractor {
|
||||
}
|
||||
|
||||
// Определяем тип инициализации:
|
||||
// - Если mono не существует - это ПЕРВАЯ инициализация (может быть как серверная так и пользовательская)
|
||||
// - Если existingMono.init_by_server уже установлен - сохраняем его (не меняем источник)
|
||||
// - Если mono существует но init_by_server не установлен - значит это пользовательская инициализация
|
||||
const isServerInit = !existingMono ? true : existingMono.init_by_server === true;
|
||||
// - Если data.is_server_init === true (вызов от провайдера через server-secret) — ВСЕГДА серверная.
|
||||
// Это разблокирует перезапись user-init данных провайдером при следующем initSystem.
|
||||
// - Иначе сохраняем прежнюю логику: первая инициализация = серверная, повторная — наследует флаг.
|
||||
const isServerInit = data.is_server_init === true
|
||||
? true
|
||||
: !existingMono
|
||||
? true
|
||||
: existingMono.init_by_server === true;
|
||||
|
||||
// Проверяем права на обновление:
|
||||
// Пользователь не может обновлять данные, установленные сервером
|
||||
@@ -58,11 +62,11 @@ export class InitInteractor {
|
||||
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
|
||||
}
|
||||
|
||||
// Устанавливаем флаг источника инициализации только если это первая инициализация
|
||||
// - Для серверной инициализации (первый раз): устанавливаем true
|
||||
// - Для пользовательской инициализации (первый раз): устанавливаем false
|
||||
// - При повторной инициализации: НЕ меняем флаг (сохраняем изначальный источник)
|
||||
if (!existingMono) {
|
||||
// Устанавливаем флаг источника инициализации:
|
||||
// - Первая инициализация: ставим isServerInit как есть (true для server-side, true для user-side при первом init — историческое поведение).
|
||||
// - Повторная: если is_server_init=true пришёл от провайдера — поднимаем флаг в true (даже если до этого было user-init).
|
||||
// Это нужно чтобы провайдер мог перезаписать данные после того как пользователь успел заполнить форму первым.
|
||||
if (!existingMono || data.is_server_init === true) {
|
||||
await this.monoStatusRepository.setInitByServer(isServerInit);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,8 +93,9 @@ const envVarsSchema = z.object({
|
||||
.default('https://союз-русь.рф/anketa')
|
||||
.describe('ссылка на анкету для получения членства в союзе кооперативов'),
|
||||
IS_UNIONED: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((v) => v === 'true')
|
||||
.describe('флаг, указывающий что требуется членство в союзе для подключения к кооперативной экономике'),
|
||||
MATRIX_UNION_PERSON_ID: z.string().optional().describe('Matrix userId представителя союза для связи с кооперативами'),
|
||||
MATRIX_UNION_NAME: z.string().default('СПО РУСЬ').describe('Название союза для подписания комнат связи'),
|
||||
@@ -170,6 +171,28 @@ const envVarsSchema = z.object({
|
||||
OPENAI_BASE_URL: z.string().optional().describe('Базовый URL для Whisper API (через chatcoop-proxy nginx)'),
|
||||
WHISPER_MODEL: z.string().default('whisper-1').describe('Модель Whisper для STT'),
|
||||
WHISPER_LANGUAGE: z.string().default('ru').describe('Язык для Whisper STT'),
|
||||
|
||||
// Файловое хранилище (MinIO в dev/контуре кооператива; S3 в проде по плану E59-N).
|
||||
// MINIO_ENDPOINT без default: если не задан — file storage стартует в no-op,
|
||||
// HeadBucket не делается, операции get/put отдают понятную ошибку.
|
||||
MINIO_ENDPOINT: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Endpoint S3-совместимого бэкенда; в compose — service name. Пусто = file storage отключён.'),
|
||||
MINIO_ACCESS_KEY: z.string().default('minioadmin').describe('Access-key для MinIO/S3'),
|
||||
MINIO_SECRET_KEY: z.string().default('minioadmin').describe('Secret-key для MinIO/S3'),
|
||||
MINIO_BUCKET: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Имя физического бакета; по умолчанию `coop-${COOPNAME}`'),
|
||||
FILE_STORAGE_SIGNING_SECRET: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('HMAC-секрет для подписи read-URL; пусто — берётся SERVER_SECRET'),
|
||||
FILE_STORAGE_PUBLIC_BASE_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
|
||||
});
|
||||
|
||||
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
|
||||
@@ -293,4 +316,12 @@ export default {
|
||||
whisper_model: envVars.data.WHISPER_MODEL,
|
||||
whisper_language: envVars.data.WHISPER_LANGUAGE,
|
||||
},
|
||||
file_storage: {
|
||||
endpoint: envVars.data.MINIO_ENDPOINT,
|
||||
access_key: envVars.data.MINIO_ACCESS_KEY,
|
||||
secret_key: envVars.data.MINIO_SECRET_KEY,
|
||||
bucket: envVars.data.MINIO_BUCKET || `coop-${envVars.data.COOPNAME}`,
|
||||
signing_secret: envVars.data.FILE_STORAGE_SIGNING_SECRET || envVars.data.SERVER_SECRET,
|
||||
public_base_url: envVars.data.FILE_STORAGE_PUBLIC_BASE_URL || envVars.data.BACKEND_URL,
|
||||
},
|
||||
};
|
||||
|
||||
+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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,13 @@ export const PROCESS_HASH_LOCATOR: Readonly<Record<string, HashLocation[]>> = Ob
|
||||
// capital::pgproperties.property_hash — приём имущества в паевой фонд.
|
||||
'p.cap.prop': [{ code: 'capital', table: 'pgproperties', field: 'property_hash' }],
|
||||
|
||||
// p.cap.preimp — первичный учёт РИД-взноса до перехода кооператива на
|
||||
// электронный учёт (`o.cap.preimp`, ISSUE w.cap.preimp). Сущностной таблицы
|
||||
// под этот учёт пока нет — пред-импорт-записи живут только в
|
||||
// `userwallets[w.cap.preimp]`. Process_hash берётся из blockchain_actions
|
||||
// (как у одноактовых процессов).
|
||||
'p.cap.preimp': [],
|
||||
|
||||
'p.wal.depo': [{ code: 'wallet', table: 'deposits', field: 'deposit_hash' }],
|
||||
'p.wal.wthdrw': [{ code: 'wallet', table: 'withdraws', field: 'withdraw_hash' }],
|
||||
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -2,4 +2,8 @@ import type { CreateOrganizationDataInputDomainInterface } from '~/domain/accoun
|
||||
|
||||
export interface InitInputDomainInterface {
|
||||
organization_data: CreateOrganizationDataInputDomainInterface;
|
||||
// Если true — инициализация инициирована провайдером (через server-secret),
|
||||
// флаг init_by_server проставляется в true безусловно. Если undefined/false —
|
||||
// сохраняется текущая логика: setInitByServer(true) только при первой init.
|
||||
is_server_init?: boolean;
|
||||
}
|
||||
|
||||
+4
-2
@@ -90,14 +90,16 @@ export class CommitOutputDTO extends BaseOutputDTO {
|
||||
commit_hash!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Описание коммита',
|
||||
})
|
||||
description!: string;
|
||||
description?: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Метаданные коммита',
|
||||
})
|
||||
meta!: string;
|
||||
meta?: string;
|
||||
|
||||
@Field(() => GraphQLJSON, {
|
||||
nullable: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user