Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b84b02f1b4 | |||
| c33ec53230 | |||
| 7dcca3a2ae | |||
| 70752b2caa | |||
| 8c5568bcf8 | |||
| 30b79ad1f7 | |||
| 87747d1782 | |||
| f7a182e51f | |||
| 9774abb296 | |||
| 724cd381cc | |||
| 5aa1a21a97 | |||
| 933e5b5ffe | |||
| d886d780fc | |||
| 68b37efbe9 | |||
| e810d3152f | |||
| 99c22b0064 | |||
| 4c0fbefca9 | |||
| 8fdc99a7f9 | |||
| 8d27c4e245 | |||
| 0e3ccb4356 | |||
| 306835d0fc | |||
| 10e5b83ac9 | |||
| 33d936e5c8 | |||
| a73f07fa0c | |||
| 77f358e865 | |||
| 2afc71c281 | |||
| 9cf19aa8fe | |||
| 4b8121b0b7 |
+199
-151
@@ -1,45 +1,49 @@
|
||||
name: Release
|
||||
|
||||
# Релизный workflow по push'у в testnet/main (FF-промоушн-модель, см.
|
||||
# scripts/RELEASE.md).
|
||||
# Единый релизный workflow по push'у тэга `v*`.
|
||||
#
|
||||
# Порядок (через jobs.needs):
|
||||
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
|
||||
# 2) publish-packages — npm publish через lerna (только ветка main)
|
||||
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
|
||||
# Gitea (только ветка main); он сам тянет mono и
|
||||
# пересобирает доки.
|
||||
#
|
||||
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
|
||||
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
|
||||
# 2) publish-packages — npm publish через lerna (если не -alpha)
|
||||
# 3) publish-docs — mkdocs + standards-site → gh-pages (если не -alpha + ветка main)
|
||||
# 4) trigger-coopenomics — repository_dispatch в coopenomics/coopenomics (тот же гейт, что docs)
|
||||
#
|
||||
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
|
||||
# если релиз контрактов или контейнеров провалился.
|
||||
# если релиз контрактов или контейнеров провалился. Раньше четыре workflow'а
|
||||
# стартовали параллельно по одному тэгу — отсюда гонка из инцидента 2026-05-13
|
||||
# с ledger2 walletop sweep'ом (см. историю release.yaml до объединения).
|
||||
#
|
||||
# Триггер — push в testnet/main с изменением lerna.json. Версию бампает
|
||||
# `lerna version` ОДИН раз на dev (scripts/cut-release.sh); тот же коммит едет
|
||||
# вверх по fast-forward (scripts/promote.sh). lerna.json меняется только на
|
||||
# релизном бампе, поэтому обычные feature-пуши деплой не триггерят. dev в
|
||||
# триггере НЕТ: cut на dev только бампает версию, деплой начинается с testnet
|
||||
# (staging) и завершается main (production).
|
||||
# Гейт `-alpha` унифицирован: пакеты + доки + триггер docs пропускают только
|
||||
# не-alpha тэги. -beta/-rc/-test в проекте не используются (см. CLAUDE.md),
|
||||
# поэтому проще иметь один паттерн `!contains(github.ref, '-alpha')`.
|
||||
# IS_PROD в release-job остаётся на `(alpha|beta|rc|test)` — он определяет
|
||||
# деплой-webhook (testnet vs production) и тэг `:latest` у образов;
|
||||
# намеренно строже, чтобы случайный -rc-тэг не уехал на прод.
|
||||
#
|
||||
# Окружение определяет ВЕТКА (main → production, testnet → staging), а не
|
||||
# суффикс версии: единая версия едет во все среды, поэтому source of truth —
|
||||
# ветка, на которую сделан промоушн. Версия читается из закоммиченного
|
||||
# lerna.json. Гейты npm-publish/доки — `branch == main`.
|
||||
# Резолв ветки. Тэг семантики окружения не несёт (например `v2026.5.13-alpha-2`
|
||||
# может лежать на testnet или dev — определяет именно ветка, на которую сделан
|
||||
# merge перед тэгом). Поэтому ищем первое совпадение SHA с remote-ветками
|
||||
# main/testnet/dev. branch экспортируется как outputs.branch для downstream jobs.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [testnet, main]
|
||||
paths:
|
||||
- 'lerna.json'
|
||||
workflow_dispatch:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# Гейт TS-типов перед сборкой образов. Reusable workflow `typecheck.yaml`
|
||||
# запускает vue-tsc на desktop и tsc --noEmit на controller. Нужно потому,
|
||||
# что `lerna run build` в корневом Dockerfile НЕ ловит TS-ошибки:
|
||||
# quasar build идёт через esbuild с выключенным vueTsc, а у controller'а
|
||||
# build-скрипта вообще нет (ts-node в рантайме).
|
||||
typecheck:
|
||||
uses: ./.github/workflows/typecheck.yaml
|
||||
|
||||
release:
|
||||
needs: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.resolve.outputs.tag_name }}
|
||||
@@ -51,22 +55,22 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve branch, build mode and version
|
||||
- name: Resolve branch, build mode and tags
|
||||
id: resolve
|
||||
run: |
|
||||
# Ветка — это ветка push'а (или выбранная в workflow_dispatch).
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
TAG_NAME="${{ github.ref_name }}"
|
||||
SHA="${{ github.sha }}"
|
||||
|
||||
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
|
||||
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
|
||||
# testnet/main. Тэг как триггер больше не используется.
|
||||
RAW_VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' lerna.json | head -1)"
|
||||
if [ -z "$RAW_VERSION" ]; then
|
||||
echo "::error::Не удалось прочитать version из lerna.json"
|
||||
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
|
||||
TAG_NAME="v${RAW_VERSION}"
|
||||
|
||||
case "$BRANCH" in
|
||||
main)
|
||||
@@ -84,22 +88,17 @@ jobs:
|
||||
CONTRACTS_TAG=dev
|
||||
CONTRACTS_EXTRA=
|
||||
;;
|
||||
*)
|
||||
echo "::error::Неподдерживаемая ветка $BRANCH (ожидались testnet/main/dev)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Окружение деплоя определяет ВЕТКА: main → production, иначе → testnet.
|
||||
# IS_PROD управляет деплой-webhook (PRODUCTION vs TESTNET) и тэгом :latest
|
||||
# у образов. Раньше критерием был суффикс -alpha у тэга; теперь единая
|
||||
# версия едет во все среды, поэтому источник истины — ветка промоушна.
|
||||
if [ "$BRANCH" = "main" ]; then
|
||||
IS_PROD=true
|
||||
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
|
||||
else
|
||||
# IS_PROD определяет webhook деплоя (PRODUCTION vs TESTNET) и тэг :latest.
|
||||
# Сознательно строже гейта publish-* — случайный -rc/-beta тэг не должен
|
||||
# уехать на прод-вебхук, даже если в npm/docs его опубликовали.
|
||||
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
|
||||
|
||||
{
|
||||
@@ -128,41 +127,14 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# === Этап 1: контракты ===
|
||||
# CDT ставим из .deb (C9S/cdt v4.2.0) прямо в окружение job'а и
|
||||
# компилируем напрямую — БЕЗ вложенного docker. Почему не build-all.sh:
|
||||
# тот монтирует $(pwd):/project в sibling-контейнер, а под Gitea
|
||||
# act_runner сам job исполняется в контейнере → хостовый демон не видит
|
||||
# этот путь, /project пуст, cmake падает "no CMakeLists.txt".
|
||||
# build-all.sh остаётся для локальной сборки (оборачивает тот же
|
||||
# build_contracts_cdt.sh в docker).
|
||||
# CMakeLists хардкодит toolchain /cdt/build/...; .deb кладёт CDT в
|
||||
# /usr/opt/cdt/4.2.0, а сам CDTWasmToolchain.cmake указывает на /usr
|
||||
# абсолютно — поэтому симлинк /cdt/build → /usr/opt/cdt/4.2.0 сводит пути
|
||||
# без правки CMakeLists.
|
||||
|
||||
- name: Install CDT 4.2.0 toolchain (.deb)
|
||||
run: |
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update
|
||||
# build-essential — для host-компилятора (project() в CMakeLists);
|
||||
# libz3-4/libtinfo6/libxml2/zlib1g — рантайм бинарей CDT (clang-9,
|
||||
# ld.lld и т.д.). Пакет cdt объявляет только libcurl4-gnutls-dev,
|
||||
# поэтому остальные .so ставим явно — в образе dicoop/blockchain они
|
||||
# были из сборки исходников, из .deb не тянутся.
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates cmake make build-essential \
|
||||
libz3-4 libtinfo6 libxml2 zlib1g
|
||||
curl -fsSL -o /tmp/cdt.deb \
|
||||
https://git.coopenomics.world/C9S/cdt/releases/download/v4.2.0/cdt_4.2.0-1_amd64.deb
|
||||
$SUDO apt-get install -y /tmp/cdt.deb
|
||||
$SUDO mkdir -p /cdt
|
||||
$SUDO ln -sfn /usr/opt/cdt/4.2.0 /cdt/build
|
||||
cdt-cpp --version || true
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build_contracts_cdt.sh "$BUILD_MODE"
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
@@ -243,17 +215,6 @@ jobs:
|
||||
- name: Build and push contracts image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
# Ретрай push'а: резолвинг registry-1.docker.io на runner'е изредка
|
||||
# моргает (DNS-таймаут к 127.0.0.53) — 3 попытки с паузой.
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
IMAGE="dicoop/contracts"
|
||||
SHORT_SHA="${SHA::7}"
|
||||
docker build \
|
||||
@@ -262,14 +223,14 @@ jobs:
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$CONTRACTS_TAG" \
|
||||
./docker/.context
|
||||
dpush "$IMAGE:$CONTRACTS_TAG"
|
||||
docker push "$IMAGE:$CONTRACTS_TAG"
|
||||
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
dpush "$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"
|
||||
dpush "$IMAGE:$CONTRACTS_EXTRA"
|
||||
docker push "$IMAGE:$CONTRACTS_EXTRA"
|
||||
fi
|
||||
|
||||
- name: Verify pushed contracts image
|
||||
@@ -282,31 +243,15 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
run: |
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
|
||||
dpush "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
|
||||
dpush dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
- name: Build and push service images
|
||||
run: |
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
build_service() {
|
||||
local SVC="$1" PKG="$2" CMD="$3"
|
||||
local DOCKERFILE="Dockerfile.$SVC"
|
||||
@@ -315,10 +260,10 @@ jobs:
|
||||
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
|
||||
} > "$DOCKERFILE"
|
||||
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
|
||||
dpush "dicoop/$SVC:$TAG_NAME"
|
||||
docker push "dicoop/$SVC:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
|
||||
dpush "dicoop/$SVC:latest"
|
||||
docker push "dicoop/$SVC:latest"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -340,16 +285,14 @@ jobs:
|
||||
|
||||
# ============================================================================
|
||||
# publish-packages — npm publish (бывший publish-packages.yaml).
|
||||
# Гейт: ветка main. Версия для publish — из закоммиченного package.json
|
||||
# (lerna publish from-package); её бампит cut-release.sh на dev, тот же коммит
|
||||
# доезжает до main по FF.
|
||||
# Гейт: не-alpha. Не зависит от ветки.
|
||||
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0
|
||||
# = pnpm 9/10). Иначе action-setup может подтянуть другую версию и переписать
|
||||
# pnpm-lock.yaml → Lerna EUNCOMMIT.
|
||||
# ============================================================================
|
||||
publish-packages:
|
||||
needs: release
|
||||
if: ${{ needs.release.outputs.branch == 'main' }}
|
||||
if: ${{ !contains(github.ref, '-alpha') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -371,44 +314,149 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# ============================================================================
|
||||
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
|
||||
# Гейт: ветка main.
|
||||
#
|
||||
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
|
||||
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
|
||||
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
|
||||
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
|
||||
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
|
||||
# Целевой workflow слушает workflow_dispatch с входами
|
||||
# mono_sha/mono_ref.
|
||||
# publish-docs — mkdocs + standards-site → gh-pages (бывший publish-docs.yaml).
|
||||
# Гейт: не-alpha + ветка main.
|
||||
# ВНИМАНИЕ: gh-pages step пушит на github.com/coopenomics/mono — это GitHub
|
||||
# Pages. При переезде на Gitea Actions нужно будет либо подложить PAT для
|
||||
# внешнего push'а в GitHub, либо переключить публикацию на Gitea Pages.
|
||||
# ============================================================================
|
||||
publish-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Версия pnpm берётся из `packageManager` корневого package.json,
|
||||
# синхронно с publish-packages job'ом и 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 Python requirements
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm install
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Patch spectaql-config.yml for CI
|
||||
run: |
|
||||
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Show patched spectaql-config.yml
|
||||
run: cat spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Build cooptypes
|
||||
run: pnpm run build
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Generate controller docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Copy controller docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/graphql
|
||||
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
|
||||
|
||||
- name: Generate sdk docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/sdk
|
||||
|
||||
- name: Copy sdk docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/sdk
|
||||
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
|
||||
|
||||
- name: Generate cooptypes docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Copy cooptypes docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/cooptypes
|
||||
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
|
||||
|
||||
# ── standards-site → /standards/ на docs.цифровой-кооператив.рф ──
|
||||
# Отдельный Vue-сайт с BPMN-графом кооперативных стандартов,
|
||||
# публикуется как поддиректория на том же домене. Vite собирает
|
||||
# с base='/standards/' (см. vite.config.ts), относительные пути
|
||||
# внутри dist/ и hash-router работают корректно.
|
||||
# dist кладём в docs/standards/ ДО mkdocs build — так же, как
|
||||
# graphql/sdk/cooptypes; mkdocs сам включит его в итоговый site/.
|
||||
- name: Build standards-site
|
||||
run: pnpm run build
|
||||
working-directory: ./components/contracts/standards-site
|
||||
|
||||
- name: Copy standards-site into docs/standards
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/standards
|
||||
cp -r ./components/contracts/standards-site/dist/* ./components/docs/docs/standards/
|
||||
|
||||
- name: Build docs (mkdocs)
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
mkdocs build
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Remove specific large file before publishing
|
||||
run: |
|
||||
rm -f ./components/docs/site/sdk/typedoc.json
|
||||
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
|
||||
echo "ERROR: typedoc.json still exists!"
|
||||
exit 1
|
||||
else
|
||||
echo "SUCCESS: typedoc.json removed successfully"
|
||||
fi
|
||||
|
||||
- name: Publish to GitHub Pages
|
||||
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
|
||||
working-directory: ./components/docs
|
||||
env:
|
||||
GIT_AUTHOR_NAME: github-actions
|
||||
GIT_AUTHOR_EMAIL: github-actions@github.com
|
||||
GIT_COMMITTER_NAME: github-actions
|
||||
GIT_COMMITTER_EMAIL: github-actions@github.com
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger docs deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
|
||||
# ============================================================================
|
||||
# trigger-coopenomics-docs — repository_dispatch в coopenomics/coopenomics
|
||||
# (бывший build-contracts-docs.yaml). Гейт идентичен publish-docs.
|
||||
# ============================================================================
|
||||
trigger-coopenomics-docs:
|
||||
needs: release
|
||||
if: ${{ needs.release.outputs.branch == 'main' }}
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
|
||||
run: |
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
|
||||
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
|
||||
|
||||
# ============================================================================
|
||||
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
|
||||
# webhook DOCS_DEPLOY_WEBHOOK_URL. Отдельная от coopenomics публикация —
|
||||
# обе доки уезжают синхронно по релизу. Извлечён из бывшего publish-docs:
|
||||
# gh-pages на github.com выпилен как мёртвый, остался реальный deploy-webhook
|
||||
# (приёмник деплоит доки на своей стороне). Гейт: ветка main.
|
||||
# ============================================================================
|
||||
trigger-mono-docs:
|
||||
needs: release
|
||||
if: ${{ needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger docs deployment webhook
|
||||
run: |
|
||||
curl -fsSL -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
|
||||
@@ -13,25 +13,17 @@ name: Typecheck
|
||||
#
|
||||
# Триггеры:
|
||||
# - pull_request на dev — гейт перед мерджем в основную ветку;
|
||||
# - workflow_call — оставлен для переиспользования, но release.yaml его
|
||||
# больше НЕ вызывает (гейт из релиза убран как избыточный — типы
|
||||
# проверяются на PR в dev до того, как код доедет до тэга).
|
||||
# - workflow_call — переиспользуется из release.yaml как `needs:` у release-job,
|
||||
# чтобы битый тэг не уехал в DockerHub.
|
||||
#
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт на dev достаточен).
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт достаточен,
|
||||
# а тэги покрывает workflow_call из release.yaml).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
workflow_call:
|
||||
|
||||
# Новый push в PR-ветку отменяет ещё бегущий typecheck по той же ветке —
|
||||
# иначе 30-минутные прогоны копятся очередью. Группа по head_ref (исходная
|
||||
# ветка PR), для workflow_call fallback на ref. Отмена только своих же
|
||||
# прежних прогонов, разные PR друг друга не трогают.
|
||||
concurrency:
|
||||
group: typecheck-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
desktop:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
lerna-debug.log
|
||||
components/controller/graph.png
|
||||
blockchain-data/
|
||||
@@ -23,5 +22,3 @@ components/contracts/cpp/ledger2/scripts/out/
|
||||
|
||||
.env.testnet
|
||||
.pnpm-store
|
||||
schema.gql
|
||||
.claude
|
||||
|
||||
@@ -38,8 +38,6 @@ ln -s /home/admin/mono-ai-4/components/controller/.env $WT/components/controller
|
||||
|
||||
**Базовая ветка mono-ai-1 — `dev`.** Прямые коммиты в `dev` для мелких фиксов разрешены и не требуют feature-ветки/PR (отменено 2026-05-22 пользователем). Для крупных задач — feature-ветка (`feat/...` или `fix/...`) от dev и PR в dev; merge делает пользователь на GitHub. Push в `main` / `mvp` — по-прежнему только через PR.
|
||||
|
||||
**Релиз/деплой — FF-промоушн `dev → testnet → main`** (см. `scripts/RELEASE.md`). Версию бампает `lerna` ОДИН раз на dev (`scripts/cut-release.sh`), тот же коммит едет вверх по fast-forward (`scripts/promote.sh testnet|main`). `testnet`/`main` не несут своих коммитов — только FF-указатели, поэтому merge-конфликтов нет. **В `testnet`/`main` прямых коммитов и повторных бампов версии быть не должно** — это ломает FF. Деплой триггерит push в testnet/main с изменением `lerna.json`; окружение определяет ветка (main → prod). Старые `publish-alpha.sh`/`publish-prod.sh` (merge `-X theirs` + per-branch бамп + back-merge) удалены — они и плодили 20-package.json конфликты.
|
||||
|
||||
**Не stash'ить `-u` при unstaged WIP пользователя.** Это создаёт окно для потери при drop/конфликте. Если нужно временно убрать unstaged — `git stash push -- <конкретные-paths>` либо коммит-в-feature, и только свои файлы. Кейс 2026-05-18: после `git stash -u`+`drop` при конфликте потерял WIP пользователя (infra.ts/config.ts/quasar.config.cjs и др.).
|
||||
|
||||
### Story-by-story для проекта «Стол заказов» (mono-ai-4 на marketplace2)
|
||||
@@ -186,35 +184,6 @@ await sendPOST('/v1/graphql', { query: QUERY, variables });
|
||||
|
||||
Канон — `features/Branch/CreateBranch/{api,model}/index.ts`: тип в model `export type IXxxInput = Mutations.X.Y.IInput['data']`; функция в api `function (data: IXxxInput) { ... variables: { data } }`. Не делать `as` cast'ов.
|
||||
|
||||
## ⚠️ ДИЗАЙН-КАНОН desktop — ОБЯЗАТЕЛЕН К ИСПОЛЬЗОВАНИЮ
|
||||
|
||||
**ПРИ ЛЮБОЙ ВЁРСТКЕ В `components/desktop/` НЕ ВЫДУМЫВАТЬ СТИЛИ.** Всегда сначала свериться с каноном. Не строй гипотез о цветах/радиусах/типографике/паттернах из памяти — иди и читай.
|
||||
|
||||
**Полная спецификация канона — в skill `/mono-desktop-canon`** (правила обёрток, цвета/токены, иконки, структура страницы, stop-signals). Здесь — короткая выжимка; при расхождении побеждает skill.
|
||||
|
||||
**Источник истины — в самом репозитории** (НЕ внешние HTML/прототипы):
|
||||
|
||||
| Что | Путь |
|
||||
|---|---|
|
||||
| Токены палитры/типографики/spacing/radii (`--p-*`) | `components/desktop/src/css/mono-platform/tokens.css` |
|
||||
| Прототип-классы компонентов + typography-утилиты `.t-*` | `components/desktop/src/css/mono-platform/components.css` |
|
||||
| Оверрайды Quasar под канон | `components/desktop/src/css/mono-platform/quasar-canon.css` |
|
||||
| Минимальная SCSS-палитра Quasar (`$primary`/`$dark`) | `components/desktop/src/css/quasar.variables.scss` |
|
||||
| Обёртки / доменные блоки / layout | `components/desktop/src/shared/ui/{base,domain,layout}/` |
|
||||
| **Живой эталон** (`/_dev/ui` в dev-сборке) | `components/desktop/src/pages/_dev/ui/index.vue` |
|
||||
|
||||
**При сомнении — открыть `tokens.css` и `_dev/ui/index.vue`, смотреть как сделано там.** Внешний `shared/MONO Design System.html` и `auth-prototype/` каноном НЕ являются — это устаревшие прототипы.
|
||||
|
||||
**Запреты:**
|
||||
- Экран собирается из готовых компонентов: `shared/ui/base` (вместо сырых `q-input`/`q-btn`/`q-card`/`q-table`/`q-chip`/`q-dialog`/`q-select`), `shared/ui/domain` (WalletCard, DataRow, DocumentRow, IdentityPanel…), `shared/ui/layout` (PageHead, PageTabs, AppHeader/AppDrawer). Голый Quasar — только где обёртки нет (`q-icon`, `q-toggle`, `q-list`, `q-menu`, `q-tooltip`, `q-tabs`, `q-separator`, `q-spinner`, `q-inner-loading`…). Props обёрток не угадывать — читать `*.types.ts` рядом.
|
||||
- Цвет — только токены `var(--p-*)` (поверхности `--p-surface*`, текст `--p-ink*`, линии `--p-line*`, акцент `--p-primary`, статусы `--p-pos/neg/warn/info`) либо utility-классы/color-props. Никаких сырых hex/rgb. Темы light/dark переключаются через `[data-theme]` на `<html>` — токены следуют сами.
|
||||
- Spacing/радиусы/типографика — токены `--p-1..--p-10` (4px…72px), `--p-r-sm/md/lg/xl`, `--p-fs-*`/`--p-lh-*` либо классы `.t-*`. Без хардкод-px.
|
||||
- Иконки — `q-icon(name='…')` именами Material Icons. **FontAwesome (`fa-*`) запрещён** — заменять на Material-эквивалент попутно.
|
||||
- Запрещённые/удаляемые токены: `--mp-*` (старый `marketplace-tokens.scss`), `--tr-*`, `--ds-*`. Легитимны только `--p-*`, `--q-*` (Quasar автоген), `--prog-*`.
|
||||
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — Quasar-overrides централизованы в `quasar-canon.css`.
|
||||
|
||||
**Кейс 2026-05-28:** при сомнении в каноне пошёл искать его во внешнем HTML/auth-prototype вместо репозитория — перевёрстал не туда, переделывал. SoT — репо (`tokens.css` + `_dev/ui`) и skill `/mono-desktop-canon`, не внешние файлы.
|
||||
|
||||
## Frontend desktop — English имена
|
||||
|
||||
В `components/desktop/` и любом Vue/TS frontend коде **все имена идентификаторов — английские**:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -362,9 +362,9 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
)
|
||||
.action(async (paths: string[]) => {
|
||||
const root = requireRoot()
|
||||
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity } = await runAdd(root, paths)
|
||||
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts } = await runAdd(root, paths)
|
||||
success(
|
||||
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}; не blago-сущности (нет type=project/issue/story во frontmatter): ${skippedNonEntity}.`,
|
||||
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}.`,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -38,22 +38,6 @@ export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
|
||||
}
|
||||
}
|
||||
|
||||
/** Не бросает: возвращает тип сущности blago, если файл — корректный project/issue/story; иначе undefined. */
|
||||
export function peekBlagoEntityType(raw: string): EntityFrontmatterType | undefined {
|
||||
let file: ReturnType<typeof matter>
|
||||
try {
|
||||
file = matter(raw)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
const t = file.data?.type
|
||||
if (t === 'project' || t === 'issue' || t === 'story') {
|
||||
return t
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
|
||||
const trimmed = body.endsWith('\n') ? body : `${body}\n`
|
||||
// matter.stringify(string, data) сначала парсит string как frontmatter — body, начинающийся
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { peekBlagoEntityType } from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { expandBlagoUserTargetsToRelativePaths } from './capital-target-expand.js'
|
||||
import { isBlagoSyncExcludedDirName, isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
|
||||
@@ -29,39 +28,19 @@ async function collectMarkdownFiles(absDir: string): Promise<string[]> {
|
||||
return out
|
||||
}
|
||||
|
||||
type FileCheck =
|
||||
| { kind: 'unreadable' }
|
||||
| { kind: 'non-entity' }
|
||||
| { kind: 'clean' }
|
||||
| { kind: 'dirty' }
|
||||
|
||||
/**
|
||||
* Решение по одному файлу для staging:
|
||||
* — нечитаемый → dirty (push сам сообщит вменяемую ошибку),
|
||||
* — без корректного frontmatter type → non-entity (README/notes/CLAUDE/AGENTS и пр. не отправляем),
|
||||
* — есть в индексе и sha256 совпал → clean,
|
||||
* — иначе dirty.
|
||||
*/
|
||||
async function classifyFileForAdd(
|
||||
root: string,
|
||||
rel: string,
|
||||
index: Awaited<ReturnType<typeof loadIndex>>,
|
||||
): Promise<FileCheck> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(path.join(root, rel), 'utf8')
|
||||
}
|
||||
catch {
|
||||
return { kind: 'unreadable' }
|
||||
}
|
||||
if (peekBlagoEntityType(raw) === undefined) {
|
||||
return { kind: 'non-entity' }
|
||||
}
|
||||
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
|
||||
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
|
||||
const entry = findByRelativePath(index, rel)
|
||||
if (!entry) {
|
||||
return { kind: 'dirty' }
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, rel), 'utf8')
|
||||
return sha256Hex(raw) !== entry.content_etag_local
|
||||
}
|
||||
catch {
|
||||
return true
|
||||
}
|
||||
return sha256Hex(raw) === entry.content_etag_local ? { kind: 'clean' } : { kind: 'dirty' }
|
||||
}
|
||||
|
||||
export interface RunAddResult {
|
||||
@@ -71,8 +50,6 @@ export interface RunAddResult {
|
||||
skippedIgnored: number
|
||||
/** messages/ и meetings/ — только синхронизация с сервера, в push не идут */
|
||||
skippedPullOnlyArtifacts: number
|
||||
/** Файлы без корректного blago-frontmatter (README, notes, CLAUDE/AGENTS и пр.) — в staging не попадают */
|
||||
skippedNonEntity: number
|
||||
}
|
||||
|
||||
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
|
||||
@@ -88,7 +65,6 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
|
||||
let skippedUnchanged = 0
|
||||
let skippedIgnored = 0
|
||||
let skippedPullOnlyArtifacts = 0
|
||||
let skippedNonEntity = 0
|
||||
|
||||
for (const t of expanded) {
|
||||
const abs = path.resolve(root, t)
|
||||
@@ -110,12 +86,7 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
|
||||
skippedPullOnlyArtifacts += 1
|
||||
continue
|
||||
}
|
||||
const check = await classifyFileForAdd(root, rel, index)
|
||||
if (check.kind === 'non-entity') {
|
||||
skippedNonEntity += 1
|
||||
continue
|
||||
}
|
||||
if (check.kind === 'clean') {
|
||||
if (!(await isDirtyVsIndex(root, rel, index))) {
|
||||
skippedUnchanged += 1
|
||||
continue
|
||||
}
|
||||
@@ -125,5 +96,5 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
|
||||
|
||||
const stagedPaths = [...set].sort()
|
||||
await saveStaging(root, { paths: stagedPaths })
|
||||
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity }
|
||||
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts }
|
||||
}
|
||||
|
||||
@@ -9,15 +9,12 @@ export interface CommunicationCursorsFile {
|
||||
messageLastTsByRoom: Record<string, number>
|
||||
/** project_hash → ISO instant: транскрипции с endedAt ≤ этого момента уже выгружены */
|
||||
transcriptionLastEndedExclusiveByProject: Record<string, string>
|
||||
/** matrixRoomId → ISO instant: транскрипции непроектной комнаты с endedAt ≤ этого момента уже выгружены */
|
||||
transcriptionLastEndedExclusiveByRoom: Record<string, string>
|
||||
}
|
||||
|
||||
function empty(): CommunicationCursorsFile {
|
||||
return {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +32,6 @@ export async function loadCommunicationCursors(root: string): Promise<Communicat
|
||||
&& typeof parsed.transcriptionLastEndedExclusiveByProject === 'object'
|
||||
? { ...parsed.transcriptionLastEndedExclusiveByProject }
|
||||
: {},
|
||||
transcriptionLastEndedExclusiveByRoom:
|
||||
parsed.transcriptionLastEndedExclusiveByRoom !== undefined
|
||||
&& typeof parsed.transcriptionLastEndedExclusiveByRoom === 'object'
|
||||
? { ...parsed.transcriptionLastEndedExclusiveByRoom }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -43,6 +43,7 @@ export function projectCommunicationDayToMarkdown(
|
||||
}
|
||||
|
||||
export interface TranscriptionHeaderModel {
|
||||
matrixRoomId: string
|
||||
roomId: string
|
||||
startedAt: Date | string
|
||||
endedAt: Date | string | null | undefined
|
||||
@@ -70,6 +71,7 @@ export function renderCallTranscriptionMarkdown(
|
||||
const header = [
|
||||
`# Транскрипция звонка`,
|
||||
``,
|
||||
`- Matrix room: \`${transcription.matrixRoomId}\``,
|
||||
`- LiveKit room: \`${transcription.roomId}\``,
|
||||
`- Начало: ${start.toISOString()}`,
|
||||
`- Окончание: ${end ? end.toISOString() : '—'}`,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
transcriptionMeetingFileStemUtc,
|
||||
type CommunicationDayLine,
|
||||
} from './communication-markdown.js'
|
||||
import { generateSlug, workspaceBasePath, type ProjectPathModel } from './layout.js'
|
||||
import { workspaceBasePath, type ProjectPathModel } from './layout.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
|
||||
interface ProjectRowLite {
|
||||
@@ -183,19 +183,6 @@ async function listRooms(ctx: AuthenticatedContext, projectHash: string) {
|
||||
return q[Queries.ChatCoop.ListProjectCommunicationRooms.name] ?? []
|
||||
}
|
||||
|
||||
/** Стабильная папка непроектной комнаты в `rooms/`. Системные — фиксированные, комнаты секретаря — slug + хвост id (уникальность). */
|
||||
function nonProjectRoomFolder(kind: string, matrixRoomId: string, displayLabel: string): string {
|
||||
if (kind === 'MEMBERS') {
|
||||
return 'komnata-paishchikov'
|
||||
}
|
||||
if (kind === 'COUNCIL') {
|
||||
return 'komnata-soveta'
|
||||
}
|
||||
const slug = generateSlug(displayLabel) || 'komnata'
|
||||
const shortId = createHash('sha256').update(matrixRoomId, 'utf8').digest('hex').slice(0, 6)
|
||||
return `${slug}-${shortId}`
|
||||
}
|
||||
|
||||
export async function pullProjectCommunicationArtifacts(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
@@ -211,7 +198,6 @@ export async function pullProjectCommunicationArtifacts(
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +356,7 @@ export async function pullProjectCommunicationArtifacts(
|
||||
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
|
||||
const md = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: String(tr.matrixRoomId),
|
||||
roomId: String(tr.roomId),
|
||||
startedAt,
|
||||
endedAt: endedAtTr,
|
||||
@@ -435,207 +422,3 @@ export async function pullProjectCommunicationArtifacts(
|
||||
warn(`Не удалось сохранить курсоры переписки: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull переписки и транскрипций из комнат ВНЕ проектов Capital (пайщики, совет, комнаты секретаря).
|
||||
* Раскладка — отдельная верхняя папка `rooms/<folder>/{messages,meetings}/`, чтобы не смешивать с
|
||||
* проектными `meetings/`. Логика идентична проектной, но bucket = одна комната, курсор транскрипций — по matrixRoomId.
|
||||
*/
|
||||
export async function pullNonProjectCommunicationArtifacts(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
): Promise<void> {
|
||||
let rooms: { matrixRoomId: string, displayLabel: string, kind: string }[]
|
||||
try {
|
||||
const q = await ctx.client.Query(Queries.ChatCoop.ListNonProjectCommunicationRooms.query, {})
|
||||
rooms = (q[Queries.ChatCoop.ListNonProjectCommunicationRooms.name] ?? []) as typeof rooms
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Список непроектных комнат (chatcoopListNonProjectCommunicationRooms): ${formatThrownValue(e)}`)
|
||||
return
|
||||
}
|
||||
if (rooms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let cursors: CommunicationCursorsFile
|
||||
try {
|
||||
cursors = await loadCommunicationCursors(ctx.root)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Курсоры переписки (комнаты): не удалось прочитать, начинаем с пустых: ${formatThrownValue(e)}`)
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
const folder = nonProjectRoomFolder(room.kind, room.matrixRoomId, room.displayLabel)
|
||||
const basePath = `rooms/${folder}`
|
||||
const roomTitle = room.displayLabel || room.matrixRoomId
|
||||
|
||||
// Сообщения комнаты — по календарным суткам UTC новее курсора.
|
||||
try {
|
||||
const last = cursors.messageLastTsByRoom[room.matrixRoomId]
|
||||
const afterTs = last ?? 0
|
||||
const datesQ = await ctx.client.Query(Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, afterOriginServerTsExclusive: afterTs } },
|
||||
})
|
||||
const dates = (datesQ[Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.name] ?? []).sort()
|
||||
for (const utcDate of dates) {
|
||||
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
|
||||
})
|
||||
const linesRaw = mq[Queries.ChatCoop.GetRoomMessagesForUtcDate.name] ?? []
|
||||
const lines: CommunicationDayLine[] = linesRaw.map(m => ({
|
||||
originServerTs: m.originServerTs,
|
||||
authorLabel: m.authorLabel,
|
||||
coopUsername: m.coopUsername,
|
||||
kind: String(m.kind),
|
||||
bodyText: m.bodyText,
|
||||
}))
|
||||
if (lines.length === 0) {
|
||||
continue
|
||||
}
|
||||
const content = projectCommunicationDayToMarkdown(roomTitle, room.matrixRoomId, utcDate, [
|
||||
{ displayLabel: room.displayLabel, matrixRoomId: room.matrixRoomId, lines },
|
||||
])
|
||||
const rel = `${basePath}/messages/${utcDate}.md`
|
||||
const entityHash = messageDayEntityHash(room.matrixRoomId, utcDate)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'room_message_day',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
|
||||
label: `переписка ${utcDate} (${room.matrixRoomId})`,
|
||||
})
|
||||
}
|
||||
|
||||
const maxQ = await ctx.client.Query(Queries.ChatCoop.GetMaxOriginServerTsForRoom.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId } },
|
||||
})
|
||||
const maxTs = maxQ[Queries.ChatCoop.GetMaxOriginServerTsForRoom.name] as number | null | undefined
|
||||
if (maxTs !== undefined && maxTs !== null && Number.isFinite(maxTs)) {
|
||||
cursors.messageLastTsByRoom[room.matrixRoomId] = maxTs
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Переписка Matrix, комната ${room.matrixRoomId} (${roomTitle}): ${formatThrownValue(e)}`)
|
||||
}
|
||||
|
||||
// Транскрипции звонков комнаты + sibling memo.
|
||||
try {
|
||||
const tExIso = cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId]
|
||||
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
|
||||
|
||||
interface TranscriptionCandidate {
|
||||
id: string
|
||||
endedAt: Date
|
||||
memo: string
|
||||
updatedAt: Date | undefined
|
||||
}
|
||||
const byId = new Map<string, TranscriptionCandidate>()
|
||||
const tq = await ctx.client.Query(Queries.ChatCoop.GetTranscriptions.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, limit: CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT, offset: 0 } },
|
||||
})
|
||||
const list = tq[Queries.ChatCoop.GetTranscriptions.name] ?? []
|
||||
for (const t of list) {
|
||||
const end = dateFromUnknown(t.endedAt)
|
||||
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
|
||||
continue
|
||||
}
|
||||
const prev = byId.get(t.id)
|
||||
if (!prev || end > prev.endedAt) {
|
||||
byId.set(t.id, {
|
||||
id: t.id,
|
||||
endedAt: end,
|
||||
memo: typeof t.memo === 'string' ? t.memo : '',
|
||||
updatedAt: dateFromUnknown(t.updatedAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
|
||||
|
||||
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
|
||||
let maxEnded: Date | null = null
|
||||
for (const c of newMeetings) {
|
||||
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
|
||||
variables: { data: { id: c.id } },
|
||||
})
|
||||
const pack = packQ[Queries.ChatCoop.GetTranscription.name]
|
||||
if (!pack?.transcription || pack.transcription.status !== Zeus.TranscriptionStatus.COMPLETED) {
|
||||
continue
|
||||
}
|
||||
const tr = pack.transcription
|
||||
const startedAt: Date | string = dateFromUnknown(tr.startedAt) ?? (tr.startedAt as Date | string)
|
||||
const endedAtTr: Date | string | null | undefined
|
||||
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
|
||||
const md = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
roomId: String(tr.roomId),
|
||||
startedAt,
|
||||
endedAt: endedAtTr,
|
||||
},
|
||||
pack.segments.map(s => ({
|
||||
speakerName: s.speakerName,
|
||||
text: s.text,
|
||||
startOffset: s.startOffset,
|
||||
endOffset: s.endOffset,
|
||||
})),
|
||||
)
|
||||
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
|
||||
const rel = `${basePath}/meetings/${stem}.md`
|
||||
const entityHash = c.id.toLowerCase()
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'call_transcription',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content: md,
|
||||
remoteUpdatedAt: toUpdatedIso(c.endedAt),
|
||||
label: `транскрипция ${c.id}`,
|
||||
})
|
||||
if (!maxEnded || c.endedAt > maxEnded) {
|
||||
maxEnded = c.endedAt
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of allCompleted) {
|
||||
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
|
||||
const memoRel = `${basePath}/meetings/${stem}.memo.md`
|
||||
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
|
||||
await syncTranscriptionMemoFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
transcriptionId: c.id,
|
||||
relativePath: memoRel,
|
||||
serverMemo: c.memo,
|
||||
remoteUpdatedAtIso: memoRemoteUpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if (maxEnded) {
|
||||
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = maxEnded.toISOString()
|
||||
}
|
||||
else if (tExIso === undefined) {
|
||||
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Транскрипции звонков, комната ${room.matrixRoomId}: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await saveCommunicationCursors(ctx.root, cursors)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Не удалось сохранить курсоры переписки (комнаты): ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
storyFileRelativePath,
|
||||
workspaceBasePath,
|
||||
} from './layout.js'
|
||||
import { pullNonProjectCommunicationArtifacts, pullProjectCommunicationArtifacts } from './pull-communication.js'
|
||||
import { pullProjectCommunicationArtifacts } from './pull-communication.js'
|
||||
import { scaffoldBmadWorkspacesAfterPull } from './scaffold-bmad-workspace.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
|
||||
@@ -329,8 +329,6 @@ export async function runPull(ctx: AuthenticatedContext, options: RunPullOptions
|
||||
|
||||
await pullProjectCommunicationArtifacts(ctx, index, allProjects, projectByHash)
|
||||
|
||||
await pullNonProjectCommunicationArtifacts(ctx, index)
|
||||
|
||||
await scaffoldBmadWorkspacesAfterPull(ctx, allProjects, projectByHash)
|
||||
|
||||
await saveIndex(ctx.root, index)
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Mutations, Queries } from '@coopenomics/sdk'
|
||||
import { parseBlagoMarkdown, peekBlagoEntityType } from '../format/index.js'
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
import { warn } from '../ui/output.js'
|
||||
@@ -58,30 +58,6 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
|
||||
}
|
||||
staging = await loadStaging(ctx.root)
|
||||
}
|
||||
// Не-сущности (README/notes/CLAUDE/AGENTS и пр., в .md без type=project|issue|story) тоже не должны валить весь push.
|
||||
// Чистим их автоматически — иначе одна забытая заметка блокирует отправку реальных правок.
|
||||
const stagingAbs = (p: string): string => path.join(ctx.root, normalizeRelativePath(p))
|
||||
const nonEntityInStaging: string[] = []
|
||||
for (const p of staging.paths) {
|
||||
try {
|
||||
const raw = await fs.readFile(stagingAbs(p), 'utf8')
|
||||
if (peekBlagoEntityType(raw) === undefined) {
|
||||
nonEntityInStaging.push(normalizeRelativePath(p))
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// нечитаемый файл — пусть основной цикл бросит понятную ошибку с путём
|
||||
}
|
||||
}
|
||||
if (nonEntityInStaging.length > 0) {
|
||||
const drop = new Set(nonEntityInStaging)
|
||||
const kept = staging.paths.filter(p => !drop.has(normalizeRelativePath(p)))
|
||||
await saveStaging(ctx.root, { paths: [...new Set(kept.map(p => normalizeRelativePath(p)))].sort() })
|
||||
for (const p of nonEntityInStaging) {
|
||||
warn(`Убрано из staging (нет blago-frontmatter type=project|issue|story): ${p}`)
|
||||
}
|
||||
staging = await loadStaging(ctx.root)
|
||||
}
|
||||
if (staging.paths.length === 0) {
|
||||
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь | id проекта | projectId-issueId>')
|
||||
}
|
||||
@@ -99,24 +75,8 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
|
||||
}
|
||||
const abs = path.join(ctx.root, n)
|
||||
const raw = await fs.readFile(abs, 'utf8')
|
||||
let parsed: ReturnType<typeof parseBlagoMarkdown>
|
||||
try {
|
||||
parsed = parseBlagoMarkdown(raw)
|
||||
}
|
||||
catch (err) {
|
||||
// Без префикса путём из staging вылезает «type: undefined» без указания файла.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Файл «${n}»: ${msg}`)
|
||||
}
|
||||
let type: ReturnType<typeof validateParsedForPush>['type']
|
||||
let hash: string
|
||||
try {
|
||||
({ type, hash } = validateParsedForPush(parsed))
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Файл «${n}»: ${msg}`)
|
||||
}
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
const { type, hash } = validateParsedForPush(parsed)
|
||||
const entry = findByHash(index, type, hash)
|
||||
const pKind = pendingKindForEntityType(type)
|
||||
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
|
||||
|
||||
@@ -367,6 +367,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
|
||||
if (entry.entity_type === 'call_transcription') {
|
||||
interface TranscriptionRestorePack {
|
||||
transcription: {
|
||||
matrixRoomId: string
|
||||
roomId: string
|
||||
startedAt: Date | string
|
||||
endedAt: Date | string | null | undefined
|
||||
@@ -393,6 +394,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
|
||||
}
|
||||
const content = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: tr.matrixRoomId,
|
||||
roomId: tr.roomId,
|
||||
startedAt: tr.startedAt,
|
||||
endedAt: tr.endedAt,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
|
||||
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
|
||||
# Загружаем per-instance конфиг из корня репо.
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
@@ -10,34 +9,26 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Останавливаем и удаляем контейнеры (сеть НЕ трогаем — к ней может быть
|
||||
# подключён внешний контейнер вроде provider-backend; down её снёс бы с ошибкой).
|
||||
echo "Останавливаем и удаляем контейнеры..."
|
||||
docker compose rm -fsv mongo postgres monoredis cooparser coopback || true
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем тома баз данных для чистого старта (named volumes rm -v не чистит)
|
||||
echo "Удаляем тома баз данных..."
|
||||
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT_DIR")}"
|
||||
docker volume rm "${PROJECT}_postgres_data" "${PROJECT}_mongo_data" 2>/dev/null || true
|
||||
|
||||
# Удаляем blockchain data через контейнер (alpine под root стирает данные
|
||||
# независимо от владельца — без sudo на любой ноде; на проде nodeos пишет под root).
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных + Redis (monoredis).
|
||||
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
|
||||
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash.
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
|
||||
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -51,7 +42,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
|
||||
# Запускаем boot процесс (clean: только программы Благорост/маркетплейс)
|
||||
# Запускаем boot процесс
|
||||
echo "Запускаем boot процесс..."
|
||||
pnpm run boot:clean
|
||||
|
||||
@@ -60,6 +51,6 @@ echo "Запускаем parser..."
|
||||
docker compose up -d cooparser
|
||||
|
||||
echo "Запускаем контроллер..."
|
||||
docker compose up -d --force-recreate coopback || true
|
||||
docker compose restart coopback || true
|
||||
|
||||
echo "Перезапуск завершен!"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
|
||||
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
|
||||
# Загружаем per-instance конфиг из корня репо.
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
@@ -10,32 +9,30 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Останавливаем контроллер перед очисткой данных
|
||||
echo "Останавливаем контроллер..."
|
||||
docker compose down coopback || true
|
||||
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres monoredis cooparser coopback || true
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем blockchain data.
|
||||
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
|
||||
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
|
||||
# данные под root). Единый способ с reboot.sh / clean_reboot.sh.
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных + Redis (monoredis).
|
||||
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
|
||||
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash,
|
||||
# и провайдер не может взять org-данные partner1 (PROVIDER_URL=coopback:2998).
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
|
||||
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -49,7 +46,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
|
||||
# Запускаем boot процесс (расширенный: совет + пайщики; partner1 — при EXTRA_RENT=1)
|
||||
# Запускаем boot процесс
|
||||
echo "Запускаем boot процесс..."
|
||||
pnpm run boot:extra
|
||||
|
||||
@@ -58,6 +55,6 @@ echo "Запускаем parser..."
|
||||
docker compose up -d cooparser
|
||||
|
||||
echo "Запускаем контроллер..."
|
||||
docker compose up -d --force-recreate coopback || true
|
||||
docker compose up -d coopback
|
||||
|
||||
echo "Перезапуск завершен!"
|
||||
|
||||
@@ -291,46 +291,31 @@ export default class Blockchain {
|
||||
async activateFeature(feature: Feature) {
|
||||
await this.update_pass_instance()
|
||||
|
||||
try {
|
||||
await this.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
account: 'eosio',
|
||||
name: 'activate',
|
||||
authorization: [
|
||||
{
|
||||
actor: 'eosio',
|
||||
permission: 'active',
|
||||
},
|
||||
],
|
||||
data: {
|
||||
feature_digest: feature.hash,
|
||||
await this.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
account: 'eosio',
|
||||
name: 'activate',
|
||||
authorization: [
|
||||
{
|
||||
actor: 'eosio',
|
||||
permission: 'active',
|
||||
},
|
||||
],
|
||||
data: {
|
||||
feature_digest: feature.hash,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
blocksBehind: 3,
|
||||
expireSeconds: 30,
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
blocksBehind: 3,
|
||||
expireSeconds: 30,
|
||||
},
|
||||
)
|
||||
|
||||
console.log('Фича активирована: ', feature.name)
|
||||
}
|
||||
catch (e: any) {
|
||||
// На уже забутстрапленном (не обнулённом) чейне протокол-фича уже активна —
|
||||
// nodeos отдаёт protocol_feature_exception (code 3250000). Это не повод
|
||||
// ронять весь boot: фича на месте, нужный результат достигнут, продолжаем.
|
||||
const errName = e?.json?.error?.name
|
||||
const errCode = e?.json?.error?.code
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (errName === 'protocol_feature_exception' || errCode === 3250000 || /already activated/i.test(msg)) {
|
||||
console.warn(`Фича уже активирована, пропускаю: ${feature.name}`)
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
console.log('Фича активирована: ', feature.name)
|
||||
}
|
||||
|
||||
async createToken(params: TokenContract.Interfaces.ICreate) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"protocol_feature_type": "builtin",
|
||||
"dependencies": [],
|
||||
"description_digest": "2ad6b7aabba8edbc87b46f3340df4d0db0e4b65474db3e1cd5db33fb685a506b",
|
||||
"subjective_restrictions": {
|
||||
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
|
||||
"preactivation_required": true,
|
||||
"enabled": true
|
||||
},
|
||||
"builtin_feature_codename": "ASSERT_RECOVER_KEY_ACCOUNT"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import config from '../configs'
|
||||
import { initExtensionsInPostgres, initSystemStatus } from '../postgres-init'
|
||||
import { installExtraData, installInitialData, startInfra } from './infra'
|
||||
import { startCoop } from './cooperative'
|
||||
import { CooperativeClass, startCoop } from './cooperative'
|
||||
|
||||
export async function boot() {
|
||||
const blockchain = await startInfra()
|
||||
@@ -13,29 +14,18 @@ export async function boot() {
|
||||
}
|
||||
|
||||
export async function bootClean() {
|
||||
// Только инфраструктура: контракты, фичи, токен, системные параметры.
|
||||
// Ни совета, ни программ — программы (createPrograms) требуют существующий
|
||||
// совет и создаются в boot/bootExtra внутри installInitialData. В clean их
|
||||
// не делаем (иначе soviet::createprog падает с «Совет не найден»).
|
||||
await startInfra()
|
||||
const blockchain = await startInfra()
|
||||
|
||||
console.log('Создаём программы (Благорост и маркетплейс)')
|
||||
|
||||
const cooperative = new CooperativeClass(blockchain)
|
||||
await cooperative.createPrograms(config.provider)
|
||||
}
|
||||
|
||||
export async function bootExtra() {
|
||||
const blockchain = await startInfra()
|
||||
await installInitialData(blockchain, true) // Создать расширенный совет (устанавливает статус 'active' в MongoDB)
|
||||
|
||||
// installExtraData регистрирует partner1 как coop с auto-approve от провайдера —
|
||||
// это и есть программная on-chain активация, которая триггерит аренду VM в
|
||||
// провайдере (PENDING→RENT). boot:extra используется НЕ только для аренды
|
||||
// сервера (например, просто пересев совета/чейна для других задач), поэтому
|
||||
// провижининг partner1 включается ТОЛЬКО под флагом EXTRA_RENT=1.
|
||||
if (process.env.EXTRA_RENT === '1') {
|
||||
console.log('EXTRA_RENT=1 → installExtraData: провижининг partner1 (триггер аренды)')
|
||||
await installExtraData(blockchain) // Регистрируем partner1 как coop (active)
|
||||
}
|
||||
else {
|
||||
console.log('EXTRA_RENT не задан → пропускаем провижининг partner1 (аренда не запускается)')
|
||||
}
|
||||
await installExtraData(blockchain) // Добавить дополнительных пайщиков
|
||||
|
||||
console.log('Инициализируем статус системы в PostgreSQL')
|
||||
await initSystemStatus() // Устанавливает статус 'active' в PostgreSQL
|
||||
|
||||
@@ -15,9 +15,6 @@ import { sleep } from '../utils'
|
||||
import { generateRandomSHA256 } from '../utils/randomHash'
|
||||
import { initUsersInPostgres, initVaultInPostgres } from '../postgres-init'
|
||||
import { CooperativeClass } from './cooperative'
|
||||
import { signProgramAgreement } from './sign-program-agreement'
|
||||
import { fakeDocument } from '../tests/shared/fakeDocument'
|
||||
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
@@ -677,17 +674,10 @@ export async function installExtraData(blockchain: Blockchain) {
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
// Записываем partner1 в ЦПП Кошелька оператора (voskhod, program_id=1).
|
||||
// БЕЗ этого провайдерский performInitialTransfers (150 AXON на partner1 ДО
|
||||
// перехода в ACTIVE, см. provider CLAUDE.md §13) падает ассертом
|
||||
// eosio.token::is_can_transfer «Получатель не является участником целевой
|
||||
// потребительской программы кошелька» — и инстанс навсегда застревает в
|
||||
// INSTALL. wallet::signagree (auth voskhod@active) делает partner1 членом
|
||||
// ЦПП Кошелька. Та же механика, что для обычных пайщиков в participant.ts.
|
||||
console.log('Подписываем wallet-соглашение за partner1 (членство в ЦПП Кошелька)')
|
||||
await signProgramAgreement(blockchain, config.provider, account.username, walletProgramId, walletDraftId, fakeDocument)
|
||||
|
||||
// Ресурсы для партнёра: powerup CPU/NET.
|
||||
// Ресурсы для партнёра: powerup CPU/NET. transfer токенов пропускаем —
|
||||
// eosio.token::transfer падает на проверке membership в wallet program,
|
||||
// которая для partner1 не настроена. Для provider sync + Hostkey-flow
|
||||
// токены не нужны: tx от имени partner1 пойдут от soviet/admin.
|
||||
await blockchain.powerup({
|
||||
payer: 'eosio',
|
||||
receiver: account.username,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Локальная сборка всех контрактов: оборачивает build_contracts_cdt.sh в
|
||||
# CDT-образ. Сам cmake/make живёт в build_contracts_cdt.sh — он же
|
||||
# используется в CI (там CDT ставится из .deb, без docker), поэтому
|
||||
# флаги сборки в одном месте и не разъезжаются.
|
||||
|
||||
mode="${1:-prod}"
|
||||
|
||||
if [ "$mode" = "test" ]; then
|
||||
is_testnet="ON"
|
||||
else
|
||||
is_testnet="OFF"
|
||||
fi
|
||||
|
||||
docker run --rm --name cdt \
|
||||
--volume "$(pwd):/project" \
|
||||
-w /project \
|
||||
dicoop/blockchain:latest \
|
||||
/bin/bash -c "./build_contracts_cdt.sh $mode"
|
||||
/bin/bash -c "mkdir -p build && cd build && cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET=$is_testnet .. && make"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Компиляция всех контрактов кооперативной экономики через CDT.
|
||||
#
|
||||
# ВАЖНО: скрипт запускается УЖЕ внутри окружения с установленным CDT —
|
||||
# тулчейн ожидается по пути /cdt/build/lib/cmake/cdt/CDTWasmToolchain.cmake
|
||||
# (он захардкожен в CMakeLists.txt). Здесь НЕТ docker — только cmake/make.
|
||||
#
|
||||
# Два способа подготовить это окружение:
|
||||
# - локально: build-all.sh оборачивает скрипт в `docker run dicoop/blockchain:latest`
|
||||
# (в образе CDT уже лежит в /cdt/build);
|
||||
# - в CI: CDT ставится из .deb (C9S/cdt), а /cdt/build симлинкуется на
|
||||
# /usr/opt/cdt/<ver> (см. .github/workflows/release.yaml).
|
||||
#
|
||||
# Один источник cmake-флагов для обоих путей — чтобы локальная и CI-сборка
|
||||
# не разъезжались.
|
||||
set -euo pipefail
|
||||
|
||||
mode="${1:-prod}"
|
||||
if [ "$mode" = "test" ]; then
|
||||
is_testnet="ON"
|
||||
else
|
||||
is_testnet="OFF"
|
||||
fi
|
||||
|
||||
mkdir -p build
|
||||
cd build
|
||||
cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET="$is_testnet" ..
|
||||
make
|
||||
+2
-2
@@ -72,8 +72,8 @@ void capital::importcontrib(eosio::name coopname, eosio::name username, checksum
|
||||
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) {
|
||||
// Поле blocked упразднено (2026-05-24): резерв/блокировка на кошельках
|
||||
// больше не используется, проверка preimp.blocked == 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,
|
||||
|
||||
@@ -9,26 +9,23 @@ namespace Capital::Core {
|
||||
* @brief Совокупный баланс программы Благорост на уровне кооператива.
|
||||
*
|
||||
* Источник — ledger2 L2 (`wallets[BLAGOROST_FUND]`), агрегированный по всем
|
||||
* пайщикам. Σ L3.available[w.cap.blago] == L2.available (Эпик 3 invariant),
|
||||
* поэтому L2 — авторитетная сумма для CRPS-знаменателя. Если кошелёк ещё не
|
||||
* создан (никто не инвестировал) — нулевой баланс.
|
||||
*
|
||||
* Поле blocked упразднено (2026-05-24): субсчёт «заблокировано» больше не
|
||||
* используется, баланс кошелька = available.
|
||||
* пайщикам. Σ L3.{available, blocked}[w.cap.blago] == L2.{available, blocked}
|
||||
* (Эпик 3 invariant), поэтому L2 — авторитетная сумма для CRPS-знаменателя.
|
||||
* Если кошелёк ещё не создан (никто не инвестировал) — нулевой баланс.
|
||||
*/
|
||||
eosio::asset get_capital_program_share_balance(eosio::name coopname) {
|
||||
wallets2_index wallets(_ledger2, coopname.value);
|
||||
auto it = wallets.find(ledger2_wallets::BLAGOROST_FUND.value);
|
||||
if (it == wallets.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
return it->available + it->blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Доля пайщика в Благоросте (available) из ledger2 L3.
|
||||
* @brief Доля пайщика в Благоросте (available + blocked) из ledger2 L3.
|
||||
*
|
||||
* Источник — `userwallets[(w.cap.blago, username)]`. Запись существует
|
||||
* только пока available > 0 (Эпик 3 §6: auto-delete на нуле), поэтому
|
||||
* отсутствие = нулевой баланс. Поле blocked упразднено (2026-05-24).
|
||||
* только пока available + blocked > 0 (Эпик 3 §6: auto-delete на нуле),
|
||||
* поэтому отсутствие = нулевой баланс.
|
||||
*/
|
||||
eosio::asset get_capital_program_user_share_balance(eosio::name coopname, eosio::name username) {
|
||||
userwallets_index user_wallets(_ledger2, coopname.value);
|
||||
@@ -36,6 +33,6 @@ namespace Capital::Core {
|
||||
auto key = combine_ids(ledger2_wallets::BLAGOROST_FUND.value, username.value);
|
||||
auto it = idx.find(key);
|
||||
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
return it->available + it->blocked;
|
||||
}
|
||||
}
|
||||
@@ -67,12 +67,12 @@ void ledger2::revert(eosio::name coopname,
|
||||
eosio::check(original_operation_id != 0, "revert: original_operation_id обязателен");
|
||||
|
||||
// -------- validate mirror_wallet_op --------
|
||||
// Зеркало revert — только TRANSFER (обмен wallet_from/wallet_to) либо BURN
|
||||
// (зеркало ISSUE: изъятие с wallet_from). Бывшие BLOCK/UNBLOCK упразднены
|
||||
// (2026-05-24); ISSUE/NONE как зеркало смысла не имеют.
|
||||
eosio::check(mirror_wallet_op == static_cast<uint8_t>(WalletOp::TRANSFER) ||
|
||||
mirror_wallet_op == static_cast<uint8_t>(WalletOp::BURN),
|
||||
"revert: mirror_wallet_op должен быть TRANSFER или BURN");
|
||||
eosio::check(mirror_wallet_op <= 5, "revert: неизвестный mirror_wallet_op");
|
||||
// Не позволяем откатывать через BLOCK/UNBLOCK — они асимметричны и нет
|
||||
// адекватного зеркала в одной операции (BLOCK + UNBLOCK — обратные сами по себе).
|
||||
eosio::check(mirror_wallet_op != static_cast<uint8_t>(WalletOp::BLOCK) &&
|
||||
mirror_wallet_op != static_cast<uint8_t>(WalletOp::UNBLOCK),
|
||||
"revert: BLOCK/UNBLOCK не подлежат откату через revert (они симметричны сами себе)");
|
||||
|
||||
// -------- validate mirror wallets/accounts --------
|
||||
if (mirror_wallet_from.value != 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/burn).
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/block/unblock/burn/burn_blocked).
|
||||
*
|
||||
* Внутренний action ledger2 — вызывается только через inline из apply().
|
||||
* Auth: только сам ledger2 (require_auth(get_self())).
|
||||
@@ -53,15 +53,13 @@ void ledger2::walletop(eosio::name coopname,
|
||||
eosio::check(amount.symbol == _root_govern_symbol,
|
||||
"walletop: некорректный символ валюты");
|
||||
eosio::check(memo.size() < 256, "walletop: memo > 255");
|
||||
// Допустимы только ISSUE(0), TRANSFER(1), BURN(4). NONE(5) — это только
|
||||
// бухпроводка без кошелькового движения, apply.cpp для неё walletop не диспатчит;
|
||||
// прямой вызов был бы no-op и сбил бы parity. Бывшие BLOCK(2)/UNBLOCK(3)/
|
||||
// BURN_BLOCKED(6) упразднены (2026-05-24): резерв возврата теперь TRANSFER на
|
||||
// w.wal.wpend, их op_code больше не валиден.
|
||||
eosio::check(op_code == static_cast<uint8_t>(WalletOp::ISSUE) ||
|
||||
op_code == static_cast<uint8_t>(WalletOp::TRANSFER) ||
|
||||
op_code == static_cast<uint8_t>(WalletOp::BURN),
|
||||
"walletop: недопустимый op_code");
|
||||
// op_code = 5 (NONE) намеренно не допускается: NONE-операции — это только
|
||||
// бухпроводка без кошелькового движения, apply.cpp не диспатчит для них walletop.
|
||||
// Прямой вызов с op_code=5 был бы no-op и сбил бы инвариант parity.
|
||||
// Допустимы 0..4 (ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN) и 6 (BURN_BLOCKED).
|
||||
eosio::check(op_code <= static_cast<uint8_t>(WalletOp::BURN_BLOCKED) &&
|
||||
op_code != static_cast<uint8_t>(WalletOp::NONE),
|
||||
"walletop: неизвестный op_code");
|
||||
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
@@ -174,7 +172,7 @@ void ledger2::walletop(eosio::name coopname,
|
||||
// миграций 048/049.
|
||||
//
|
||||
// walletop по построению применяет одно и то же `amount` к L2 и L3 (см.
|
||||
// ниже case'ы ISSUE/TRANSFER/BURN), а sender-guard на
|
||||
// ниже case'ы ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN/BURN_BLOCKED), а sender-guard на
|
||||
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
|
||||
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
|
||||
// вне транзакционного hot path.
|
||||
@@ -229,6 +227,60 @@ void ledger2::walletop(eosio::name coopname,
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
case WalletOp::BLOCK: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BLOCK: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BLOCK: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->available >= amount,
|
||||
std::string{"walletop BLOCK: недостаточно available на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) {
|
||||
w.available -= amount;
|
||||
w.blocked += amount;
|
||||
});
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->available >= amount,
|
||||
std::string{"walletop BLOCK: недостаточно L3-available у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) {
|
||||
r.available -= amount;
|
||||
r.blocked += amount;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WalletOp::UNBLOCK: {
|
||||
eosio::check(wallet_from.value != 0, "walletop UNBLOCK: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop UNBLOCK: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->blocked >= amount,
|
||||
std::string{"walletop UNBLOCK: недостаточно blocked на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) {
|
||||
w.blocked -= amount;
|
||||
w.available += amount;
|
||||
});
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->blocked >= amount,
|
||||
std::string{"walletop UNBLOCK: недостаточно L3-blocked у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) {
|
||||
r.blocked -= amount;
|
||||
r.available += amount;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WalletOp::BURN: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BURN: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BURN: wallet_to должен быть пустым");
|
||||
@@ -259,6 +311,30 @@ void ledger2::walletop(eosio::name coopname,
|
||||
eosio::check(false, "walletop NONE: запрещённый op_code");
|
||||
break;
|
||||
}
|
||||
case WalletOp::BURN_BLOCKED: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BURN_BLOCKED: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BURN_BLOCKED: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно blocked на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) { w.blocked -= amount; });
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно L3-blocked у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) { r.blocked -= amount; });
|
||||
}
|
||||
|
||||
cleanup_l2_if_empty(wallet_from);
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Универсальное миграционное действие контракта ledger2 — точка
|
||||
@@ -9,33 +9,23 @@
|
||||
* после её прогона на проде тело очищается до пустого `require_auth(get_self())`
|
||||
* (как в `capital::migrate`).
|
||||
*
|
||||
* Текущая задача (2026-05-24): свёртка `blocked → available` по ВСЕМ коопам.
|
||||
* Текущая задача (2026-05-21): чистка осиротевших L3-записей `w.cap.gen`
|
||||
* на voskhod после смены WalletKind GENERATOR_FUND с USER_SHARED на
|
||||
* COOPERATIVE (см. `lib/core/ledger2/wallets.hpp`).
|
||||
*
|
||||
* Контекст: механика «заблокированного» баланса упразднена (см.
|
||||
* `lib/core/ledger2/operations.hpp` — удалены WalletOp BLOCK/UNBLOCK/BURN_BLOCKED;
|
||||
* резерв возврата паевого теперь выражается переводом на кошелёк-резерв
|
||||
* `w.wal.wpend`). Поле `blocked` остаётся в таблицах `wallets2`/`userwallets`
|
||||
* как deprecated (физическое удаление поля = небезопасная смена layout таблицы
|
||||
* на живых коопах, выносится в отдельный cleanup-деплой). Перед тем как поле
|
||||
* перестанет поддерживаться кодом, накопленные `blocked`-остатки нужно вернуть
|
||||
* в `available`, чтобы средства не «зависли» на упразднённом субсчёте.
|
||||
* Контекст: на voskhod `convertsegm` падал «недостаточно L3-средств у
|
||||
* пайщика» в проектах, где CRPS перераспределял доли между сегментами.
|
||||
* w.cap.gen был USER_SHARED, а CRPS в approvecmmt не делал per-user
|
||||
* компенсирующих TRANSFER между сегментами: `Σ COMMIT_RID == Σ ACCEPT_RID`
|
||||
* соблюдался только на проекте, не на пайщике.
|
||||
*
|
||||
* Действие: пройти всех кооперативов (cooperatives2 в scope registrator) и для
|
||||
* каждого свернуть `blocked → available` на уровнях L2 (`wallets2`) и L3
|
||||
* (`userwallets`): `available += blocked; blocked = 0`. Сумма средств на кошельке
|
||||
* не меняется — только субсчёт.
|
||||
* Архитектурный фикс: w.cap.gen — COOPERATIVE-пул без L3. Чтобы UI/бэкенд
|
||||
* не врали остатками по «личным» w.cap.gen, удаляем осиротевшие L3-записи
|
||||
* прямым `userwallets.erase`. L2-баланс `wallets2[w.cap.gen]` уже верен
|
||||
* (синхронен с Σ старых userwallets), его не трогаем.
|
||||
*
|
||||
* Идемпотентно: после свёртки `blocked == 0`, повторный прогон — no-op.
|
||||
*
|
||||
* Сигнатура без аргументов — действие вызывается автоматически при деплое
|
||||
* контракта (как и прочие задачи migrate); проходит по всем кооперативам сам.
|
||||
*
|
||||
* ПРЕДУСЛОВИЕ (операционное): на момент прогона не должно быть заявок на возврат
|
||||
* «в полёте» (статусы pending/authorized в `wallet::withdraws`) — их `blocked`
|
||||
* относится к старой механике и при свёртке в `available` вернётся пайщику как
|
||||
* свободные средства, а последующий `completewthd` (BURN с `w.wal.wpend`) не
|
||||
* найдёт резерва. Незавершённые заявки нужно довести (complete/decline) ДО
|
||||
* деплоя с этой миграцией.
|
||||
* Идемпотентно: повторный вызов на чистой БД — no-op (lower_bound пуст).
|
||||
* Только voskhod: на остальных кооперативах w.cap.gen не использовался.
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*
|
||||
@@ -44,101 +34,24 @@
|
||||
void ledger2::migrate() {
|
||||
require_auth(get_self());
|
||||
|
||||
cooperatives2_index coops(_registrator, _registrator.value);
|
||||
const eosio::name target_coop = "voskhod"_n;
|
||||
|
||||
for (auto c = coops.begin(); c != coops.end(); ++c) {
|
||||
const eosio::name coopname = c->username;
|
||||
userwallets_index user_wallets(get_self(), target_coop.value);
|
||||
auto idx = user_wallets.get_index<"bywallet"_n>();
|
||||
|
||||
// --- L3: userwallets[coopname] ---
|
||||
// Модифицируем только не-ключевые поля (available/blocked) — итерация по
|
||||
// первичному индексу с modify безопасна (порядок строк не меняется).
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
for (auto it = user_wallets.begin(); it != user_wallets.end(); ++it) {
|
||||
if (it->blocked.amount <= 0) continue;
|
||||
user_wallets.modify(it, get_self(), [&](auto& r) {
|
||||
r.available += r.blocked;
|
||||
r.blocked = eosio::asset(0, r.blocked.symbol);
|
||||
});
|
||||
}
|
||||
|
||||
// --- L2: wallets2[coopname] ---
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
for (auto it = wallets.begin(); it != wallets.end(); ++it) {
|
||||
if (it->blocked.amount <= 0) continue;
|
||||
wallets.modify(it, get_self(), [&](auto& w) {
|
||||
w.available += w.blocked;
|
||||
w.blocked = eosio::asset(0, w.blocked.symbol);
|
||||
});
|
||||
}
|
||||
// Собираем primary id записей до erase (модификация контейнера при
|
||||
// итерации через secondary index — небезопасна).
|
||||
std::vector<uint64_t> ids_to_erase;
|
||||
for (auto it = idx.lower_bound(ledger2_wallets::GENERATOR_FUND.value);
|
||||
it != idx.end() && it->wallet_name == ledger2_wallets::GENERATOR_FUND;
|
||||
++it) {
|
||||
ids_to_erase.push_back(it->id);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Разовая коррекция СЧЕТОВ под удаление фантомных пайщиков fgrtejiwnynn
|
||||
// (инцидент 2026-06-04). Каждый из 6 дублей-аккаунтов при регистрации внёс
|
||||
// минимальный паевой 300 RUB (o.reg.putmin: Dr 51 / Cr 80, ISSUE w.reg.minshr).
|
||||
// Эти суммы — фантомные (нет реального пайщика), их нужно убрать из паевого
|
||||
// фонда и с расчётного счёта. Снимаем L3-доли фантомов на w.reg.minshr,
|
||||
// суммируем фактически снятое и ровно на эту сумму уменьшаем L2-кошелёк и
|
||||
// бухсчета 80 (Cr) и 51 (Dr). Реальные пайщики (их доли на том же w.reg.minshr
|
||||
// и счетах) не затрагиваются — трогаем только перечисленные username.
|
||||
//
|
||||
// Список синхронизирован с registrator::migrate и soviet::migrate.
|
||||
// Идемпотентно: после первого прогона L3-строк фантомов нет → removed == 0 →
|
||||
// L2/бухсчета не трогаются (вся коррекция в одной atomic-tx).
|
||||
{
|
||||
const eosio::name PHANTOM_COOP = "fgrtejiwnynn"_n;
|
||||
const std::array<eosio::name, 6> PHANTOMS = {
|
||||
"bbsezpgufvmm"_n, "errwcgjwverm"_n, "hcfsluqsfehw"_n,
|
||||
"kgkzdadfpzki"_n, "nzuyijobapsv"_n, "tplwfwbujugq"_n,
|
||||
};
|
||||
const eosio::name MINSHR = ledger2_wallets::MIN_SHARE_FUND; // w.reg.minshr
|
||||
|
||||
eosio::asset removed(0, _root_govern_symbol);
|
||||
|
||||
// 1) Снимаем L3-доли фантомов на w.reg.minshr, суммируем снятое.
|
||||
userwallets_index uw(get_self(), PHANTOM_COOP.value);
|
||||
auto uw_byuser = uw.get_index<"byuser"_n>();
|
||||
for (const auto& u : PHANTOMS) {
|
||||
auto it = uw_byuser.lower_bound(u.value);
|
||||
while (it != uw_byuser.end() && it->username == u) {
|
||||
if (it->wallet_name == MINSHR) {
|
||||
removed += it->available + it->blocked;
|
||||
it = uw_byuser.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.amount > 0) {
|
||||
// 2) L2 w.reg.minshr: available -= removed; удалить кошелёк, если опустел.
|
||||
wallets2_index w2(get_self(), PHANTOM_COOP.value);
|
||||
auto wit = w2.find(MINSHR.value);
|
||||
if (wit != w2.end()) {
|
||||
w2.modify(wit, get_self(), [&](auto& w) { w.available -= removed; });
|
||||
if (wit->is_empty()) w2.erase(wit);
|
||||
}
|
||||
|
||||
// 3) Бухсчета (scope=coopname): Cr 80 (паевой фонд) -= removed,
|
||||
// Dr 51 (расчётный счёт) -= removed; пересчёт сальдо по типу счёта.
|
||||
// Инвариант Σ Dr == Σ Cr сохраняется (обе стороны уменьшены на removed).
|
||||
accounts2_index acc(get_self(), PHANTOM_COOP.value);
|
||||
|
||||
auto a80 = acc.find(ledger2_accounts::SHARE_FUND);
|
||||
if (a80 != acc.end()) {
|
||||
acc.modify(a80, get_self(), [&](auto& a) {
|
||||
a.credit_balance -= removed;
|
||||
a.balance = account2::compute_balance(a.account_type, a.debit_balance, a.credit_balance);
|
||||
});
|
||||
}
|
||||
|
||||
auto a51 = acc.find(ledger2_accounts::BANK_ACCOUNT);
|
||||
if (a51 != acc.end()) {
|
||||
acc.modify(a51, get_self(), [&](auto& a) {
|
||||
a.debit_balance -= removed;
|
||||
a.balance = account2::compute_balance(a.account_type, a.debit_balance, a.credit_balance);
|
||||
});
|
||||
}
|
||||
for (uint64_t id : ids_to_erase) {
|
||||
auto pri = user_wallets.find(id);
|
||||
if (pri != user_wallets.end()) {
|
||||
user_wallets.erase(pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
#ifdef IS_TESTNET
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 1; /*!< минимальное количество членов совета (тест) */
|
||||
#else
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 3; /*!< минимальное количество членов совета (прод) */
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 5; /*!< минимальное количество членов совета (прод) */
|
||||
#endif
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,11 +23,6 @@ enum class AccountType : uint8_t {
|
||||
ACTIVE_PASSIVE = 2,
|
||||
};
|
||||
|
||||
// Пересмотр 2026-06-06: добавлен счёт 76 «Расчёты с пайщиками» (А-П) —
|
||||
// суспенс для регистрационного взноса между приёмом денег кассой
|
||||
// (Dr 51 / Cr 76) и решением совета. На одобрении переносится на 80/86
|
||||
// (Dr 76 / Cr 80, Dr 76 / Cr 86), на отказе возвращается (Dr 76 / Cr 51).
|
||||
|
||||
/**
|
||||
* @brief План счетов ledger2 (MVP) со смещением *1000.
|
||||
*
|
||||
@@ -37,13 +32,12 @@ enum class AccountType : uint8_t {
|
||||
* состояния «принятый коммит» (паевой взнос имуществом в переходе РИД
|
||||
* в программу Благорост): commit → Dr 08 / Cr 80, accept → Dr 04 / Cr 08.
|
||||
*
|
||||
* Состав (7 счетов):
|
||||
* Состав (6 счетов):
|
||||
*
|
||||
* - 04 — Нематериальные активы (РИД, принятые в паевой фонд)
|
||||
* - 08 — Вложения во внеоборотные активы (промежуточное состояние)
|
||||
* - 51 — Расчётный счёт
|
||||
* - 58 — Финансовые вложения (выданные пайщикам беспроцентные займы)
|
||||
* - 76 — Расчёты с пайщиками (суспенс регистрационного взноса до решения совета)
|
||||
* - 80 — Паевой фонд (складочный капитал)
|
||||
* - 86 — Целевое финансирование (без субсчетов)
|
||||
*
|
||||
@@ -61,9 +55,6 @@ struct ledger2_accounts {
|
||||
static constexpr uint64_t BANK_ACCOUNT = 51 * 1000; ///< 51 — Расчётный счёт (А)
|
||||
static constexpr uint64_t FINANCIAL_INVESTMENTS = 58 * 1000; ///< 58 — Финансовые вложения (А)
|
||||
|
||||
// Активно-пассивные
|
||||
static constexpr uint64_t PARTICIPANT_SETTLEMENTS = 76 * 1000; ///< 76 — Расчёты с пайщиками (А-П): суспенс регистрационного взноса до решения совета
|
||||
|
||||
// Пассивы
|
||||
static constexpr uint64_t SHARE_FUND = 80 * 1000; ///< 80 — Паевой фонд (П)
|
||||
static constexpr uint64_t TARGET_RECEIPTS = 86 * 1000; ///< 86 — Целевое финансирование (П)
|
||||
@@ -88,12 +79,11 @@ struct Ledger2AccountMeta {
|
||||
* `constexpr std::array` + `string_view` — чтобы не было dynamic init
|
||||
* при загрузке контракта и тип был полностью заморожен на этапе сборки.
|
||||
*/
|
||||
inline constexpr std::array<Ledger2AccountMeta, 7> LEDGER2_ACCOUNT_MAP = {{
|
||||
inline constexpr std::array<Ledger2AccountMeta, 6> LEDGER2_ACCOUNT_MAP = {{
|
||||
{ ledger2_accounts::INTANGIBLE_ASSETS, "Нематериальные активы", AccountType::ACTIVE },
|
||||
{ ledger2_accounts::NON_CURRENT_INVESTMENTS, "Вложения во внеоборотные активы", AccountType::ACTIVE },
|
||||
{ ledger2_accounts::BANK_ACCOUNT, "Расчётный счёт", AccountType::ACTIVE },
|
||||
{ ledger2_accounts::FINANCIAL_INVESTMENTS, "Финансовые вложения", AccountType::ACTIVE },
|
||||
{ ledger2_accounts::PARTICIPANT_SETTLEMENTS, "Расчёты с пайщиками", AccountType::ACTIVE_PASSIVE },
|
||||
{ ledger2_accounts::SHARE_FUND, "Паевой фонд (складочный капитал)", AccountType::PASSIVE },
|
||||
{ ledger2_accounts::TARGET_RECEIPTS, "Целевое финансирование", AccountType::PASSIVE },
|
||||
}};
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
* - `WalletOp::WALLET_ONLY` удалён (ADR-003): «без бухпроводок» определяется
|
||||
* парой `(debit_account_id == 0, credit_account_id == 0)` на уровне записи.
|
||||
* Для `o.cap.invest` теперь TRANSFER без проводок (оба account_id == 0).
|
||||
* - `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
|
||||
* без `wallet_to`. Используется в `o.wal.wthcpl` (сжигание резерва возврата
|
||||
* с `w.wal.wpend`), `o.cap.drppre` и как зеркало ISSUE в `revert`.
|
||||
* - Добавлен `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
|
||||
* без `wallet_to`. На текущем этапе в `OPERATION_REGISTRY` не используется —
|
||||
* зарезервирован под будущие операции штатного сжигания.
|
||||
* - Единые программные кошельки: `BLAGOROST_FUND` (`w.cap.blago`) и
|
||||
* `GENERATOR_FUND` (`w.cap.gen`) — заменили ранее декомпозированные
|
||||
* `bginv/bgprop/bgrid/bgmem` и `gncom/gnmem` (ADR-009).
|
||||
@@ -54,21 +54,16 @@ namespace operations {
|
||||
|
||||
// registrator
|
||||
namespace registrator {
|
||||
inline constexpr eosio::name PAY_ENTRANCE = "o.reg.payent"_n; ///< Оплата вступительного взноса (Dr 51 / Cr 86, ISSUE ENTRANCE_FEES). Одношаговый путь adduser (без совета).
|
||||
inline constexpr eosio::name PUT_MINSHARE = "o.reg.putmin"_n; ///< Внесение минимального паевого при регистрации (Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND). Одношаговый путь adduser (без совета).
|
||||
// Двухфазный путь через совет (reguser → confirmpay → confirmreg/declinereg):
|
||||
inline constexpr eosio::name RECEIVE_PAYMENT = "o.reg.inpay"_n; ///< Приём регистрационного взноса кассой в ожидание решения совета (Dr 51 / Cr 76, ISSUE REGISTRATION_PENDING).
|
||||
inline constexpr eosio::name SETTLE_MINSHARE = "o.reg.setmin"_n; ///< Зачисление минимального паевого по решению совета (Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND).
|
||||
inline constexpr eosio::name SETTLE_ENTRANCE = "o.reg.setent"_n; ///< Зачисление вступительного по решению совета (Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES).
|
||||
inline constexpr eosio::name REFUND = "o.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (Dr 76 / Cr 51, BURN REGISTRATION_PENDING — деньги уходят из системы банковским переводом кандидату).
|
||||
inline constexpr eosio::name PAY_ENTRANCE = "o.reg.payent"_n; ///< Оплата вступительного взноса (Dr 51 / Cr 86, ISSUE ENTRANCE_FEES).
|
||||
inline constexpr eosio::name PUT_MINSHARE = "o.reg.putmin"_n; ///< Внесение минимального паевого при регистрации (Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND).
|
||||
}
|
||||
|
||||
// wallet
|
||||
namespace wallet {
|
||||
inline constexpr eosio::name COMPLETE_DEPOSIT = "o.wal.depcpl"_n; ///< Завершение внесения паевого взноса (Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY).
|
||||
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN с WITHDRAW_PENDING — деньги уходят из системы, без wallet_to).
|
||||
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING (резерв, без Dr/Cr).
|
||||
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY (снятие резерва, без Dr/Cr).
|
||||
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY — деньги уходят из системы, без wallet_to).
|
||||
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: BLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: UNBLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
}
|
||||
|
||||
// capital
|
||||
@@ -134,23 +129,14 @@ namespace operations {
|
||||
* `(debit_account_id == 0, credit_account_id == 0)` на уровне записи реестра.
|
||||
* Compile-time правило `(debit==0) ⇔ (credit==0)` ловит смешанные пары.
|
||||
*/
|
||||
//
|
||||
// Удаление BLOCK/UNBLOCK/BURN_BLOCKED (2026-05-24): механика «заблокированного»
|
||||
// баланса упразднена. Резерв средств под заявку на возврат паевого теперь
|
||||
// выражается переводом на отдельный кошелёк-резерв `w.wal.wpend` (TRANSFER),
|
||||
// возврат резерва — обратным TRANSFER, завершение — BURN с резерва. Поле
|
||||
// `blocked` в таблицах wallets2/userwallets оставлено deprecated (всегда 0
|
||||
// после ledger2::migrate-свёртки) — физическое удаление поля = небезопасная
|
||||
// смена layout таблицы на живых коопах, выносится в отдельный cleanup-деплой.
|
||||
//
|
||||
// Числовые значения ISSUE/TRANSFER/BURN/NONE СОХРАНЕНЫ (не перенумерованы),
|
||||
// чтобы исторические op_code в blockchain_actions читались бэкендом без сдвига
|
||||
// смысла (2 и 3 — бывшие BLOCK/UNBLOCK — больше не выдаются и невалидны на входе).
|
||||
enum class WalletOp : uint8_t {
|
||||
ISSUE = 0, ///< первичный вход средств на кошелёк wallet_to (wallet_from = empty)
|
||||
TRANSFER = 1, ///< перемещение wallet_from → wallet_to (с Dr/Cr ИЛИ без — по парам account_id)
|
||||
BLOCK = 2, ///< available-=amount, blocked+=amount на wallet_from
|
||||
UNBLOCK = 3, ///< blocked-=amount, available+=amount на wallet_from
|
||||
BURN = 4, ///< изъятие amount с wallet_from->available, без wallet_to. Покрывает оба кейса: (a) штатное сжигание как бизнес-операция в OPERATION_REGISTRY; (b) зеркало ISSUE при `ledger2::revert` (различие — через operation_code: `o.adj.rev` для adjustment-mirror).
|
||||
NONE = 5, ///< только бухпроводка без перемещения средств (wallet_from = empty, wallet_to = empty, debit ≠ 0, credit ≠ 0). Покрывает кейсы внутрибалансовых проводок типа Dr 04 / Cr 08 (приём РИД в НМА), когда кошелёк уже на нужном программном фонде.
|
||||
BURN_BLOCKED = 6, ///< изъятие amount из wallet_from->blocked, без wallet_to. Используется при завершении возврата паевого (`o.wal.wthcpl`): средства предварительно заблокированы через BLOCK (REQUEST_WITHDRAW), на завершении сжигаются — выходят из системы (получателя на цепи нет). На L3 синхронно: from_uw->blocked -= amount.
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -159,7 +145,7 @@ enum class WalletOp : uint8_t {
|
||||
* Семантика полей по `wallet_op`:
|
||||
* - ISSUE: wallet_from = eosio::name{}, wallet_to = required.
|
||||
* - TRANSFER: wallet_from = required, wallet_to = required (≠ from).
|
||||
* - BURN: wallet_from = required, wallet_to = eosio::name{}.
|
||||
* - BLOCK / UNBLOCK / BURN / BURN_BLOCKED: wallet_from = required, wallet_to = eosio::name{}.
|
||||
*
|
||||
* Семантика бух.проводки:
|
||||
* - Без проводок: debit_account_id == 0 И credit_account_id == 0.
|
||||
@@ -171,7 +157,7 @@ struct OperationRegistryEntry {
|
||||
eosio::name process_type; ///< тип процесса с префиксом `p.<contract>.<noun>`
|
||||
WalletOp wallet_op;
|
||||
eosio::name wallet_from; ///< пустое имя для ISSUE
|
||||
eosio::name wallet_to; ///< пустое имя для BURN
|
||||
eosio::name wallet_to; ///< пустое имя для BLOCK/UNBLOCK/BURN/BURN_BLOCKED
|
||||
uint64_t debit_account_id; ///< 0 если без бухпроводки (тогда credit_account_id тоже == 0)
|
||||
uint64_t credit_account_id; ///< 0 если без бухпроводки (тогда debit_account_id тоже == 0)
|
||||
const char* human_name;
|
||||
@@ -193,44 +179,17 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
"Минимальный паевой взнос пайщика при регистрации" },
|
||||
|
||||
// 2a. Приём регистрационного взноса кассой (поток через совет): Dr 51 / Cr 76, ISSUE REGISTRATION_PENDING.
|
||||
// Деньги получены, но взнос ещё не признан — висит на расчётах с пайщиком (76)
|
||||
// до решения совета. Сумма = вступительный + минимальный паевой.
|
||||
{ operations::registrator::RECEIVE_PAYMENT, processes::registrator::ACCEPT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::REGISTRATION_PENDING,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::PARTICIPANT_SETTLEMENTS,
|
||||
"Приём регистрационного взноса в ожидание решения совета" },
|
||||
|
||||
// 2b. Зачисление минимального паевого по решению совета: Dr 76 / Cr 80, TRANSFER REGISTRATION_PENDING → MIN_SHARE_FUND.
|
||||
{ operations::registrator::SETTLE_MINSHARE, processes::registrator::ACCEPT, WalletOp::TRANSFER,
|
||||
ledger2_wallets::REGISTRATION_PENDING, ledger2_wallets::MIN_SHARE_FUND,
|
||||
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::SHARE_FUND,
|
||||
"Зачисление минимального паевого взноса по решению совета" },
|
||||
|
||||
// 2c. Зачисление вступительного по решению совета: Dr 76 / Cr 86, TRANSFER REGISTRATION_PENDING → ENTRANCE_FEES.
|
||||
{ operations::registrator::SETTLE_ENTRANCE, processes::registrator::ACCEPT, WalletOp::TRANSFER,
|
||||
ledger2_wallets::REGISTRATION_PENDING, ledger2_wallets::ENTRANCE_FEES,
|
||||
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::TARGET_RECEIPTS,
|
||||
"Зачисление вступительного взноса по решению совета" },
|
||||
|
||||
// 2d. Возврат регистрационного взноса при отказе совета: Dr 76 / Cr 51, BURN REGISTRATION_PENDING.
|
||||
// Отдельный процесс p.reg.refund: приём взноса прерывается, начинается возврат.
|
||||
// Деньги уходят из системы (банковский перевод кандидату), получателя на цепи нет.
|
||||
{ operations::registrator::REFUND, processes::registrator::REFUND, WalletOp::BURN,
|
||||
ledger2_wallets::REGISTRATION_PENDING, eosio::name{},
|
||||
ledger2_accounts::PARTICIPANT_SETTLEMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат регистрационного взноса при отказе совета" },
|
||||
|
||||
// 3. Внесение паевого взноса: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
|
||||
{ operations::wallet::COMPLETE_DEPOSIT, processes::wallet::DEPOSIT, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
"Внесение пайщиком паевого взноса" },
|
||||
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN WITHDRAW_PENDING.
|
||||
// Сжигание из кошелька-резерва (TRANSFER в резерв был на REQUEST_WITHDRAW):
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY.
|
||||
// Сжигание из заблокированной суммы пайщика (BLOCK был на REQUEST_WITHDRAW):
|
||||
// деньги уходят из системы (банковский перевод пайщику), получателя на цепи нет.
|
||||
// Бухгалтерия: паевой фонд уменьшается (Дт 80), расчётный счёт уменьшается (Кт 51).
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN,
|
||||
ledger2_wallets::WITHDRAW_PENDING, eosio::name{},
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN_BLOCKED,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат паевого взноса пайщику" },
|
||||
|
||||
@@ -314,19 +273,17 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::TARGET_RECEIPTS,
|
||||
"Трансляция паевого взноса из ЦПП «Цифровой Кошелёк» в членский взнос за пользование инфраструктурой" },
|
||||
|
||||
// 15. Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING
|
||||
// (без Dr/Cr — оба кошелька на счёте 80; резерв средств на время рассмотрения).
|
||||
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::SHARE_FUND_PAY, ledger2_wallets::WITHDRAW_PENDING,
|
||||
// 15. Запрос на возврат паевого: BLOCK SHARE_FUND_PAY (без Dr/Cr — внутри одного бухсчёта 80).
|
||||
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BLOCK,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
0, 0,
|
||||
"Резервирование паевого под запрос на возврат" },
|
||||
"Блокировка паевого под запрос на возврат" },
|
||||
|
||||
// 16. Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY
|
||||
// (без Dr/Cr — зеркало REQUEST_WITHDRAW; возврат резерва пайщику).
|
||||
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::WITHDRAW_PENDING, ledger2_wallets::SHARE_FUND_PAY,
|
||||
// 16. Отклонение запроса на возврат: UNBLOCK SHARE_FUND_PAY (без Dr/Cr — зеркало REQUEST_WITHDRAW).
|
||||
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::UNBLOCK,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
0, 0,
|
||||
"Снятие резерва паевого после отклонения запроса на возврат" },
|
||||
"Разблокировка паевого после отклонения запроса на возврат" },
|
||||
|
||||
// 17. Возврат из ЦПП «Благорост» в Цифровой Кошелёк: TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY (без Dr/Cr — оба счёта 80, зеркало INVEST).
|
||||
{ operations::capital::WITHDRAW_FROM_CAPITAL, processes::capital::WTHCAP, WalletOp::TRANSFER,
|
||||
@@ -383,7 +340,7 @@ static constexpr size_t OPERATION_REGISTRY_SIZE = sizeof(OPERATION_REGISTRY) / s
|
||||
// оба существуют в `LEDGER2_ACCOUNT_MAP`.
|
||||
// 4. Для TRANSFER: `wallet_from` ≠ `wallet_to`, оба ≠ 0.
|
||||
// 5. Для ISSUE: `wallet_from` == 0 и `wallet_to` ≠ 0.
|
||||
// 6. Для BURN: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 6. Для BLOCK / UNBLOCK / BURN / BURN_BLOCKED: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 7. Все id кошельков из записей существуют в `LEDGER2_WALLET_REGISTRY`.
|
||||
namespace ledger2_registry_detail {
|
||||
constexpr bool operation_codes_unique() {
|
||||
@@ -427,11 +384,11 @@ namespace ledger2_registry_detail {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Правило 6: BURN — wallet_from required, wallet_to == 0 (ADR-003).
|
||||
// Правило 6: BURN / BURN_BLOCKED — wallet_from required, wallet_to == 0 (ADR-003).
|
||||
constexpr bool burn_pattern_correct() {
|
||||
for (size_t i = 0; i < OPERATION_REGISTRY_SIZE; ++i) {
|
||||
const auto& e = OPERATION_REGISTRY[i];
|
||||
if (e.wallet_op != WalletOp::BURN) continue;
|
||||
if (e.wallet_op != WalletOp::BURN && e.wallet_op != WalletOp::BURN_BLOCKED) continue;
|
||||
if (e.wallet_from.value == 0) return false;
|
||||
if (e.wallet_to.value != 0) return false;
|
||||
}
|
||||
@@ -483,7 +440,7 @@ static_assert(ledger2_registry_detail::dr_ne_cr_when_posting(),
|
||||
static_assert(ledger2_registry_detail::transfer_wallet_from_ne_to(),
|
||||
"OPERATION_REGISTRY: TRANSFER с wallet_from == wallet_to или одним из них == 0");
|
||||
static_assert(ledger2_registry_detail::burn_pattern_correct(),
|
||||
"OPERATION_REGISTRY: BURN требует wallet_from ≠ 0 и wallet_to == 0");
|
||||
"OPERATION_REGISTRY: BURN / BURN_BLOCKED требует wallet_from ≠ 0 и wallet_to == 0");
|
||||
static_assert(ledger2_registry_detail::none_pattern_correct(),
|
||||
"OPERATION_REGISTRY: NONE требует wallet_from == 0, wallet_to == 0 и обе проводки заполненными");
|
||||
static_assert(ledger2_registry_detail::accounts_exist_in_map(),
|
||||
|
||||
@@ -35,8 +35,7 @@ namespace processes {
|
||||
|
||||
// registrator
|
||||
namespace registrator {
|
||||
inline constexpr eosio::name ACCEPT = "p.reg.accept"_n; ///< Приём пайщика в кооператив (o.reg.payent + o.reg.putmin; для потока через совет — o.reg.inpay + o.reg.setmin + o.reg.setent).
|
||||
inline constexpr eosio::name REFUND = "p.reg.refund"_n; ///< Возврат регистрационного взноса при отказе совета (o.reg.refund). Отдельный процесс: приём взноса прерывается, начинается возврат.
|
||||
inline constexpr eosio::name ACCEPT = "p.reg.accept"_n; ///< Приём пайщика в кооператив (o.reg.payent + o.reg.putmin).
|
||||
}
|
||||
|
||||
// wallet
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* w.mkt.* — Маркетплейс (выплаты поставщикам)
|
||||
*
|
||||
* Sentinel `eosio::name{}` (пустое имя, value=0) — «кошелёк вне системы»
|
||||
* для ISSUE (нет wallet_from) и для BURN (нет wallet_to).
|
||||
* для ISSUE (нет wallet_from) и для BURN/BLOCK/UNBLOCK (нет wallet_to).
|
||||
*
|
||||
* При первом ISSUE/TRANSFER кошелёк создаётся автоматически по записи
|
||||
* из WALLET_REGISTRY. При обнулении available+blocked запись удаляется.
|
||||
@@ -46,13 +46,11 @@ struct ledger2_wallets {
|
||||
// wallet — паевой фонд + возвраты + ЦК
|
||||
static constexpr eosio::name SHARE_FUND_PAY = "w.wal.share"_n; ///< Паевой взнос пайщика (USER_SHARED)
|
||||
static constexpr eosio::name CK_MEMBER = "w.wal.member"_n; ///< ЦК — членская часть пайщика (USER_SHARED)
|
||||
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: исторический sink возвратов. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
|
||||
static constexpr eosio::name WITHDRAW_PENDING = "w.wal.wpend"_n; ///< Резерв паевого под заявку на возврат (COOPERATIVE-пул). o.wal.wthreq переводит сюда с w.wal.share, o.wal.wthdec возвращает обратно, o.wal.wthcpl сжигает отсюда. Заменил механику blocked/BLOCK/UNBLOCK 2026-05-24.
|
||||
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: после переключения o.wal.wthcpl с TRANSFER на BURN_BLOCKED кошелёк больше не получает новых средств. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
|
||||
|
||||
// registrator — минимальный паевой + вступительные + суспенс
|
||||
// registrator — минимальный паевой + вступительные
|
||||
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)
|
||||
static constexpr eosio::name REGISTRATION_PENDING = "w.reg.pend"_n; ///< Регистрационный взнос в ожидании решения совета (суспенс счёта 76, USER_SHARED, без сверки соглашений — кандидат ещё не член). o.reg.inpay → сюда (Dr 51/Cr 76); o.reg.setmin/setent уносят на 80/86; o.reg.refund сжигает (Dr 76/Cr 51).
|
||||
|
||||
// soviet — членские (инфраструктура) + делегатские + хоз.расходы + использованные паевые
|
||||
static constexpr eosio::name INFRA_FEES = "w.sov.infra"_n; ///< Членские взносы за инфраструктуру кооп. платформы (COOPERATIVE)
|
||||
@@ -96,16 +94,15 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 16> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (6) — L3-разрез по пайщику
|
||||
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (5) — 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::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::REGISTRATION_PENDING, "Регистрационный взнос в ожидании решения совета", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (10) — единый кооперативный баланс, без L3
|
||||
// COOPERATIVE (9) — единый кооперативный баланс, без L3
|
||||
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
|
||||
// CRPS-распределение между сегментами проекта не поддерживает per-user
|
||||
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
|
||||
@@ -113,7 +110,6 @@ inline constexpr std::array<Ledger2WalletMeta, 16> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам (deprecated, не используется в новых операциях)", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAW_PENDING, "Резерв паевого под заявку на возврат", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::DELEGATE_FEES, "Делегатские членские взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SOV_EXPENSES, "Хозяйственные расходы из числа целевого финансирования", WalletKind::COOPERATIVE },
|
||||
@@ -232,14 +228,13 @@ struct Ledger2WalletProgramMapping {
|
||||
uint64_t required_program_id; // 0 = исключение (без проверки)
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletProgramMapping, 7> 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 — РИД-учёт до перехода на электронный учёт, без проверки */ },
|
||||
{ ledger2_wallets::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
|
||||
}};
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,12 +19,6 @@ using createapprv_interface = void(CREATEAPPRV_SIGNATURE);
|
||||
#define DECLINE_CALLBACK_SIGNATURE name coopname, checksum256 hash, std::string reason
|
||||
using decline_callback_interface = void(DECLINE_CALLBACK_SIGNATURE);
|
||||
|
||||
// Общая ветка отказа решения совета: отправка коллбэка отклонения инициатору
|
||||
// (если решение пришло из внешнего процесса) + удаление решения из таблицы.
|
||||
// Используется при просрочке (cancelexprd) и при достижении отрицательного
|
||||
// консенсуса голосов «против» (voteagainst). Определение — cancelexprd.cpp.
|
||||
void decline_and_erase_decision(eosio::name coopname, uint64_t decision_id, const std::string &reason);
|
||||
|
||||
#define AUTHORIZE_CALLBACK_SIGNATURE name coopname, checksum256 hash, document2 authorization
|
||||
using authorize_callback_interface = void(AUTHORIZE_CALLBACK_SIGNATURE);
|
||||
|
||||
|
||||
@@ -12,18 +12,3 @@ account get_account_or_fail(eosio::name username) {
|
||||
|
||||
return *account_row;
|
||||
}
|
||||
|
||||
// Возврат аккаунта к состоянию «можно подать заявку заново»: снимаем карточку
|
||||
// участника (type) и сторейджи, которые проставил reguser. Сам аккаунт (username,
|
||||
// ключи) сохраняется — повторная подача идёт на том же аккаунте. Вызывается при
|
||||
// терминальном закрытии кандидата (отказ совета + возврат взноса): после этого
|
||||
// reguser снова проходит проверку type=="" и принимает новое заявление.
|
||||
inline void reset_account_card(eosio::name username) {
|
||||
accounts_index accounts(_registrator, _registrator.value);
|
||||
auto account_row = accounts.find(username.value);
|
||||
if (account_row == accounts.end()) return;
|
||||
accounts.modify(account_row, _registrator, [&](auto &a) {
|
||||
a.type = ""_n;
|
||||
a.storages.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
# Источники правды в коде:
|
||||
# • cpp/registrator/registrator.hpp — actions
|
||||
# • cpp/registrator/src/user/{reguser,confirmpay,confirmreg,declinepay,declinereg}.cpp
|
||||
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (o.reg.inpay, o.reg.setmin, o.reg.setent)
|
||||
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (o.reg.payent, o.reg.putmin)
|
||||
# • cpp/lib/core/ledger2/processes.hpp — processes::registrator::ACCEPT
|
||||
# • cpp/lib/core/ledger2/wallets.hpp — w.reg.pend, w.reg.entry, w.reg.minshr
|
||||
# • cpp/lib/core/ledger2/accounts.hpp — 51 / 76 / 80 / 86
|
||||
# • cpp/lib/core/ledger2/wallets.hpp — w.reg.entry, w.reg.minshr
|
||||
# • cpp/lib/core/ledger2/accounts.hpp — 51 / 80 / 86
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
|
||||
@@ -51,9 +51,8 @@ actions:
|
||||
role: progress
|
||||
purpose: >
|
||||
Кассир подтверждает зачисление взносов: карточка переходит в `payed`,
|
||||
повестка `joincoop` с колбэками отправлена в совет. Деньги получены,
|
||||
но взнос ещё не признан — он ставится на расчёты с пайщиком
|
||||
(Дт 51 / Кт 76) и ждёт решения совета.
|
||||
повестка `joincoop` с колбэками отправлена в совет. Учётных проводок
|
||||
ещё нет.
|
||||
|
||||
- name: registrator::declinepay
|
||||
human: Отклонить оплату
|
||||
@@ -61,27 +60,25 @@ actions:
|
||||
role: reject
|
||||
purpose: >
|
||||
Кассир отклоняет платёж: запись кандидата удаляется, операции в книге
|
||||
Ledger2 не создаются (деньги ещё не были приняты). Кандидат может
|
||||
подать заявление повторно.
|
||||
Ledger2 не создаются. Кандидат может подать заявление повторно.
|
||||
|
||||
- name: registrator::confirmreg
|
||||
human: Утвердить советом
|
||||
actor: soviet
|
||||
role: closer
|
||||
purpose: >
|
||||
Совет утверждает приём: статус карточки → `active`, взнос признаётся —
|
||||
сумма с расчётов с пайщиком (счёт 76) переносится на паевой фонд (80)
|
||||
и целевое финансирование (86) операциями `o.reg.setmin` и
|
||||
`o.reg.setent`. Кандидат добавлен в список участников.
|
||||
Совет утверждает приём: статус карточки → `active`, в книге Ledger2
|
||||
одновременно проводятся `o.reg.payent` и `o.reg.putmin`, кандидат
|
||||
добавлен в список участников.
|
||||
|
||||
- name: registrator::declinereg
|
||||
human: Отклонить советом
|
||||
actor: soviet
|
||||
role: reject
|
||||
purpose: >
|
||||
Совет отказывает в приёме: процесс приёма взноса прерывается и
|
||||
начинается отдельный процесс возврата «Возврат регистрационного
|
||||
взноса» (p.reg.refund) — полученная сумма возвращается кандидату.
|
||||
Совет отказывает в приёме: запись кандидата удаляется, операции
|
||||
в книге Ledger2 не создаются. Возврат внесённых сумм оформляется
|
||||
отдельно.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
# Составная сущность: статус отслеживается в таблице `candidates` (created / payed),
|
||||
@@ -102,36 +99,23 @@ states:
|
||||
- name: payed
|
||||
human: Взносы оплачены
|
||||
description: >
|
||||
Деньги получены, карточка в `payed`, в совет ушла повестка `joincoop`.
|
||||
Полученная сумма стоит на расчётах с пайщиком (Дт 51 / Кт 76) и ждёт
|
||||
решения совета: при одобрении — признаётся взносом, при отказе —
|
||||
возвращается.
|
||||
Деньги получены, карточка в `payed`, в совет ушла повестка
|
||||
`joincoop`. Проводок ещё нет — они сработают на решении совета.
|
||||
kind: normal
|
||||
|
||||
- name: active
|
||||
human: Пайщик активен
|
||||
description: >
|
||||
`accounts.status` = `active`, карточка кандидата удалена, в книге
|
||||
Ledger2 взнос признан — сумма перенесена со счёта 76 на 80 и 86
|
||||
операциями `o.reg.setmin` и `o.reg.setent`. С этого момента доступны
|
||||
все остальные кооперативные процессы.
|
||||
Ledger2 проведены `o.reg.payent` и `o.reg.putmin`. С этого момента
|
||||
доступны все остальные кооперативные процессы.
|
||||
kind: final
|
||||
|
||||
- name: removed
|
||||
human: Отклонено
|
||||
description: >
|
||||
Запись кандидата удалена на этапе оплаты (платёж не поступил/отклонён) —
|
||||
деньги не принимались, проводок не было.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
- name: refunding
|
||||
human: Возврат в процессе
|
||||
description: >
|
||||
Совет отказал кандидату, чьи деньги уже стоят на счёте 76. Процесс
|
||||
приёма прерывается, и управление переходит в отдельный процесс
|
||||
«Возврат регистрационного взноса» (p.reg.refund), где касса проводит
|
||||
возврат и подтверждает его.
|
||||
Запись кандидата удалена, проводок в книге Ledger2 не было.
|
||||
Если взносы успели поступить, возврат оформляется вне процесса.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -149,9 +133,6 @@ transitions:
|
||||
to: payed
|
||||
action: registrator::confirmpay
|
||||
actor: gateway_operator
|
||||
ledger_code: p.reg.accept
|
||||
operations:
|
||||
- o.reg.inpay
|
||||
guards:
|
||||
- Кассир подтвердил зачисление суммы = initial + minimum.
|
||||
|
||||
@@ -168,22 +149,17 @@ transitions:
|
||||
actor: soviet
|
||||
ledger_code: p.reg.accept
|
||||
operations:
|
||||
- o.reg.setmin
|
||||
- o.reg.setent
|
||||
- o.reg.payent
|
||||
- o.reg.putmin
|
||||
guards:
|
||||
- Совет принял положительное решение по повестке.
|
||||
|
||||
- from: payed
|
||||
to: refunding
|
||||
to: removed
|
||||
action: registrator::declinereg
|
||||
actor: soviet
|
||||
operations: []
|
||||
guards:
|
||||
- Совет принял отрицательное решение по повестке.
|
||||
- >
|
||||
Проводок здесь нет: процесс приёма прерывается и управление переходит
|
||||
в отдельный процесс «Возврат регистрационного взноса» (p.reg.refund),
|
||||
где возврат проводится на шаге подтверждения кассой (o.reg.refund).
|
||||
|
||||
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
|
||||
scenario:
|
||||
@@ -214,37 +190,33 @@ scenario:
|
||||
actor: gateway_operator
|
||||
action: registrator::confirmpay
|
||||
description: >
|
||||
Деньги пришли — встают на расчёты с пайщиком, карточка движется к совету.
|
||||
Деньги пришли — карточка движется к совету.
|
||||
|
||||
Кандидат оплачивает счёт (банковский перевод или криптоплатёж).
|
||||
Кассир, получив подтверждение зачисления от платёжной системы,
|
||||
вызывает `confirmpay`. Контракт переводит кандидата в статус
|
||||
`payed`, создаёт повестку в совете с колбэками
|
||||
`confirmreg` / `declinereg` и ставит полученную сумму на учёт
|
||||
операцией `o.reg.inpay` (Дт 51 / Кт 76) — деньги получены, но
|
||||
взнос ещё не признан и ждёт решения совета.
|
||||
`payed` и создаёт повестку в совете с колбэками
|
||||
`confirmreg` / `declinereg`. Учётных проводок ещё нет.
|
||||
pre:
|
||||
- Кандидат в статусе `created`.
|
||||
- На расчётный счёт получена сумма initial + minimum.
|
||||
post:
|
||||
- Кандидат в статусе `payed`.
|
||||
- В совете открыта повестка `joincoop`.
|
||||
- В книге Ledger2 проведена o.reg.inpay (Дт 51 / Кт 76).
|
||||
|
||||
- step: 3
|
||||
title: Утверждение советом
|
||||
actor: soviet
|
||||
action: registrator::confirmreg
|
||||
description: >
|
||||
Совет утверждает приём — взнос признаётся, кандидат становится пайщиком.
|
||||
Совет утверждает приём — два взноса встают на учёт, кандидат становится пайщиком.
|
||||
|
||||
Совет рассматривает повестку `joincoop`. При положительном
|
||||
решении контракт `confirmreg`: (1) меняет `accounts.status`
|
||||
на `active`; (2) добавляет нового пайщика в список участников
|
||||
совета (`soviet::addpartcpnt`); (3) признаёт взнос — переносит
|
||||
сумму с расчётов с пайщиком (76) на паевой фонд и целевое
|
||||
финансирование двумя операциями: `o.reg.setmin` (минимальный
|
||||
паевой, Дт 76 / Кт 80) и `o.reg.setent` (вступительный, Дт 76 / Кт 86).
|
||||
совета (`soviet::addpartcpnt`); (3) применяет в книге Ledger2
|
||||
две операции — `o.reg.payent` (вступительный взнос, Дт 51 / Кт 86)
|
||||
и `o.reg.putmin` (минимальный паевой взнос, Дт 51 / Кт 80).
|
||||
Запись кандидата удаляется.
|
||||
pre:
|
||||
- Кандидат в статусе `payed`.
|
||||
@@ -252,7 +224,7 @@ scenario:
|
||||
post:
|
||||
- accounts.status = `active` — пайщик стал активным членом кооператива.
|
||||
- Пайщик добавлен в soviet::participants.
|
||||
- В книге Ledger2 проведены o.reg.setmin + o.reg.setent.
|
||||
- В книге Ledger2 проведены o.reg.payent + o.reg.putmin.
|
||||
- Запись кандидата удалена из таблицы candidates.
|
||||
|
||||
alternatives:
|
||||
@@ -264,8 +236,8 @@ scenario:
|
||||
Платёж не прошёл или был отменён.
|
||||
|
||||
Кассир вызывает `declinepay`, кандидат удаляется, операции
|
||||
в книге Ledger2 не создаются (деньги ещё не приняты). Кандидат
|
||||
может повторить подачу заявления.
|
||||
в книге Ledger2 не создаются. Кандидат может повторить подачу
|
||||
заявления.
|
||||
|
||||
- branch: Отказ совета
|
||||
at_step: 3
|
||||
@@ -274,11 +246,9 @@ scenario:
|
||||
description: >
|
||||
Совет отказывает кандидату.
|
||||
|
||||
Процесс приёма взноса прерывается и начинается отдельный процесс
|
||||
«Возврат регистрационного взноса» (p.reg.refund): кандидат переводится
|
||||
в состояние возврата, кассе поручается вернуть деньги. Сама проводка
|
||||
Дт 76 / Кт 51 и удаление кандидата происходят там — на шаге, когда
|
||||
касса фактически проведёт возврат и подтвердит его.
|
||||
Запись кандидата удаляется, совершённый платёж подлежит возврату
|
||||
(возврат оформляется вне этого процесса). Операции в книге Ledger2
|
||||
не создаются.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
@@ -295,72 +265,39 @@ documents:
|
||||
stored_in: "(authorization — в параметре действия, не хранится в candidates после удаления записи)"
|
||||
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
# Процесс мульти-операционный: три записи OPERATION_REGISTRY с process_type = p.reg.accept.
|
||||
# Двухфазный учёт: на приёме денег (confirmpay) сумма ставится на расчёты с
|
||||
# пайщиком (76), на одобрении совета (confirmreg) — признаётся взносом (80/86).
|
||||
#
|
||||
# ПЕРЕХОДНЫЙ ПЕРИОД (до 30.07.2026): кандидаты, чей платёж был принят ДО релиза
|
||||
# двухфазного учёта, не имеют суспенса на счёте 76. Для них одобрение совета
|
||||
# проводится по старому одношаговому пути — прямой ISSUE Дт 51 / Кт 80
|
||||
# (o.reg.putmin) и Дт 51 / Кт 86 (o.reg.payent), без переноса с 76. Признак —
|
||||
# отсутствие баланса кандидата на w.reg.pend (см. registration_migration.hpp).
|
||||
# Процесс мульти-операционный: две записи OPERATION_REGISTRY с process_type = p.reg.accept.
|
||||
# Обе срабатывают на закрывающем действии confirmreg.
|
||||
operations:
|
||||
- ledger_code: o.reg.inpay
|
||||
human_name: Приём регистрационного взноса в ожидание решения совета
|
||||
- ledger_code: o.reg.payent
|
||||
human_name: Вступительный взнос пайщика
|
||||
wallet_op: ISSUE
|
||||
wallet_from: ''
|
||||
wallet_to: w.reg.pend
|
||||
debit: 51
|
||||
credit: 76
|
||||
amount_ref: candidate.initial + candidate.minimum
|
||||
triggered_by: registrator::confirmpay
|
||||
description: >
|
||||
Постановка полученного регистрационного взноса на учёт. Сумма
|
||||
«вступительный + минимальный паевой» зачисляется на кошелёк
|
||||
«Регистрационный взнос в ожидании решения совета» (w.reg.pend);
|
||||
двойная запись Дт 51 / Кт 76 — деньги на расчётном счёте, обязательство
|
||||
перед пайщиком на счёте расчётов. Признание или возврат — позже.
|
||||
|
||||
- ledger_code: o.reg.setmin
|
||||
human_name: Зачисление минимального паевого взноса по решению совета
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.reg.pend
|
||||
wallet_to: w.reg.minshr
|
||||
debit: 76
|
||||
credit: 80
|
||||
amount_ref: candidate.minimum
|
||||
triggered_by: registrator::confirmreg
|
||||
description: >
|
||||
Признание минимального паевого взноса по решению совета. Сумма
|
||||
`candidate.minimum` переносится с кошелька ожидания (w.reg.pend) на
|
||||
«Минимальный паевой взнос» (w.reg.minshr); двойная запись Дт 76 / Кт 80 —
|
||||
стартовый вклад в паевой фонд.
|
||||
|
||||
- ledger_code: o.reg.setent
|
||||
human_name: Зачисление вступительного взноса по решению совета
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.reg.pend
|
||||
wallet_to: w.reg.entry
|
||||
debit: 76
|
||||
debit: 51
|
||||
credit: 86
|
||||
amount_ref: candidate.initial
|
||||
triggered_by: registrator::confirmreg
|
||||
description: >
|
||||
Признание вступительного взноса по решению совета. Сумма
|
||||
`candidate.initial` переносится с кошелька ожидания (w.reg.pend) на
|
||||
«Вступительные взносы» (w.reg.entry); двойная запись Дт 76 / Кт 86 —
|
||||
целевые поступления в кооперативный фонд.
|
||||
Постановка вступительного взноса на учёт. Сумма `candidate.initial`
|
||||
зачисляется на кошелёк «Вступительные взносы» (w.reg.entry); двойная
|
||||
запись Дт 51 / Кт 86 — целевые поступления в кооперативный фонд.
|
||||
|
||||
- ledger_code: o.reg.putmin
|
||||
human_name: Минимальный паевой взнос при регистрации
|
||||
wallet_op: ISSUE
|
||||
wallet_from: ''
|
||||
wallet_to: w.reg.minshr
|
||||
debit: 51
|
||||
credit: 80
|
||||
amount_ref: candidate.minimum
|
||||
triggered_by: registrator::confirmreg
|
||||
description: >
|
||||
Парная операция к `o.reg.payent`. Сумма `candidate.minimum`
|
||||
появляется на кошельке «Минимальный паевой взнос» (w.reg.minshr);
|
||||
двойная запись Дт 51 / Кт 80 — стартовый вклад в паевой фонд.
|
||||
|
||||
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
|
||||
related:
|
||||
- process_type: p.reg.refund
|
||||
id: public_registrator_refund_process
|
||||
relation: triggers
|
||||
note: >
|
||||
При отрицательном решении совета процесс приёма прерывается и
|
||||
запускается «Возврат регистрационного взноса» (p.reg.refund): сумма,
|
||||
стоящая на расчётах с пайщиком (счёт 76), возвращается кандидату.
|
||||
|
||||
- process_type: p.wal.depo
|
||||
id: public_wallet_deposit_process
|
||||
relation: triggers
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Стандарт «Возврат регистрационного взноса» — процесс возврата кандидату
|
||||
# полученного регистрационного взноса при отказе совета в приёме.
|
||||
#
|
||||
# Это самостоятельный процесс: приём пайщика (p.reg.accept) прерывается на
|
||||
# решении совета, и вместо признания взноса начинается его возврат. Возврат
|
||||
# идёт в два шага: совет выносит отказ и поручает кассе вернуть деньги, а
|
||||
# списание со счёта происходит только после того, как касса фактически проведёт
|
||||
# исходящий перевод кандидату и подтвердит это. До подтверждения деньги стоят
|
||||
# на расчётах с пайщиком (счёт 76).
|
||||
#
|
||||
# Источники правды в коде:
|
||||
# • cpp/registrator/registrator.hpp — действия declinereg / refundpay
|
||||
# • cpp/registrator/src/user/declinereg.cpp — отказ совета, создание исходящего возврата
|
||||
# • cpp/registrator/src/user/refundpay.cpp — подтверждение возврата кассой, проводка и закрытие
|
||||
# • cpp/registrator/src/user/declinerfnd.cpp — страж: отмена возврата невозможна
|
||||
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (o.reg.refund)
|
||||
# • cpp/lib/core/ledger2/processes.hpp — processes::registrator::REFUND
|
||||
# • cpp/lib/core/ledger2/wallets.hpp — w.reg.pend
|
||||
# • cpp/lib/core/ledger2/accounts.hpp — 51 / 76
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
|
||||
process_type: p.reg.refund
|
||||
id: public_registrator_refund_process
|
||||
title: Возврат регистрационного взноса
|
||||
slug: refund
|
||||
status: proposed
|
||||
contract: registrator
|
||||
summary: >
|
||||
Когда совет отказывает кандидату в приёме, полученный регистрационный
|
||||
взнос возвращается ему: совет поручает возврат, касса проводит перевод и
|
||||
подтверждает его, после чего запись кандидата закрывается.
|
||||
purpose: >
|
||||
«Возврат регистрационного взноса» — терминальный процесс приёма пайщика
|
||||
по отрицательному решению. Полученные кассой деньги стоят на расчётах
|
||||
с пайщиком (счёт 76) в ожидании решения; при отказе совета они
|
||||
возвращаются кандидату. Возврат денег не может быть отменён — права
|
||||
кандидата на свои средства безусловны.
|
||||
roles:
|
||||
- contributor # несостоявшийся пайщик (получатель возврата)
|
||||
- soviet # совет кооператива (выносит отказ и поручает возврат)
|
||||
- chairman # председатель / кассир (проводит и подтверждает возврат)
|
||||
|
||||
# ── Секция 2. Действия контракта (блокчейн-уровень) ─────────────────────────
|
||||
actions:
|
||||
- name: registrator::declinereg
|
||||
human: Отклонить советом
|
||||
actor: soviet
|
||||
role: opener
|
||||
purpose: >
|
||||
Совет отказывает кандидату в приёме. Процесс приёма прерывается:
|
||||
контракт переводит кандидата в состояние возврата и поручает кассе
|
||||
вернуть полученный взнос. Деньги остаются на расчётах с пайщиком
|
||||
(счёт 76) — со счёта они ещё не уходят.
|
||||
|
||||
- name: registrator::refundpay
|
||||
human: Подтвердить возврат
|
||||
actor: chairman
|
||||
role: closer
|
||||
purpose: >
|
||||
Касса фактически провела исходящий перевод денег кандидату и
|
||||
подтверждает это. Контракт проводит возврат обратной проводкой
|
||||
(Дт 76 / Кт 51) операцией `o.reg.refund` и закрывает запись кандидата.
|
||||
После этого аккаунт освобождается для повторной подачи заявления на
|
||||
том же аккаунте: несостоявшийся пайщик может подать заявку заново.
|
||||
|
||||
- name: registrator::declinerfnd
|
||||
human: Отклонить возврат (недопустимо)
|
||||
actor: chairman
|
||||
role: guard
|
||||
purpose: >
|
||||
Отмена возврата регистрационного взноса невозможна — действие всегда
|
||||
завершается ошибкой. Существует только как обязательная развилка
|
||||
процессинга; в интерфейсе кассы кнопки отклонения возврата нет.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: "registrator::candidate"
|
||||
entity_human: "Кандидат"
|
||||
entity_source: cpp/registrator/src/user/declinereg.cpp
|
||||
|
||||
states:
|
||||
- name: payed
|
||||
human: Взносы оплачены
|
||||
description: >
|
||||
Входное состояние процесса: деньги получены и стоят на расчётах
|
||||
с пайщиком (w.reg.pend, счёт 76), повестка `joincoop` рассмотрена
|
||||
советом с отрицательным решением.
|
||||
kind: normal
|
||||
|
||||
- name: refunding
|
||||
human: Возврат в процессе
|
||||
description: >
|
||||
Совет отказал и поручил кассе вернуть взнос. Создан исходящий платёж
|
||||
возврата; деньги по-прежнему стоят на расчётах с пайщиком (w.reg.pend,
|
||||
счёт 76) и ждут, пока касса фактически проведёт перевод и подтвердит его.
|
||||
kind: normal
|
||||
|
||||
- name: refunded
|
||||
human: Взнос возвращён
|
||||
description: >
|
||||
Касса подтвердила исходящий перевод. В книге Ledger2 проведена
|
||||
обратная операция `o.reg.refund` (Дт 76 / Кт 51) — обязательство перед
|
||||
кандидатом закрыто, деньги ушли со счёта. Запись кандидата удалена.
|
||||
kind: final
|
||||
|
||||
transitions:
|
||||
- from: payed
|
||||
to: refunding
|
||||
action: registrator::declinereg
|
||||
actor: soviet
|
||||
operations: []
|
||||
guards:
|
||||
- Совет принял отрицательное решение по повестке `joincoop`.
|
||||
- Кандидат в статусе `payed` (деньги были получены и стоят на счёте 76).
|
||||
|
||||
- from: refunding
|
||||
to: refunded
|
||||
action: registrator::refundpay
|
||||
actor: chairman
|
||||
ledger_code: p.reg.refund
|
||||
operations:
|
||||
- o.reg.refund
|
||||
guards:
|
||||
- Касса фактически провела исходящий перевод денег кандидату.
|
||||
- Кандидат в статусе `refunding`.
|
||||
|
||||
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
|
||||
scenario:
|
||||
steps:
|
||||
- step: 1
|
||||
title: Отказ совета и поручение на возврат
|
||||
actor: soviet
|
||||
action: registrator::declinereg
|
||||
description: >
|
||||
Совет рассматривает повестку `joincoop` и принимает отрицательное
|
||||
решение. Процесс приёма прерывается: кандидат переводится в состояние
|
||||
«возврат в процессе», и кассе поручается вернуть полученный
|
||||
регистрационный взнос. Деньги остаются на расчётах с пайщиком
|
||||
(w.reg.pend, счёт 76) — со счёта они ещё не уходят. Кандидат получает
|
||||
уведомление об отказе.
|
||||
pre:
|
||||
- Кандидат в статусе `payed` — деньги получены и стоят на счёте 76.
|
||||
- Совет принял отрицательное решение.
|
||||
post:
|
||||
- Кандидат в статусе `refunding`.
|
||||
- Создан исходящий платёж возврата на сумму взноса.
|
||||
- Деньги по-прежнему на счёте 76 (проводки ещё нет).
|
||||
|
||||
- step: 2
|
||||
title: Касса возвращает деньги и подтверждает
|
||||
actor: chairman
|
||||
action: registrator::refundpay
|
||||
description: >
|
||||
Касса проводит фактический перевод денег кандидату и подтверждает,
|
||||
что возврат выполнен. Контракт возвращает полученный регистрационный
|
||||
взнос обратной проводкой `o.reg.refund` (Дт 76 / Кт 51): сумма
|
||||
«вступительный + минимальный паевой» списывается с кошелька ожидания
|
||||
(w.reg.pend), обязательство перед кандидатом закрывается, деньги
|
||||
уходят со счёта. Запись кандидата удаляется. Отменить возврат нельзя.
|
||||
pre:
|
||||
- Кандидат в статусе `refunding`.
|
||||
- Касса фактически провела исходящий перевод кандидату.
|
||||
post:
|
||||
- В книге Ledger2 проведена o.reg.refund (Дт 76 / Кт 51).
|
||||
- Запись кандидата удалена из картотеки кандидатов.
|
||||
- Кандидат получил свои деньги.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
- action: registrator::declinereg
|
||||
title: Решение совета об отказе в приёме пайщика
|
||||
registry_id: 501
|
||||
signed_by: [ Совет ]
|
||||
stored_in: "(решение совета — в повестке joincoop)"
|
||||
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
# Возврат проводится одной операцией o.reg.refund — на шаге подтверждения кассой
|
||||
# (registrator::refundpay), не на решении совета. Отмена возврата невозможна
|
||||
# (действие-страж declinerfnd всегда завершается ошибкой).
|
||||
#
|
||||
# ПЕРЕХОДНЫЙ ПЕРИОД (до 30.07.2026): кандидаты, чей платёж был принят ДО релиза
|
||||
# двухфазного учёта, суспенса на счёте 76 не имеют — для них on-chain проводки
|
||||
# возврата нет (как было раньше), деньги возвращает бэкенд банковским переводом,
|
||||
# а запись кандидата закрывается сразу при отказе совета. Операция o.reg.refund
|
||||
# проводится только при наличии баланса на w.reg.pend.
|
||||
operations:
|
||||
- ledger_code: o.reg.refund
|
||||
human_name: Возврат регистрационного взноса при отказе совета
|
||||
wallet_op: BURN
|
||||
wallet_from: w.reg.pend
|
||||
wallet_to: ''
|
||||
debit: 76
|
||||
credit: 51
|
||||
amount_ref: candidate.initial + candidate.minimum
|
||||
triggered_by: registrator::refundpay
|
||||
description: >
|
||||
Возврат полученного регистрационного взноса. Сумма «вступительный +
|
||||
минимальный паевой» списывается с кошелька «Регистрационный взнос
|
||||
в ожидании решения совета» (w.reg.pend); двойная запись Дт 76 / Кт 51 —
|
||||
закрытие обязательства перед кандидатом и уход денег с расчётного
|
||||
счёта. Получателя на цепи нет — деньги фактически переводит касса.
|
||||
|
||||
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
|
||||
related:
|
||||
- process_type: p.reg.accept
|
||||
id: public_registrator_accept_process
|
||||
relation: interrupts
|
||||
note: >
|
||||
Возврат — отрицательная развилка процесса «Приём пайщика»
|
||||
(p.reg.accept). Деньги, поставленные на расчёты с пайщиком операцией
|
||||
`o.reg.inpay` на этапе оплаты, при отказе совета не признаются
|
||||
взносом, а возвращаются этим процессом.
|
||||
@@ -8,8 +8,6 @@
|
||||
#include "src/user/confirmreg.cpp"
|
||||
#include "src/user/declinereg.cpp"
|
||||
#include "src/user/declinepay.cpp"
|
||||
#include "src/user/refundpay.cpp"
|
||||
#include "src/user/declinerfnd.cpp"
|
||||
#include "src/user/reguser.cpp"
|
||||
|
||||
#include "src/account/createbranch.cpp"
|
||||
|
||||
@@ -88,8 +88,6 @@ public:
|
||||
[[eosio::action]] void confirmpay(name coopname, checksum256 registration_hash);
|
||||
[[eosio::action]] void declinepay(name coopname, checksum256 registration_hash, std::string reason);
|
||||
[[eosio::action]] void declinereg(name coopname, checksum256 registration_hash, std::string reason);
|
||||
[[eosio::action]] void refundpay(name coopname, checksum256 registration_hash);
|
||||
[[eosio::action]] void declinerfnd(name coopname, checksum256 registration_hash, std::string reason);
|
||||
|
||||
|
||||
[[eosio::action]] void createbranch(eosio::name coopname, eosio::name braname);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#include <array>
|
||||
|
||||
/**
|
||||
* @brief Миграция данных контракта.
|
||||
* Обновляет счетчики активных пайщиков для всех кооперативов
|
||||
@@ -42,56 +40,4 @@
|
||||
coop.active_participants_count = active_count;
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Разовая очистка ФАНТОМНЫХ пайщиков кооператива fgrtejiwnynn (инцидент
|
||||
// 2026-06-04). Причина: install.interactor прогнан 3× (2 провала + успех);
|
||||
// каждый прогон минтил на цепи новые аккаунты совета (adduser) со случайным
|
||||
// username, а откат в catch чистил только off-chain. На цепи осели 6 дублей-
|
||||
// аккаунтов одних и тех же 3 учредителей (см. ~/gorozhane-dup-install-cleanup.md
|
||||
// и ~/fixes.md). Удаляем записи registrator::accounts и пересчитываем счётчик
|
||||
// активных пайщиков из soviet::participants (источник истины).
|
||||
//
|
||||
// Список username — единственный авторитет (дедуп off-chain по ФИО невозможен
|
||||
// on-chain — личных данных в registrator::accounts нет). ОДИН И ТОТ ЖЕ список
|
||||
// продублирован в soviet::migrate и ledger2::migrate — править синхронно.
|
||||
// Идемпотентно: повторный прогон не находит записей → no-op.
|
||||
{
|
||||
const eosio::name PHANTOM_COOP = "fgrtejiwnynn"_n;
|
||||
const std::array<eosio::name, 6> PHANTOMS = {
|
||||
"bbsezpgufvmm"_n, "errwcgjwverm"_n, "hcfsluqsfehw"_n,
|
||||
"kgkzdadfpzki"_n, "nzuyijobapsv"_n, "tplwfwbujugq"_n,
|
||||
};
|
||||
|
||||
auto pcoop = coops2.find(PHANTOM_COOP.value);
|
||||
if (pcoop != coops2.end() && pcoop->is_cooperative) {
|
||||
accounts_index accounts(_registrator, _registrator.value);
|
||||
|
||||
for (const auto& u : PHANTOMS) {
|
||||
auto acc = accounts.find(u.value);
|
||||
if (acc == accounts.end()) continue; // уже удалён / отсутствует
|
||||
// Дедуп-гард: трогаем только аккаунты, зарегистрированные ЭТИМ коопом.
|
||||
if (acc->registrator != PHANTOM_COOP) continue;
|
||||
accounts.erase(acc);
|
||||
}
|
||||
|
||||
// Пересчитываем счётчик активных пайщиков из реестра soviet::participants
|
||||
// (источник истины), ИСКЛЮЧАЯ фантомов явно — счётчик сходится к верному
|
||||
// значению за ОДИН деплой независимо от порядка выполнения migrate
|
||||
// (registrator::migrate vs soviet::migrate). Если бы считали всех has_vote,
|
||||
// а soviet ещё не удалил фантомов — счётчик временно завысился бы.
|
||||
participants_index pparts(_soviet, PHANTOM_COOP.value);
|
||||
uint64_t active = 0;
|
||||
for (auto p = pparts.begin(); p != pparts.end(); ++p) {
|
||||
bool is_phantom = false;
|
||||
for (const auto& ph : PHANTOMS) {
|
||||
if (ph == p->username) { is_phantom = true; break; }
|
||||
}
|
||||
if (!is_phantom && p->has_vote) active++;
|
||||
}
|
||||
coops2.modify(pcoop, _registrator, [&](auto& coop) {
|
||||
coop.active_participants_count = active;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,7 @@ void registrator::confirmpay(name coopname, checksum256 registration_hash){
|
||||
candidates.modify(candidate, _registrator, [&](auto &c){
|
||||
c.status = "payed"_n;
|
||||
});
|
||||
|
||||
// Деньги получены кассой, но взнос ещё не признан — ставим на расчёты с
|
||||
// пайщиком (Dr 51 / Cr 76, ISSUE w.reg.pend) до решения совета. Сумма —
|
||||
// вступительный + минимальный паевой. Признание/возврат — в confirmreg/declinereg.
|
||||
eosio::asset registration_quantity = candidate -> initial + candidate -> minimum;
|
||||
std::string memo = "Приём регистрационного взноса в ожидание решения совета от username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::registrator::RECEIVE_PAYMENT, registration_quantity, candidate -> username, registration_hash, memo);
|
||||
|
||||
|
||||
action(permission_level{ _registrator, "active"_n}, _soviet, "createagenda"_n,
|
||||
std::make_tuple(
|
||||
coopname,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#include "registration_migration.hpp"
|
||||
|
||||
/**
|
||||
* @brief Подтверждение регистрации пользователя.
|
||||
* Подтверждает регистрацию кандидата советом и добавляет его в кооператив
|
||||
@@ -47,28 +45,11 @@ void registrator::confirmreg(eosio::name coopname, checksum256 registration_hash
|
||||
)
|
||||
).send();
|
||||
|
||||
// Совет одобрил — признаём взнос. Гарды amount>0: у кооператива может не быть
|
||||
// вступительного (initial==0) — ledger2::apply падает на нулевой сумме.
|
||||
//
|
||||
// MIGRATION (снять условие после 30.07.2026, см. registration_migration.hpp):
|
||||
// для кандидатов, принятых ДО релиза двухфазного учёта (нет баланса на
|
||||
// w.reg.pend), признаём по старому пути — прямой ISSUE (Dr 51 / Cr 80|86);
|
||||
// для новых — переносим суспенс с 76 на 80/86 (TRANSFER w.reg.pend → ...).
|
||||
bool has_pending = Registrator::get_registration_pending_balance(coopname, candidate -> username).amount > 0;
|
||||
std::string memo = "Минимальный паевой взнос при вступлении пайщика с username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::registrator::PUT_MINSHARE, candidate -> minimum, candidate -> username, registration_hash, memo);
|
||||
|
||||
if (candidate -> minimum.amount > 0) {
|
||||
std::string memo = "Зачисление минимального паевого взноса по решению совета, username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname,
|
||||
has_pending ? operations::registrator::SETTLE_MINSHARE : operations::registrator::PUT_MINSHARE,
|
||||
candidate -> minimum, candidate -> username, registration_hash, memo);
|
||||
}
|
||||
|
||||
if (candidate -> initial.amount > 0) {
|
||||
std::string memo = "Зачисление вступительного взноса по решению совета, username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname,
|
||||
has_pending ? operations::registrator::SETTLE_ENTRANCE : operations::registrator::PAY_ENTRANCE,
|
||||
candidate -> initial, candidate -> username, registration_hash, memo);
|
||||
}
|
||||
memo = "Вступительный взнос при вступлении пайщика с username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::registrator::PAY_ENTRANCE, candidate -> initial, candidate -> username, registration_hash, memo);
|
||||
// Увеличиваем счетчик активных пайщиков
|
||||
cooperatives2_index cooperatives(_registrator, _registrator.value);
|
||||
auto coop_itr = cooperatives.find(coopname.value);
|
||||
|
||||
@@ -14,7 +14,7 @@ void registrator::declinepay(name coopname, checksum256 registration_hash, std::
|
||||
|
||||
auto exist_candidate = Registrator::get_candidate_by_hash(coopname, registration_hash);
|
||||
|
||||
eosio::check(exist_candidate.has_value(), "Кандидат не найден");
|
||||
eosio::check(!exist_candidate.has_value(), "Кандидат не найден");
|
||||
|
||||
Registrator::candidates_index candidates(_registrator, coopname.value);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#include "registration_migration.hpp"
|
||||
|
||||
/**
|
||||
* @brief Отклонение регистрации пользователя.
|
||||
* Отклоняет регистрацию кандидата советом
|
||||
@@ -12,53 +10,5 @@
|
||||
* @note Авторизация требуется от аккаунта: @p soviet
|
||||
*/
|
||||
void registrator::declinereg(name coopname, checksum256 registration_hash, std::string reason) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto candidate = Registrator::get_candidate_by_hash(coopname, registration_hash);
|
||||
eosio::check(candidate.has_value(), "Кандидат не найден");
|
||||
|
||||
// оповещаем кандидата об отказе совета
|
||||
require_recipient(candidate -> username);
|
||||
|
||||
// Совет отказал — процесс приёма взноса прерывается, начинается процесс
|
||||
// возврата (p.reg.refund). Деньги НЕ списываем здесь: они стоят на расчётах
|
||||
// с пайщиком (счёт 76, кошелёк w.reg.pend) до тех пор, пока касса фактически
|
||||
// не проведёт исходящий возврат. Обратную проводку Дт 76 / Кт 51 выполняет
|
||||
// коллбэк refundpay по подтверждению кассы (gateway::outcomplete) — симметрично
|
||||
// возврату паевого в контракте 'wallet' (authwthd → createoutpay → completewthd).
|
||||
//
|
||||
// MIGRATION (снять условие после 30.07.2026, см. registration_migration.hpp):
|
||||
// кандидаты, принятые ДО релиза двухфазного учёта, суспенса на w.reg.pend
|
||||
// не имеют — для них on-chain возврата нет (как было раньше), банковский
|
||||
// возврат оформляет бэкенд, а запись кандидата закрываем сразу.
|
||||
eosio::asset pending = Registrator::get_registration_pending_balance(coopname, candidate -> username);
|
||||
|
||||
Registrator::candidates_index candidates(_registrator, coopname.value);
|
||||
auto it = candidates.find(candidate -> username.value);
|
||||
|
||||
if (pending.amount > 0) {
|
||||
// Новый путь: переводим кандидата в возврат и создаём исходящий платёж в
|
||||
// gateway с коллбэком после подтверждения кассой. Деньги остаются на счёте 76
|
||||
// вплоть до фактического проведения возврата кассой (refundpay).
|
||||
candidates.modify(it, _registrator, [&](auto &c){
|
||||
c.status = "refunding"_n;
|
||||
});
|
||||
|
||||
action(permission_level{ _registrator, "active"_n}, _gateway, "createoutpay"_n,
|
||||
std::make_tuple(
|
||||
coopname,
|
||||
candidate -> username,
|
||||
registration_hash,
|
||||
pending,
|
||||
_registrator,
|
||||
"refundpay"_n,
|
||||
"declinerfnd"_n
|
||||
)
|
||||
).send();
|
||||
} else {
|
||||
// Миграционный путь: возвращать на цепи нечего — закрываем кандидата и
|
||||
// освобождаем аккаунт для повторной подачи (снимаем карточку участника).
|
||||
if (it != candidates.end()) candidates.erase(it);
|
||||
reset_account_card(candidate -> username);
|
||||
}
|
||||
}
|
||||
//событие отклонения регистрации советом
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @brief Отклонение возврата регистрационного взноса — НЕДОПУСТИМО.
|
||||
* Возврат полученного регистрационного взноса при отказе совета не может быть
|
||||
* отменён: средства кандидата обязаны быть ему возвращены. Действие существует
|
||||
* только как обязательный decline-коллбэк gateway (createoutpay требует имя
|
||||
* decline-действия) и всегда завершается ошибкой. На фронте кнопки отклонения
|
||||
* возврата нет; этот страж защищает от прямого вызова gateway::outdecline.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param registration_hash Хэш регистрации (= outcome_hash исходящего платежа)
|
||||
* @param reason Причина отклонения (игнорируется)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p gateway
|
||||
*/
|
||||
void registrator::declinerfnd(name coopname, checksum256 registration_hash, std::string reason) {
|
||||
require_auth(_gateway);
|
||||
eosio::check(false, "Отмена возврата регистрационного взноса невозможна");
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#include "registration_migration.hpp"
|
||||
|
||||
/**
|
||||
* @brief Подтверждение возврата регистрационного взноса.
|
||||
* Коллбэк gateway после фактического проведения кассой исходящего возврата
|
||||
* (gateway::outcomplete). Проводит обратную проводку Дт 76 / Кт 51 (сжигание
|
||||
* суспенса w.reg.pend) и закрывает запись кандидата. Деньги уходят со счёта 76
|
||||
* ровно в этот момент — не раньше, чем касса подтвердит исходящий перевод.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param registration_hash Хэш регистрации (= outcome_hash исходящего платежа)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p gateway
|
||||
*/
|
||||
void registrator::refundpay(name coopname, checksum256 registration_hash) {
|
||||
require_auth(_gateway);
|
||||
|
||||
auto candidate = Registrator::get_candidate_by_hash(coopname, registration_hash);
|
||||
eosio::check(candidate.has_value(), "Кандидат не найден");
|
||||
eosio::check(candidate -> status == "refunding"_n, "Кандидат не находится в процессе возврата");
|
||||
|
||||
// оповещаем кандидата о завершении возврата
|
||||
require_recipient(candidate -> username);
|
||||
|
||||
// Сжигаем фактический остаток на w.reg.pend — он равен полученной сумме
|
||||
// (вступительный + минимальный паевой). Обратная проводка Дт 76 / Кт 51.
|
||||
eosio::asset pending = Registrator::get_registration_pending_balance(coopname, candidate -> username);
|
||||
eosio::check(pending.amount > 0, "Нет средств на возврат");
|
||||
|
||||
std::string memo = "Возврат регистрационного взноса при отказе совета, username=" + candidate -> username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::registrator::REFUND, pending, candidate -> username, registration_hash, memo);
|
||||
|
||||
// Возврат завершён — удаляем кандидата из картотеки.
|
||||
Registrator::candidates_index candidates(_registrator, coopname.value);
|
||||
auto it = candidates.find(candidate -> username.value);
|
||||
candidates.erase(it);
|
||||
|
||||
// Освобождаем аккаунт для повторной подачи на том же username: снимаем
|
||||
// карточку участника, иначе reguser упадёт «повторное получение невозможно».
|
||||
reset_account_card(candidate -> username);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ВРЕМЕННЫЙ МИГРАЦИОННЫЙ КОД — СНЯТЬ УСЛОВИЕ ПОСЛЕ 30.07.2026.
|
||||
//
|
||||
// На момент релиза двухфазного учёта регистрационного взноса (счёт 76,
|
||||
// кошелёк w.reg.pend) в системе могут быть «зависшие» кандидаты: их платёж
|
||||
// уже принят кассой (статус `payed`), но ПО СТАРОМУ пути — без операции
|
||||
// o.reg.inpay, то есть БЕЗ баланса на w.reg.pend.
|
||||
//
|
||||
// Когда совет вынесет по ним решение уже на новом контракте:
|
||||
// • confirmreg — нельзя делать TRANSFER с пустого w.reg.pend (упадёт
|
||||
// «недостаточно средств»); для таких кандидатов признаём взнос по-старому
|
||||
// прямым ISSUE (o.reg.putmin / o.reg.payent, Dr 51 / Cr 80|86).
|
||||
// • declinereg — возврата через ledger2 нет (как было раньше): on-chain
|
||||
// суспенса нет, банковский возврат оформляет бэкенд.
|
||||
//
|
||||
// Признак пути — наличие баланса кандидата на w.reg.pend (счёт 76):
|
||||
// > 0 → принят по новому пути (o.reg.inpay) → признание/возврат через 76;
|
||||
// == 0 → принят до релиза → старый путь.
|
||||
//
|
||||
// После 30.07.2026 (когда все зависшие на момент релиза кандидаты гарантированно
|
||||
// получат решение совета) этот файл и ветвления в confirmreg/declinereg убрать,
|
||||
// оставив только новый путь.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
namespace Registrator {
|
||||
|
||||
/**
|
||||
* @brief Баланс кандидата на суспенс-кошельке регистрационного взноса
|
||||
* (w.reg.pend, счёт 76) из ledger2 L3 (userwallets). Запись существует только
|
||||
* пока available > 0 (auto-delete на нуле), поэтому отсутствие = нулевой баланс.
|
||||
*/
|
||||
inline eosio::asset get_registration_pending_balance(eosio::name coopname, eosio::name username) {
|
||||
userwallets_index user_wallets(_ledger2, coopname.value);
|
||||
auto idx = user_wallets.get_index<"byuserwallet"_n>();
|
||||
auto it = idx.find(combine_ids(ledger2_wallets::REGISTRATION_PENDING.value, username.value));
|
||||
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -5,7 +5,7 @@
|
||||
# получить от совета согласие на выполнение действия. Поток:
|
||||
#
|
||||
# контракт-инициатор → newpackage / create_agenda → soviet::decisions
|
||||
# ↓ голосование (votefor / voteagainst)
|
||||
# ↓ голосование (votefor / voteagainst / cancelvote)
|
||||
# approved=true → authorize (председатель)
|
||||
# ↓
|
||||
# authorized=true → exec → callback в исходный контракт (confirmreg /
|
||||
@@ -19,8 +19,8 @@
|
||||
#
|
||||
# Источники в коде:
|
||||
# • cpp/soviet/src/doc/newpackage.cpp
|
||||
# • cpp/soviet/src/vote/{votefor,voteagainst}.cpp
|
||||
# • cpp/soviet/src/decision/{authorize,cancelexprd,declinedec,exec}.cpp
|
||||
# • cpp/soviet/src/vote/{votefor,voteagainst,cancelvote}.cpp
|
||||
# • cpp/soviet/src/decision/{authorize,cancelexprd,exec}.cpp
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
process_type: sov.authpkg
|
||||
@@ -74,9 +74,17 @@ actions:
|
||||
actor: Член совета
|
||||
role: reject
|
||||
purpose: >
|
||||
Член совета голосует «против». Голос фиксируется; решение само не
|
||||
закрывается. Когда против высказалось больше половины состава совета,
|
||||
председатель снимает решение явным действием (см. «Отклонить решение»).
|
||||
Член совета голосует «против». При достижении консенсуса по отказу
|
||||
решение помечается как rejected и при exec в исходный контракт
|
||||
уйдёт decline-callback.
|
||||
|
||||
- name: soviet::cancelvote
|
||||
human: Отозвать голос
|
||||
actor: Член совета
|
||||
role: progress
|
||||
purpose: >
|
||||
Член совета отзывает ранее поданный голос до перехода решения
|
||||
в финальный статус.
|
||||
|
||||
- name: soviet::authorize
|
||||
human: Утвердить протокол
|
||||
@@ -99,22 +107,11 @@ actions:
|
||||
|
||||
- name: soviet::cancelexprd
|
||||
human: Отменить просроченное
|
||||
actor: Кооператив
|
||||
role: reject
|
||||
purpose: >
|
||||
Если решение не было утверждено в срок, оно отменяется как
|
||||
просроченное: в исходный процесс уходит уведомление об отказе,
|
||||
решение закрывается. Снятие просроченных решений происходит
|
||||
автоматически по истечении срока.
|
||||
|
||||
- name: soviet::declinedec
|
||||
human: Отклонить решение
|
||||
actor: Председатель
|
||||
role: reject
|
||||
purpose: >
|
||||
Если против решения проголосовало больше половины состава совета,
|
||||
председатель явно отклоняет его, не дожидаясь истечения срока: в
|
||||
исходный процесс уходит уведомление об отказе, решение закрывается.
|
||||
Если решение не было утверждено в срок, оно отменяется как
|
||||
просроченное, в исходный контракт callback не уходит.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: soviet::decision
|
||||
@@ -153,9 +150,9 @@ states:
|
||||
- name: rejected
|
||||
human: Отклонено
|
||||
description: >
|
||||
Председатель отклонил решение по большинству «против» (declinedec) либо
|
||||
решение отменено как просроченное (cancelexprd, автоматически по сроку).
|
||||
В обоих случаях в исходный контракт уходит decline-callback.
|
||||
Совет проголосовал «против» либо решение отменено как просроченное.
|
||||
В исходный контракт уходит decline-callback (или callback
|
||||
не вызывается, если просрочка).
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -177,15 +174,15 @@ transitions:
|
||||
|
||||
- from: pending
|
||||
to: rejected
|
||||
action: soviet::declinedec
|
||||
actor: Председатель
|
||||
action: soviet::voteagainst
|
||||
actor: Член совета
|
||||
guards:
|
||||
- Большинство голосов «против».
|
||||
|
||||
- from: pending
|
||||
to: rejected
|
||||
action: soviet::cancelexprd
|
||||
actor: Кооператив
|
||||
actor: Председатель
|
||||
guards:
|
||||
- Истёк срок принятия решения.
|
||||
|
||||
@@ -226,8 +223,8 @@ scenario:
|
||||
actor: Член совета
|
||||
action: soviet::votefor
|
||||
description: >
|
||||
Члены совета голосуют по решению (votefor / voteagainst).
|
||||
При достижении большинства «за» — approved=true.
|
||||
Члены совета голосуют по решению (votefor / voteagainst /
|
||||
cancelvote). При достижении большинства «за» — approved=true.
|
||||
pre:
|
||||
- Решение в `pending`.
|
||||
post:
|
||||
@@ -262,21 +259,18 @@ scenario:
|
||||
alternatives:
|
||||
- branch: Отказ совета
|
||||
at_step: 2
|
||||
action: soviet::declinedec
|
||||
actor: Председатель
|
||||
action: soviet::voteagainst
|
||||
actor: Член совета
|
||||
description: >
|
||||
Когда против высказалось больше половины состава совета, голос «против»
|
||||
лишь фиксируется. Председатель явно отклоняет решение (declinedec), не
|
||||
дожидаясь срока: в исходный контракт уходит decline-callback (например,
|
||||
registrator::declinereg), запись решения удаляется.
|
||||
Большинство «против» — при exec в исходный контракт уйдёт
|
||||
decline-callback (например, registrator::declinereg).
|
||||
|
||||
- branch: Просрочка
|
||||
at_step: 2
|
||||
action: soviet::cancelexprd
|
||||
actor: Кооператив
|
||||
actor: Председатель
|
||||
description: >
|
||||
Решение не принято в срок — автоматически отменяется по истечении
|
||||
срока: в исходный контракт уходит decline-callback, запись удаляется.
|
||||
Решение не принято в срок, отменяется без вызова callback.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
# Документ повестки приходит из исходного процесса — конкретный registry_id
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
# • cpp/soviet/src/decision/freedecision.cpp
|
||||
# • cpp/soviet/src/vote/votefor.cpp
|
||||
# • cpp/soviet/src/vote/voteagainst.cpp
|
||||
# • cpp/soviet/src/vote/cancelvote.cpp
|
||||
# • cpp/soviet/src/decision/authorize.cpp
|
||||
# • cpp/soviet/src/decision/exec.cpp
|
||||
# • cpp/soviet/src/decision/cancelexprd.cpp
|
||||
# • cpp/soviet/src/decision/declinedec.cpp (внутри cancelexprd.cpp)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
process_type: sov.decision
|
||||
@@ -63,9 +63,17 @@ actions:
|
||||
actor: Член совета
|
||||
role: reject
|
||||
purpose: >
|
||||
Член совета голосует «против». Голос фиксируется; решение само не
|
||||
закрывается. Когда против высказалось больше половины состава совета,
|
||||
председатель снимает решение явным действием (см. «Отклонить решение»).
|
||||
Член совета голосует «против». При достижении консенсуса по отказу
|
||||
решение переходит в статус rejected; протокол не оформляется,
|
||||
исполнения не происходит.
|
||||
|
||||
- name: soviet::cancelvote
|
||||
human: Отозвать голос
|
||||
actor: Член совета
|
||||
role: progress
|
||||
purpose: >
|
||||
Член совета отзывает ранее поданный голос (за или против) до того,
|
||||
как решение перешло в финальный статус.
|
||||
|
||||
- name: soviet::authorize
|
||||
human: Утвердить протокол
|
||||
@@ -88,21 +96,11 @@ actions:
|
||||
|
||||
- name: soviet::cancelexprd
|
||||
human: Отменить просроченное
|
||||
actor: Кооператив
|
||||
actor: Член совета
|
||||
role: reject
|
||||
purpose: >
|
||||
Если решение не было утверждено в срок, оно отменяется как
|
||||
просроченное. Запись удаляется без исполнения. Снятие просроченных
|
||||
решений происходит автоматически по истечении срока.
|
||||
|
||||
- name: soviet::declinedec
|
||||
human: Отклонить решение
|
||||
actor: Председатель
|
||||
role: reject
|
||||
purpose: >
|
||||
Если против решения проголосовало больше половины состава совета,
|
||||
председатель явно отклоняет его, не дожидаясь истечения срока.
|
||||
Запись удаляется без исполнения.
|
||||
Если решение не было утверждено в срок, оно может быть отменено
|
||||
как просроченное. Запись удаляется без исполнения.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: soviet::decision
|
||||
@@ -167,15 +165,15 @@ transitions:
|
||||
|
||||
- from: created
|
||||
to: rejected
|
||||
action: soviet::declinedec
|
||||
actor: Председатель
|
||||
action: soviet::voteagainst
|
||||
actor: Член совета
|
||||
guards:
|
||||
- Большинство голосов «против».
|
||||
|
||||
- from: created
|
||||
to: rejected
|
||||
action: soviet::cancelexprd
|
||||
actor: Кооператив
|
||||
actor: Член совета
|
||||
guards:
|
||||
- Истёк срок принятия решения.
|
||||
|
||||
@@ -216,7 +214,7 @@ scenario:
|
||||
actor: Член совета
|
||||
action: soviet::votefor
|
||||
description: >
|
||||
Члены совета поочерёдно голосуют (votefor / voteagainst).
|
||||
Члены совета поочерёдно голосуют (votefor / voteagainst / cancelvote).
|
||||
При достижении большинства «за» решение помечается как approved.
|
||||
pre:
|
||||
- Решение ещё не закрыто (created).
|
||||
@@ -250,22 +248,21 @@ scenario:
|
||||
- События newresolved/newdecision разосланы.
|
||||
|
||||
alternatives:
|
||||
- branch: Отклонение советом
|
||||
- branch: Отклонение голосованием
|
||||
at_step: 2
|
||||
action: soviet::declinedec
|
||||
actor: Председатель
|
||||
action: soviet::voteagainst
|
||||
actor: Член совета
|
||||
description: >
|
||||
Когда против высказалось большинство состава совета, голос «против»
|
||||
лишь фиксируется. Председатель явно отклоняет решение (declinedec) —
|
||||
запись удаляется, протокол не оформляется.
|
||||
Большинство голосов «против» — решение помечается как rejected
|
||||
и не утверждается председателем.
|
||||
|
||||
- branch: Просрочка
|
||||
at_step: 2
|
||||
action: soviet::cancelexprd
|
||||
actor: Кооператив
|
||||
actor: Член совета
|
||||
description: >
|
||||
Если решение не было принято в срок, оно автоматически отменяется
|
||||
по истечении срока — запись удаляется как просроченная.
|
||||
Если решение не было принято в срок, любой член совета может
|
||||
вызвать cancelexprd — запись удаляется как просроченная.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
|
||||
@@ -201,7 +201,6 @@ public:
|
||||
//decisions
|
||||
[[eosio::action]] void withdraw(eosio::name coopname, eosio::name username, uint64_t withdraw_id, document2 statement);
|
||||
[[eosio::action]] void cancelexprd(eosio::name coopname, uint64_t decision_id);
|
||||
[[eosio::action]] void declinedec(eosio::name coopname, uint64_t decision_id);
|
||||
|
||||
void withdraw_effect(eosio::name executer, eosio::name coopname, uint64_t decision_id, uint64_t batch_id);
|
||||
|
||||
|
||||
@@ -9,14 +9,10 @@
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
* @note Авторизация требуется от аккаунта: @p chairman
|
||||
*/
|
||||
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
|
||||
// Авторизует кооператив (действие проводится через бэкенд ключом кооператива).
|
||||
// Согласие председателя при этом не теряется: оно подтверждается криптографически
|
||||
// его личной подписью на документе (verify_document_or_fail ниже) и проверкой
|
||||
// is_valid_chairman — без валидного подписанного председателем документа авторизация невозможна.
|
||||
require_auth(coopname);
|
||||
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
|
||||
require_auth(chairman);
|
||||
|
||||
boards_index boards(_soviet, coopname.value);
|
||||
autosigner_index autosigner(_soviet, coopname.value);
|
||||
@@ -31,9 +27,7 @@ void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t deci
|
||||
|
||||
verify_document_or_fail(document);
|
||||
|
||||
// RAM-плательщик — кооператив: транзакцию авторизует coopname (require_auth выше),
|
||||
// поэтому увеличивать RAM аккаунта председателя нельзя (он tx не подписывал).
|
||||
decisions.modify(decision, coopname, [&](auto &d){
|
||||
decisions.modify(decision, chairman, [&](auto &d){
|
||||
d.authorized_by = chairman;
|
||||
d.authorized = !decision -> authorized;
|
||||
d.authorization = document;
|
||||
|
||||
@@ -9,23 +9,30 @@
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
// Общая ветка отказа решения: при наличии полей коллбэка отправляет инициатору
|
||||
// коллбэк отклонения, затем удаляет решение из оперативной таблицы. Вызывается
|
||||
// из cancelexprd (просрочка) и voteagainst (отрицательный консенсус).
|
||||
void decline_and_erase_decision(eosio::name coopname, uint64_t decision_id, const std::string &reason) {
|
||||
void soviet::cancelexprd(eosio::name coopname, uint64_t decision_id) {
|
||||
require_auth(coopname);
|
||||
|
||||
decisions_index decisions(_soviet, coopname.value);
|
||||
auto decision = decisions.find(decision_id);
|
||||
eosio::check(decision != decisions.end(), "Решение не найдено");
|
||||
|
||||
|
||||
// Проверка истечения срока
|
||||
bool expired = true;
|
||||
if ( decision -> expired_at.has_value() ) {
|
||||
expired = (eosio::time_point_sec(eosio::current_time_point()) > decision->expired_at.value());
|
||||
}
|
||||
|
||||
eosio::check(expired, "Срок действия решения ещё не истёк");
|
||||
|
||||
// Проверка наличия всех необходимых полей для отправки коллбэка
|
||||
if (decision->callback_contract.has_value() &&
|
||||
decision->decline_callback.has_value() &&
|
||||
decision->hash.has_value() &&
|
||||
decision->callback_contract.value() != ""_n &&
|
||||
decision->confirm_callback.value() != ""_n &&
|
||||
if (decision->callback_contract.has_value() &&
|
||||
decision->decline_callback.has_value() &&
|
||||
decision->hash.has_value() &&
|
||||
decision->callback_contract.value() != ""_n &&
|
||||
decision->confirm_callback.value() != ""_n &&
|
||||
decision->decline_callback.value() != ""_n
|
||||
) {
|
||||
|
||||
|
||||
// Отправка коллбэка отклонения
|
||||
Action::send<decline_callback_interface>(
|
||||
decision->callback_contract.value(),
|
||||
@@ -34,61 +41,10 @@ void decline_and_erase_decision(eosio::name coopname, uint64_t decision_id, cons
|
||||
_soviet,
|
||||
coopname,
|
||||
decision->hash.value(),
|
||||
reason
|
||||
std::string("Отклонение по истечению срока")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Удаление решения
|
||||
decisions.erase(decision);
|
||||
}
|
||||
|
||||
void soviet::cancelexprd(eosio::name coopname, uint64_t decision_id) {
|
||||
require_auth(coopname);
|
||||
|
||||
decisions_index decisions(_soviet, coopname.value);
|
||||
auto decision = decisions.find(decision_id);
|
||||
eosio::check(decision != decisions.end(), "Решение не найдено");
|
||||
|
||||
// Проверка истечения срока
|
||||
bool expired = true;
|
||||
if ( decision -> expired_at.has_value() ) {
|
||||
expired = (eosio::time_point_sec(eosio::current_time_point()) > decision->expired_at.value());
|
||||
}
|
||||
|
||||
eosio::check(expired, "Срок действия решения ещё не истёк");
|
||||
|
||||
decline_and_erase_decision(coopname, decision_id, std::string("Отклонение по истечению срока"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Явное отклонение решения советом по отрицательному консенсусу
|
||||
* Проводится председателем (ключом кооператива), когда против решения
|
||||
* проголосовало большинство состава совета, до истечения срока. Развязано с
|
||||
* cancelexprd: просрочка гасится автоматически по сроку, а отрицательно принятое
|
||||
* решение снимается с повестки только этим явным действием.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param decision_id Идентификатор решения для отклонения
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
void soviet::declinedec(eosio::name coopname, uint64_t decision_id) {
|
||||
require_auth(coopname);
|
||||
|
||||
decisions_index decisions(_soviet, coopname.value);
|
||||
auto decision = decisions.find(decision_id);
|
||||
eosio::check(decision != decisions.end(), "Решение не найдено");
|
||||
|
||||
// Отрицательный консенсус: против должно высказаться больше половины состава
|
||||
// совета (порог зеркален votefor — 50%, строгое «больше»).
|
||||
auto board = get_board_by_type_or_fail(coopname, "soviet"_n);
|
||||
auto [votes_for_count, votes_against_count] = decision->get_votes_count();
|
||||
uint64_t total_members = board.get_members_count();
|
||||
uint64_t consensus_percent = 50;
|
||||
|
||||
eosio::check(votes_against_count * 100 > total_members * consensus_percent,
|
||||
"Решение не отклонено советом: нет большинства голосов против");
|
||||
|
||||
decline_and_erase_decision(coopname, decision_id, std::string("Отклонено советом: большинство голосов против"));
|
||||
}
|
||||
@@ -10,14 +10,11 @@
|
||||
* @param coopname Имя кооператива
|
||||
* @param decision_id Идентификатор решения для исполнения
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
* @note Авторизация требуется от аккаунта: @p executer
|
||||
*/
|
||||
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
|
||||
// Исполнение проводится через бэкенд ключом кооператива. Параметр executer остаётся
|
||||
// в payload как аудит-метка (кто инициировал исполнение) и не используется для авторизации
|
||||
// inline-эффектов — те выполняются под собственными правами контракта (_soviet/_gateway/_fund).
|
||||
require_auth(coopname);
|
||||
|
||||
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
|
||||
require_auth(executer);
|
||||
|
||||
decisions_index decisions(_soviet, coopname.value);
|
||||
auto decision = decisions.find(decision_id);
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
|
||||
row.type = "user"_n;
|
||||
row.coopname = coopname;
|
||||
row.program_id = 0;
|
||||
row.draft_id = 4;
|
||||
row.draft_id = 3;
|
||||
});
|
||||
|
||||
//создаём соглашение для шаблона политики конфиденциальности
|
||||
@@ -132,7 +132,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
|
||||
row.type = "privacy"_n;
|
||||
row.coopname = coopname;
|
||||
row.program_id = 0;
|
||||
row.draft_id = 3;
|
||||
row.draft_id = 4;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#include <array>
|
||||
|
||||
/**
|
||||
* @brief Миграция данных системы
|
||||
* Выполняет миграцию данных системы при обновлении контракта.
|
||||
@@ -22,46 +20,6 @@ void soviet::migrate() {
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Разовая очистка ФАНТОМНЫХ участников кооператива fgrtejiwnynn (инцидент
|
||||
// 2026-06-04). Удаляем строки soviet::participants для 6 дублей-аккаунтов,
|
||||
// оставшихся на цепи после 3-кратного прогона install.interactor (см.
|
||||
// ~/gorozhane-dup-install-cleanup.md). Эти участники не голосовали и не
|
||||
// входят в совет (createboard для них не выполнялся).
|
||||
//
|
||||
// Список username синхронизирован с registrator::migrate и ledger2::migrate.
|
||||
// Дедуп-гард: удаляем фантомов ТОЛЬКО если в коопе остаётся хотя бы один
|
||||
// НЕ-фантомный участник (никогда не опустошаем реестр). Идемпотентно.
|
||||
{
|
||||
const eosio::name PHANTOM_COOP = "fgrtejiwnynn"_n;
|
||||
const std::array<eosio::name, 6> PHANTOMS = {
|
||||
"bbsezpgufvmm"_n, "errwcgjwverm"_n, "hcfsluqsfehw"_n,
|
||||
"kgkzdadfpzki"_n, "nzuyijobapsv"_n, "tplwfwbujugq"_n,
|
||||
};
|
||||
|
||||
auto is_phantom = [&](eosio::name u) {
|
||||
for (const auto& p : PHANTOMS) if (p == u) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
participants_index participants(_soviet, PHANTOM_COOP.value);
|
||||
|
||||
// Гарантируем, что после удаления останется ≥1 реальный участник.
|
||||
uint64_t real_participants = 0;
|
||||
for (auto it = participants.begin(); it != participants.end(); ++it) {
|
||||
if (!is_phantom(it->username)) real_participants++;
|
||||
}
|
||||
|
||||
if (real_participants > 0) {
|
||||
for (const auto& u : PHANTOMS) {
|
||||
auto it = participants.find(u.value);
|
||||
if (it != participants.end()) {
|
||||
participants.erase(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Старый код миграции (закомментирован)
|
||||
// cooperatives2_index coops(_registrator, _registrator.value);
|
||||
|
||||
@@ -60,12 +60,8 @@ void soviet::voteagainst(
|
||||
auto board = get_board_by_type_or_fail(coopname, "soviet"_n);
|
||||
eosio::check(board.is_voting_member(username), "У вас нет права голоса");
|
||||
|
||||
decision -> check_for_any_vote_exist(username);
|
||||
decision -> check_for_any_vote_exist(username);
|
||||
|
||||
// Голос «против» только фиксируется. Отрицательный консенсус НЕ затирает
|
||||
// решение автоматически: отказ при достигнутом большинстве «против» проводит
|
||||
// председатель явным действием declinedec (развязано с авто-отменой по сроку
|
||||
// cancelexprd). Так принятое отрицательно решение не исчезает само из повестки.
|
||||
add_vote_against(coopname, username, decision->id);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ add_contract(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/powerup.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/voting.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/limit_auth_changes.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/block_info.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/finalizer_key.cpp)
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/block_info.cpp)
|
||||
|
||||
if(IS_TESTNET)
|
||||
target_compile_definitions(eosio.system PUBLIC IS_TESTNET=1)
|
||||
|
||||
-41
@@ -511,32 +511,6 @@ namespace eosiosystem {
|
||||
indexed_by<"byexpires"_n, const_mem_fun<powerup_order, uint64_t, &powerup_order::by_expires>>
|
||||
> powerup_order_table;
|
||||
|
||||
/**
|
||||
* Параметры финализаторов Savanna (порт setfinalizer из upstream eosio.bios).
|
||||
* Этап 1 (bootstrap): политику финализаторов задаёт оператор напрямую.
|
||||
* Этап 2 — выборная машинерия финализаторов под кооперативную модель
|
||||
* голосования (1 пайщик = 1 голос); upstream-вариант regfinkey/switchtosvnn
|
||||
* (top-21 по stake-весу) нашей модели не соответствует и потому не переносится как есть.
|
||||
*/
|
||||
constexpr size_t max_finalizers = 64*1024;
|
||||
constexpr size_t max_finalizer_description_size = 256;
|
||||
|
||||
struct finalizer_authority {
|
||||
std::string description; // человеко-читаемое описание финализатора
|
||||
uint64_t weight = 0; // вес голоса финализатора для достижения threshold
|
||||
std::string public_key; // BLS-ключ финализатора в формате base64 (PUB_BLS...)
|
||||
std::string pop; // proof of possession ключа (SIG_BLS...)
|
||||
|
||||
EOSLIB_SERIALIZE(finalizer_authority, (description)(weight)(public_key)(pop))
|
||||
};
|
||||
|
||||
struct finalizer_policy {
|
||||
uint64_t threshold = 0; // порог суммарного веса для финализации
|
||||
std::vector<finalizer_authority> finalizers;
|
||||
|
||||
EOSLIB_SERIALIZE(finalizer_policy, (threshold)(finalizers));
|
||||
};
|
||||
|
||||
/**
|
||||
* The `eosio.system` smart contract is provided by `block.one` as a sample system contract, and it defines the structures and actions needed for blockchain's core functionality.
|
||||
*
|
||||
@@ -642,21 +616,6 @@ namespace eosiosystem {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @brief Предложить новую политику финализаторов Savanna.
|
||||
* Проверяет BLS-ключи и proof of possession каждого финализатора и
|
||||
* передаёт политику в хост-функцию set_finalizers. Вступает в силу,
|
||||
* когда не вытеснена более поздней политикой. Первый вызов после
|
||||
* активации протокол-фичи SAVANNA запускает переход цепи на
|
||||
* instant finality.
|
||||
*
|
||||
* @param finalizer_policy - предлагаемая политика финализаторов
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p get_self()
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void setfinalizer( const finalizer_policy& finalizer_policy );
|
||||
|
||||
[[eosio::action]]
|
||||
void onblock( ignore<block_header> header );
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
#include <eosio.system/eosio.system.hpp>
|
||||
|
||||
#include <eosio/crypto_bls_ext.hpp>
|
||||
#include <eosio/instant_finality.hpp>
|
||||
|
||||
#include <cstring>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace eosiosystem {
|
||||
|
||||
// Порт setfinalizer из upstream eosio.bios (reference-contracts):
|
||||
// все дорогие проверки выполняются здесь, чтобы хост-функция
|
||||
// set_finalizers гарантированно не упала.
|
||||
// Это bootstrap-механизм этапа 1 (политика от оператора); выборная
|
||||
// машинерия финализаторов под кооперативное голосование — этап 2.
|
||||
void system_contract::setfinalizer( const finalizer_policy& finalizer_policy ) {
|
||||
require_auth( get_self() );
|
||||
|
||||
check(finalizer_policy.finalizers.size() <= max_finalizers, "number of finalizers exceeds the maximum allowed");
|
||||
check(finalizer_policy.finalizers.size() > 0, "require at least one finalizer");
|
||||
|
||||
eosio::finalizer_policy fin_policy;
|
||||
fin_policy.threshold = finalizer_policy.threshold;
|
||||
fin_policy.finalizers.reserve(finalizer_policy.finalizers.size());
|
||||
|
||||
const std::string pk_prefix = "PUB_BLS";
|
||||
const std::string sig_prefix = "SIG_BLS";
|
||||
|
||||
// сырой affine-формат (bls_g1 = std::array<char, 96>) для проверки уникальности
|
||||
struct g1_hash {
|
||||
std::size_t operator()(const eosio::bls_g1& g1) const {
|
||||
std::hash<const char*> hash_func;
|
||||
return hash_func(g1.data());
|
||||
}
|
||||
};
|
||||
struct g1_equal {
|
||||
bool operator()(const eosio::bls_g1& lhs, const eosio::bls_g1& rhs) const {
|
||||
return std::memcmp(lhs.data(), rhs.data(), lhs.size()) == 0;
|
||||
}
|
||||
};
|
||||
std::unordered_set<eosio::bls_g1, g1_hash, g1_equal> unique_finalizer_keys;
|
||||
|
||||
uint64_t weight_sum = 0;
|
||||
|
||||
for (const auto& f: finalizer_policy.finalizers) {
|
||||
check(f.description.size() <= max_finalizer_description_size, "Finalizer description greater than max allowed size");
|
||||
|
||||
// базовые проверки формата ключа
|
||||
check(f.public_key.substr(0, pk_prefix.length()) == pk_prefix, "public key shoud start with PUB_BLS");
|
||||
check(f.pop.substr(0, sig_prefix.length()) == sig_prefix, "proof of possession signature should start with SIG_BLS");
|
||||
|
||||
// защита от переполнения суммы весов
|
||||
check(std::numeric_limits<uint64_t>::max() - weight_sum >= f.weight, "sum of weights causes uint64_t overflow");
|
||||
weight_sum += f.weight;
|
||||
|
||||
// decode_bls_public_key_to_g1 сам падает (check) на невалидном ключе
|
||||
const auto pk = eosio::decode_bls_public_key_to_g1(f.public_key);
|
||||
check(unique_finalizer_keys.insert(pk).second, "duplicate public key");
|
||||
|
||||
const auto signature = eosio::decode_bls_signature_to_g2(f.pop);
|
||||
|
||||
// проверка владения приватным ключом
|
||||
check(eosio::bls_pop_verify(pk, signature), "proof of possession failed");
|
||||
|
||||
std::vector<char> pk_vector(pk.begin(), pk.end());
|
||||
fin_policy.finalizers.emplace_back(eosio::finalizer_authority{f.description, f.weight, std::move(pk_vector)});
|
||||
}
|
||||
|
||||
check( weight_sum >= finalizer_policy.threshold && finalizer_policy.threshold > weight_sum / 2,
|
||||
"Finalizer policy threshold must be greater than half of the sum of the weights, and less than or equal to the sum of the weights");
|
||||
|
||||
set_finalizers(std::move(fin_policy));
|
||||
}
|
||||
|
||||
} /// namespace eosiosystem
|
||||
@@ -3,11 +3,10 @@
|
||||
# программу «Цифровой Кошелёк» (SHARE_FUND_PAY).
|
||||
#
|
||||
# Четырёхактовый процесс с одной авторизацией советом. Запись заявки живёт в
|
||||
# таблице `withdraws` от создания до выплаты или отказа. На подаче заявки сумма
|
||||
# переводится в кошелёк-резерв (WITHDRAW_PENDING, w.wal.wpend). На закрывающем
|
||||
# действии completewthd срабатывает операция o.wal.wthcpl — сжигание суммы из
|
||||
# резерва (BURN w.wal.wpend): деньги уходят из системы пайщику банковским
|
||||
# переводом, получателя на цепи нет.
|
||||
# таблице `withdraws` от создания до выплаты или отказа. На закрывающем
|
||||
# действии completewthd срабатывает одна операция o.wal.wthcpl — сжигание
|
||||
# заблокированной суммы (BURN_BLOCKED w.wal.share): деньги уходят из системы
|
||||
# пайщику банковским переводом, получателя на цепи нет.
|
||||
#
|
||||
# Источники правды в коде:
|
||||
# • cpp/wallet/wallet.hpp — actions
|
||||
@@ -44,8 +43,8 @@ actions:
|
||||
actor: contributor
|
||||
role: opener
|
||||
purpose: >
|
||||
Пайщик создаёт заявку на возврат паевого взноса. Контракт переводит
|
||||
сумму с кошелька SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING и
|
||||
Пайщик создаёт заявку на возврат паевого взноса. Контракт блокирует
|
||||
сумму на кошельке SHARE_FUND_PAY (статус available → blocked) и
|
||||
создаёт повестку в совете о возврате с callback'ом авторизации.
|
||||
- name: wallet::authwthd
|
||||
human: Авторизовать выплату
|
||||
@@ -61,17 +60,16 @@ actions:
|
||||
role: closer
|
||||
purpose: >
|
||||
Gateway подтвердил списание средств в пользу пайщика. Это закрывающее
|
||||
действие: применяется операция o.wal.wthcpl (BURN w.wal.wpend)
|
||||
— сумма из резерва сжигается, в бухгалтерии проходит обратная
|
||||
действие: применяется операция o.wal.wthcpl (BURN_BLOCKED w.wal.share)
|
||||
— заблокированная сумма сжигается, в бухгалтерии проходит обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удаляется.
|
||||
- name: wallet::declinewthd
|
||||
human: Отклонить
|
||||
actor: soviet
|
||||
role: reject
|
||||
purpose: >
|
||||
Отказ на любом из этапов до выплаты. Сумма возвращается из резерва на
|
||||
кошелёк пайщика (o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY),
|
||||
запись заявки удаляется.
|
||||
Отказ на любом из этапов до выплаты. Сумма разблокируется на кошельке
|
||||
пайщика (UNBLOCK через o.wal.wthdec), запись заявки удаляется.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: wallet::withdraw
|
||||
@@ -82,8 +80,8 @@ states:
|
||||
- name: pending
|
||||
human: Ожидает решения совета
|
||||
description: >
|
||||
Заявка создана, сумма переведена в кошелёк-резерв (SHARE_FUND_PAY →
|
||||
WITHDRAW_PENDING), повестка отправлена в совет.
|
||||
Заявка создана, сумма заблокирована на кошельке пайщика (available → blocked),
|
||||
повестка отправлена в совет.
|
||||
kind: normal
|
||||
- name: authorized
|
||||
human: Выплата отправлена
|
||||
@@ -94,15 +92,15 @@ states:
|
||||
- name: completed
|
||||
human: Возврат выплачен
|
||||
description: >
|
||||
Gateway подтвердил списание, сумма из резерва сжигается
|
||||
(BURN w.wal.wpend). В бухгалтерии прошла обратная
|
||||
Gateway подтвердил списание, заблокированная сумма сжигается на кошельке
|
||||
пайщика (BURN_BLOCKED w.wal.share). В бухгалтерии прошла обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удалена.
|
||||
kind: final
|
||||
- name: removed
|
||||
human: Отклонено
|
||||
description: >
|
||||
Возврат не состоялся: сумма возвращена из резерва на кошелёк пайщика
|
||||
(o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
|
||||
Возврат не состоялся: сумма разблокирована (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -174,7 +172,7 @@ scenario:
|
||||
- Сумма возврата ≤ доступного остатка.
|
||||
post:
|
||||
- В таблице withdraws создана запись со статусом `pending`.
|
||||
- Сумма переведена с SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING.
|
||||
- На кошельке пайщика сумма переведена available → blocked.
|
||||
- Совет получил повестку о возврате.
|
||||
|
||||
- step: 2
|
||||
@@ -199,13 +197,13 @@ scenario:
|
||||
action: wallet::completewthd
|
||||
description: >
|
||||
Gateway подтверждает списание средств в пользу пайщика. Контракт
|
||||
применяет ledger2-операцию o.wal.wthcpl (BURN w.wal.wpend с
|
||||
применяет ledger2-операцию o.wal.wthcpl (BURN_BLOCKED w.wal.share с
|
||||
обратной проводкой Дт 80 / Кт 51) и удаляет запись заявки.
|
||||
pre:
|
||||
- Заявка в статусе `authorized`.
|
||||
- Gateway подтвердил списание.
|
||||
post:
|
||||
- Сумма из резерва сожжена (WITHDRAW_PENDING, w.wal.wpend).
|
||||
- Заблокированная сумма сожжена с SHARE_FUND_PAY (w.wal.share).
|
||||
- В ledger2 применена операция o.wal.wthcpl.
|
||||
- Запись заявки удалена.
|
||||
|
||||
@@ -216,8 +214,8 @@ scenario:
|
||||
actor: soviet
|
||||
description: >
|
||||
Совет принял отрицательное решение либо Gateway отклонил платёж.
|
||||
Сумма возвращена из резерва на кошелёк пайщика (o.wal.wthdec:
|
||||
WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
|
||||
Сумма разблокирована на кошельке пайщика (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
@@ -236,45 +234,45 @@ documents:
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
operations:
|
||||
- ledger_code: o.wal.wthreq
|
||||
human_name: Резервирование паевого под запрос на возврат
|
||||
wallet_op: TRANSFER
|
||||
human_name: Блокировка паевого под запрос на возврат
|
||||
wallet_op: BLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: w.wal.wpend
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::createwthd
|
||||
description: >
|
||||
На создании заявки контракт переводит сумму с кошелька пайщика
|
||||
(SHARE_FUND_PAY) в кошелёк-резерв (WITHDRAW_PENDING). Бухгалтерская
|
||||
проводка не создаётся — движение внутри одного бухсчёта 80.
|
||||
На создании заявки контракт блокирует сумму на кошельке пайщика
|
||||
(SHARE_FUND_PAY): available → blocked. Бухгалтерская проводка не
|
||||
создаётся — движение внутри одного бухсчёта 80.
|
||||
|
||||
- ledger_code: o.wal.wthdec
|
||||
human_name: Снятие резерва паевого после отклонения запроса на возврат
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.wal.wpend
|
||||
wallet_to: w.wal.share
|
||||
human_name: Разблокировка паевого после отклонения запроса на возврат
|
||||
wallet_op: UNBLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::declinewthd
|
||||
description: >
|
||||
Зеркало REQUEST_WITHDRAW: сумма возвращается из резерва (WITHDRAW_PENDING)
|
||||
на кошелёк пайщика (SHARE_FUND_PAY). Бухгалтерская проводка не создаётся.
|
||||
Зеркало REQUEST_WITHDRAW: blocked → available на кошельке пайщика.
|
||||
Бухгалтерская проводка не создаётся.
|
||||
|
||||
- ledger_code: o.wal.wthcpl
|
||||
human_name: Возврат паевого взноса пайщику
|
||||
wallet_op: BURN
|
||||
wallet_from: w.wal.wpend # кошелёк-резерв возвратов
|
||||
wallet_op: BURN_BLOCKED
|
||||
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
|
||||
wallet_to: ~ # деньги уходят из системы, получателя на цепи нет
|
||||
debit: 80 # Паевой фонд (складочный капитал)
|
||||
credit: 51 # Расчётный счёт
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::completewthd
|
||||
description: >
|
||||
Обратная операция к o.wal.depcpl: зарезервированная сумма сжигается с
|
||||
кошелька-резерва (w.wal.wpend) — деньги уходят из системы банковским
|
||||
переводом пайщику. На цепи получателя нет.
|
||||
Обратная операция к o.wal.depcpl: заблокированная сумма сжигается с
|
||||
кошелька пайщика «ЦПП Цифровой Кошелёк» (w.wal.share) — деньги уходят
|
||||
из системы банковским переводом пайщику. На цепи получателя нет.
|
||||
В бухгалтерии прошла проводка Дт 80 / Кт 51 — паевой фонд уменьшился,
|
||||
деньги ушли с расчётного счёта пайщику.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -9,6 +9,9 @@
|
||||
"build:one": "./build.sh $1",
|
||||
"build:one:test": "contract=$1 mode=test ./build.sh",
|
||||
"enter": "./boot/scripts/enter.sh",
|
||||
"testnet": "git checkout testnet && git merge dev && lerna publish prerelease && git push && git checkout dev && git merge testnet",
|
||||
"production": "git checkout main && git merge -X theirs testnet && lerna publish --conventional-commits --conventional-graduate && git push && git checkout dev && git merge main",
|
||||
"docs-publish": "pnpm gh-pages --nojekyll -d docs/site --repo git@github.com:coopenomics/monocoop.git",
|
||||
"standards:dev": "pnpm --filter @coopenomics/standards-site dev",
|
||||
"standards:build": "pnpm --filter @coopenomics/standards-site build",
|
||||
"standards:preview": "pnpm --filter @coopenomics/standards-site preview"
|
||||
|
||||
@@ -71,6 +71,12 @@ SENTRY_DSN=
|
||||
GITHUB_TOKEN=
|
||||
|
||||
|
||||
# OpenSearch (поисковая система документов, опционально)
|
||||
OPENSEARCH_NODE=https://opensearch:9200
|
||||
OPENSEARCH_ENABLED=false
|
||||
OPENSEARCH_USERNAME=admin
|
||||
OPENSEARCH_PASSWORD=
|
||||
|
||||
LIVEKIT_URL=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
# Convert text file line endings to lf
|
||||
* text eol=lf
|
||||
*.js text
|
||||
|
||||
# Бинарные шрифты — без EOL-нормализации (перекрывает "* text eol=lf")
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
@@ -472,7 +472,7 @@ BaseExtModule
|
||||
- `ChairmanBlockchainAdapter` — взаимодействие с контрактом `soviet`
|
||||
- **Резолверы**: `ApprovalResolver`, `ChairmanOnboardingResolver`
|
||||
- **Крон**: Проверка решений каждые N минут (`checkInterval`)
|
||||
- **Конфигурация**: `checkInterval`, `onboarding_*` — флаги прохождения мастера настройки
|
||||
- **Конфигурация**: `checkInterval`, `cancelApprovedDecisions`, `onboarding_*` — флаги прохождения мастера настройки
|
||||
|
||||
### `capital` — Благорост (целевая программа)
|
||||
- **Структура**: `domain/`, `infrastructure/`, `application/` — самое крупное расширение
|
||||
|
||||
@@ -58,6 +58,7 @@ _Критичные правила и паттерны для AI-агентов
|
||||
### Language-Specific (TypeScript)
|
||||
|
||||
- **Bigint из PostgreSQL приходит как STRING.** Всегда `Number(blockNum) < Number(currentBlockNum)`, никогда не полагаться на `<`/`>` для `bigint`-колонок напрямую.
|
||||
- **block_num — разнобой типов в текущей кодовой базе (Story 4.3 audit).** `BaseTypeormEntity` (capital + shared) использует `@Column({ type: 'integer' }) block_num!: number` (PG возвращает number — корректно). Инфраструктурные entity (`ActionEntity`, `DeltaEntity`, `ForkEntity`, `SyncStateEntity`) используют `@Column({ type: 'bigint' }) block_num!: number` — **type-mismatch** (runtime будет string, TS говорит number). `ConsumerDedupEntity` — `bigint` + `string | null` (корректно). Это технический долг (Epic 9 backlog: bigint Transformer). Hot-path везде явно делает `Number()` либо использует TypeORM bind, так что runtime safe. При добавлении нового блокчейн-зеркала — выбрать одно из: (a) `integer` + `number`; (b) `bigint` + Transformer возвращающий `number`; (c) `bigint` + `string` с явным `Number()` в кодe. НЕ `bigint` + declaration `number` без Transformer.
|
||||
- **`Object.assign(this, blockchainData)` запрещено** в sync-сущностях — ломает типизацию. Только явное копирование полей.
|
||||
- **Lowercase hash полей (`project_hash`, `listing_hash`)** — нормализация **в конструкторе/mapValue**, НЕ в mapper'ах и НЕ повторно в `updateFromBlockchain`.
|
||||
- **Discriminated union для write-mutation response:** `{ status: 'applied' | 'pending' | 'failed' | 'conflict' }`. Клиент должен switch на статусе.
|
||||
@@ -76,6 +77,8 @@ _Критичные правила и паттерны для AI-агентов
|
||||
- `@Column({ type: 'bigint', nullable: true })` для `block_num`. Для `jsonb` — `@Column({ type: 'jsonb' })`.
|
||||
- `ADD COLUMN NOT NULL` на больших таблицах — **двухэтапно**: ADD nullable → backfill → ALTER NOT NULL.
|
||||
- Repository `extends BaseBlockchainRepository<DomainEntity, TypeormEntity>`. `findBySyncKey`, `createIfNotExists`, `deleteByBlockNumGreaterThan`, `restoreFromVersions` — **наследуются**, не реализовывать руками.
|
||||
- **Контракт «entity с block_num → repo extends BaseBlockchainRepository» (Story 4.3).** Любая `*.typeorm-entity.ts` extends `BaseTypeormEntity` ОБЯЗАНА иметь репозиторий extends `BaseBlockchainRepository` — иначе `entity_versions` не пишется (silent), а форк-rollback превращается в hard delete без восстановления. CI grep-guard: `tests/unit/blockchain/base-blockchain-repository.contract.test.ts`. Allowlist для 5 off-chain entity (`comment`, `cycle`, `issue`, `story`, `time-entry` — vestigial block_num, не блокчейн-зеркала). Новое исключение в allowlist — только с обоснованием в audit-report.
|
||||
- **Архив форка вместо hard-delete (Story 4.4).** `handleFork(N, eventId?)` НЕ удаляет live-ряды и снимки версий — переносит в `invalidated_entities` / `invalidated_entity_versions` (атомарно через DataSource.transaction). Порядок: archiveInvalidatedSince → restoreFromVersions → archiveInvalidatedVersionsSince. `fork_event_id` группирует записи одного форка (для forensic). Retention — `BlockchainArchiveRetentionService` ежечасно удаляет архив старше `LIB - 1000` блоков. LIB читается через `BlockchainService.getInfo()` (вариант C, RPC `/v1/chain/get_info`). Окно `RETENTION_HORIZON_BLOCKS = 1000` ХАРДКОД (свойство сети, не оператора). Env-переключатели: `BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED` (default true), `BLOCKCHAIN_ARCHIVE_RETENTION_CRON` (default `0 * * * *`).
|
||||
|
||||
**parser2 integration:**
|
||||
- `ParserClient` subscribe с `subscriptionId = "controller-${coopname}"`, `consumerName = "primary"` (детерминирован), `startFromBlock: 'last_known'`.
|
||||
@@ -87,14 +90,16 @@ _Критичные правила и паттерны для AI-агентов
|
||||
- `{contract}{Entity}Updated` / `Deleted` / `RolledBack` / `PendingRetry` / `Failed` — **pubsub канал** для GraphQL subscriptions. Per-contract, не global.
|
||||
- `entitysynced::{contract}::{table}` — для business-side-effect listeners (Matrix / Notification / и т.п.).
|
||||
|
||||
### Composite-Entity (ADR-008) — СТРОГО
|
||||
### Composite-Entity (ADR-008) — будущая цель, не сейчас
|
||||
|
||||
- Namespaced: `entity.db.X` (DB-поля) / `entity.bc?.Y` (blockchain, nullable) / `entity.derived.Z` (computed getters).
|
||||
- **НЕ** писать `entity.X` напрямую — ломает изоляцию.
|
||||
- Конструктор `(databaseData, blockchainData?)` — обязан `throw` на sync-key mismatch.
|
||||
- `updateFromBlockchain` возвращает **новый экземпляр** (immutable) или мутирует только `this.bc`, `this.block_num`, `this.present` — БЕЗ `Object.assign`.
|
||||
- `derived` getter — детерминирован (NO `new Date()` в конструкторе / getter — ломает snapshot tests).
|
||||
- Все signed-document поля нормализуются через `AbstractDeltaMapper.normalizeSignedDocuments` на основе `signedDocumentFields: SignedDocField[]` декларативно, НЕ руками в mapper.
|
||||
ADR-008 описывает целевой паттерн `entity.db.X` / `entity.bc?.Y` / `entity.derived.Z`, заменяющий
|
||||
`Object.assign(this, blockchainData)`. Переход вынесен за пределы MVP-релиза parser2 в отдельный
|
||||
sync-arch sanitation-эпик: blast radius на 22 entity + потребители «плоских» полей в resolver'ах
|
||||
делают эту миграцию большой и рискованной задачей, несвязанной с заменой транспорта parser1→parser2.
|
||||
|
||||
До отдельного эпика — текущий код продолжает использовать flat-namespace + `Object.assign` в
|
||||
`updateFromBlockchain`. Не вводить namespace частично на одной entity — двойной канон хуже единого
|
||||
старого.
|
||||
|
||||
### Dispatch pipeline (ADR-002, ADR-009) — СТРОГО
|
||||
|
||||
@@ -216,7 +221,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
- `BLOCKCHAIN_RECONCILE_CRON` default `'0 * * * *'`
|
||||
- `BLOCKCHAIN_RECONCILE_SAMPLE_SIZE` default 100
|
||||
- `BLOCKCHAIN_RECONCILE_TOLERANCE_BLOCKS` default 10
|
||||
- `BLOCKCHAIN_FORK_PAUSE_TIMEOUT_MS` default 30000
|
||||
- `BLOCKCHAIN_DLQ_MAX_RETRIES` default 5
|
||||
- `BLOCKCHAIN_MAX_TX_RETRIES` default 3
|
||||
- `BLOCKCHAIN_PENDING_TX_MAX_AGE_SECONDS` default 3600
|
||||
@@ -251,12 +255,13 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
- `await capitalBlockchainPort.getProject(hash)` в resolver / application — **retire**. Только `repository.findBySyncKey`.
|
||||
- RPC fallback "если PG не отдал" — **запрещено**. Если PG null → pending status наверх.
|
||||
|
||||
### ❌ Composite-entity anti-patterns
|
||||
### ❌ Domain-entity anti-patterns
|
||||
|
||||
- `project.matrix_room_id` (flat access) — **запрещено**. Правильно: `project.db.matrix_room_id`.
|
||||
- `project.master` (flat access) — **запрещено**. Правильно: `project.bc?.master`.
|
||||
- `Object.assign(this, blockchainData)` в update — **запрещено**.
|
||||
- Новый `new Date()` в конструкторе или derived-getter — **запрещено** (ломает snapshot-tests).
|
||||
- Миграция на namespace `entity.db.X` / `entity.bc?.Y` — целевой паттерн ADR-008, но отложен
|
||||
в отдельный sync-arch sanitation-эпик: текущий код всё ещё использует flat-namespace +
|
||||
`Object.assign(this, blockchainData)` в `updateFromBlockchain`. Не разводить два стандарта
|
||||
частично.
|
||||
|
||||
### ❌ Fork handling anti-patterns
|
||||
|
||||
@@ -296,7 +301,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- `JSON.stringify` на сущностях с nested signed-documents — использовать `json-stable-stringify` для canonical checksum.
|
||||
- Reconciliation cron — sample N=100 rows, **не** full scan на hot path.
|
||||
- IPFS fetch для signed-doc — **lazy resolver вне consumer critical path**, не в mapper.
|
||||
- `waitForDelta` memory leak — timer cleanup обязателен (см. INV-T10).
|
||||
|
||||
@@ -27,19 +27,11 @@ RUN apt-get update && apt-get install -y \
|
||||
libharfbuzz-dev \
|
||||
libcairo2 \
|
||||
libpango1.0-dev \
|
||||
fontconfig \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& python3 -m venv /venv \
|
||||
&& /venv/bin/pip install WeasyPrint==67 \
|
||||
&& rm -rf /root/.cache/pip
|
||||
|
||||
# Шрифт Arial устанавливаем СИСТЕМНО (а не встраиваем base64 в каждый html).
|
||||
# Иначе weasyprint парсил бы ~270 КБ TTF на каждый рендер (≈1.3 сек/документ).
|
||||
# Системный шрифт fontconfig парсит один раз и кеширует. Файл тот же —
|
||||
# глифы/вёрстка идентичны прежним.
|
||||
COPY assets/fonts/arial.ttf /usr/share/fonts/truetype/arial/arial.ttf
|
||||
RUN fc-cache -f && fc-match Arial
|
||||
|
||||
# Установка пути к виртуальному окружению в PATH
|
||||
ENV PATH="/venv/bin:$PATH"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"watch": ["./src"],
|
||||
"ext": "ts",
|
||||
"delay": 2000,
|
||||
"signal": "SIGTERM"
|
||||
"ext": "ts"
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.5.23-4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.5.23-4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.6.9-18",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
@@ -76,6 +76,7 @@
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
"@coopenomics/parser2": "^1.2.0",
|
||||
"@coopenomics/provider-client": "2025.11.12-alpha-1",
|
||||
"@coopenomics/sdk": "workspace:*",
|
||||
"@graphql-codegen/typescript-graphql-request": "^6.2.0",
|
||||
@@ -97,7 +98,9 @@
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@nestjs/throttler": "^6.3.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"@novu/api": "^1.4.0",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opensearch-project/opensearch": "^3.5.1",
|
||||
"@sentry/nestjs": "^10.32.1",
|
||||
"@sentry/node": "^10.32.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
@@ -191,12 +194,12 @@
|
||||
"@antfu/eslint-config": "^2.24.1",
|
||||
"@graphql-codegen/cli": "^5.0.3",
|
||||
"@graphql-codegen/typescript": "^4.1.1",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@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",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.26",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,14 @@ import { GraphqlModule } from './infrastructure/graphql/graphql.module';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import config from '~/config/config';
|
||||
import { BlockchainModule } from './infrastructure/blockchain/blockchain.module';
|
||||
import { ForkRegistryModule } from './shared/sync/fork';
|
||||
import { GeneratorInfrastructureModule } from './infrastructure/generator/generator.module';
|
||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { NovuModule } from './infrastructure/novu/novu.module';
|
||||
import { EventsInfrastructureModule } from './infrastructure/events/events.module';
|
||||
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
|
||||
@@ -35,6 +38,7 @@ import { DesktopDomainModule } from './domain/desktop/desktop-domain.module';
|
||||
import { MeetDomainModule } from './domain/meet/meet-domain.module';
|
||||
import { GatewayDomainModule } from './domain/gateway/gateway-domain.module';
|
||||
import { VaultDomainModule } from './domain/vault/vault-domain.module';
|
||||
import { NotificationDomainModule } from './domain/notification/notification-domain.module';
|
||||
import { LedgerDomainModule } from './domain/ledger/ledger-domain.module';
|
||||
import { ProcessRegistryDomainModule } from './domain/process-registry/process-registry-domain.module';
|
||||
import { ParserDomainModule } from './domain/parser/parser-domain.module';
|
||||
@@ -56,7 +60,6 @@ import { QueueModule } from './application/queue/queue-app.module';
|
||||
import { DocumentModule } from './application/document/document.module';
|
||||
import { RedisAppModule } from './application/redis/redis-app.module';
|
||||
import { DecisionModule } from './application/free-decision/decision.module';
|
||||
import { DecisionAuthorizeModule } from './application/decision/decision.module';
|
||||
import { AgreementModule } from './application/agreement/agreement.module';
|
||||
import { ParticipantModule } from './application/participant/participant.module';
|
||||
import { AgendaModule } from './application/agenda/agenda.module';
|
||||
@@ -66,7 +69,6 @@ import { MeetModule } from './application/meet/meet.module';
|
||||
import { GatewayModule } from './application/gateway/gateway.module';
|
||||
import { WalletModule } from './application/wallet/wallet.module';
|
||||
import { NotificationModule } from './application/notification/notification.module';
|
||||
import { NotificationCenterModule } from './application/notification-center/notification-center.module';
|
||||
import { LedgerModule } from './application/ledger/ledger.module';
|
||||
import { Ledger2Module } from './application/ledger2/ledger2.module';
|
||||
import { ProcessRegistryModule } from './application/process-registry/process-registry.module';
|
||||
@@ -78,7 +80,6 @@ import { SettingsApplicationModule } from './application/settings/settings.modul
|
||||
import { RegistrationModule } from './application/registration/registration.module';
|
||||
import { OnboardingApplicationModule } from './application/onboarding/onboarding-application.module';
|
||||
import { SearchModule } from './application/search/search.module';
|
||||
import { SignedDocumentsModule } from './application/signed-documents/signed-documents.module';
|
||||
import { MutationLoggingInterceptor } from './application/common/interceptors/mutation-logging.interceptor';
|
||||
|
||||
@Module({
|
||||
@@ -86,20 +87,23 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // Чтобы .env был доступен глобально
|
||||
}),
|
||||
ScheduleModule.forRoot(), // @Cron / @Interval / @Timeout (Story 4.4 retention)
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
limit: 50,
|
||||
},
|
||||
]),
|
||||
ScheduleModule.forRoot(), // @Interval/@Cron — нужен outbox-worker'у Центра уведомлений
|
||||
// Infrastructure modules
|
||||
MongooseModule.forRoot(config.mongoose.url),
|
||||
DatabaseModule,
|
||||
GraphqlModule,
|
||||
ForkRegistryModule,
|
||||
BlockchainModule,
|
||||
GeneratorInfrastructureModule,
|
||||
RedisModule,
|
||||
NovuModule,
|
||||
SearchInfrastructureModule,
|
||||
EventsInfrastructureModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
@@ -131,6 +135,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
MeetDomainModule,
|
||||
GatewayDomainModule,
|
||||
VaultDomainModule,
|
||||
NotificationDomainModule,
|
||||
LedgerDomainModule,
|
||||
ProcessRegistryDomainModule,
|
||||
ParserDomainModule,
|
||||
@@ -141,7 +146,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
AccountModule,
|
||||
AgreementModule,
|
||||
AgendaModule,
|
||||
DecisionAuthorizeModule,
|
||||
AppStoreModule,
|
||||
AuthModule,
|
||||
DesktopModule,
|
||||
@@ -159,7 +163,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
GatewayModule,
|
||||
WalletModule,
|
||||
NotificationModule,
|
||||
NotificationCenterModule,
|
||||
LedgerModule,
|
||||
Ledger2Module,
|
||||
ProcessRegistryModule,
|
||||
@@ -171,7 +174,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
RegistrationModule,
|
||||
OnboardingApplicationModule,
|
||||
SearchModule,
|
||||
SignedDocumentsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { AccountResolver } from './resolvers/account.resolver';
|
||||
import { AccountService } from './services/account.service';
|
||||
import { AccountInteractor } from './interactors/account.interactor';
|
||||
import { RegistrationDeclineListener } from './services/registration-decline.listener';
|
||||
import { AccountDomainModule } from '~/domain/account/account-domain.module';
|
||||
import { NotificationDomainModule } from '~/domain/notification/notification-domain.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { TokenApplicationModule } from '~/application/token/token-application.module';
|
||||
import { EventsInfrastructureModule } from '~/infrastructure/events/events.module';
|
||||
@@ -11,12 +11,13 @@ import { EventsInfrastructureModule } from '~/infrastructure/events/events.modul
|
||||
@Module({
|
||||
imports: [
|
||||
AccountDomainModule,
|
||||
NotificationDomainModule,
|
||||
forwardRef(() => UserDomainModule),
|
||||
forwardRef(() => TokenApplicationModule),
|
||||
EventsInfrastructureModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [AccountInteractor, AccountService, AccountResolver, RegistrationDeclineListener],
|
||||
providers: [AccountInteractor, AccountService, AccountResolver],
|
||||
exports: [AccountInteractor, AccountService],
|
||||
})
|
||||
export class AccountModule {}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { MonoAccountDTO } from './mono-account.dto';
|
||||
import { UserAccountDTO } from '~/application/account/dto/base-user-account.dto';
|
||||
import { ParticipantAccountDTO } from './participant-account.dto';
|
||||
import { PrivateAccountDTO } from './private-account.dto';
|
||||
import { RegistrationPaymentDTO } from './registration-payment.dto';
|
||||
|
||||
@ObjectType('Account')
|
||||
export class AccountDTO {
|
||||
@@ -59,15 +58,6 @@ export class AccountDTO {
|
||||
@Type(() => PrivateAccountDTO)
|
||||
public readonly private_account!: PrivateAccountDTO | null;
|
||||
|
||||
@Field(() => RegistrationPaymentDTO, {
|
||||
description:
|
||||
'сводка по вступительному (регистрационному) платежу пайщика. Позволяет восстановить шаг регистрации (ожидание решения совета или отклонение платежа) после перезагрузки страницы и в любой вкладке.',
|
||||
nullable: true,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => RegistrationPaymentDTO)
|
||||
public readonly registration_payment!: RegistrationPaymentDTO | null;
|
||||
|
||||
constructor(entity: AccountDomainEntity) {
|
||||
this.username = entity.username;
|
||||
this.blockchain_account = entity.blockchain_account || null;
|
||||
@@ -75,8 +65,5 @@ export class AccountDTO {
|
||||
this.user_account = entity.user_account ? new UserAccountDTO(entity.user_account) : null;
|
||||
this.participant_account = entity.participant_account ? new ParticipantAccountDTO(entity.participant_account) : null;
|
||||
this.private_account = entity.private_account ? new PrivateAccountDTO(entity.private_account) : null;
|
||||
this.registration_payment = entity.registration_payment
|
||||
? new RegistrationPaymentDTO(entity.registration_payment)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ObjectType, Field } from '@nestjs/graphql';
|
||||
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
|
||||
import type { RegistrationPaymentDomainInterface } from '~/domain/account/interfaces/registration-payment-domain.interface';
|
||||
|
||||
@ObjectType('RegistrationPayment')
|
||||
export class RegistrationPaymentDTO {
|
||||
@Field(() => PaymentStatusEnum, { description: 'Статус вступительного платежа' })
|
||||
public readonly status: PaymentStatusEnum;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Причина изменения статуса. При отклонении платежа — причина отказа, которую видит пайщик.',
|
||||
nullable: true,
|
||||
})
|
||||
public readonly message?: string | null;
|
||||
|
||||
@Field(() => String, { description: 'Хэш платежа' })
|
||||
public readonly hash: string;
|
||||
|
||||
@Field(() => Number, { description: 'Сумма вступительного платежа' })
|
||||
public readonly quantity: number;
|
||||
|
||||
@Field(() => String, { description: 'Символ валюты платежа' })
|
||||
public readonly symbol: string;
|
||||
|
||||
constructor(entity: RegistrationPaymentDomainInterface) {
|
||||
this.status = entity.status;
|
||||
this.message = entity.message ?? null;
|
||||
this.hash = entity.hash;
|
||||
this.quantity = entity.quantity;
|
||||
this.symbol = entity.symbol;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { HttpApiError } from '~/utils/httpApiError';
|
||||
import { UserDomainService, USER_DOMAIN_SERVICE } from '~/domain/user/services/user-domain.service';
|
||||
import { TokenApplicationService } from '~/application/token/services/token-application.service';
|
||||
import { GENERATOR_PORT, GeneratorPort } from '~/domain/document/ports/generator.port';
|
||||
import {
|
||||
NOTIFICATION_DOMAIN_SERVICE,
|
||||
NotificationDomainService,
|
||||
} from '~/domain/notification/services/notification-domain.service';
|
||||
import { EventsService } from '~/infrastructure/events/events.service';
|
||||
import type { GetAccountsInputDomainInterface } from '~/domain/account/interfaces/get-accounts-input.interface';
|
||||
import type {
|
||||
@@ -33,12 +37,8 @@ import { EntrepreneurDomainEntity } from '~/domain/branch/entities/entrepreneur-
|
||||
import { ACCOUNT_BLOCKCHAIN_PORT, AccountBlockchainPort } from '~/domain/account/interfaces/account-blockchain.port';
|
||||
import { CANDIDATE_REPOSITORY, CandidateRepository } from '~/domain/account/repository/candidate.repository';
|
||||
import { userStatus } from '~/types/user.types';
|
||||
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
|
||||
import { PaymentTypeEnum } from '~/domain/gateway/enums/payment-type.enum';
|
||||
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
|
||||
import { CandidateStatus } from '~/domain/registration/enum';
|
||||
import { sha256 } from '~/utils/sha256';
|
||||
import { registrationProfileFingerprint } from '~/utils/registration-profile-fingerprint';
|
||||
import { normalizeUserEmail } from '~/utils/normalize-user-email';
|
||||
import type {
|
||||
SearchPrivateAccountsInputDomainInterface,
|
||||
@@ -56,12 +56,12 @@ export class AccountInteractor {
|
||||
private readonly searchPrivateAccountsRepository: SearchPrivateAccountsRepository,
|
||||
@Inject(ACCOUNT_BLOCKCHAIN_PORT) private readonly accountBlockchainPort: AccountBlockchainPort,
|
||||
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository,
|
||||
@Inject(NOTIFICATION_DOMAIN_SERVICE) private readonly notificationDomainService: NotificationDomainService,
|
||||
private readonly eventsService: EventsService,
|
||||
@Inject(GENERATOR_PORT) private readonly generatorPort: GeneratorPort,
|
||||
private readonly tokenApplicationService: TokenApplicationService,
|
||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService,
|
||||
@Inject(PAYMENT_REPOSITORY) private readonly paymentRepository: PaymentRepository
|
||||
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService
|
||||
) {}
|
||||
|
||||
private readonly logger = new Logger(AccountInteractor.name);
|
||||
@@ -167,6 +167,25 @@ export class AccountInteractor {
|
||||
throw new Error('Не получены входные данные для обновления');
|
||||
}
|
||||
|
||||
// Novu: не блокируем ответ мутации — догоняем подписчика в фоне
|
||||
try {
|
||||
const account = await this.getAccount(user.username);
|
||||
void this.notificationDomainService
|
||||
.createSubscriberFromAccount(account)
|
||||
.then(() => {
|
||||
this.logger.log(`Подписчик NOVU обновлён для ${data.username}`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`Ошибка обновления подписчика NOVU для ${data.username}: ${message}`, stack);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`Ошибка подготовки синхронизации NOVU для ${data.username}: ${message}`, stack);
|
||||
}
|
||||
|
||||
// Получаем финальный аккаунт
|
||||
const account = await this.getAccount(user.username);
|
||||
|
||||
@@ -182,40 +201,7 @@ export class AccountInteractor {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление аккаунта из системы учёта провайдера (off-chain).
|
||||
*
|
||||
* deleteUserByUsername удаляет только строку users в PG — блокчейн не трогает.
|
||||
* Назначение — почистить реестр от незавершённых/отклонённых регистраций
|
||||
* (тестеры, брошенные попытки) и освободить занятый e-mail для перерегистрации.
|
||||
*
|
||||
* Единственный гард — НЕ принят в кооператив. participant_account
|
||||
* (accepted|blocked) появляется только после приёма советом, его наличие ⟺ член
|
||||
* кооператива. Всё остальное (черновик, заявление, оплачен/на рассмотрении
|
||||
* совета, отклонён, возврат) удаляемо — это и есть «незавершённая регистрация».
|
||||
*
|
||||
* Статус для гейта НЕ годится: воронка реально доходит лишь до `registered`
|
||||
* (отказ совета и возврат взноса оставляют статус `registered`, а `failed`/
|
||||
* `refunded` users.status никем не выставляются). Прежний allow-list
|
||||
* `[created, joined, payed, failed, refunded]` исключал именно отклонённые/
|
||||
* оплаченные брошенные регистрации — ровно те, ради которых задумана чистка.
|
||||
*
|
||||
* On-chain аккаунт заводится при оплате взноса (статус registered) и в EOSIO
|
||||
* неудаляем — остаётся осиротевшим, имя в цепи занято навсегда. Для
|
||||
* регистрационной чистки это приемлемо: off-chain учёт провайдера и цепь
|
||||
* намеренно расходятся (см. registration-decline.listener — карточку кандидата
|
||||
* на цепи снимает refundpay, системный аккаунт остаётся).
|
||||
*/
|
||||
async deleteAccount(username: string): Promise<void> {
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
|
||||
if (account.participant_account) {
|
||||
throw new HttpApiError(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'Пайщик принят в кооператив — удаление невозможно.'
|
||||
);
|
||||
}
|
||||
|
||||
await this.userDomainService.deleteUserByUsername(username);
|
||||
}
|
||||
|
||||
@@ -240,11 +226,11 @@ export class AccountInteractor {
|
||||
const user = await this.createUser({ ...data, role: 'user' });
|
||||
const tokens = await this.tokenApplicationService.generateAuthTokens(user.id);
|
||||
|
||||
// Настраиваем identity получателя
|
||||
// Настраиваем подписчика NOVU
|
||||
try {
|
||||
await this.accountDomainService.setupNotificationSubscriber(user.username, 'регистрации');
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка настройки identity получателя при регистрации ${data.username}: ${error.message}`, error.stack);
|
||||
this.logger.error(`Ошибка настройки подписчика NOVU при регистрации ${data.username}: ${error.message}`, error.stack);
|
||||
}
|
||||
|
||||
// Создаем нового кандидата в репозитории
|
||||
@@ -282,58 +268,7 @@ export class AccountInteractor {
|
||||
}
|
||||
|
||||
async getAccount(username: string): Promise<AccountDomainEntity> {
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
|
||||
// Прокидываем сводку по вступительному платежу, чтобы фронт восстановил шаг
|
||||
// регистрации (ожидание/отклонение платежа) после перезагрузки и в любой вкладке.
|
||||
// Актуально только пока пайщик ещё не принят (нет participant_account).
|
||||
if (!account.participant_account) {
|
||||
const payment = await this.paymentRepository.findLatestByUsernameAndType(
|
||||
username,
|
||||
PaymentTypeEnum.REGISTRATION
|
||||
);
|
||||
// Отказ совета (declinereg) заводит исходящий возврат REGISTRATION_REFUND.
|
||||
// Сообщаем фронту отдельным статусом REFUNDED (входящий рег-платёж этот статус
|
||||
// никогда не принимает) — иначе экран ожидания висит на «ожидаем решение
|
||||
// совета», т.к. сам входящий платёж остаётся COMPLETED. Отказ относится к
|
||||
// ТЕКУЩЕМУ циклу только если возврат свежее последнего вступительного платежа:
|
||||
// после повторной подачи создаётся новый REGISTRATION (свежее старого возврата),
|
||||
// и прошлый отказ больше не всплывает.
|
||||
const refund = await this.paymentRepository.findLatestByUsernameAndType(
|
||||
username,
|
||||
PaymentTypeEnum.REGISTRATION_REFUND
|
||||
);
|
||||
const isCouncilDeclined =
|
||||
!!refund &&
|
||||
(!payment || new Date(refund.created_at).getTime() >= new Date(payment.created_at).getTime());
|
||||
|
||||
if (isCouncilDeclined && refund) {
|
||||
// Два шага возврата для UI: пока касса не подтвердила исходящий платёж
|
||||
// (refund != COMPLETED) — PROCESSING (кнопки повторной подачи нет, аккаунт
|
||||
// на цепи ещё занят карточкой); после подтверждения — REFUNDED (refundpay
|
||||
// снял карточку, повторная подача возможна).
|
||||
const done = refund.status === PaymentStatusEnum.COMPLETED;
|
||||
account.registration_payment = {
|
||||
status: done ? PaymentStatusEnum.REFUNDED : PaymentStatusEnum.PROCESSING,
|
||||
message: done
|
||||
? 'Совет отказал в приёме. Регистрационный взнос возвращён. Вы можете подать заявку заново.'
|
||||
: 'Совет отказал в приёме. Регистрационный взнос возвращается. Дождитесь его завершения, чтобы подать заявку заново.',
|
||||
hash: refund.hash,
|
||||
quantity: refund.quantity,
|
||||
symbol: refund.symbol,
|
||||
};
|
||||
} else if (payment) {
|
||||
account.registration_payment = {
|
||||
status: payment.status,
|
||||
message: payment.message ?? null,
|
||||
hash: payment.hash,
|
||||
quantity: payment.quantity,
|
||||
symbol: payment.symbol,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return account;
|
||||
return await this.accountDomainService.getAccount(username);
|
||||
}
|
||||
|
||||
async getAccounts(
|
||||
@@ -350,11 +285,7 @@ export class AccountInteractor {
|
||||
};
|
||||
|
||||
for (const account of provider_accounts.items) {
|
||||
// this.getAccount (а не accountDomainService.getAccount) — чтобы реестр получил
|
||||
// registration_payment: иначе отказ совета (Registered + возврат взноса) на
|
||||
// списке неотличим от «ожидает совет». Обогащение стоит 2 доп. запроса к
|
||||
// платежам только для непринятых аккаунтов (гард `!participant_account`).
|
||||
const item = await this.getAccount(account.username);
|
||||
const item = await this.accountDomainService.getAccount(account.username);
|
||||
result.items.push(item);
|
||||
}
|
||||
|
||||
@@ -374,29 +305,6 @@ export class AccountInteractor {
|
||||
throw new HttpApiError(HttpStatus.NOT_FOUND, `Кандидат с именем ${username} не найден`);
|
||||
}
|
||||
|
||||
// Гард A (замок профиля): на цепь не должны уйти данные, отличные от
|
||||
// подписанных в заявлении. Если в meta кандидата зафиксирован отпечаток
|
||||
// (заявление подписано после внедрения гарда) — сверяем с текущим
|
||||
// профилем. Кандидаты без отпечатка (регистрация шла до внедрения)
|
||||
// пропускаются, чтобы не сломать уже идущие регистрации.
|
||||
let candidateMeta: Record<string, any> = {};
|
||||
try {
|
||||
candidateMeta = candidate.meta ? JSON.parse(candidate.meta) : {};
|
||||
} catch {
|
||||
candidateMeta = {};
|
||||
}
|
||||
const lockedFingerprint = candidateMeta?.registration_profile_fingerprint;
|
||||
if (lockedFingerprint) {
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
const currentFingerprint = registrationProfileFingerprint(account?.private_account);
|
||||
if (currentFingerprint && currentFingerprint !== lockedFingerprint) {
|
||||
throw new HttpApiError(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'Данные пайщика изменены после подписания заявления. Пожалуйста, переподпишите заявление перед регистрацией.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Вызываем порт для регистрации в блокчейне, передавая объект кандидата целиком
|
||||
await this.accountBlockchainPort.registerBlockchainAccount(candidate);
|
||||
@@ -421,109 +329,6 @@ export class AccountInteractor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Откат регистрации к редактированию данных (recovery drift/decline).
|
||||
*
|
||||
* Применим ТОЛЬКО до создания аккаунта в блокчейне: после reguser username
|
||||
* необратимо занят, и нужна перерегистрация — это отдельная ветка. Здесь же,
|
||||
* пока аккаунта в цепи нет, безопасно вернуть пайщика к началу:
|
||||
* - снимаем заморозку профиля и e-mail (users.status → created);
|
||||
* - сбрасываем кандидата (документы и отпечаток заявления), чтобы заявление
|
||||
* пришлось переподписать под актуальные данные;
|
||||
* - удаляем непринятую попытку регистрационного платежа, чтобы экран
|
||||
* ожидания не показывал старый отказ.
|
||||
*
|
||||
* Если средства уже приняты (PAID/COMPLETED) — откат запрещён: нужен возврат
|
||||
* средств (отдельная ветка refund).
|
||||
*/
|
||||
async resetRegistration(username: string): Promise<AccountDomainEntity> {
|
||||
const user = await this.userDomainService.getUserByUsername(username);
|
||||
if (!user) {
|
||||
throw new HttpApiError(HttpStatus.NOT_FOUND, `Пользователь ${username} не найден`);
|
||||
}
|
||||
|
||||
// Повторная подача после отказа совета (вариант 1): в отличие от обычного
|
||||
// отката (до приёма платежа), здесь аккаунт уже в блокчейне и взнос был принят —
|
||||
// это нормально. Карточку участника на цепи снимает refundpay при завершении
|
||||
// возврата, поэтому пускаем только когда возврат COMPLETED (иначе повторный
|
||||
// reguser упадёт «повторное получение невозможно»). Отказ относится к текущему
|
||||
// циклу, только если возврат свежее последнего вступительного платежа.
|
||||
const payment = await this.paymentRepository.findLatestByUsernameAndType(
|
||||
username,
|
||||
PaymentTypeEnum.REGISTRATION
|
||||
);
|
||||
const refund = await this.paymentRepository.findLatestByUsernameAndType(
|
||||
username,
|
||||
PaymentTypeEnum.REGISTRATION_REFUND
|
||||
);
|
||||
const isCouncilDeclined =
|
||||
!!refund &&
|
||||
(!payment || new Date(refund.created_at).getTime() >= new Date(payment.created_at).getTime());
|
||||
|
||||
if (isCouncilDeclined && refund) {
|
||||
if (refund.status !== PaymentStatusEnum.COMPLETED) {
|
||||
throw new HttpApiError(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'Возврат регистрационного взноса ещё выполняется. Подайте заявку заново после его завершения.'
|
||||
);
|
||||
}
|
||||
// Старые платежи не удаляем: getAccount различает циклы по дате — новый
|
||||
// вступительный платёж будет свежее этого возврата, и прошлый отказ исчезнет.
|
||||
} else {
|
||||
// Обычный откат до приёма платежа: аккаунт ещё не в блокчейне, взнос не принят.
|
||||
const blockchainAccount = await this.accountDomainService.getBlockchainAccount(username);
|
||||
if (
|
||||
blockchainAccount ||
|
||||
user.status === userStatus['4_Registered'] ||
|
||||
user.status === userStatus['5_Active']
|
||||
) {
|
||||
throw new HttpApiError(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'Регистрация уже отправлена в блокчейн — откат к редактированию невозможен, требуется перерегистрация.'
|
||||
);
|
||||
}
|
||||
if (payment) {
|
||||
if (payment.status === PaymentStatusEnum.PAID || payment.status === PaymentStatusEnum.COMPLETED) {
|
||||
throw new HttpApiError(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'Вступительный взнос уже принят — для отката требуется возврат средств.'
|
||||
);
|
||||
}
|
||||
if (payment.id) {
|
||||
await this.paymentRepository.delete(payment.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Кандидат: чистим документы и отпечаток заявления (потребуется переподпись).
|
||||
const candidate = await this.candidateRepository.findByUsername(username);
|
||||
if (candidate) {
|
||||
let candidateMeta: Record<string, any> = {};
|
||||
try {
|
||||
candidateMeta = candidate.meta ? JSON.parse(candidate.meta) : {};
|
||||
} catch {
|
||||
candidateMeta = {};
|
||||
}
|
||||
delete candidateMeta.registration_profile_fingerprint;
|
||||
await this.candidateRepository.update(username, {
|
||||
status: CandidateStatus.PENDING,
|
||||
documents: {},
|
||||
meta: JSON.stringify(candidateMeta),
|
||||
});
|
||||
}
|
||||
|
||||
// Снимаем заморозку: профиль и e-mail снова можно менять.
|
||||
await this.userDomainService.updateUserByUsername(username, {
|
||||
status: userStatus['1_Created'],
|
||||
is_registered: false,
|
||||
has_account: false,
|
||||
});
|
||||
|
||||
this.logger.log(`Регистрация ${username} откатана к редактированию данных`);
|
||||
|
||||
return await this.accountDomainService.getAccount(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск приватных аккаунтов по запросу
|
||||
* @param input Входные данные для поиска
|
||||
|
||||
@@ -12,11 +12,8 @@ import type { PaginationResultDomainInterface } from '~/domain/common/interfaces
|
||||
import { RegisterAccountInputDTO } from '../dto/register-account-input.dto';
|
||||
import { RegisteredAccountDTO } from '../dto/registered-account.dto';
|
||||
import { UpdateAccountInputDTO } from '../dto/update-account-input.dto';
|
||||
import { DeleteAccountInputDTO } from '../dto/delete-account-input.dto';
|
||||
import { SearchPrivateAccountsInputDTO } from '../dto/search-private-accounts-input.dto';
|
||||
import { PrivateAccountSearchResultDTO } from '../dto/search-private-accounts-result.dto';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
|
||||
export const AccountsPaginationResult = createPaginationResult(AccountDTO, 'Accounts');
|
||||
|
||||
@@ -72,31 +69,20 @@ export class AccountResolver {
|
||||
return this.accountService.registerAccount(data);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'deleteAccount',
|
||||
description:
|
||||
'Удалить аккаунт пайщика из системы учёта провайдера. Доступно только для незавершённых регистрационных статусов (черновик, неоплачен/отклонён). Активный, заблокированный и любой зарегистрированный в блокчейне аккаунт удалить нельзя. Используется для очистки реестра и освобождения e-mail под перерегистрацию.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
async deleteAccount(
|
||||
@Args('data', { type: () => DeleteAccountInputDTO })
|
||||
data: DeleteAccountInputDTO
|
||||
): Promise<boolean> {
|
||||
await this.accountService.deleteAccount(data);
|
||||
// @Mutation(() => Boolean, {
|
||||
// name: 'deleteAccount',
|
||||
// description: 'Удалить аккаунт из системы учёта провайдера',
|
||||
// })
|
||||
// @UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
// @AuthRoles(['chairman', 'member'])
|
||||
// async deleteAccount(
|
||||
// @Args('data', { type: () => DeleteAccountInputDTO })
|
||||
// data: DeleteAccountInputDTO
|
||||
// ): Promise<boolean> {
|
||||
// await this.accountService.deleteAccount(data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => AccountDTO, {
|
||||
name: 'resetRegistration',
|
||||
description:
|
||||
'Откатить собственную незавершённую регистрацию к редактированию данных: снимает заморозку профиля и e-mail, сбрасывает подписанное заявление и непринятую попытку вступительного платежа. Доступно только до отправки регистрации в блокчейн; если взнос уже принят — требуется возврат средств.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async resetRegistration(@CurrentUser() currentUser: MonoAccountDomainInterface): Promise<AccountDTO> {
|
||||
return await this.accountService.resetRegistration(currentUser.username);
|
||||
}
|
||||
// return true;
|
||||
// }
|
||||
|
||||
@Mutation(() => AccountDTO, {
|
||||
name: 'updateAccount',
|
||||
|
||||
@@ -30,12 +30,6 @@ export class AccountService {
|
||||
return new AccountDTO(account);
|
||||
}
|
||||
|
||||
public async resetRegistration(username: string): Promise<AccountDTO> {
|
||||
const account = await this.accountInteractor.resetRegistration(username);
|
||||
|
||||
return new AccountDTO(account);
|
||||
}
|
||||
|
||||
public async getAccounts(
|
||||
data?: GetAccountsInputDTO,
|
||||
options?: PaginationInputDTO
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
|
||||
import { CANDIDATE_REPOSITORY, CandidateRepository } from '~/domain/account/repository/candidate.repository';
|
||||
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
|
||||
import { PaymentTypeEnum, PaymentDirectionEnum, VAT_EXEMPT_NOTE } from '~/domain/gateway/enums/payment-type.enum';
|
||||
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
|
||||
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
|
||||
import { generateUniqueHash } from '~/utils/generate-hash.util';
|
||||
import { RegistratorContract } from 'cooptypes';
|
||||
|
||||
// Имя on-chain события отказа в регистрации — из констант cooptypes, не хардкод.
|
||||
const REGISTRATOR = RegistratorContract.contractName.production;
|
||||
const DECLINE_REG_EVENT = `action::${REGISTRATOR}::${RegistratorContract.Actions.DeclineRegistration.actionName}`;
|
||||
|
||||
/**
|
||||
* Заводит исходящий платёж-возврат при отказе совета в приёме пайщика.
|
||||
*
|
||||
* Отказ приходит on-chain как registrator::declinereg — он срабатывает и при
|
||||
* истечении срока повестки, и при отрицательном консенсусе (>50% совета против,
|
||||
* PR #94), через общую decline-callback ветку.
|
||||
*
|
||||
* На момент отказа взнос по фондам НЕ разносился (confirmreg не было), но реальные
|
||||
* деньги пайщика уже у кооператива на расчётном счёте (стоят на расчётах с пайщиком,
|
||||
* счёт 76). Заводим исходящий платёж в реестр, чтобы кассир вернул вступительный +
|
||||
* мин.паевой. Подтверждение кассиром проводит on-chain возврат: gateway::outcomplete
|
||||
* → registrator::refundpay (обратная проводка Дт 76 / Кт 51), см.
|
||||
* gateway.interactor.processOutgoingPayment. Отклонить такой возврат нельзя.
|
||||
*
|
||||
* Хэш платежа-возврата = registration_hash: on-chain исходящий объект (создан
|
||||
* declinereg через gateway::createoutpay) идентифицируется этим же хэшем, и
|
||||
* completeOutcome({ outcome_hash }) при подтверждении кассой должен совпасть с ним.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RegistrationDeclineListener {
|
||||
private readonly logger = new Logger(RegistrationDeclineListener.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PAYMENT_REPOSITORY) private readonly paymentRepository: PaymentRepository,
|
||||
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository,
|
||||
) {}
|
||||
|
||||
@OnEvent(DECLINE_REG_EVENT)
|
||||
async onDeclineRegistration(action: ActionDomainInterface): Promise<void> {
|
||||
const registration_hash = action?.data?.registration_hash as string | undefined;
|
||||
if (!registration_hash) return;
|
||||
|
||||
const candidate = await this.candidateRepository.findByRegistrationHash(registration_hash);
|
||||
if (!candidate) {
|
||||
this.logger.warn(`declinereg: кандидат по registration_hash=${registration_hash} не найден — пропуск возврата`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Исходный вступительный платёж — источник суммы и номера (№ в QR = hash8).
|
||||
const original = await this.paymentRepository.findLatestByUsernameAndType(
|
||||
candidate.username,
|
||||
PaymentTypeEnum.REGISTRATION,
|
||||
);
|
||||
if (!original) {
|
||||
this.logger.warn(`declinereg: исходный регистрационный платёж для ${candidate.username} не найден — пропуск возврата`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Идемпотентность: повторный приход события (replay/форк) не плодит возвраты.
|
||||
// Но проверяем возврат именно ТЕКУЩЕГО цикла. На повторной подаче после прошлого
|
||||
// отказа в БД остаётся возврат прошлого цикла, и его hash совпадает с новым
|
||||
// (registration_hash = sha256(username) детерминирован). Возврат текущего цикла
|
||||
// создаётся ПОЗЖЕ текущего вступительного платежа — по этому и отличаем. Иначе
|
||||
// старый возврат блокировал бы создание нового, latest refund оставался бы старше
|
||||
// нового платежа, и фронт завис бы на «платёж принят». Детекция цикла по дате —
|
||||
// как в account.interactor (getAccount / resetRegistration / createRegistrationPayment).
|
||||
const existingRefund = await this.paymentRepository.findLatestByUsernameAndType(
|
||||
candidate.username,
|
||||
PaymentTypeEnum.REGISTRATION_REFUND,
|
||||
);
|
||||
if (
|
||||
existingRefund &&
|
||||
new Date(existingRefund.created_at).getTime() >= new Date(original.created_at).getTime()
|
||||
) {
|
||||
this.logger.debug(
|
||||
`declinereg: возврат текущего цикла для ${candidate.username} уже существует (${existingRefund.id}) — пропуск`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const refund: PaymentDomainInterface = {
|
||||
id: '',
|
||||
coopname: candidate.coopname || original.coopname,
|
||||
username: candidate.username,
|
||||
quantity: original.quantity,
|
||||
symbol: original.symbol,
|
||||
type: PaymentTypeEnum.REGISTRATION_REFUND,
|
||||
direction: PaymentDirectionEnum.OUTGOING,
|
||||
// Совет уже решил (отказ) — повторная авторизация не нужна, платёж сразу
|
||||
// готов к выплате кассиром (PENDING виден кассиру и имеет кнопку «Подтвердить»).
|
||||
status: PaymentStatusEnum.PENDING,
|
||||
// Назначение — зеркало входящего взноса: тот же № (первые 8 символов хэша
|
||||
// исходного платежа, что показывался пайщику в QR) и та же НДС-оговорка
|
||||
// (VAT_EXEMPT_NOTE). Назначение целиком хранится в memo — провайдеры её
|
||||
// больше не дописывают. Кассир копирует этот текст as-is в возвратный платёж.
|
||||
memo: `Возврат вступительного и минимального паевого взносов №${original.hash.slice(0, 8)}. ${VAT_EXEMPT_NOTE}`,
|
||||
provider: original.provider,
|
||||
secret: generateUniqueHash(),
|
||||
// Хэш = registration_hash: совпадает с outcome_hash on-chain исходящего
|
||||
// объекта (gateway::createoutpay в declinereg), чтобы completeOutcome при
|
||||
// подтверждении кассой нашёл его и вызвал registrator::refundpay.
|
||||
hash: registration_hash,
|
||||
expired_at: undefined, // бессрочный — как и прочие исходящие платежи
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
const created = await this.paymentRepository.create(refund);
|
||||
this.logger.log(
|
||||
`declinereg: создан возврат ${created.id} для ${candidate.username} на ${original.quantity} ${original.symbol}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,6 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
|
||||
],
|
||||
controllers: [],
|
||||
providers: [AgendaInteractor, AgendaResolver, AgendaService],
|
||||
exports: [AgendaService],
|
||||
exports: [],
|
||||
})
|
||||
export class AgendaModule {}
|
||||
|
||||
@@ -26,16 +26,9 @@ export class AgendaWithDocumentsDTO implements AgendaWithDocumentsDomainInterfac
|
||||
data: AgendaWithDocumentsDomainInterface,
|
||||
usernameCertificate?: UserCertificateDomainInterface | null,
|
||||
votesForCertificates?: UserCertificateDomainInterface[],
|
||||
votesAgainstCertificates?: UserCertificateDomainInterface[],
|
||||
councilMembersCount = 0
|
||||
votesAgainstCertificates?: UserCertificateDomainInterface[]
|
||||
) {
|
||||
this.table = new BlockchainDecisionDTO(
|
||||
data.table,
|
||||
usernameCertificate,
|
||||
votesForCertificates,
|
||||
votesAgainstCertificates,
|
||||
councilMembersCount
|
||||
);
|
||||
this.table = new BlockchainDecisionDTO(data.table, usernameCertificate, votesForCertificates, votesAgainstCertificates);
|
||||
this.action = new BlockchainActionDTO(data.action);
|
||||
this.documents = new DocumentPackageAggregateDTO(data.documents);
|
||||
}
|
||||
|
||||
@@ -88,25 +88,16 @@ export class BlockchainDecisionDTO implements SovietContract.Tables.Decisions.ID
|
||||
@Field(() => String, { nullable: true })
|
||||
hash!: string;
|
||||
|
||||
@Field(() => Number, {
|
||||
description:
|
||||
'Текущее число членов совета (всего, как считает контракт). Нужно фронту для вычисления порога принятия/отклонения: за/против * 100 > council_members_count * 50',
|
||||
})
|
||||
council_members_count!: number;
|
||||
|
||||
constructor(
|
||||
data?: any,
|
||||
usernameCertificate?: UserCertificateDomainInterface | null,
|
||||
votesForCertificates?: UserCertificateDomainInterface[],
|
||||
votesAgainstCertificates?: UserCertificateDomainInterface[],
|
||||
councilMembersCount = 0
|
||||
votesAgainstCertificates?: UserCertificateDomainInterface[]
|
||||
) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
this.council_members_count = councilMembersCount;
|
||||
|
||||
// Вспомогательная функция для создания сертификата DTO
|
||||
const createCertificateDTO = (
|
||||
certificate: UserCertificateDomainInterface | null
|
||||
|
||||
@@ -80,47 +80,4 @@ export class AgendaInteractor {
|
||||
}
|
||||
return agenda;
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает ОДИН пункт повестки по хэшу документа-заявления. Нужен сразу после
|
||||
* публикации свободного решения: возвращаем только что созданный вопрос фронту
|
||||
* немедленно, без ожидания общего поллинга. Возвращает null, пока парсер ещё не
|
||||
* проиндексировал действие newsubmitted или документ-заявление — тогда
|
||||
* вызывающая сторона повторит попытку через паузу.
|
||||
*/
|
||||
async getAgendaItemByHash(coopname: string, hash: string): Promise<AgendaWithDocumentsDomainInterface | null> {
|
||||
const target = String(hash).toUpperCase();
|
||||
|
||||
// decision появляется на чейне почти мгновенно после публикации.
|
||||
const decisions = (await this.blockchainPort.getAllRows(
|
||||
SovietContract.contractName.production,
|
||||
coopname,
|
||||
'decisions'
|
||||
)) as SovietContract.Tables.Decisions.IDecision[];
|
||||
|
||||
const decision = decisions.find((d) => String(d.hash).toUpperCase() === target);
|
||||
if (!decision) return null;
|
||||
|
||||
// action newsubmitted индексируется парсером с лагом (~2 c) — пока его нет,
|
||||
// вернём null, и вызывающая сторона повторит тик.
|
||||
const actionResponse = await getActions(`${process.env.SIMPLE_EXPLORER_API}/get-actions`, {
|
||||
filter: JSON.stringify({
|
||||
account: SovietContract.contractName.production,
|
||||
name: SovietContract.Actions.Registry.NewSubmitted.actionName,
|
||||
receiver: process.env.COOPNAME,
|
||||
'data.package': target,
|
||||
}),
|
||||
page: 1,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const action = actionResponse?.results?.[0];
|
||||
if (!action) return null;
|
||||
|
||||
const documents = await this.documentPackageAggregator.buildDocumentPackageAggregate(action);
|
||||
// Тот же фильтр, что в getAgenda: без агрегата заявления пункт не отображается.
|
||||
if (!documents.statement?.documentAggregate) return null;
|
||||
|
||||
return { table: decision, action, documents };
|
||||
}
|
||||
}
|
||||
|
||||
+15
-13
@@ -1,11 +1,13 @@
|
||||
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { NOTIFICATION_PORT, type NotificationPort } from '~/domain/notification/interfaces/notify.port';
|
||||
import { NovuWorkflowAdapter } from '~/infrastructure/novu/novu-workflow.adapter';
|
||||
import { NOVU_WORKFLOW_PORT } from '~/domain/notification/interfaces/novu-workflow.port';
|
||||
import { ACCOUNT_DATA_PORT, AccountDataPort } from '~/domain/account/ports/account-data.port';
|
||||
import config from '~/config/config';
|
||||
import { SovietContract } from 'cooptypes';
|
||||
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
|
||||
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
import { Workflows } from '@coopenomics/notifications';
|
||||
|
||||
/**
|
||||
@@ -17,8 +19,8 @@ import { Workflows } from '@coopenomics/notifications';
|
||||
@Injectable()
|
||||
export class AgendaNotificationService implements OnModuleInit {
|
||||
constructor(
|
||||
@Inject(NOTIFICATION_PORT)
|
||||
private readonly notificationPort: NotificationPort,
|
||||
@Inject(NOVU_WORKFLOW_PORT)
|
||||
private readonly novuWorkflowAdapter: NovuWorkflowAdapter,
|
||||
@Inject(ACCOUNT_DATA_PORT)
|
||||
private readonly accountPort: AccountDataPort,
|
||||
private readonly logger: WinstonLoggerService
|
||||
@@ -89,17 +91,17 @@ export class AgendaNotificationService implements OnModuleInit {
|
||||
continue;
|
||||
}
|
||||
|
||||
const triggerData: WorkflowTriggerDomainInterface = {
|
||||
name: Workflows.NewAgenda.id,
|
||||
to: {
|
||||
subscriberId: memberSubscriberId,
|
||||
email: memberEmail,
|
||||
},
|
||||
payload,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.notificationPort.notify({
|
||||
coopname: action.coopname,
|
||||
workflowId: Workflows.NewAgenda.id,
|
||||
to: {
|
||||
subscriberId: memberSubscriberId,
|
||||
email: memberEmail,
|
||||
username: member.username,
|
||||
},
|
||||
payload,
|
||||
});
|
||||
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||
sentCount++;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка отправки уведомления члену совета ${member.username}: ${error.message}`);
|
||||
|
||||
@@ -3,25 +3,17 @@ import { AgendaWithDocumentsDTO } from '../dto/agenda-with-documents.dto';
|
||||
import { AgendaInteractor } from '../interactors/agenda.interactor';
|
||||
import { UserCertificateDomainPort, USER_CERTIFICATE_DOMAIN_PORT } from '~/domain/user/ports/user-certificate-domain.port';
|
||||
import type { UserCertificateDomainInterface } from '~/domain/user/interfaces/user-certificate-domain.interface';
|
||||
import { SOVIET_BLOCKCHAIN_PORT, SovietBlockchainPort } from '~/domain/common/ports/soviet-blockchain.port';
|
||||
import config from '~/config/config';
|
||||
|
||||
@Injectable()
|
||||
export class AgendaService {
|
||||
constructor(
|
||||
private readonly agendaInteractor: AgendaInteractor,
|
||||
@Inject(USER_CERTIFICATE_DOMAIN_PORT) private readonly userCertificateDomainPort: UserCertificateDomainPort,
|
||||
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort
|
||||
@Inject(USER_CERTIFICATE_DOMAIN_PORT) private readonly userCertificateDomainPort: UserCertificateDomainPort
|
||||
) {}
|
||||
|
||||
public async getAgenda(): Promise<AgendaWithDocumentsDTO[]> {
|
||||
const agenda = await this.agendaInteractor.getAgenda();
|
||||
|
||||
// Состав совета (`soviet`) дёргаем один раз — фронту нужен порог консенсуса.
|
||||
// Считаем всех членов совета (как контрактный get_members_count = members.size()).
|
||||
const boards = await this.sovietBlockchainPort.getBoards(config.coopname);
|
||||
const councilMembersCount = boards.find((b) => b.type === 'soviet')?.members.length ?? 0;
|
||||
|
||||
// Обрабатываем каждый элемент повестки дня
|
||||
const processedAgenda = await Promise.all(
|
||||
agenda.map(async (item) => {
|
||||
@@ -46,34 +38,10 @@ export class AgendaService {
|
||||
}
|
||||
}
|
||||
|
||||
return new AgendaWithDocumentsDTO(
|
||||
item,
|
||||
usernameCertificate,
|
||||
votesForCertificates,
|
||||
votesAgainstCertificates,
|
||||
councilMembersCount
|
||||
);
|
||||
return new AgendaWithDocumentsDTO(item, usernameCertificate, votesForCertificates, votesAgainstCertificates);
|
||||
})
|
||||
);
|
||||
|
||||
return processedAgenda;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает ОДИН пункт повестки по хэшу документа-заявления (или null, если он
|
||||
* ещё не проиндексирован парсером). Используется сразу после публикации
|
||||
* свободного решения, чтобы фронт показал созданный вопрос немедленно.
|
||||
*/
|
||||
public async getAgendaItemByHash(hash: string): Promise<AgendaWithDocumentsDTO | null> {
|
||||
const item = await this.agendaInteractor.getAgendaItemByHash(config.coopname, hash);
|
||||
if (!item) return null;
|
||||
|
||||
const boards = await this.sovietBlockchainPort.getBoards(config.coopname);
|
||||
const councilMembersCount = boards.find((b) => b.type === 'soviet')?.members.length ?? 0;
|
||||
|
||||
const usernameCertificate = await this.userCertificateDomainPort.getCertificateByUsername(item.table.username);
|
||||
|
||||
// Только что созданный вопрос ещё без голосов — сертификаты голосовавших пусты.
|
||||
return new AgendaWithDocumentsDTO(item, usernameCertificate, [], [], councilMembersCount);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,11 +1,13 @@
|
||||
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { NOTIFICATION_PORT, type NotificationPort } from '~/domain/notification/interfaces/notify.port';
|
||||
import { NovuWorkflowAdapter } from '~/infrastructure/novu/novu-workflow.adapter';
|
||||
import { NOVU_WORKFLOW_PORT } from '~/domain/notification/interfaces/novu-workflow.port';
|
||||
import { ACCOUNT_DATA_PORT, AccountDataPort } from '~/domain/account/ports/account-data.port';
|
||||
import config from '~/config/config';
|
||||
import { SovietContract } from 'cooptypes';
|
||||
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
|
||||
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
import { Workflows } from '@coopenomics/notifications';
|
||||
|
||||
/**
|
||||
@@ -17,8 +19,8 @@ import { Workflows } from '@coopenomics/notifications';
|
||||
@Injectable()
|
||||
export class DecisionNotificationService implements OnModuleInit {
|
||||
constructor(
|
||||
@Inject(NOTIFICATION_PORT)
|
||||
private readonly notificationPort: NotificationPort,
|
||||
@Inject(NOVU_WORKFLOW_PORT)
|
||||
private readonly novuWorkflowAdapter: NovuWorkflowAdapter,
|
||||
@Inject(ACCOUNT_DATA_PORT)
|
||||
private readonly accountPort: AccountDataPort,
|
||||
private readonly logger: WinstonLoggerService
|
||||
@@ -58,7 +60,7 @@ export class DecisionNotificationService implements OnModuleInit {
|
||||
const userEmail = user.provider_account?.email;
|
||||
const subscriberId = user.provider_account?.subscriber_id?.trim();
|
||||
if (!subscriberId) {
|
||||
this.logger.warn(`subscriber_id пользователя ${username} не найден`);
|
||||
this.logger.warn(`subscriber_id пользователя ${username} не найден — пропуск Novu`);
|
||||
return;
|
||||
}
|
||||
if (!userEmail) {
|
||||
@@ -78,17 +80,17 @@ export class DecisionNotificationService implements OnModuleInit {
|
||||
decisionUrl: `${config.frontend_url}`,
|
||||
};
|
||||
|
||||
// Отправляем уведомление через Центр уведомлений
|
||||
await this.notificationPort.notify({
|
||||
coopname: action.coopname,
|
||||
workflowId: Workflows.DecisionApproved.id,
|
||||
// Отправляем уведомление
|
||||
const triggerData: WorkflowTriggerDomainInterface = {
|
||||
name: Workflows.DecisionApproved.id,
|
||||
to: {
|
||||
subscriberId,
|
||||
email: userEmail,
|
||||
username,
|
||||
},
|
||||
payload,
|
||||
});
|
||||
};
|
||||
|
||||
await this.novuWorkflowAdapter.triggerWorkflow(triggerData);
|
||||
this.logger.log(`Уведомление отправлено пользователю ${username} о принятии решения ${decisionId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка при обработке нового решения совета: ${error.message}`, error.stack);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DecisionResolver } from './resolvers/decision.resolver';
|
||||
import { DecisionService } from './services/decision.service';
|
||||
import { DecisionInteractor } from './use-cases/decision.interactor';
|
||||
|
||||
@Module({
|
||||
providers: [DecisionResolver, DecisionService, DecisionInteractor],
|
||||
})
|
||||
export class DecisionAuthorizeModule {}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Field, InputType, Int } from '@nestjs/graphql';
|
||||
import { IsNumber, IsString, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
|
||||
@InputType('AuthorizeDecisionInput')
|
||||
export class AuthorizeDecisionInputDTO {
|
||||
@Field(() => String, { description: 'Имя аккаунта кооператива' })
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Имя аккаунта председателя совета' })
|
||||
@IsString()
|
||||
chairman!: string;
|
||||
|
||||
@Field(() => Int, { description: 'Идентификатор решения' })
|
||||
@IsNumber()
|
||||
decision_id!: number;
|
||||
|
||||
@Field(() => SignedDigitalDocumentInputDTO, { description: 'Подписанный председателем документ утверждения решения' })
|
||||
@ValidateNested()
|
||||
@Type(() => SignedDigitalDocumentInputDTO)
|
||||
document!: SignedDigitalDocumentInputDTO;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Field, InputType, Int } from '@nestjs/graphql';
|
||||
import { IsNumber, IsString } from 'class-validator';
|
||||
|
||||
@InputType('DeclineDecisionInput')
|
||||
export class DeclineDecisionInputDTO {
|
||||
@Field(() => String, { description: 'Имя аккаунта кооператива' })
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => Int, { description: 'Идентификатор решения' })
|
||||
@IsNumber()
|
||||
decision_id!: number;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Resolver, Mutation, Args } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
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 { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
|
||||
import { DecisionService } from '../services/decision.service';
|
||||
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
|
||||
import { DeclineDecisionInputDTO } from '../dto/decline-decision-input.dto';
|
||||
|
||||
@Resolver()
|
||||
export class DecisionResolver {
|
||||
constructor(private readonly decisionService: DecisionService) {}
|
||||
|
||||
@Mutation(() => TransactionDTO, {
|
||||
name: 'authorizeDecision',
|
||||
description: 'Утвердить и исполнить решение совета',
|
||||
})
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
async authorizeDecision(
|
||||
@Args('data', { type: () => AuthorizeDecisionInputDTO }) data: AuthorizeDecisionInputDTO
|
||||
): Promise<TransactionDTO> {
|
||||
return this.decisionService.authorizeDecision(data);
|
||||
}
|
||||
|
||||
@Mutation(() => TransactionDTO, {
|
||||
name: 'declineDecision',
|
||||
description: 'Отклонить решение совета по отрицательному консенсусу (большинство голосов против)',
|
||||
})
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
async declineDecision(
|
||||
@Args('data', { type: () => DeclineDecisionInputDTO }) data: DeclineDecisionInputDTO
|
||||
): Promise<TransactionDTO> {
|
||||
return this.decisionService.declineDecision(data);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DecisionInteractor } from '../use-cases/decision.interactor';
|
||||
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
|
||||
import { DeclineDecisionInputDTO } from '../dto/decline-decision-input.dto';
|
||||
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DecisionService {
|
||||
constructor(private readonly decisionInteractor: DecisionInteractor) {}
|
||||
|
||||
public async authorizeDecision(data: AuthorizeDecisionInputDTO): Promise<TransactionDTO> {
|
||||
const result = await this.decisionInteractor.authorizeDecision(data);
|
||||
return result as TransactionDTO;
|
||||
}
|
||||
|
||||
public async declineDecision(data: DeclineDecisionInputDTO): Promise<TransactionDTO> {
|
||||
const result = await this.decisionInteractor.declineDecision(data);
|
||||
return result as TransactionDTO;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { SOVIET_BLOCKCHAIN_PORT, SovietBlockchainPort } from '~/domain/common/ports/soviet-blockchain.port';
|
||||
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
|
||||
import { SovietContract } from 'cooptypes';
|
||||
import type { TransactResult } from '@wharfkit/session';
|
||||
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
|
||||
import { DeclineDecisionInputDTO } from '../dto/decline-decision-input.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DecisionInteractor {
|
||||
constructor(
|
||||
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort,
|
||||
private readonly domainToBlockchainUtils: DomainToBlockchainUtils
|
||||
) {}
|
||||
|
||||
async authorizeDecision(data: AuthorizeDecisionInputDTO): Promise<TransactResult> {
|
||||
const authorizeData: SovietContract.Actions.Decisions.Authorize.IAuthorize = {
|
||||
coopname: data.coopname,
|
||||
chairman: data.chairman,
|
||||
decision_id: data.decision_id,
|
||||
document: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.document),
|
||||
};
|
||||
|
||||
const execData: SovietContract.Actions.Decisions.Exec.IExec = {
|
||||
// executer — аудит-метка инициатора; авторизация транзакции идёт ключом кооператива.
|
||||
executer: data.chairman,
|
||||
coopname: data.coopname,
|
||||
decision_id: data.decision_id,
|
||||
};
|
||||
|
||||
return await this.sovietBlockchainPort.authorizeDecision(authorizeData, execData);
|
||||
}
|
||||
|
||||
async declineDecision(data: DeclineDecisionInputDTO): Promise<TransactResult> {
|
||||
const declineData: SovietContract.Actions.Decisions.Declinedec.IDeclineDecision = {
|
||||
coopname: data.coopname,
|
||||
decision_id: data.decision_id,
|
||||
};
|
||||
|
||||
return await this.sovietBlockchainPort.declineDecision(declineData);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,6 @@ import { DocumentPackageAggregator } from '~/domain/document/aggregators/documen
|
||||
import { DocumentAggregator } from '~/domain/document/aggregators/document.aggregator';
|
||||
import type { DocumentAggregateDomainInterface } from '~/domain/document/interfaces/document-domain-aggregate.interface';
|
||||
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
|
||||
import {
|
||||
SIGNED_DOCUMENT_REPOSITORY,
|
||||
type SignedDocumentRepository,
|
||||
} from '~/domain/document/repository/signed-document.repository';
|
||||
import { SignedDocumentStatus } from '~/domain/document/enums/signed-document-status.enum';
|
||||
import config from '~/config/config';
|
||||
|
||||
@Injectable()
|
||||
export class DocumentInteractor {
|
||||
@@ -22,8 +16,7 @@ export class DocumentInteractor {
|
||||
@Inject(forwardRef(() => DocumentDomainService)) private readonly documentDomainService: DocumentDomainService,
|
||||
@Inject(forwardRef(() => DocumentPackageAggregator))
|
||||
private readonly documentPackageAggregator: DocumentPackageAggregator,
|
||||
@Inject(forwardRef(() => DocumentAggregator)) private readonly documentAggregator: DocumentAggregator,
|
||||
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly signedDocumentRepository: SignedDocumentRepository
|
||||
@Inject(forwardRef(() => DocumentAggregator)) private readonly documentAggregator: DocumentAggregator
|
||||
) {}
|
||||
|
||||
public async generateDocument(data: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
|
||||
@@ -33,56 +26,52 @@ export class DocumentInteractor {
|
||||
public async getDocumentsAggregate(
|
||||
data: GetDocumentsInputDomainInterface
|
||||
): Promise<PaginationResultDomainInterface<DocumentPackageAggregateDomainInterface>> {
|
||||
const { type, page = 1, limit = 100, query = {}, after_block, before_block, actions } = data;
|
||||
const { type = 'newsubmitted', page = 1, limit = 100, query, after_block, before_block, actions } = data;
|
||||
|
||||
// Read-path из PG-реестра подписанных документов (C28-21): отдаём ГОТОВЫЙ агрегат,
|
||||
// собранный на этапе ingestion/backfill — без обращений к explorer/Mongo и без сборки на лету.
|
||||
const result = await this.signedDocumentRepository.findAggregates({
|
||||
coopname: config.coopname,
|
||||
status: this.mapTypeToStatus(type),
|
||||
actions: actions && actions.length > 0 ? (actions as unknown as string[]) : undefined,
|
||||
username: this.extractReceiverScope(query),
|
||||
hash: this.extractHashFilter(query),
|
||||
afterBlock: after_block,
|
||||
beforeBlock: before_block,
|
||||
// Добавляем фильтры по блокам в query
|
||||
const blockFilters: Record<string, unknown> = { ...query };
|
||||
if (after_block !== undefined || before_block !== undefined) {
|
||||
const blockFilter: Record<string, unknown> = {};
|
||||
|
||||
if (after_block !== undefined) {
|
||||
blockFilter.$gte = after_block;
|
||||
}
|
||||
if (before_block !== undefined) {
|
||||
blockFilter.$lte = before_block;
|
||||
}
|
||||
|
||||
blockFilters.block_num = blockFilter;
|
||||
}
|
||||
|
||||
// Добавляем фильтр по массиву действий (data.action)
|
||||
// DocumentAction enum значения автоматически преобразуются в строки
|
||||
if (actions && actions.length > 0) {
|
||||
blockFilters['data.action'] = { $in: actions };
|
||||
}
|
||||
|
||||
const signedDocuments = await this.documentDomainService.getImmutableSignedDocuments({
|
||||
type,
|
||||
page,
|
||||
limit,
|
||||
query: blockFilters,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items,
|
||||
totalCount: result.total,
|
||||
totalPages: Math.ceil(result.total / limit) || 0,
|
||||
const response: PaginationResultDomainInterface<DocumentPackageAggregateDomainInterface> = {
|
||||
items: [],
|
||||
totalCount: signedDocuments.total,
|
||||
totalPages: Math.ceil(signedDocuments.total / limit),
|
||||
currentPage: page,
|
||||
};
|
||||
}
|
||||
|
||||
// Прежний фильтр был по ИМЕНИ действия, не по статусу-партиции:
|
||||
// «Все входящие» (newsubmitted) — у каждого пакета есть submitted-действие, поэтому это ВСЕ
|
||||
// документы кооператива (решённые/аннулированные тоже входят) → статус НЕ ограничиваем;
|
||||
// «Только утверждённые» (newresolved) — лишь решённые → status=Resolved.
|
||||
// (Партиция по статусу скрыла бы решённые документы из вкладки «Все входящие» — регрессия.)
|
||||
private mapTypeToStatus(type?: 'newsubmitted' | 'newresolved'): SignedDocumentStatus | undefined {
|
||||
return type === 'newresolved' ? SignedDocumentStatus.Resolved : undefined;
|
||||
}
|
||||
for (const raw_action_document of signedDocuments.results) {
|
||||
const documentPackageAggregate = await this.documentPackageAggregator.buildDocumentPackageAggregate(
|
||||
raw_action_document
|
||||
);
|
||||
|
||||
// Скоуп выборки повторяет прежний explorer-фильтр `receiver: username` из DocumentService.
|
||||
// Документ soviet через require_recipient уведомляет И кооператив, И пайщика-субъекта, поэтому:
|
||||
// receiver === coopname → весь кооператив (фильтр только по coopname — как и было);
|
||||
// receiver === <пайщик> → его документы (колонка username = субъект заявления).
|
||||
private extractReceiverScope(query: Record<string, unknown>): string | undefined {
|
||||
const receiver = query['receiver'];
|
||||
if (typeof receiver === 'string' && receiver.length > 0 && receiver !== config.coopname) {
|
||||
return receiver;
|
||||
response.items.push(documentPackageAggregate);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Фильтр по конкретному документу: Union-страница шлёт filter.document.hash (upper-case).
|
||||
private extractHashFilter(query: Record<string, unknown>): string | undefined {
|
||||
const document = query['document'] as Record<string, unknown> | undefined;
|
||||
const hash = (document?.['hash'] ?? query['hash']) as unknown;
|
||||
return typeof hash === 'string' && hash.length > 0 ? hash : undefined;
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,6 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
|
||||
import { GeneratorInfrastructureModule } from '~/infrastructure/generator/generator.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { FreeDecisionInteractor } from './interactors/free-decision.interactor';
|
||||
import { AgendaModule } from '../agenda/agenda.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -16,7 +15,6 @@ import { AgendaModule } from '../agenda/agenda.module';
|
||||
DocumentDomainModule,
|
||||
GeneratorInfrastructureModule,
|
||||
forwardRef(() => UserDomainModule),
|
||||
AgendaModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [FreeDecisionResolver, FreeDecisionService, FreeDecisionInteractor],
|
||||
|
||||
+3
-6
@@ -12,7 +12,6 @@ import { CreateProjectFreeDecisionInputDTO } from '../dto/create-project-free-de
|
||||
import { FreeDecisionGenerateDocumentInputDTO } from '../../document/documents-dto/free-decision-document.dto';
|
||||
import { FreeDecisionService } from '../services/free-decision.service';
|
||||
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
import { AgendaWithDocumentsDTO } from '~/application/agenda/dto/agenda-with-documents.dto';
|
||||
|
||||
@Resolver()
|
||||
export class FreeDecisionResolver {
|
||||
@@ -50,18 +49,16 @@ export class FreeDecisionResolver {
|
||||
return this.freeDecisionService.generateFreeDecision(data, options);
|
||||
}
|
||||
|
||||
@Mutation(() => AgendaWithDocumentsDTO, {
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'publishProjectOfFreeDecision',
|
||||
nullable: true,
|
||||
description:
|
||||
'Опубликовать предложенную повестку и проект решения для голосования совета. Возвращает созданный пункт повестки (или null, если он ещё не проиндексирован) для немедленного отображения на фронте.',
|
||||
description: 'Опубликовать предложенную повестку и проект решения для дальнейшего голосования совета по нему',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async publishProjectOfFreeDecision(
|
||||
@Args('data', { type: () => PublishProjectFreeDecisionInputDTO })
|
||||
data: PublishProjectFreeDecisionInputDTO
|
||||
): Promise<AgendaWithDocumentsDTO | null> {
|
||||
): Promise<boolean> {
|
||||
return this.freeDecisionService.publishProjectOfFreeDecision(data);
|
||||
}
|
||||
|
||||
|
||||
+4
-36
@@ -8,25 +8,10 @@ import type { FreeDecisionGenerateDocumentInputDTO } from '../../document/docume
|
||||
import { FreeDecisionInteractor } from '~/application/free-decision/interactors/free-decision.interactor';
|
||||
import type { ProjectFreeDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/project-free-decision-document.dto';
|
||||
import type { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
import { AgendaService } from '~/application/agenda/services/agenda.service';
|
||||
import type { AgendaWithDocumentsDTO } from '~/application/agenda/dto/agenda-with-documents.dto';
|
||||
|
||||
// Повестка собирается join'ом таблицы decisions из блокчейна (доступна сразу)
|
||||
// с проиндексированными парсером действием newsubmitted и документом-заявлением.
|
||||
// Решение появляется на чейне мгновенно, но индексация парсером action'а занимает
|
||||
// ~2 c — поэтому опрашиваем повестку короткими тиками, пока вопрос не соберётся,
|
||||
// и возвращаем его фронту немедленно (без ожидания общего поллинга страницы).
|
||||
const PUBLISH_FETCH_DELAY_MS = 400;
|
||||
const PUBLISH_FETCH_ATTEMPTS = 13; // ~5 c суммарно — запас над типичными ~2 c
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@Injectable()
|
||||
export class FreeDecisionService {
|
||||
constructor(
|
||||
private readonly freeDecisionInteractor: FreeDecisionInteractor,
|
||||
private readonly agendaService: AgendaService
|
||||
) {}
|
||||
constructor(private readonly freeDecisionInteractor: FreeDecisionInteractor) {}
|
||||
|
||||
public async generateProjectOfFreeDecision(
|
||||
data: ProjectFreeDecisionGenerateDocumentInputDTO,
|
||||
@@ -46,26 +31,9 @@ export class FreeDecisionService {
|
||||
return document as unknown as GeneratedDocumentDTO;
|
||||
}
|
||||
|
||||
public async publishProjectOfFreeDecision(
|
||||
data: PublishProjectFreeDecisionInputDTO
|
||||
): Promise<AgendaWithDocumentsDTO | null> {
|
||||
await this.freeDecisionInteractor.publishProjectOfFreeDecision(data);
|
||||
|
||||
// decision.hash в блокчейне == «общий хэш» подписанного заявления (hash =
|
||||
// doc_hash + meta_hash), НЕ doc_hash (хэш только содержимого) — их легко
|
||||
// перепутать, и матч по doc_hash молча никогда не сработает.
|
||||
const hash = data.document.hash;
|
||||
|
||||
let item: AgendaWithDocumentsDTO | null = null;
|
||||
for (let attempt = 0; attempt < PUBLISH_FETCH_ATTEMPTS; attempt++) {
|
||||
item = await this.agendaService.getAgendaItemByHash(hash);
|
||||
if (item) break;
|
||||
await sleep(PUBLISH_FETCH_DELAY_MS);
|
||||
}
|
||||
|
||||
// null допустим: если парсер не успел проиндексировать — фронт покажет вопрос
|
||||
// на ближайшем тике поллинга (деградация, не ошибка).
|
||||
return item;
|
||||
public async publishProjectOfFreeDecision(data: PublishProjectFreeDecisionInputDTO): Promise<boolean> {
|
||||
const selected = await this.freeDecisionInteractor.publishProjectOfFreeDecision(data);
|
||||
return selected;
|
||||
}
|
||||
|
||||
public async createProjectOfFreeDecision(data: CreateProjectFreeDecisionInputDTO): Promise<CreatedProjectFreeDecisionDTO> {
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
} from '~/domain/common/interfaces/pagination.interface';
|
||||
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
|
||||
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
|
||||
import type { PaymentDomainInterface } from '~/domain/gateway/interfaces/payment-domain.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GatewayInteractorAdapter implements GatewayInteractorPort {
|
||||
@@ -37,14 +36,6 @@ export class GatewayInteractorAdapter implements GatewayInteractorPort {
|
||||
return await this.gatewayInteractor.createWithdraw(data);
|
||||
}
|
||||
|
||||
async prepareWithdraw(data: CreateWithdrawInputDomainInterface): Promise<PaymentDomainInterface> {
|
||||
return await this.gatewayInteractor.prepareWithdraw(data);
|
||||
}
|
||||
|
||||
async persistWithdraw(paymentData: PaymentDomainInterface): Promise<PaymentDomainEntity> {
|
||||
return await this.gatewayInteractor.persistWithdraw(paymentData);
|
||||
}
|
||||
|
||||
async setPaymentStatus(data: SetPaymentStatusInputDomainInterface): Promise<PaymentDomainEntity> {
|
||||
return await this.gatewayInteractor.setPaymentStatus(data);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,4 @@ export class SetPaymentStatusInputDTO {
|
||||
|
||||
@Field(() => PaymentStatusEnum, { description: 'Новый статус платежа' })
|
||||
status!: PaymentStatusEnum;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Причина изменения статуса. При отклонении платежа показывается пайщику как причина отказа.',
|
||||
nullable: true,
|
||||
})
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PaymentController } from './controllers/payment.controller';
|
||||
import { GatewayInteractor } from './interactors/gateway.interactor';
|
||||
import { GatewayNotificationHandler } from './handlers/gateway-notification.handler';
|
||||
import { GatewayDomainModule } from '~/domain/gateway/gateway-domain.module';
|
||||
import { NovuModule } from '~/infrastructure/novu/novu.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { AccountInfrastructureModule } from '~/infrastructure/account/account-infrastructure.module';
|
||||
import { SystemModule } from '~/application/system/system.module';
|
||||
@@ -18,6 +19,7 @@ import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
imports: [
|
||||
forwardRef(() => GatewayDomainModule),
|
||||
forwardRef(() => GatewayInfrastructureModule),
|
||||
NovuModule,
|
||||
UserInfrastructureModule,
|
||||
UserDomainModule,
|
||||
AccountInfrastructureModule,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user